Terrain And World Rendering
WDT And ADT
The WoW renderer starts from a WDT path and tracks a 64x64 tile grid. Each present tile resolves to an ADT file:
Important local constants:
| Constant | Value | Meaning |
|---|---|---|
WOW_WDT_TILES |
64 |
WDT tile grid size per axis. |
WOW_ADT_SIZE |
533.333313f |
World units per ADT tile. |
WOW_ADT_CHUNK_SIZE |
WOW_ADT_SIZE / 16 |
World units per ADT chunk. |
WOW_ADT_UNIT_SIZE |
WOW_ADT_CHUNK_SIZE / 8 |
Fine height-grid unit. |
WOW_MCVT_COUNT |
9 * 9 + 8 * 8 |
Height samples per ADT chunk. |
The current renderer loads a small ADT window around the active area and keeps an alpha atlas for terrain splat masks.
ADT Chunk Tags
The code compares chunk tags in reversed byte order. Important ADT tags currently handled:
| Tag In Code | Normal Tag | Purpose |
|---|---|---|
XETM |
MTEX |
Texture filename block. |
XDMM |
MMDX |
Doodad model filename block. |
DIMM |
MMID |
Doodad model filename offsets. |
FDDM |
MDDF |
Doodad placement definitions. |
OMWM |
MWMO |
WMO filename block. |
DIWM |
MWID |
WMO filename offsets. |
FDOM |
MODF |
WMO placement definitions. |
KNCM |
MCNK |
Terrain chunk. |
TVCM |
MCVT |
Terrain heights. |
RNCM |
MCNR |
Terrain normals. |
YLCM |
MCLY |
Texture layers. |
LACM |
MCAL |
Alpha maps. |
HSCM |
MCSH |
Baked sun shadow bitfield. |
QLCM |
MCLQ |
Inline liquid geometry (Vanilla/TBC). |
O2HM |
MH2O |
Tile-level liquid layer data (WotLK+). |
Terrain Layers
Each ADT chunk can carry up to four texture layers. The renderer stores:
- up to four texture handles,
- a per-chunk alpha atlas coordinate,
- decoded alpha maps,
- chunk position,
WOW_MCVT_COUNTheights,- optional normals,
- bounds for culling.
The splat path has a small Z bias, polygon offset, and height-delta guard to keep decal geometry close to terrain without exploding across sharp height changes. If the camera's ADT window has not produced terrain samples yet, it emits a flat quad instead of dropping the draw.
Dynamic Splat Batching
WoW splats use top-down rectangular projection, matching the terrain-decal model used by modern large-world renderers. Terrain-conforming splats sample one shared (cols + 1) x (rows + 1) height lattice and reuse those vertices between adjacent cells; the old cell-local loop queried four corners per cell. Small splats use at least a 4x4 fitted grid (96 triangle vertices and 25 shared height samples), so selection rings follow local ADT deformation instead of intersecting it as a single flat quad.
Both paths queue vertices into fixed material batches keyed by texture and shader. R_GameDrawAlphaSurfaces() flushes each occupied batch with one GL_STREAM_DRAW buffer re-specification and one draw call. Re-specifying the complete streaming buffer permits driver-side buffer orphaning and avoids overwriting storage still consumed by the GPU, so extra terrain-fit triangles do not create extra draw calls. WOW_SPLAT_BATCHES (8) slots and WOW_SPLAT_BATCH_VERTICES (4096) cap the batch table; more distinct materials than slots forces a full flush, which is fine for today's shadow + selection-ring material set — bump both if new splat materials appear.
Creature selection depends on the replicated entityState.radius. WoW model collision radii may be 0.5, so the WoW build encodes this field with the two-byte NFT_PACKED_FLOAT under #ifdef WOW; NFT_ROUND truncated such radii to zero and caused the renderer to reject the resulting zero-area circle. WC3 keeps NFT_ROUND because its building/destructable selection radii exceed the packed-float ±65.5 range.
WoWee instead builds a flat 48-segment procedural disc, floor-snaps its center periodically, raises it by 0.17, and disables depth testing. That avoids terrain clipping cheaply, but permits the ring to show through intervening geometry. OpenWarcraft keeps depth testing and fits the batched mesh to terrain; polygon offset plus the small world-space bias handle coplanar depth precision.
References:
- Khronos: Buffer Object Streaming
- Khronos: Basics of Polygon Offset
- GameDev StackExchange: fitted-mesh terrain decals
- Activision: Large Scale Terrain Rendering, decal rendering
Grass
The WoW renderer builds lightweight grass geometry while loading each ADT chunk. Placement is derived from ADT texture layer data:
MCLY.effect_idmarks terrain layers that should emit ground clutter.- The decoded 64x64
MCALalpha maps decide where those layers are visible. - Chunk height samples place each grass clump on the terrain surface.
Each generated clump is two crossed, tapered blade triangles in a chunk-local VAO. Rendering uses a small WoW-owned shader with camera-distance fade and cheap vertex wind, and culls whole chunk grass buffers before drawing. The first-pass tuning constants are:
| Constant | Value | Meaning |
|---|---|---|
WOW_GRASS_DENSITY |
1.0f |
Scales generated clumps per eligible layer sample. |
WOW_GRASS_DRAW_DISTANCE |
220.0f |
Camera-space draw/fade distance for grass chunks. |
This is intentionally a first-pass ground-effect renderer. Exact client-style GroundEffectTexture.dbc and GroundEffectDoodad.dbc model selection can replace the placeholder blade geometry without changing the ADT placement path.
Height Queries
games/world-of-warcraft/common/world_wow.c keeps a 16-ADT LRU height cache for collision/spawn queries. It loads MCVT height samples from MCNK chunks and resolves point height by splitting a local cell around the center sample into triangles, then using barycentric interpolation.
CM_WowFloorHeight extends that terrain result with WMO floors. The ADT cache retains lightweight MWMO/MWID/MODF instances; shared WMO collision geometry is loaded lazily only when a floor ray enters an instance bound. Root MOHD supplies the group count, and each group supplies MOVT/MOVI plus its authored collision BSP in MOBN/MOBR. MOBR is authoritative for which MOVI triangles belong to collision; do not discard those references using render-oriented MOPY flags. A bounded Northshire run measured 2,123 triangle tests for 300 floor calls (7.08 per query) instead of scanning all 9,453 Abbey triangles. If a WMO genuinely lacks MOBN/MOBR, the loader logs it, derives collision candidates from MOPY, and builds a 32x32 local-XZ index. Game movement owns the query; the client camera follows the replicated player entity Z and must not run the collision query again.
Player movement applies two additional constraints before accepting that floor:
- Outdoor terrain may change height only within a 50-degree walkable slope. This prevents the old XY-first movement from climbing or descending mountain faces into the terrain beneath city WMOs.
CM_WowMoveBlockedsweeps the player center and both cylinder edges at shin and chest height through nearby authored WMO BSP triangles. World-normalabs(z) < 0.65classifies walls; floor-like triangles remain owned byCM_WowFloorHeight. A WMO without BSP uses the same 32x32 collision grid instead of silently losing wall collision.
The current sweep blocks the complete move rather than projecting the remainder along the hit plane. Add Quake-style slide movement if diagonal movement against walls needs to retain its tangential component; do not weaken or bypass the authored wall query.
Doodads And WMOs
ADT object references are renderer-owned today:
MDDFentries produce doodad instances backed by M2 models.MODFentries produce WMO instances.- Doodads are bucketed for draw-distance culling.
- Static M2s without keyed transform tracks are grouped by model and submitted through reusable instance VBOs.
- WMO triangles are coalesced by texture within each group. A second model-wide material layout is used when at least half the model's groups are visible; sparse views retain group culling.
- Missing doodad/WMO models are counted and can be represented by debug marker geometry when debug flags are enabled.
Game entities are not spawned for every ADT doodad. games/world-of-warcraft/game/g_wow.c logs that static ADT doodads are renderer-owned and not synchronized as entities.
WMO lighting
Classic WMO surface lighting is split by group ownership. Group MOCV stores BGRA vertex colors and MONR stores matching normals. Interior groups (MOGP.flags & 0x2000) use baked MOCV plus authored MOHD/MOLT contributions without outdoor directional lighting. Exterior groups receive the same synthesized outdoor ambient/directional term as terrain and use recovered MOCV as an authored tint. Keep indoor and outdoor geometry in separate material batches even when they share a texture, otherwise model-wide batching loses the lighting mode. Missing MOCV uses neutral 127,127,127; it is not replaced with guessed ambient data.
Northshire's NSAbbey.wmo confirms the contract in the shipped Classic data: 14 groups and 42 MOLT lights; its interior groups have matching MOVT/MONR/MOCV counts, while an exterior group has MONR but no MOCV. MOLT is not surface lighting: Classic format research identifies MOCV as the only lighting for interior WMO geometry and describes the root lights as inputs for M2 doodads and characters. Preserve MOLT for the future WMO-contained doodad lighting path; do not add its lights to WMO wall shading and double-light the baked result.
The shader split in r_wowmap_shader.c is:
vec3 mocv = 2.0 * v_color.rgb; // recover Wow_FixMocvAlpha's /2
if (uWmoIndoor != 0)
color.rgb = color.rgb * mocv + uWmoAmbient + uWmoLightAdd;
else
color.rgb *= v_lighting * max(mocv, vec3(0.5));
Key invariants:
Wow_FixMocvAlpha(CPU,r_wowmap_wmo.c) divides raw BGRA values by 2. The shader 2× cancels only that division.uWmoAmbient(MOHD amb_color / 255) anduWmoLightAdd(Wow_ComputeMoltContribution) are additive after the MOCV term, never inside the 2×.uWmoIndoor, sourced from the batch's MOGP group, selects the branch. Do not reuse MOCV alpha as an indoor flag because batch-A vertices retain authored alpha.- Exterior
v_lightingusesMONRand the sameambient + diffuse·N·Lsun as terrain. The MOCV floor prevents malformed or absent colors from blacking out a facade. - Old flat-light bug: exterior batches used only recovered MOCV, so Stormwind walls did not react to sun direction even while adjacent terrain did.
- Old overbright bug:
color.rgb *= 2.0 * (ambient + lightAdd + MOCV + lighting). For a neutral wall (MOCV=0.5,lighting=0.75), this produced2×(0.5+0.75) = 2.5×.
The vendored WoWee reference uses the same ownership split in data/WoWee/assets/shaders/wmo.frag.glsl: interiors consume baked vertex color, while exterior groups use ambient plus directional light and multiply by a bounded vertex-color tint.
The current opaque same-texture coalescing does not yet implement the full MOMT blend-mode and MOGP batch A/B/C ordering contract. Add material blend classification before enabling WMO transparency; keep transparent batches ordered rather than folding them into the opaque model-wide buffers.
WMO group bounds are rejected when their transformed bounding sphere lies wholly beyond the fully opaque fog distance. This matters more than the projection plane: without the CPU rejection, OpenGL still submits every material batch and lets clipping happen after the expensive Metal/OpenGL state work. Large buildings crossing the fog boundary remain visible because the test subtracts their sphere radius.
WMO visibility direction
Large buildings, city blocks, caves, and most dungeon shells are WMO geometry. A WMO is divided into groups (typically rooms or exterior sections), while MOPT/MOPR describe portals and group relationships. Treating a dungeon as one ordinary mesh throws away the format's principal visibility accelerator.
Many instance maps use the WDT global-WMO form: MPHD marks the map accordingly and WDT-level MWMO/MODF chunks place the shell instead of per-tile ADTs doing so. Wow_LoadWdtTiles currently consumes only MPHD and MAIN, so global-WMO placement must be added before those dungeon shells can share the normal WMO rendering and collision paths. Do not invent terrain tiles for these maps.
The current renderer already performs two useful reductions: per-group frustum/fog culling for sparse views and model-wide material batches for dense views. The next meaningful upgrade is conservative portal traversal when the camera is confidently inside an interior group:
- Parse group flags and root
MOPT/MOPRdata. - Locate the camera's interior group.
- Traverse only portals facing/intersecting the current clipped frustum.
- Fall back to normal group frustum culling whenever containment or portal data is ambiguous; exterior groups remain visible.
This is preferable to hardware occlusion queries on the macOS OpenGL-to-Metal path, where query readback can serialize the CPU and GPU. It also attacks the real cost visible in profiles: avoided group/material submissions and texture/pipeline changes. Instancing helps repeated WMO placements, but Northshire's expensive buildings are mostly unique instances, so it is secondary to portal visibility and material sorting. Indexed group buffers can later remove the current expanded triangle-list vertex duplication without changing visibility.
Human-start direction spike (2026-08-22)
A bounded Human-start comparison at yaw 0/90 confirmed that the direction spike is WMO work, not grass or ADT doodads:
| View | Total draws | WMO groups/draws | ADT doodad draws |
|---|---|---|---|
| yaw 0 | ~1,246 | 49 / 176 | 692 |
| yaw 90 | ~3,663 | 252 / 2,455 | 679 |
The loaded 3x3 ADT window repeats Stormwind.wmo six times with the same authoritative MODF unique_id=10047 and identical transform. Fixed: Wow_AddWmoInstance and Wow_AddDoodadInstance now track accepted non-zero unique_ids in wowMap_t.placed_wmo_ids / placed_dood_ids (dynamic arrays) and skip any placement whose id is already in the set. These arrays are freed in Wow_FreeWmoInstances / Wow_FreeDoodadInstances, which Wow_ClearLoadedAdts calls when the ADT window shifts. Do not deduplicate by path — distinct authored placements legitimately share a model. WoWee follows this exact ownership rule with placedDoodadIds and placedWmoIds.
Stormwind's streets are WMO floors above the outdoor ADT, not misplaced geometry. Before slope and wall movement collision, a player could run over the mountains from Northshire and descend onto that underlying terrain, leaving the correctly placed city overhead. The official warp stormwind destination (-9152.0, 410.9, 92.9) loads World/WMO/Azeroth/Buildings/Stormwind/Stormwind.wmo; the local 1.5 archive reports 906,888 retained collision triangles. Verify the supported entrance path with:
After placement deduplication, retain per-group frustum/fog culling and implement conservative interior portal traversal. The retail 1.12.1 client organizes the frame as a scene walk over spatial cells, performs per-kind frustum plus optional occlusion rejection, appends survivors to intrusive render lists, and drains terrain, WMO, doodad, M2, liquid, and far-band passes separately. Its WMO renderer has distinct inside/outside portal visibility and a portal flood. WMO group MODR references are now retained and use the same per-frame group visibility result for embedded doodads; shared references are deduplicated, and the rare unreferenced MODD remains scoped to a visible parent. A Human-start run reduced WMO doodad instances from all 7,147 loaded definitions to 199 belonging to six visible WMOs/29 groups (97.2% fewer submissions).
WoWee's WMO renderer is the closest portal implementation reference: it uses conservative interior-only traversal, seeds from camera and character groups, never portal-culls exterior groups, and falls back to frustum culling when containment is ambiguous. Its collision path does not consume MOBN/MOBR: bspNodes is declared but never populated, while floor queries use all MOVI triangles in per-group 2D grids after instance/group bounds rejection. Keep WoWee's conservative portal rules, but retain our cheaper authored BSP collision. The classic 1.12.1 client reverse engineering identifies separate inside/outside portal visibility and portal-flood routines plus the WMO segment-intersection wrapper. TrinityCore's world-object position update likewise records a distinct static floor and current WMO from its full terrain/collision query.
Bounded verification:
make run-wow ARGS="+set wow_playerinfo '\race\Human\sex\Male\class\1\appearance\0' +map playercreate +com_frame_limit 400"
Expected startup evidence near Northshire includes NSAbbey.wmo with 9,453 collision triangles. Do not log per-floor-query data in production.
Quake II world-model analogy
A WMO is not a game-module model in the Quake II sense. Quake II's server calls CM_LoadMap before game->SpawnEntities; qcommon/cmodel.c retains the BSP collision nodes, brushes, visibility, areas, area portals, and entity string. The game DLL receives the entity string and queries collision through imports. The renderer separately loads the same BSP into render-only surfaces. It does not give parsed BSP geometry to the game DLL.
Keep the equivalent ownership split here:
games/world-of-warcraft/common/world_wow.cowns terrain and WMO collision/containment data required by authoritative simulation.Wow_LoadMapalready initiates this path throughCM_LoadMapbefore spawning entities.games/world-of-warcraft/game/owns semantic state: creatures, doors, triggers, transports, quests, and any authored WMO-contained gameplay objects. It queriesCM_*; it must not parseMOVT,MOBN, orMOBRor retain GPU materials.games/world-of-warcraft/renderer/wow/independently owns WMO vertex buffers, materials, baked surface lighting, portal visibility, and render registration lifetime.
The useful analogy is one streamed world plus many reusable inline brush models, not thousands of independent Quake levels. An ADT supplies WMO instances (MODF transforms and bounds); a WMO root supplies shared groups; each group is comparable to a BSP area/room; MOPT/MOPR are comparable to area-portal connectivity; MOBN/MOBR are the collision acceleration structure. A global-WMO dungeon is the special case closest to one Quake level.
Quake II can load the whole BSP eagerly because a map is one bounded file. Azeroth cannot eagerly retain every ADT and WMO. Preserve the 16-ADT working set and shared path-keyed WMO collision cache, but move asset discovery out of the hot trace when streaming work is added: expose a CM_WowPrepareArea(origin, radius)-style collision-world operation, call it from authoritative game movement/spawn lifecycle, and incrementally load nearby ADT instance tables and referenced WMO collision models. CM_WowFloorHeight should then be a query over prepared state, with synchronous loading retained only until that streaming lifecycle exists. Do not expose raw WMO structs across the game/common boundary.
Naming should follow the existing engine convention: public collision-world operations use CM_*; private format loaders use CMod_*-style names if world_wow.c is split into a dedicated WMO collision loader; renderer registration remains R_*/Wow_LoadWmoModel. Do not reuse R_LoadModel or game G_RegisterModel for collision WMOs—their lifetimes and data are different.
Streaming texture reclaim (generation marking)
The world texture cache is r_image_cache in renderer/r_texture.c — the real owner of every loaded texture (a wow_world.textures entry only holds a redundant path→pointer; R_ReleaseTexture no-ops while a texture is still in r_image_cache). Terrain layer and WMO material textures loaded via Wow_LoadTexture(path, streamable=true) accumulated for the whole session because Wow_ClearLoadedAdts (ADT window slide) frees only geometry, never textures — a progressive file-RSS leak that OOM-kills memory-limited handhelds (RG40xx: ~445 MB file-RSS at kill; on Mali/dmabuf GPU textures show as file-RSS).
Fix follows Quake 2's registration_sequence, not reference counting. Q3 (tr_image.c) allocates images in a permanent hunk and bulk-frees via R_DeleteTextures on map change — no eviction, because a BSP map is one bounded lifetime. Q2's streaming-ish analog stamps every asset touched during a registration pass and frees the unstamped ones at R_EndRegistration. Generation marking beats refcounting here because all world geometry is rebuilt on every window slide, so every live texture reference is re-established (and re-stamped) that generation — a stale stamp provably means nothing resident uses it, with no inc/dec balancing to get wrong.
Mechanics:
- rImageCacheEntry_t carries streamed, pinned, generation. R_LoadTextureStreamed sets r_load_streamed so the owning entry is stamped with the current r_stream_generation. Any load through the normal R_LoadTexture, including a path alias sharing the same allocation, permanently pins that owner so a non-streaming consumer (entity/UI/other game) can never have its texture reclaimed underneath it.
- Reclaim removes the stale owner and every path alias referencing its allocation before the single GL deletion. Leaving aliases cached would turn later lookups into use-after-free even though aliases do not own the allocation.
- Wow_LoadNearbyAdts: R_AdvanceTextureGeneration() before loading the new tiles, R_ReclaimStreamedTextures(WOW_TEXTURE_KEEP_GENERATIONS) after. keep_recent=2 keeps the last few windows to avoid reload thrash in the 6-of-9 overlapping tiles.
- Safe because chunks/WMOs are freed by Wow_ClearLoadedAdts before the sweep, so no live geometry references a reclaimed texture. Minimap tiles (wow_world.minimap_tiles[][], map-scoped) load with streamable=false and stay pinned. Doodad M2 textures load via base R_LoadTexture (pinned), so doodad models are never broken — their geometry cache (wow_world.doodad_models) still accumulates and needs a separate coordinated model+texture eviction (TODO).
WMO Draw Performance
The August 2026 RG40xx address mapping for a reported
3-4 FPS run reaches three established hot paths: instanced grass submission (Wow_DrawGrass → M2_RenderInstanced →
R_DrawIndexedBuffer32Instanced), terrain/WMO doodad queuing, and entity-shadow terrain height queries. The gist contains
resolved addresses but no sample counts, build revision, camera, renderer toggles, or GPU timing; it cannot rank those paths
by itself. A same-camera Human-start isolation run on an M1 at 2048x1536 ranked the current costs: default 86-91 FPS,
r_grass 0 94-95, r_unit_shadows 0 105-106, r_wmos 0 154, and r_doodads 0 174. Doodad/WMO M2 submission, not
grass density alone, is the highest-leverage handheld target; keep the authored far clip while optimizing that transport.
A cumulative post-fix ladder on that camera further separates the frame: full 103-107 FPS; HUD/minimap off 109-112; entities/shadows/particles off 117-119; grass off still 117-120; doodads off 304; WMOs off 389; terrain/FOW off 610-623 with zero draws. These are cumulative switches, not independent percentages. The large doodad and WMO steps remain the renderer priority; the zero-draw result is the GL clear/swap plus ordinary client/server loop floor on this machine.
r_norefresh 1 skips SCR_DrawScreenField entirely while preserving input, packet parsing, client commands, snapshots, and
the local server. With r_stats 1, [R_NOREFRESH] loops=N reports outer client/server loop iterations per second instead of
renderer FPS. The same scene sustains about 177K loops/s on the M1. Temporary startup A/B gates measured ~175K with WoW game
simulation disabled, ~177K with snapshot construction disabled, ~303K with snapshot writing/delivery disabled, and ~320K
with packet polling also disabled. The gates were removed after measurement: they show that AI/gameplay and visibility-list
construction are negligible here, while the loopback protocol/client exchange is the remaining no-refresh work. Loop rate is
not rendered FPS and is not portable to the RG40xx; repeat the command on that device to obtain its CPU-side ceiling:
build/bin/openwow -data data/world-of-warcraft +map playercreate \
+set r_norefresh 1 +set r_stats 1 +com_frame_limit 3000000
Commit e471c472 retained one packed VBO per M2 model but expanded every skin index into a unique vertex and then uploaded
a 32-bit EBO containing only 0, 1, 2, .... Targeted loader logs confirmed identity=1 for every sampled character,
doodad, and grass model (for example 11,367 vertices plus a redundant 45,468-byte EBO). The EBO cannot provide vertex-cache
reuse: it only adds index bandwidth and glDrawElements* driver work, including the gist's
R_DrawIndexedBuffer32Instanced path. M2 batches now retain (first,count) array ranges over the same packed VBO and use
glDrawArrays*; this preserves the model-buffer RAM consolidation without manufacturing indices.
The gist's shadow stack was also actionable. Before the fix, fast blob shadows called R_GameGetHeightAtPoint before their
frustum test. A bounded diagnostic counted 32,768 height queries: 26,659 were rejected afterward, while a cheap AABB at the
server-authored entity Z rejected 26,658 of the same shadows before the query. The renderer now performs that early test and
retains the terrain-adjusted test for visible blobs. With both changes, the same default run settles at 102-105 FPS while
rendering the unchanged ~1,178 draws, 1.29M triangles, and 162.9K instances; the screenshot path confirmed identical visible
M2 grass/doodads and shadows. The Apple gain is about 15%, while removing the identity-EBO path is expected to matter more on
the RG40xx GL translation stack.
The same bounded run found the CPU terrain-height helper walking an average 1,108.8 of 2,304 resident MCNKs per successful query. The streamed height-atlas indices now retain a direct MCNK pointer table, so a query computes its atlas cell and reads one chunk. This is the CPU counterpart to the existing GPU height atlas and has explicit out-of-window and empty-cell misses.
Root cause of 3fps regression: Wow_QueueWmoDoodads (r_wowmap_objects.c) called every frame for all WMO instances. Inside: Wow_LoadDoodadModel performs an O(n) strcasecmp linked-list scan; a second O(n) scan finds the wowDoodadModel_t *group. With 43 WMOs × ~100 doodad defs = 4300 lookups/frame at O(200), the profiler confirmed 860K strcasecmp_l calls/frame (78% of frame time).
Fixes applied:
def_groupscache (wowWmoModel_t):wowDoodadModel_t **def_groupspopulated on firstWow_QueueWmoDoodadscall per model. Subsequent frames use O(1) pointer lookup; freed inWow_FreeWmoModels.- Move-to-front in
Wow_LoadDoodadModel(r_wowmap_objects.c): after a cache hit, the entry moves to the front ofwow_world.doodad_models. Grass generates 465K calls with 14 unique models — after the first batch, each model hits at position 0. - WMO precompute (
r_wowmap.c,Wow_DrawTerrainAndWmos):Matrix3_normal,Wow_WmoContainsPoint,Wow_ComputeMoltContribution, and group visibility count computed once per WMO, cached in awmo_cache[]stack array (max 256 entries). Invisible WMOs (vis=0) excluded before GPU work; WMOs with no transparent batches skip pass 1. - Constant uniforms hoisted:
uUseWeightedBlend=0anduAlphaOrigin=(0,0)set once before the WMO loop, not per-WMO per pass.
Measured results (M1 Pro, Eastern Kingdoms, Northshire): 3fps → 50fps after def_groups cache; → 60fps after WMO precompute + skip; 97fps with r_wmos 0; 120fps (vsync) with r_wmos 0 + r_grass 0.
Remaining cost: 43 visible WMOs × 2 passes ≈ 6ms/frame; grass height atlas (465K instanced blades) ≈ 2ms/frame; together they exceed the 8.33ms 120Hz boundary → 60fps vsync. Path to 120fps: UBO-based per-WMO matrices (eliminates per-WMO glUniform* per pass) or portal-based group visibility (reduces groups drawn per frame).
Distance Fog And Hard Clip
WoW uses two distances: geometry becomes fully fog-colored first, then a slightly farther hard plane clips it. Blizzard describes this exact separation and the later terrain/model LOD work required to extend it in Engineer's Workshop: Extended Draw Distance.
The installed 1.5 dbc.MPQ has no Light.dbc, LightParams.dbc,
LightIntBand.dbc, or LightFloatBand.dbc; verify that before attempting the later
DBC-driven lighting chain:
Consequently this client uses an explicit outdoor fallback: fog starts at 500, is
opaque at 650, and the camera hard-clips at 700 world units. This is twice the
reverse-engineered classic farclip default of 350 while remaining inside the current
small ADT streaming design. The local reference is data/whoa-master/src/world/CWorldParam.cpp
(default) plus data/whoa-master/src/world/CWorld.cpp (183.33--791.67 classic clamp).
r_fog, r_fog_start, and r_fog_end allow live visual
diagnosis; r_fog 0 intentionally exposes the hard boundary. Terrain/WMO use the WoW
world shader, while M2 entities and instanced doodads use the shared MDX/M2/M3 model
shader's existing fog uniforms.
Sun Direction And Light Color
The 1.5 archive has no lighting DBCs and no .lit files (pre-1.9 zones stored
lighting in per-zone .lit, moved to Light*.dbc in 1.9), so there is no authored
sun path or light color to read. The client therefore synthesizes both:
- Direction —
Wow_SunDirection(r_wowmap_draw.c) derives the sun from time-of-day (Wow_DayFraction=tr.viewDef.time / WOW_DAY_LENGTH_MS). It is WoWee's synthesizeddirectionalDir(data/WoWee/src/rendering/lighting_manager.cppsampleLightParams), negated and Y-up→Z-up swapped to engine axes.day_frac0=midnight, 0.25=dawn(sun in -X), 0.5=noon, 0.75=dusk(sun in +X). The direction is "toward the sun" (what the model/terrain shaders expect forN·L). - Color —
WOW_LIGHT_AMBIENT_*/WOW_LIGHT_DIFFUSE_*ingames/world-of-warcraft/common/ui_constants.h, WoWee's documented no-DBC fallback tint (cool ambient, warm diffuse). Diffuse is halved from WoWee's(1.0,0.95,0.85)soambient + diffusestays ≤ 1.0 in the engine's non-HDR lighting.
Consumers: terrain (r_wowmap_shader.c vertex shader computes
v_lighting = ambient + diffuse·N·L), grass (uSunDir.z elevation), and M2 models
(r_m2.c submits the sun through R_SetModelLighting). For the authoritative chain that
is missing here, see WoWee: Light.dbc → LightParams.dbc →
LightIntBand.dbc/LightFloatBand.dbc (time-of-day color/fog bands, half-minutes
0–2879).
Baked terrain data in this 1.5 archive: MCNK has no MCCV (vertex color is
WotLK+; the parser reads it but the fallback is white, and its BGRA bytes still need
the RGBA swap Wow_Color performs for MOCV), but does carry MCSH (256 per ADT),
the 64×64×8-bit baked sun shadow map. MCSH has two distinct uses:
- Alpha-map shadow modulation (CPU, at decode time): for every MCAL texel where
the corresponding MCSH bit is set, darken the alpha value before uploading:
alpha = (0xB2 * alpha) >> 8(≈70% of original). Implemented inr_wowmap_adt.cafterWow_DecodeAlphaMapsand beforeWow_UploadAlphaAtlasChunk. - Final-color shadow multiply (fragment shader):
out_color *= MCSH_sampleapplied after terrain lighting, so shadowed ground cells darken regardless of layer count. Requires uploading the 64×64 R8 bitfield as a per-chunk texture. Not yet implemented.
Vanilla terrain lighting is texture × (ambient + diffuse·N·L) × MCSH; apply the
fragment-shader multiply only after adding MCSH to the shader uniforms.
Alpha-map chunk-edge feathering: the classic client smooths alpha transitions at
the 8-texel border between adjacent chunks to avoid hard seam lines where two chunks
have different dominant textures. WoWee implements this as a smoothstep(1/64, 8/64,
border_distance) weight in the fragment shader. Not yet implemented; most visible at
transitions between grass and dirt in Northshire.
MDDF positions are absolute map coordinates, not tile-local coordinates. Both the
renderer and the game-side interactive-object path use CM_WowObjectPoint:
engine.x = 32 * WOW_ADT_SIZE - mddf.position.z
engine.y = 32 * WOW_ADT_SIZE - mddf.position.x
engine.z = mddf.position.y
The authored three-axis rotation and scale / 1024 are preserved. Never replace
MDDF Z with a terrain-height query: elevated statues, signs, and props are authored
relative to platforms and WMO geometry. CM_WowAdtPath also derives each ADT path
from the currently loaded WDT rather than assuming Azeroth.
A bounded Northshire diagnostic reported zero game-side ADT game objects, so the observed crusader statue is a renderer-owned MDDF instance. Its client-authored record is elevated and rotated; the corrected game-side path prevents future interactive duplicates from disagreeing with that renderer placement, but does not rewrite the source MDDF placement.
Fast placement verification
Do not assume that a visible doodad is a game entity. The two MDDF consumers are:
| Consumer | Purpose | Transform path |
|---|---|---|
renderer/wow/r_wowmap_objects.c |
All visible static doodads | Wow_ObjectPoint -> CM_WowObjectPoint |
game/g_gameobject.c |
DBC-matched interactive entities | WowGo_SetDoodadTransform -> CM_WowObjectPoint |
Check the existing startup line before changing either path:
If N=0, changing WowGo_SpawnDoodad cannot affect the visible object. The
Northshire statue investigation produced N=0 and this renderer-owned MDDF:
model=world\dungeon\scarletmonastery\passivedoodads\statues\statuehmcrusader.mdx
position=(17598.289, 90.646, 14467.403) rotation=(0, 138.5, 0) scale=1863
Compare that raw record, CM_WowObjectPoint, and its supporting WMO/platform.
Do not terrain-snap its authored Z.
Minimap
Classic WoW ships pre-baked 256x256 minimap tiles. Textures/Minimap/md5translate.trs
maps logical names such as Azeroth\map32_48.blp to hashed BLP names stored under
Textures/Minimap/. R_RegisterMap parses the map's entries once, and
Wow_DrawMinimap crops and rotates the at-most-four tiles intersecting the
camera-centered 320-world-unit square. Do not re-render terrain and WMOs into the
minimap: that duplicated hundreds of main-view draw submissions every frame.
Resolved tile handles are stored directly in the 64x64 map table, so steady-state
drawing performs no path formatting, MPQ lookup, or linked texture-cache search.
WoWee's minimap
also reads md5translate.trs, but periodically builds a nine-draw 3x3 off-screen
composite and then displays one quad. The current OpenGL path is deliberately simpler:
the small 320-unit crop intersects only one to four tiles, so direct cached quads avoid
an FBO, a 768x768 composite texture, descriptor/state restoration, and refresh work.
If the minimap radius grows past one tile, reassess that trade-off.
Diagnostic lookup:
build/bin/mpqtool -data data/world-of-warcraft cat 'Textures/Minimap/md5translate.trs'
build/bin/mpqtool -data data/world-of-warcraft cat 'Textures/Minimap/ea283abc0bf9637c3fad5e840a65b38b.blp'
Each game owns its minimap drawing via the R_GameDrawMinimap(LPCRECT screen) renderer hook, dispatched from the shared R_DrawMinimap:
- WC3 draws the
war3mapMaptexture plus the fog-of-war overlay and camera view rect. - SC2 draws its map minimap texture.
- WoW draws the translated Blizzard minimap tiles above.
Interface\FrameXML\Minimap.xml is the layout oracle: its content is 140x140
at virtual-canvas (867,22), inside the 192x192 border crop at (832,20).
The border's transparent center does not mask rectangular tile corners, so the
WoW tile draw uses SHADER_MINIMAP: vertex color carries mask-local UV and the
fragment shader smoothly clips the shared tile quads to the authored circle.
The two border atlas crops draw afterward and therefore fit the masked content
without covering it.
Water
Classic WoW uses two liquid formats depending on client version.
MCLQ (Vanilla / TBC)
MCLQ is a sub-chunk inside each MCNK rather than a tile-level block. The MCNK
header liquid_offset and liquid_size fields point to it; MCNK.flags bits 2–5
identify the liquid type (river=2, ocean=4, magma=8, slime=16). The data layout is:
- Heights: 9×9 float grid (81 floats) covering the chunk's XY footprint.
- Tile flags: 8×8 byte grid; value
0x0Fmarks a dry cell — skip it when building geometry. Remaining cells emit one quad from four adjacent height samples. - Flow data: two
SWFlowventries (direction, position, velocity) at the end; seldom populated in world terrain.
The r_wowmap_adt.c parser detects MCLQ and comments it as needing a liquid
rendering pass. No geometry is built yet.
MH2O (WotLK+)
MH2O lives at tile level (one block per ADT). A 256-entry header array (one entry per
MCNK) points to SMLiquidChunk descriptors; each descriptor carries a liquidType
DBC reference, a LiquidVertexFormat (LVF) code, min/max height shortcuts, and offsets
into per-instance heightmap data and a bitmask of which cells exist. The variable-size
sub-grid allows partial liquid coverage within a chunk — only cells where the exists
bitmask bit is set emit geometry.
Rendering plan
Both formats share the same render requirements:
- Build a quad mesh from the height grid, skipping dry/absent cells.
- Submit as a separate liquid pass after all opaque geometry (terrain, WMO, M2). Depth test ON, depth write OFF, alpha blending ON so transparent water shows geometry beneath it.
- Animate UV coordinates each frame with a time-driven scroll to approximate wave movement. A scrolling normal map can replace the flat-UV approximation later.
Water is the largest currently unrendered terrain feature.
Current Limits
- Terrain rendering is the core focus.
- Water (MCLQ / MH2O) is parsed but produces no geometry — largest unrendered feature.
- MCSH alpha-map modulation is implemented; the fragment-shader final-color multiply (upload 64×64 R8 per chunk) is not yet done.
- Alpha chunk-edge feathering is absent; hard seam lines may appear at chunk boundaries.
- DBC-driven lighting, WMO portals, and some animation fidelity are incomplete.
- Inner-vertex LOD: dropping the inner 8×8
MCVTverts beyond ~200 world units halves triangle count with no visible cost; not yet implemented. - The draw window and asset compatibility are tuned around local classic-era data.
- Production support for arbitrary WoW client versions is not present.