Route faded character instances through a dedicated translucent character
pipeline so dead and ghost characters fade as a whole model instead of only
showing translucent hair.
Opaque body batches previously wrote the shader alpha from instance opacity,
but the opaque pipeline had blending disabled, so the body stayed fully
solid. Hair went through the alpha-test path, where reduced alpha affected
coverage, making only hair appear faded.
Add a translucent pipeline with alpha blending and depth writes enabled. This
keeps normal self-occlusion for faded characters while allowing the whole
model to respect instance opacity. Reuse the existing per-batch alpha-test
flag so cutout materials such as hair keep crisp edges while fading with the
rest of the body.
Also make the whole-model fallback path bind an explicit opaque or
translucent pipeline instead of inheriting the previously bound pipeline.
Create, recreate, validate, and destroy the new pipeline alongside the other
character pipelines.
Build passes.
Terrain was only a floor clamp at the camera's final XY; a hill between the
pivot and the camera sliced straight through the view. March the heightfield
along the pivot ray (coarse steps + bisection) and feed the limit through the
same asymmetric pull-in/recover smoothing as the WMO raycast, snapping 1:1
while actively rotating.
Stormwind's walls, trim and roofs are DXT5 with an unused alpha channel — mean
alpha 17/255, every texel below the cutoff. The renderer infers an alpha cutout
for an opaque batch whenever its texture "has alpha", a rescue for character
textures, so all of Stormwind was being discarded and the sky showed through the
buildings with speckle where a stray texel cleared the threshold.
Mark backdrops as scene models and take them at their word: only an alpha-key
material (blendMode 1) cuts out. The hair-geoset and skin-composite guesses are
skipped for them too — a scene's submesh ids are all 0, which the hair guess reads
as a hair geoset for every batch.
Two bugs, one symptom each.
attachWeapons() ran at the end of applyEquipment(), past an early return taken by
any character whose body skin could not be composited. Those characters showed no
weapon at all, and kept the previously selected character's weapon and enchant,
because detaching happens in attachWeapons() too. Weapon attachment depends on
nothing in the geoset/skin work, so do it first and unconditionally.
Weapon and effect models were also loaded under fixed ids. CharacterRenderer's
model cache is keyed by id and skips loading when the id is present, so every
character after the first was handed the first one's weapon model. Key the id by
the asset path instead.
Also drop the renderUnlit plumbing added for the backdrop: preview instances take
the simple-texture shader path, which never reaches the lighting branch it
changed, so it was a no-op.
The glue scenes carry their lighting baked into their textures, but 21 of the 29
materials in the human scene are flagged lit, so all of Stormwind was being shaded
by the portrait light rig that exists to light a character.
Render backdrops unlit. The unlit path multiplies the texture by (1 + emissiveBoost),
which is what an enchant glow wants but would wash a backdrop out, so backdrops
cancel the boost and show the texture exactly as painted.
The glue scenes are authored in their own space — the human one's geometry sits
around x=-230, hundreds of units from its origin — so an instance at the origin
put the whole scene off-camera, and the 130-unit cull radius would have dropped it
anyway. Nothing was visible.
Parse the camera M2s already carry (M2Loader ignored them) and use it: the scene
stays in its authored coordinates, the character stands on its attachment 0 mark
facing the camera, and the preview camera keeps its framing distance but swings
round to the artist's viewing angle. Backdrops are exempt from culling, since
their origin says nothing about where their geometry is.
Also show the weapon's enchant glint in the preview: SMSG_CHAR_ENUM reports an
equipped item's enchant as its ItemVisual id, so it needs no SpellItemEnchantment
lookup — split resolveItemVisualModels() out to enter the chain at that point.
The preview was a 400x500 portrait clamped to 320px in a 360px panel, showing an
unarmed character against nothing. Widen the panel, let the image take the height
the details can spare, and raise the render target to 640x800 so the larger
display is not upscaled mush.
Attach the character's weapons to the preview's hand attachment points — the
equipment was already parsed from SMSG_CHAR_ENUM, but only ever applied as
geosets and textures.
Load the race's glue scene (Interface\Glues\Models\UI_<Race>) behind the
character, so humans stand in Stormwind and orcs in Durotar as they do in the
original client.
A sharpened blade should glint. Resolve the enchant's visual through
SpellItemEnchantment -> ItemVisuals -> ItemVisualEffects and attach the effect M2
to the weapon model's item-visual attachment points, riding the weapon transform.
Such a model is nothing but the additive FX batches that attached weapons
otherwise drop, so effect instances are exempted from that cull, keep their
animation advanced, and are forced additive and unlit — their materials declare
Mod/alpha blending, which would otherwise composite the glow card's black
background as an opaque quad.
Applying an enchant leaves the displayInfoId untouched, so the equipment-dirty
check now also tracks per-slot enchant ids; the visual appears without re-equipping.
prepareRender() re-uploaded every animated M2 instance's bone matrices
into the mega bone SSBO every frame — including the majority whose
bones were never recomputed because they are distance, frustum, or
frame-skip culled and keep their previous matrices. The bonesDirty
flag computeBoneMatrices() sets was never consumed. Track the slot
each instance last uploaded to per frame index and memcpy only when
the bones changed or the slot moved, cutting megabytes of redundant
host-visible writes per frame in doodad-dense scenes.
The selection circle also re-ran terrain/WMO/M2 floor raycasts every
frame; reuse the result while the target's position is unchanged,
refreshing every 30 frames so streamed-in geometry still snaps.
Bird and bat doodads appeared frozen mid-flap in the sky because bone
computation stops beyond 150 units (LOD3 distance) but the model was
still rendered in its bind pose. Now classified as sky birds and their
render distance is capped to the bone-update range, fading in smoothly
as the player approaches.
CharacterRenderer began skipping bone updates beyond only 10 yards, reduced monsters to one update every four frames beyond 20 yards, and reached one update every eight frames beyond 40 yards. Animation time continued advancing between those updates, so poses jumped forward in large steps and locomotion looked like a low-frame-rate slideshow even when movement interpolation was smooth.
Keep character and creature bones at the render frame rate through 45 yards, use every-second-frame updates from 45 to 90 yards, and reserve every-fourth-frame updates for models beyond 90 yards. This brings the creature path in line with the generic M2 renderer while preserving meaningful distant-animation cost reduction.
Verification: full wowee build succeeded and all 31 CTest tests passed.
The DBC loader skipped binary DBCs in Data/db/ on Linux because
filenames are lowercase but code requested mixed-case names. This
caused the corrupted CSV fallback to be used, where nearly all
integer fields (including GeosetGroup) were misclassified as strings
and exported as string-block lookups instead of numeric values.
Add case-insensitive filename matching for the Data/db/ directory
scan and prioritize expansion overlay DBCs. Fix kGeosetBareShins
from 503 to 501, add per-race lowestInGroup fallback (Gnome, Tauren),
and use DBC layout JSON for field indices in game_screen_hud. Exclude
field 0 (record ID) from string detection in both dbc_to_csv and the
asset extractor to prevent future CSV corruption.
Pulls in upstream's torso-twist strafing/camera work, integrity hash
hardening, and asset cleanup (removed unused AzerothCore SQL dumps and
list_mpq). Auto-merged cleanly, including camera_controller.hpp/cpp
where both this branch's suppressVerticalPhysics() addition and
upstream's strafing changes touched the file - verified with a full
rebuild afterward.
Z is intentionally locked to the tram deck while riding (the tunnel has
no real floor), but CameraController's own gravity/jump physics kept
running underneath that lock every frame since grounded never went
true on a moving M2 deck. verticalVelocity silently grew toward
terminal velocity for the whole ride and unleashed all at once the
instant the lock released at disembark, clipping the player through
the floor at "a weird angle." It also explains why jump appeared to
do nothing while riding, since canJump requires a recent grounded
frame that a tram ride never produces.
Added CameraController::suppressVerticalPhysics() (zeroes
verticalVelocity, forces grounded true) and call it every frame the
Deeprun tram Z-lock is active.
guard against corrupt-position disembark
Three separate fixes from this round's live testing:
1. Falling animation during a smooth ride ("kept like playing the
falling animation the whole time"). The M2 ride-lock code
overwrites render position directly every frame, so the camera
controller's own gravity/collision correctly finds no real ground
under a moving platform over open track and reports not-grounded -
right for physics, but that fed straight into the animation FSM as
"falling" for the whole ride. Added AnimationController::
setM2TransportRiding() to override just the animation-facing
grounded signal while actively riding, without touching the camera
controller's real physics/collision (needed for walk-while-riding
to keep working).
2. Reverted tram orientation from a clamped partial pitch back to a
full flatten. The clamp was added to address an earlier "clipping
into the ground" report, but a live comparison against the real
game client confirmed real Deeprun Tram cars stay level through
elevation changes - any visible tilt is wrong regardless of
clipping trade-offs.
3. Live data caught a transient position corruption: after riding
smoothly for ~70s, one frame produced player=(-44.9,2309.03,..) vs
tram=(2309.83,-45.4,..) - the player's X and Y are each within ~1
unit of the tram's Y and X respectively, an axis swap rather than a
real position, yielding horizDist~3330 and an instant disembark
right as the tram approached the destination platform ("kicked me
off when I almost got to the other station"). Root cause not yet
found (suspected: a raw server-coordinate movement update landing
without the usual serverToCanonical swap - that conversion has the
same X/Y-swap shape as the corruption, and a "MSG_MOVE_TELEPORT_ACK:
not enough data for movement info" was logged moments later in the
same session). Added a sanity guard: skip the disembark check
entirely on a physically-impossible single-frame jump rather than
trusting it, so a future occurrence can't eject the player over
open track on bad data while the real source gets tracked down.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pre-classify kobold candle flame at model load time instead of per-batch
string alloc+lowercase+find in the render loop. Add O(1) normal map index
(normalMapByTexPtr_) replacing O(n) textureCache scan per batch. Reuse
toUpdate_ vector across frames instead of heap-allocating every frame.
Reuse preLogicalOp in handlePacket() instead of calling fromWire() twice.
Fix double hash lookups in loadModel() and createInstance().
i_anim_renderer.hpp (unused interface), chat_fwd.hpp (unused forward
declarations), ui_constants.hpp (unused colour constants) — none are
included or referenced anywhere in the codebase.
Replace dedicated strafe/run-left/right animations with plain walk/run
plus a SpineLow bone yaw twist driven by the difference between travel
heading and camera facing. Adds setInstanceTorsoYaw() and a travelYaw
tracker in CameraController.
Camera smoothing now snaps 1:1 while actively dragging or keyboard
turning instead of always lerping, reducing perceived input lag.
Mounts use MOUNT_RUN_LEFT/RIGHT animations for strafing when available.
Default mouse invert changed to off.
Weather::update walked the particle vector twice (update, then copy
position into the GPU upload buffer) and called camera.getPosition()
once per particle inside updateParticle. Fold both passes into one
loop and hoist the camera read once. For storms with thousands of
particles, that's one pass instead of two plus N fewer member reads.
The M2 cull SSBO upload and the CPU-fallback culling path both
recomputed worldRadius / cullRadius / effectiveMaxDistSqFactor /
paddedRadius for every instance every frame, even though all of
those values depend only on static instance state (bound radius,
scale, disable-animation flag, ground-detail flag).
Add recomputeCachedCullFactors() to M2Instance and call it once
when an instance is added (both addInstance overloads). The
per-frame upload reduces to one multiply (maxRenderDistanceSq *
cached factor) plus flag packing, and the CPU-fallback culling
path drops the same redundant math. For 5000+ doodad instances
that's measurable.
WMORenderer::render did loadedModels.find(instance.modelId) three times
per visible instance per frame:
- once in the cullInstance lambda
- once in the main draw-list loop
- and the build pass earlier already gated visibility with .count()
Stash the const ModelData* on each InstanceDrawList during culling and
have the draw loop read straight through it. One hash lookup per
instance per frame instead of two.
beginSingleTimeCommands was calling vkAllocateCommandBuffers and pairing
it with vkFreeCommandBuffers in endSingleTimeCommands — one allocate/
free round-trip every immediate submit. The M2 frustum-cull dispatch
fires this every frame in beginFrame(), so that's two kernel calls per
frame just to obtain and release a command buffer.
The immediate pool already has RESET_COMMAND_BUFFER_BIT set, so cache
one buffer and reset it on each begin instead. The pool destruction
in shutdown reclaims the underlying allocation automatically.
- character_renderer: precompute the (priorityPlane, materialLayer)
batch render order on the M2 model at load time. Was allocating a
std::vector<size_t> and running stable_sort per character per frame
even though the result depends only on static model metadata.
- wmo_renderer: per-instance portal visibility was built into an
unordered_set, then copied to a vector, sorted, and binary-searched
per group. Use the set directly with find() — O(1) per group, drops
the per-instance copy + sort. Removed the now-dead scratch vector.
setPosition/setRotation/setAspectRatio/setFov now reject:
- NaN/inf inputs (would produce NaN view/proj matrix → frozen GPU
on some drivers, garbage frustum culling everywhere)
- aspectRatio <= 0 (degenerate perspective)
- fov <= 0 or >= 180 (degenerate perspective)
Camera is constructed and set from many code paths; pushing the
guards into the setters means none of them need to remember.
Mirrors the editor's WMO scale fix. WMOReady gains a scale field that
is computed from the loaded MODF placement.scale (u16 / 1024) and
forwarded to wmoRenderer->createInstance(). Without this the main
game ignored MODF scale even on WotLK ADTs that use it.
The editor's rebuildObjects path was destroying every cached model and
re-uploading it on every (debounced) change. Added M2Renderer::clearInstances
that drops only the instance list while keeping models loaded. Editor's
clearObjects switches to clearInstances (M2) + clearInstances (WMO),
and persistent path->modelId maps survive across rebuilds. clearTerrain
fully evicts when loading a new zone.
setGhostPreview reused modelId 59999 for every preview, but loadModel
returns true without doing anything when the ID is already cached. So
selecting a new NPC kept the old ghost model in GPU memory and createInstance
used the stale model. Added M2Renderer::unloadModel public API and call it
from clearGhostPreview.
- TerrainManager loads WOC collision meshes alongside WOT/WHM terrain
from both custom_zones/ and output/ directories
- CollisionData stored per-tile with triangle array + bounds
- isPositionWalkable(x, y): returns whether a world position is on
walkable terrain (barycentric point-in-triangle test)
- getCollisionFlags(x, y): returns per-triangle flags (walkable,
water, steep, indoor) for movement system integration
- Defaults to walkable when no collision data is loaded (backward compat)
- Custom zone players now have proper terrain physics boundaries
- Wire WOB buildings into WMO render pipeline (loads→converts→renders)
- Implement JSON DBC loading in DBCFile::loadJSON() with nlohmann/json
- Wire JSON DBC override into AssetManager (custom_zones/output scan)
- Add WMO→WOB conversion with full geometry (fromWMO)
- Replace placeholder WOB export with real WMO→WOB conversion in editor
- Add --convert-wmo CLI flag for batch WMO→WOB conversion
- Store discovered custom zones on Renderer with getCustomZones() accessor
- Add isCustomZone_ member to TerrainManager
All 6 Blizzard format replacements now fully load in the client:
ADT→WOT/WHM, WDT→zone.json, BLP→PNG, DBC→JSON, M2→WOM, WMO→WOB
Standalone wowee_editor tool for creating custom WoW zones.
This is a rough initial implementation — many features work but
M2/WMO rendering still has issues (frame sync, texture layout
transitions) and needs further polish.
Terrain:
- Create new blank terrain with 10 biome types (Grassland, Forest,
Jungle, Desert, Barrens, Snow, Swamp, Rocky, Beach, Volcanic)
- Load existing ADT tiles from extracted game data
- Sculpt brushes: Raise, Lower, Smooth, Flatten, Level
- Chunk edge stitching prevents seams between tiles
- Undo/redo (100-deep stack, Ctrl+Z/Ctrl+Shift+Z)
- Save to WoW ADT/WDT format
Texture Painting:
- Paint/Erase/Replace Base modes
- Full tileset texture browser (1285 textures from manifest)
- Per-zone directory filtering and search
- Alpha map editing with 4-layer limit (auto-replaces weakest)
Object Placement:
- M2 and WMO model placement with full manifest browser (11k M2s, 2k WMOs)
- M2Renderer + WMORenderer integrated (loads .skin files for WotLK)
- Ghost preview follows cursor before placing
- Ctrl+click selection, right-click context menu
- Transform gizmo (Move/Rotate/Scale with axis constraints)
- Position/rotation/scale editing in properties panel
NPC/Monster System:
- 631 creature presets scanned from manifest, categorized
(Critters, Beasts, Humanoids, Undead, Demons, etc.)
- Stats editor: level, health, mana, damage, armor, faction
- Behavior: Stationary, Patrol, Wander, Scripted
- Aggro/leash radius, respawn time, flags (hostile/vendor/etc.)
- Save creature spawns to JSON
Water:
- Place water at configurable height per chunk
- Liquid types: Water, Ocean, Magma, Slime
- Rendered as translucent colored quads
- Saved in ADT MH2O format
Infrastructure:
- Free-fly camera (WASD/QE, right-drag look, scroll speed)
- 5-mode toolbar: Sculpt | Paint | Objects | Water | NPCs
- Asset browser indexes full manifest on startup
- Editor water/marker shaders (pos+color vertex format)
- forceNoCull added to M2Renderer for editor use
- AssetManifest::getEntries() and AssetManager::getManifest() exposed
Known issues:
- M2/WMO rendering may not display on first placement (frame index
sync between update/render was misaligned — now fixed but untested
end-to-end)
- Validation layer errors on shutdown (resource cleanup ordering)
- Object placement on steep terrain can miss raycast
- No undo for texture painting or object placement yet
- Remove the -0.15 vertical offset (kVOffset) from coordinate_projection,
coordinate_display, and zone_highlight_layer; continent UV math is now
identical to zone UV math
- Switch world_map_facade aspect ratio to MAP_W/MAP_H (1002×668) and crop
the FBO image with MAP_U_MAX/MAP_V_MAX instead of stretching the full
1024×768 FBO
- Account for ImGui title bar height (GetFrameHeight) in window sizing and
zone highlight screen-space rect coordinates
- Add ZMP 128×128 grid pixel-accurate hover detection in zone_highlight_layer;
falls back to AABB when ZMP data is unavailable
- Upgrade PlayerMarkerLayer with full Vulkan lifecycle (initialize,
clearTexture, destructor); loads MinimapArrow.blp and renders a rotated
32×32 textured quad via AddImageQuad; red triangle retained as fallback
- Expose arrowRotation_ / arrowDS_ accessors on Minimap; clean up arrow DS
and texture in Minimap::shutdown()
- Wire PlayerMarkerLayer::initialize() into WorldMapFacade::initialize()
- Update coordinate-projection test: continent and zone UV are now equal
Signed-off-by: Pavel Okhlopkov <pavel.okhlopkov@flant.com>
Break the monolithic 1360-line world_map.cpp into 16 focused modules
under src/rendering/world_map/:
Architecture:
- world_map_facade: public API composing all components (PIMPL)
- world_map_types: Vulkan-free domain types (Zone, ViewLevel, etc.)
- data_repository: DBC zone loading, ZMP pixel map, POI/overlay storage
- coordinate_projection: UV projection, zone/continent lookups
- composite_renderer: Vulkan tile pipeline + off-screen compositing
- exploration_state: server mask + local exploration tracking
- view_state_machine: COSMIC→WORLD→CONTINENT→ZONE navigation
- input_handler: keyboard/mouse input → InputAction mapping
- overlay_renderer: layer-based ImGui overlay system (OCP)
- map_resolver: cross-map navigation (Outland, Northrend, etc.)
- zone_metadata: level ranges and faction data
Overlay layers (each an IOverlayLayer):
- player_marker, party_dot, taxi_node, poi_marker, quest_poi,
corpse_marker, zone_highlight, coordinate_display, subzone_tooltip
Fixes:
- Player marker no longer bleeds across continents (only shown when
player is in a zone belonging to the displayed continent)
- Zone hover uses DBC-projected AABB rectangles (restored from
original working behavior)
- Exploration overlay rendering for zone view subzones
Tests:
- 6 new test files covering coordinate projection, exploration state,
map resolver, view state machine, zone metadata, and integration
Signed-off-by: Pavel Okhlopkov <pavel.okhlopkov@flant.com>
Add complete spell visual pipeline resolving the DBC chain
(Spell → SpellVisual → SpellVisualKit → SpellVisualEffectName → M2)
with precast/cast/impact phases, bone-attached positioning, and
automatic dual-hand mirroring.
Ribbon rendering fixes:
- Parse visibility track as uint8 (was read as float, suppressing
all ribbon edges due to ~1.4e-45 failing the >0.5 check)
- Filter garbage emitters with bone=UINT_MAX unconditionally
- Guard against NaN spine positions from corrupt bone data
- Resolve ribbon textures via direct index, not textureLookup table
- Fall back to bone 0 when ribbon bone index is out of range
Particle rendering fixes:
- Reduce spell particle scale from 5x to 1.5x (was oversized)
- Exempt spell effect instances from position-based deduplication
Spell handler integration:
- Trigger precast visuals on SMSG_SPELL_START with server castTimeMs
- Trigger cast/impact visuals on SMSG_SPELL_GO
- Cancel precast visuals on cast interrupt/failure/movement
M2 classifier expansion:
- Add AmbientEmitterType enum for sound system integration
- Add 20+ foliage tokens, 4 spell effect tokens, isSmallFoliage flag
- Add markModelAsSpellEffect() to override disableAnimation
DBC layouts:
- Add SpellVisualID field to Spell.dbc for all expansion configs
Signed-off-by: Pavel Okhlopkov <pavel.okhlopkov@flant.com>
All M2 pipelines used VK_CULL_MODE_NONE, so back-facing polygons always
rendered. On NPCs whose torso meshes are single-layer geometry this
made the interior cavity visible through the back.
Create backface-culled pipeline variants (VK_CULL_MODE_BACK_BIT) and
select them at draw time unless the material has the TwoSided flag
(0x04). Foliage/ground-detail forceCutout batches and the shadow
pipeline keep VK_CULL_MODE_NONE since those cards are inherently
two-sided.
Implement GPU-driven Hierarchical-Z occlusion culling for M2 doodads
using a depth pyramid built from the previous frame's depth buffer.
The cull shader projects bounding spheres via prevViewProj (temporal
reprojection) and samples the HiZ pyramid to reject hidden objects
before the main render pass.
Key implementation details:
- Separate early compute submission (beginSingleTimeCommands + fence
wait) eliminates 2-frame visibility staleness
- Conservative safeguards prevent false culls: screen-edge guard,
full VP row-vector AABB projection (Cauchy-Schwarz), 50% sphere
inflation, depth bias, mip+1, min screen size threshold, camera
motion dampening (auto-disable on fast rotations), and per-instance
previouslyVisible flag tracking
- Graceful fallback to frustum-only culling if HiZ init fails
Fix dark WMO interiors by gating shadow map sampling on isInterior==0
in the WMO fragment shader. Interior groups (flag 0x2000) now rely
solely on pre-baked MOCV vertex-color lighting + MOHD ambient color.
Disable interiorDarken globally (was incorrectly darkening outdoor M2s
when camera was inside a WMO). Use isInsideInteriorWMO() instead of
isInsideWMO() for correct indoor detection.
New files:
- hiz_system.hpp/cpp: pyramid image management, compute pipeline,
descriptors, mip-chain build dispatch, resize handling
- hiz_build.comp.glsl: MAX-depth 2x2 reduction compute shader
- m2_cull_hiz.comp.glsl: frustum + HiZ occlusion cull compute shader
- test_indoor_shadows.cpp: 14 unit tests for shadow/interior contracts
Modified:
- CullUniformsGPU expanded 128->272 bytes (HiZ params, viewProj,
prevViewProj)
- Depth buffer images gain VK_IMAGE_USAGE_SAMPLED_BIT for HiZ reads
- wmo.frag.glsl: interior branch before unlit, shadow skip for 0x2000
- Render graph: hiz_build + compute_cull disabled (run in early compute)
- .gitignore: ignore compiled .spv binaries
- MEGA_BONE_MAX_INSTANCES: 2048 -> 4096
Signed-off-by: Pavel Okhlopkov <pavel.okhlopkov@flant.com>