# Phase N.5b — Terrain on the Modern Rendering Path — Design Spec **Status:** Brainstormed 2026-05-09; not yet implemented. **Author:** acdream lead engineer + Claude. **Builds on:** Phase N.5 (`WbDrawDispatcher` on bindless + multi-draw indirect, shipped 2026-05-08). **Predecessor docs (read first if you're new to this phase):** - [`docs/research/2026-05-09-phase-n5b-handoff.md`](../../research/2026-05-09-phase-n5b-handoff.md) — cold-start briefing. - [`docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md`](../plans/2026-05-08-phase-n5-modern-rendering.md) — N.5 plan + ship record. - [`docs/superpowers/specs/2026-05-08-phase-n5-modern-rendering-design.md`](2026-05-08-phase-n5-modern-rendering-design.md) — N.5 spec; the substrate N.5b consumes. - [`docs/ISSUES.md`](../../ISSUES.md) issue #51 — the load-bearing constraint this phase resolves. --- ## 1. Problem statement N.5 lifted **entity** rendering onto bindless textures + `glMultiDrawElementsIndirect`. CPU dispatcher is 1.23 ms/frame median at Holtburg courtyard; ~810 fps sustained; ~12-15 GL calls/frame for entities regardless of scene complexity. Terrain is still on the older per-landblock pipeline (`TerrainChunkRenderer` at [src/AcDream.App/Rendering/TerrainChunkRenderer.cs](../../../src/AcDream.App/Rendering/TerrainChunkRenderer.cs)) — bind a per-chunk VAO + IBO, issue `glDrawElements` per visible chunk. At radius=2 that's ~25 GL calls/frame for terrain; at radius=5 it scales to ~121. **N.5b's goal:** lift terrain rendering onto the same modern primitives N.5 just delivered, preserving the visible terrain pixel-for-pixel and preserving physics-vs-visual Z agreement (issue #51 / the cell-boundary wobble bug class). The work is straightforward in shape — N.5's substrate (bindless wrapper, `DrawElementsIndirectCommand` struct, `[WB-DIAG]` instrumentation, two-phase Dispose pattern) is already built. The non-trivial decision is how to handle the formula divergence between WorldBuilder and retail. --- ## 2. The formula divergence (why Path A is dead) WorldBuilder's `TerrainUtils.CalculateSplitDirection` ([references/WorldBuilder/.../TerrainUtils.cs:44-53](../../../references/WorldBuilder/WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs:44)) and acdream's `TerrainBlending.CalculateSplitDirection` ([src/AcDream.Core/Terrain/TerrainBlending.cs:56](../../../src/AcDream.Core/Terrain/TerrainBlending.cs:56)) use mathematically distinct formulas: | | Formula | Source | |---|---|---| | acdream | `dw = x*y*0x0CCAC033 - x*0x421BE3BD + y*0x6C1AC587 - 0x519B8F25; bit31` | AC2D `Landblocks.cpp:346-350` | | WB | `(seedA + 1813693831) - seedB - 1369149221 >= 0.5` (rescaled) where `seedA = (lbX*8+cellX)*214614067; seedB = (lbY*8+cellY)*1109124029` | clean-room reverse engineering | **Verified retail authority:** the named retail decomp at [`docs/research/named-retail/acclient_2013_pseudo_c.txt`](../../research/named-retail/acclient_2013_pseudo_c.txt) lines 316042-316144 (function `CLandBlockStruct::ConstructPolygons` at retail address `00531d10`) contains the constants `0x0CCAC033 / 0x6C1AC587 / 0x421BE3BD / 0x519B8F25` verbatim. **Retail uses AC2D's formula.** acdream matches retail. **WB does not.** **Quantified divergence** (per `tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs`, sweep across 255×255 landblocks × 64 cells = 4,161,600 cells): | Comparison | Disagreement rate | |---|---| | Raw enum output (WB enum vs acdream enum) | **50.02%** | | Diagonal-actually-painted (post-correcting for WB's inverted enum semantics) | **49.98%** | | Holtburg town (0xA9B0) | 29/64 cells (45.3%) wrong if using WB | | Worst landblock (0x4D96) | 47/64 cells (73.4%) wrong if using WB | | Best landblock (0x0478) | 17/64 cells (26.6%) wrong if using WB | The two formulas behave like independent random hashes. Adopting WB's pipeline wholesale (Path A) would visibly mis-render ~half the diagonals on every landblock — the cell-boundary wobble bug class would be present everywhere. **Path A is dead.** N.5b commits to Path C (see Decision 1 below): use WB's *renderer* pattern (single global VBO/EBO + slot allocator + multi-draw indirect), driven by acdream's existing `LandblockMesh.Build` which uses retail's formula. --- ## 3. Decisions log The eight brainstorm outcomes, locked. | # | Decision | Choice | Reason | |---|---|---|---| | 1 | Formula source for cell split direction | **Path C — WB renderer pattern, acdream's `LandblockMesh.Build` + `TerrainBlending.CalculateSplitDirection`** (retail's formula) | Path A measured 49.98% diagonal-painted divergence vs retail. Path B (fork-patch WB) is permanent maintenance burden. Path C keeps a known-working asset and avoids fork friction. Same per-frame perf as either alternative. | | 2 | Atlas model | **Keep `TerrainAtlas` (palCode-based fragment blending) + add bindless handles** | Visual correctness already locked in. Bindless wrapper is ~50 lines, cookie-cutter from N.5's `TextureCache.MakeResidentHandle` pattern. No perf win from adopting WB's `LandSurfaceManager`. | | 3 | Mesh ownership | **Single global VBO/EBO + slot allocator, one slot per landblock** | Required for `glMultiDrawElementsIndirect` to actually win — per-LB IBOs would force per-LB binds, defeating the point. Mirrors N.5's pattern + WB's pattern. | | 4 | Index format | **uint32 + baseVertex baked into indices on upload** | Matches WB's pattern verbatim ("maximum driver compatibility"). 192 KB extra IBO at 256 slots — rounding error vs vertex bytes. Future-proofs A.5's higher radius. | | 5 | Shader unification | **Separate `terrain_modern.vert/.frag`** | Vertex layouts are meaningfully different (terrain: 6 attribs incl. palCode; entities: position+UV+normal+per-instance matrix). Unifying forces dead code on both sides; no perf win. | | 6 | Streaming integration | **Mirror WB's slot allocator (free-list `Queue` + power-of-two grow). Skip WB's 15s unload delay.** | Free-list standard; grow-by-doubling matches N.5 buffer growth pattern. The 15s delay would compete with `StreamingLoader`'s existing hysteresis — let one component own lifecycle policy. | | 7 | Conformance test | **Pure-CPU sweep: visual mesh Z = `TerrainSurface.SampleZFromHeightmap` within 1mm, 10 representative landblocks × 100 sample points** | The exact issue #51 sentinel. ~1,000 assertions/run, <100ms, no GL infrastructure needed. Catches any silent formula or vertex-layout drift. | | 8 | Visual verification gate | **4 outdoor scenes (Holtburg flat + sloped, Foundry-area, sloped LB) × 6 visual checks** | Outdoor-only — interiors / dungeons / EnvCells are out of scope and not testable yet. The wobble check is the load-bearing #51 sentinel. | --- ## 4. Architecture overview ### Per-frame draw flow ``` TerrainModernRenderer.Draw(camera, frustum, neverCullId): 1. Walk all loaded slots → per-slot frustum cull (AABB test). Build _visibleSlots list (in-place reuse, no per-frame alloc). 2. If _visibleSlots.Count == 0: early-out. 3. Build per-frame DEIC array, one entry per visible slot: DrawElementsIndirectCommand { Count = 384, // verts/landblock InstanceCount= 1, FirstIndex = slot.FirstIndex, // baked offset into global IBO BaseVertex = 0, // already baked into indices BaseInstance = 0 } 4. If _drawIndirectCapacity < _visibleSlots.Count: delete + re-allocate _indirectBuffer (power-of-two grow). glBufferSubData(DRAW_INDIRECT_BUFFER, 0, sizeof(DEIC) * _visibleSlots.Count, deicArray) 5. shader.Use() // terrain_modern 6. Bind global VAO (_globalVao) 7. Set bindless handle uniforms: glProgramUniformHandleARB for uTerrain + uAlpha 8. Bind DRAW_INDIRECT_BUFFER (_indirectBuffer) 9. glMemoryBarrier(GL_COMMAND_BARRIER_BIT) 10. glMultiDrawElementsIndirect(Triangles, UnsignedInt, indirect=0, drawcount=_visibleSlots.Count, stride=sizeof(DEIC)) 11. Unbind VAO. GL calls per frame for terrain: ~6-8 fixed. - 1× shader.Use - 1× BindVertexArray - 2× ProgramUniformHandleARB (atlas handles) - 1× BindBuffer for DRAW_INDIRECT_BUFFER - 1× BufferSubData for DEIC array - 1× MemoryBarrier - 1× MultiDrawElementsIndirect - 1× BindVertexArray(0) ``` ### Per-landblock-load flow (streaming integration) ``` TerrainModernRenderer.AddLandblock(id, meshData, worldOrigin): 1. If id already present: RemoveLandblock(id) first (replaces). 2. Bake worldOrigin into vertex positions (CPU; ~12µs per landblock). 3. Acquire slot: if _freeSlots.TryDequeue: reuse else: slot = _nextFreeSlot++; if needed, EnsureCapacity(_nextFreeSlot). 4. Compute slot offsets: slotByteOffset_VBO = slot * 384 * 40 bytes (15,360 bytes per slot) slotByteOffset_IBO = slot * 384 * 4 bytes (1,536 bytes per slot) firstIndex = slot * 384 baseVertex = slot * 384 5. Bake baseVertex into indices on CPU (indices[i] += baseVertex). 6. glBufferSubData(VBO, slotByteOffset_VBO, vertBytes, vertData). 7. glBufferSubData(IBO, slotByteOffset_IBO, idxBytes, bakedIndices). 8. Compute slot AABB (worldOrigin.x, worldOrigin.y, minZ, +192, +192, maxZ). 9. Store SlotData {id, worldOrigin, firstIndex, indexCount, aabbMin, aabbMax}. 10. _idToSlot[id] = slot. TerrainModernRenderer.RemoveLandblock(id): 1. _idToSlot.TryGetValue(id) → slot. 2. _freeSlots.Enqueue(slot); _idToSlot.Remove(id); _slots[slot] = null. (No GPU clear — DEIC list won't reference unused slots.) EnsureCapacity(requiredSlots): newCap = max(initialCapacity, currentCap * 2) while newCap < requiredSlots: newCap *= 2. Allocate new VBO + IBO at new size. glCopyBufferSubData old → new (preserve loaded slot data). Delete old; recreate VAO pointing at new VBO+IBO. ``` ### Relation to N.5's existing dispatcher `TerrainModernRenderer` is structurally **parallel** to `WbDrawDispatcher`, not nested under it. They share: - `BindlessSupport` wrapper for `ARB_bindless_texture` calls - `DrawElementsIndirectCommand` struct (20-byte layout) - `[WB-DIAG]` instrumentation pattern (CPU `Stopwatch` + GPU `GL_TIME_ELAPSED` queries) - `SceneLighting` UBO at binding=1 But they're separate dispatchers with separate global buffers, separate VAOs, separate shaders. Per frame, `GameWindow.Draw` calls them in sequence: 1. `_wbDrawDispatcher.Draw(...)` — entities (opaque + transparent passes) 2. `_terrainModern.Draw(...)` — terrain (single opaque pass) 3. Sky / particles / debug / UI on legacy paths until later phases retire them. --- ## 5. Component changes ### Files added | File | Purpose | Approx. size | |---|---|---| | `src/AcDream.App/Rendering/TerrainModernRenderer.cs` | The new dispatcher. Owns global VBO/EBO + slot allocator + per-frame DEIC build + `glMultiDrawElementsIndirect` dispatch. | ~400-500 lines | | `src/AcDream.App/Rendering/TerrainSlotAllocator.cs` | Pure-CPU helper extracted for unit testing: free-list slot management + DEIC array builder. | ~150 lines | | `src/AcDream.App/Rendering/Shaders/terrain_modern.vert` | Vertex shader. Same per-cell layout as today's `terrain.vert` (locations 0-5). Reads bindless atlas handles via uniform. Same `SceneLighting` UBO at binding=1. Same per-vertex AdjustPlanes lighting bake. | ~150 lines | | `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | Fragment shader. Same `combineOverlays` + `combineRoad` + `maskBlend3` as today's `terrain.frag`. Samples bindless `sampler2DArray` handles via `GL_ARB_bindless_texture` extension. Same fog + lightning flash + atmosphere. | ~150 lines | | `tests/AcDream.Core.Tests/Terrain/TerrainModernConformanceTests.cs` | The Z-conformance sentinel for issue #51's bug class. ~10 representative landblocks × ~100 sample points; asserts `\|meshTriZ - TerrainSurface.SampleZFromHeightmap\| < 0.001m`. | ~150 lines | | `tests/AcDream.Core.Tests/Rendering/TerrainSlotAllocatorTests.cs` | Unit tests for the slot allocator (free-list correctness, capacity grow, AABB tracking) + DEIC build correctness. Pure CPU; no GL. | ~200 lines | ### Files modified | File | Change | |---|---| | `src/AcDream.App/Rendering/TerrainAtlas.cs` | Add `GetBindlessHandles()` returning `(ulong terrain, ulong alpha)`. Mirrors N.5's `TextureCache.MakeResidentHandle` pattern: generate handle once at first call, make resident, cache. The existing `GlTexture` / `GlAlphaTexture` `uint` properties stay (no legacy callers to migrate yet, but the path is preserved). | | `src/AcDream.App/Rendering/GameWindow.cs` | Field declaration ([line 21](../../../src/AcDream.App/Rendering/GameWindow.cs:21)): `_terrain` field type `TerrainChunkRenderer? → TerrainModernRenderer?`. Construction ([line 1391](../../../src/AcDream.App/Rendering/GameWindow.cs:1391)): `new TerrainChunkRenderer(gl, shader, atlas)` → `new TerrainModernRenderer(gl, bindless, shader, atlas)`. Wire the `[TERRAIN-DIAG]` rollup callback (mirror the existing `[WB-DIAG]` callback wiring). | | `docs/plans/2026-04-11-roadmap.md` | N.5b → "Shipped" row on completion; N.6 entry refreshed to remove "terrain on modern path" from scope. | | `docs/ISSUES.md` | Issue #51 → "Recently closed" with the SHIP commit SHA. | | `CLAUDE.md` "WB integration cribs" section | Add the N.5b crib: terrain dispatcher mirror of WB's pattern, retail-formula preserved via `LandblockMesh.Build` + `TerrainBlending.CalculateSplitDirection`. | | `memory/project_phase_n5b_state.md` (new memory file) | Captures any high-value gotchas discovered during N.5b implementation (analogous to `project_phase_n5_state.md`'s three gotchas). | ### Files deleted | File | Reason | |---|---| | `src/AcDream.App/Rendering/TerrainChunkRenderer.cs` (454 lines) | Replaced by `TerrainModernRenderer`. | | `src/AcDream.App/Rendering/TerrainRenderer.cs` (247 lines) | Older sibling — already not wired in production. Has no users. Goes away in the same commit as `TerrainChunkRenderer`. | | `src/AcDream.App/Rendering/Shaders/terrain.vert` (147 lines) | Replaced by `terrain_modern.vert`. | | `src/AcDream.App/Rendering/Shaders/terrain.frag` (149 lines) | Replaced by `terrain_modern.frag`. | ### Net diff - Adds: ~6 files, ~1,200 lines (renderer + slot-allocator + 2 shaders + 2 test files) - Removes: ~4 files, ~1,000 lines (2 old renderers + 2 old shaders) - Net: ~+200 lines for the same visual output, with the dispatcher collapsed to ~6-8 GL calls/frame regardless of scene size ### Public API of `TerrainModernRenderer` ```csharp public sealed class TerrainModernRenderer : IDisposable { public TerrainModernRenderer( GL gl, BindlessSupport bindless, Shader terrainModernShader, TerrainAtlas atlas, int initialSlotCapacity = 64); public void AddLandblock(uint landblockId, LandblockMeshData mesh, Vector3 worldOrigin); public void RemoveLandblock(uint landblockId); public void Draw(ICamera camera, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null); public int LoadedSlots { get; } // for [TERRAIN-DIAG] public int VisibleSlots { get; } // for [TERRAIN-DIAG] public int CapacitySlots { get; } // for [TERRAIN-DIAG] public void Dispose(); } ``` Same external interface as today's `TerrainChunkRenderer` (`AddLandblock` + `RemoveLandblock` + `Draw`). Drop-in at `GameWindow.cs:1391`. --- ## 6. Vertex format & shader ### Vertex format: `TerrainVertex` stays as-is (40 bytes) ```csharp [StructLayout(LayoutKind.Sequential)] public readonly record struct TerrainVertex( Vector3 Position, // 12 bytes — world-space (worldOrigin baked in by AddLandblock) Vector3 Normal, // 12 bytes — per-vertex from central-difference (Phase 3b) uint Data0, // 4 bytes — base+ovl0 tex/alpha indices uint Data1, // 4 bytes — ovl1+ovl2 tex/alpha indices uint Data2, // 4 bytes — road0+road1 tex/alpha indices uint Data3); // 4 bytes — rotations + splitDir bit // total: 40 bytes ``` Already correct, already debugged. Per-vertex normal is preserved because retail bakes AdjustPlanes lighting at the vertex stage — losing it would re-introduce the "warmer / less blue than retail" regression researched in [`docs/research/2026-04-24-lambert-brightness-split.md`](../../research/2026-04-24-lambert-brightness-split.md). VAO attribute layout (locations 0-5, unchanged from today's `terrain.vert`): | Loc | Type | Source | Purpose | |---|---|---|---| | 0 | vec3 (3 floats) | Position offset 0 | world-space position | | 1 | vec3 (3 floats) | Normal offset 12 | per-vertex normal | | 2 | uvec4 (4 bytes) | Data0 offset 24 | base+ovl0 tex/alpha | | 3 | uvec4 (4 bytes) | Data1 offset 28 | ovl1+ovl2 tex/alpha | | 4 | uvec4 (4 bytes) | Data2 offset 32 | road0+road1 tex/alpha | | 5 | uvec4 (4 bytes) | Data3 offset 36 | rotations + splitDir | ### Shader: `terrain_modern.vert/.frag` The structural change vs today's `terrain.vert/.frag` is small. The blend math, lighting bake, fog, lightning flash all stay verbatim. The only change is how textures are bound: ```glsl // terrain_modern.frag — preamble #version 460 core #extension GL_ARB_bindless_texture : require uniform sampler2DArray uTerrain; // 64-bit bindless handle, set per-frame uniform sampler2DArray uAlpha; // 64-bit bindless handle, set per-frame // SceneLighting UBO at binding=1 (unchanged from today) layout(std140, binding = 1) uniform SceneLighting { ... }; // rest is unchanged from today's terrain.frag — combineOverlays, combineRoad, // maskBlend3, applyFog, lightning flash are line-for-line identical ``` C# side per frame: ```csharp // once at startup or first Draw, after atlas is built: var (terrainHandle, alphaHandle) = atlas.GetBindlessHandles(); // MakeTextureHandleResidentARB called inside GetBindlessHandles, mirror N.5's pattern // per frame: shader.Use(); gl.ProgramUniformHandleARB(shader.Program, uTerrainLoc, terrainHandle); gl.ProgramUniformHandleARB(shader.Program, uAlphaLoc, alphaHandle); // ... bind global VAO + DEIC + glMultiDrawElementsIndirect ``` The bindless extension makes texture access syntactically identical to today's `sampler2DArray` uniform — the only difference is *how* the sampler is set on the C# side. GLSL doesn't know it's bindless. ### SSBO/UBO binding map (cross-checked with N.5) | Binding | Type | Owner | Used by | |---|---|---|---| | SSBO=0 | `Instances[]` (mat4) | `WbDrawDispatcher` | `mesh_modern.vert` | | SSBO=1 | `Batches[]` (handle+layer+flags) | `WbDrawDispatcher` | `mesh_modern.vert/.frag` | | **SSBO=2** | (reserved) | — | future per-batch terrain data when A.5 wants per-LB atlas variation | | UBO=1 | `SceneLighting` | `GameWindow` (set once/frame) | `mesh_modern.frag`, `terrain_modern.vert/.frag`, `sky.frag`, etc. | N.5b doesn't introduce a new SSBO. The atlas handles are uniforms, not SSBO entries — atlas is region-wide so per-frame upload is two `uvec2`s (16 bytes), not worth the SSBO machinery. SSBO=2 stays available for future per-batch terrain data. ### What's preserved bit-for-bit from today's shaders - `unpackOverlayLayer(...)` (rotation logic for overlays) - The `gl_VertexID % 6 → corner` table for both SWtoNE and SEtoNW splits (the geometry mapping that was debugged 2026-04-21 to match ACE's `ConstructPolygons`) - `MIN_FACTOR = 0.0` for the AdjustPlanes Lambert floor (the brightness research) - `combineOverlays` + `combineRoad` + `maskBlend3` fragment math - `applyFog` distance-blend - Lightning flash additive overlay - Per-vertex sun + ambient bake into `vLightingRGB` --- ## 7. Conformance + verification ### CPU unit tests (no GL required) **`tests/AcDream.Core.Tests/Rendering/TerrainSlotAllocatorTests.cs`** — exercises the dispatcher's pure-CPU pieces in isolation: | Test | Asserts | |---|---| | `Add_FirstLandblock_GetsSlotZero` | `_nextFreeSlot` starts at 0; first add uses slot 0 | | `Add_SecondLandblock_GetsSlotOne` | Sequential adds use sequential slots | | `RemoveThenAdd_ReusesFreedSlot` | Free-list FIFO: remove slot 0, add new LB → slot 0 again | | `Add_BeyondInitialCapacity_DoublesCapacity` | After 64 adds, 65th triggers grow to 128 | | `AddSameId_ReplacesExistingSlot` | Re-adding an LB id replaces in same slot (no leak) | | `Build_DeicArray_VisibleSlotsOnly` | DEIC array has one entry per visible slot, `firstIndex = slot * 384`, `count = 384` | | `Build_DeicArray_EmptyVisible` | No visible → empty array | | `Aabb_StoredFromWorldOrigin` | Slot's AABB is `(origin.x, origin.y, minZ)..(origin.x+192, origin.y+192, maxZ)` | **`tests/AcDream.Core.Tests/Terrain/TerrainModernConformanceTests.cs`** — the Z-conformance sentinel for issue #51's bug class. Pattern modeled on the existing `ClientConformanceTests.cs`. For each landblock: 1. Load real dat heightmap data (10 representative landblocks: Holtburg flat 0xA9B0, Holtburg sloped 0xA9B1, Foundry 0x8080, Cragstone 0xCB99, Direlands sample 0xC040, plus 5 randomly-chosen sloped landblocks from a fixed seed for variety). 2. Build mesh via `LandblockMesh.Build(...)` (the source-of-truth generator that `TerrainModernRenderer` calls internally). 3. For 100 (localX, localY) sample points uniformly distributed in `[0, 192] × [0, 192]`: - Compute `meshTriZ`: find the triangle in the built mesh containing the point, barycentric-interpolate Z from its three vertex Zs. - Compute `physicsZ = TerrainSurface.SampleZFromHeightmap(heights, heightTable, lbX, lbY, localX, localY)`. - Assert `|meshTriZ - physicsZ| < 0.001m` (1 mm tolerance — well below visible threshold). 4. Total: 10 landblocks × 100 points = 1,000 assertions per run; runs in <100 ms. If this test fires, the pipeline has silently drifted (different formula somewhere, swapped vertex order, baseVertex baked wrong, etc.) — the exact bug class issue #51 names. ### Existing tests stay green | Test file | Proves | N.5b impact | |---|---|---| | `TerrainBlendingTests.cs` | `CalculateSplitDirection` returns retail's formula | unchanged — still passes | | `LandblockMeshTests.cs` | `LandblockMesh.Build` produces correct triangles | unchanged — still passes | | `ClientConformanceTests.cs` | Existing conformance sweep | unchanged — still passes | | `SplitFormulaDivergenceTest.cs` | WB↔retail divergence is real (49.98%) | unchanged — runs as data documentation; passes | | All 71 tests in N.5 filter (Wb+MatrixComposition+TextureCacheBindless) | N.5 ship intact | unchanged — terrain is a separate dispatcher | ### `[TERRAIN-DIAG]` instrumentation A new dedicated `[TERRAIN-DIAG]` log line, parallel to the existing `[WB-DIAG]` line, so terrain perf is observable independent of entity perf. Two parallel dispatchers, two parallel diag lines: ``` [TERRAIN-DIAG] cpu_ms=avg/95th draws=N/frame visible=N loaded=N capacity=N ``` - `cpu_ms` — `Stopwatch` around `TerrainModernRenderer.Draw`. Median + 95th percentile over the 5-second rollup window. - `draws` — DEIC drawcount param (number of visible landblocks dispatched per `glMultiDrawElementsIndirect` call). Should be 6-8 GL calls fixed per frame regardless of `draws` value. - `visible` / `loaded` / `capacity` — slot accounting; for spotting growth or leaks. - `gpu_ms` — `GL_TIME_ELAPSED` query around the indirect dispatch. Same double-buffering caveat as N.5 (deferred to N.6 perf polish; will report `0/0` until then). ### Visual verification gate (user runs the client) **Scenes** (drive the character through each): 1. **Holtburg town** (~0xA9B0 area) — flat terrain + roads 2. **Holtburg sloped landblock** (~0xA9B1) — slopes + cell-boundary diagonal transitions 3. **Foundry-area** (~0x80xx) — different blend palette 4. **Any visibly-sloped outdoor landblock** — Direlands or wherever you regularly test slope behavior **Checks** at each scene: 1. **No cell-boundary wobble** — the load-bearing #51 sentinel 2. **No missing chunks / black holes** — slot allocator or DEIC misalignment 3. **No texture seams at landblock edges** — pre-N.5b regression check 4. **No z-fighting** — pre-N.5b regression check 5. **`[TERRAIN-DIAG] draws=N` ~6-8 GL calls/frame regardless of N** 6. **`[TERRAIN-DIAG] cpu_ms` at radius=5 is ≥10% lower** than the pre-N.5b baseline (recorded in `docs/plans/2026-05-09-phase-n5b-perf-baseline.md`) Acceptance: all six checks pass in all four scenes. **Outdoor-only — interiors / dungeons / EnvCells are out of scope and not testable yet**. --- ## 8. Acceptance criteria 1. Build green; existing tests stay green; new conformance test passes (`|deltaZ| < 1mm` across the sweep). 2. Visual identity to today confirmed at the four user-verification scenes. 3. `[TERRAIN-DIAG]` shows terrain at ~6-8 GL calls/frame regardless of scene size (vs today's 25-121). 4. No cell-boundary wobble at any visited landblock (the #51 sentinel). 5. **CPU dispatcher time at radius=5 ≥10% lower** than today's `TerrainChunkRenderer` per-LB-binds path. Measured via the `[TERRAIN-DIAG] cpu_ms` median over a 5-second rollup at the Holtburg test scene with radius=5; before/after numbers captured into `docs/plans/2026-05-09-phase-n5b-perf-baseline.md` (mirror N.5's perf baseline doc convention). 6. Issue #51 closed in `docs/ISSUES.md` with the SHIP commit SHA. --- ## 9. Out-of-scope (explicit boundaries) N.5b does **not** ship any of these. Each is a separate phase or backlog item: - **EnvCells / interior cells / dungeons** — different mesh source (cell-bound static geometry, not heightmap). Future phase, not currently scoped on the roadmap. - **Sky rendering** (`SkyRenderer.cs`) — N.8 territory. - **Particle rendering** (`ParticleRenderer.cs`) — N.8 territory. - **Two-tier streaming + horizon LOD** (A.5) — separate brainstorm. Different streaming primitive (visible window split into "near tier" full-detail and "far tier" coarse-LOD). N.5b deliberately doesn't touch streaming radius or LOD machinery. - **WB's `LandSurfaceManager` adoption** — Decision 2 explicitly keeps `TerrainAtlas`. Revisit only if a specific feature requires per-landblock alpha-mask bake. - **WB's `TerrainGeometryGenerator` adoption** — Path C explicitly keeps acdream's `LandblockMesh.Build` as the source of truth. Don't call into WB's generator. - **Fork-patching WB upstream** — Path C avoids this entirely. The WB submodule stays clean. - **Persistent-mapped buffers / GPU-side culling / GL_TIME_ELAPSED double-buffering** — N.6 perf polish territory; not in N.5b scope. - **Per-instance terrain "highlight" or per-LB tint** — no analogue need today; defer to backlog if a use case appears. - **Removing `Texture2D` / `sampler2D` legacy texture path** — N.6 cleanup once Sky/Terrain/Debug/particle paths all migrate. N.5b only adds the `Texture2DArray` bindless path; legacy stays for non-terrain consumers. - **Visual changes** — terrain renders pixel-for-pixel identical to today (same vertex layout, same blend math, same lighting bake). The phase is purely a dispatch-mechanism upgrade. Any visible diff means a bug, not a feature. --- ## 10. Implementation guidance The phase is sized at ~1 week. Tasks decompose into ~10 mostly-parallel chunks: 1. **`TerrainAtlas` bindless extension** — add `GetBindlessHandles()` method. ~50 lines. Independent of dispatcher. 2. **`TerrainSlotAllocator`** — pure-CPU helper class. ~150 lines. Independent of GL. 3. **`TerrainSlotAllocatorTests`** — unit tests for #2. ~200 lines. Depends on #2. 4. **`terrain_modern.vert`** — port of today's `terrain.vert` with bindless preamble. ~150 lines. Independent. 5. **`terrain_modern.frag`** — port of today's `terrain.frag` with bindless preamble. ~150 lines. Independent. 6. **`TerrainModernRenderer`** — dispatcher class wiring slot allocator + GL state + bindless handle uniforms + DEIC dispatch. ~400 lines. Depends on #1, #2. 7. **`TerrainModernConformanceTests`** — Z-conformance sentinel. ~150 lines. Depends on `LandblockMesh.Build` (existing). 8. **`GameWindow` integration** — swap `TerrainChunkRenderer` → `TerrainModernRenderer` at field+construction; add `[TERRAIN-DIAG]` rollup. ~30 lines. Depends on #6. 9. **Delete legacy** — `TerrainChunkRenderer.cs`, `TerrainRenderer.cs`, `terrain.vert`, `terrain.frag`. Depends on #8 working in production. 10. **Roadmap + ISSUES.md + memory** — close issue #51, update CLAUDE.md "WB integration cribs", write `memory/project_phase_n5b_state.md`. Depends on #8 + visual verification. Tasks 1, 2, 4, 5, 7 can land in parallel. Task 6 depends on 1+2. Task 8 depends on 6. Tasks 9 and 10 are post-verification cleanup. The plan document (next step after this spec) breaks each task into TDD-style subtasks with clear acceptance gates per subagent dispatch.