# Phase N.5b — Terrain on the Modern Rendering Path — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Lift outdoor terrain rendering onto N.5's modern primitives (bindless textures + `glMultiDrawElementsIndirect`), preserving visible identity to today and preserving physics-vs-visual Z agreement (issue #51). **Architecture:** Single global VBO/EBO with a slot allocator (one slot per landblock). Per-frame: build a `DrawElementsIndirectCommand` array from visible slots, upload, dispatch via `glMultiDrawElementsIndirect`. Atlas textures use bindless handles (one `sampler2DArray` uniform per atlas, set per-frame via `glProgramUniformHandleARB`). Mesh source is unchanged — `LandblockMesh.Build` (using retail's `FSplitNESW` formula via `TerrainBlending.CalculateSplitDirection`). **Tech Stack:** .NET 10, C#, Silk.NET.OpenGL 2.23, `Silk.NET.OpenGL.Extensions.ARB` (bindless), GLSL 4.60 + `GL_ARB_bindless_texture`. xUnit for tests. **Spec:** [`docs/superpowers/specs/2026-05-09-phase-n5b-terrain-modern-design.md`](../specs/2026-05-09-phase-n5b-terrain-modern-design.md) (commit `b35ddf3`). **Substrate:** N.5 SHIP at `27eaf4e` + ship-amendment `e0dbc9c`. --- ## File map **Create:** - `src/AcDream.App/Rendering/TerrainModernRenderer.cs` — the dispatcher (~400 lines). - `src/AcDream.Core/Terrain/TerrainSlotAllocator.cs` — pure-CPU slot management + DEIC builder (~150 lines). **In Core, not App, so the App-side renderer can compose it; tests in Core.Tests.** - `src/AcDream.App/Rendering/Shaders/terrain_modern.vert` — port of today's `terrain.vert` with bindless preamble (~150 lines). - `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` — port of today's `terrain.frag` with bindless preamble (~150 lines). - `tests/AcDream.Core.Tests/Terrain/TerrainSlotAllocatorTests.cs` — pure-CPU unit tests for slot allocator + DEIC builder (~200 lines). - `tests/AcDream.Core.Tests/Terrain/TerrainModernConformanceTests.cs` — Z-conformance sentinel for issue #51 (~150 lines). - `docs/plans/2026-05-09-phase-n5b-perf-baseline.md` — before/after CPU dispatcher numbers. - `memory/project_phase_n5b_state.md` — high-value gotchas surfaced during implementation. **Modify:** - `src/AcDream.App/Rendering/TerrainAtlas.cs` — add `BindlessSupport? bindless` ctor parameter + `GetBindlessHandles()` method + two-phase Dispose. - `src/AcDream.App/Rendering/GameWindow.cs` — field type swap + ctor swap + `[TERRAIN-DIAG]` rollup callback. - `CLAUDE.md` — add N.5b to "WB integration cribs". - `docs/plans/2026-04-11-roadmap.md` — N.5b → "Shipped" row. - `docs/ISSUES.md` — issue #51 → "Recently closed" with SHIP commit SHA. **Delete (Task 9 — only after Task 8 ships clean visually):** - `src/AcDream.App/Rendering/TerrainChunkRenderer.cs` - `src/AcDream.App/Rendering/TerrainRenderer.cs` - `src/AcDream.App/Rendering/Shaders/terrain.vert` - `src/AcDream.App/Rendering/Shaders/terrain.frag` --- ## Dependency graph (what can run in parallel) ``` Phase A (parallel — 5 subagents): T1 (TerrainAtlas bindless extension) T2 (TerrainSlotAllocator + tests, T2 = code+tests in one task) T4 (terrain_modern.vert) T5 (terrain_modern.frag) T7 (TerrainModernConformanceTests — independent of T6 because the test verifies LandblockMesh.Build output, which T6 just consumes) Phase B (after Phase A — sequential): T6 (TerrainModernRenderer — depends on T1, T2, T4, T5) Phase C (after T6 — sequential): T8 (GameWindow integration — depends on T6) USER VERIFICATION GATE (visual checks at four scenes; ship-blocking) Phase D (parallel after gate): T9 (Delete legacy) T10 (Roadmap + ISSUES + memory + perf baseline doc) ``` The user authorized up to 10 parallel subagents. Phase A uses 5; Phase D uses 2. Phase B and C are single-task serial points. --- ## Workflow per task 1. Read the spec section the task implements. 2. For TDD-friendly tasks (T2 slot allocator, T7 conformance): write the failing test → run → verify failure → implement → run → verify pass → commit. 3. For GL-integration tasks (T1, T6, T8) and shader tasks (T4, T5): implement → build green → smoke check → commit. (Cannot TDD bindless calls without a headless GL context; integration verification happens at T8.) 4. After every commit, run: - `dotnet build` (full solution; must be 0 errors) - `dotnet test --filter "FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~TerrainBlending|FullyQualifiedName~LandblockMesh"` (must be all green) Commit message convention (matching N.5): - Tasks 1-7: `phase(N.5b) Task N: ` - Tasks 8-10: `phase(N.5b): ` - Final SHIP: `phase(N.5b): SHIP — ` Always co-author: `Co-Authored-By: Claude Opus 4.7 (1M context) ` --- ## Task 1: TerrainAtlas bindless extension **Goal:** Add a `GetBindlessHandles()` method that returns 64-bit bindless handles for the terrain + alpha texture arrays. Mirror the pattern from `TextureCache.cs:32-47` (constructor takes optional `BindlessSupport`). **Files:** - Modify: `src/AcDream.App/Rendering/TerrainAtlas.cs` **No standalone tests** — `BindlessSupport.GetResidentHandle` requires a live GL context. Integration verification happens at Task 8 (the renderer uses these handles). - [ ] **Step 1.1: Add BindlessSupport ctor parameter + handle cache fields** In `src/AcDream.App/Rendering/TerrainAtlas.cs`, modify the private constructor at line 56 to accept an optional `BindlessSupport? bindless` parameter: ```csharp private readonly Wb.BindlessSupport? _bindless; // Cached bindless handles. Generated lazily on first GetBindlessHandles() call; // reused for the lifetime of the atlas. private ulong _terrainHandle; private ulong _alphaHandle; private bool _handlesGenerated; private TerrainAtlas( GL gl, Wb.BindlessSupport? bindless, uint glTexture, IReadOnlyDictionary map, int layerCount, uint glAlphaTexture, int alphaLayerCount, IReadOnlyList cornerLayers, IReadOnlyList sideLayers, IReadOnlyList roadLayers, IReadOnlyList cornerTCodes, IReadOnlyList sideTCodes, IReadOnlyList roadRCodes) { _gl = gl; _bindless = bindless; GlTexture = glTexture; // ... (rest unchanged) } ``` - [ ] **Step 1.2: Update `Build` and `BuildFallback` to accept + propagate the optional BindlessSupport** In `TerrainAtlas.Build`, change the signature: ```csharp public static TerrainAtlas Build(GL gl, DatCollection dats, Wb.BindlessSupport? bindless = null) ``` At the end of `Build`, pass `bindless` to the `new TerrainAtlas(...)` call (insert as second parameter after `gl`). In `BuildFallback`, change signature to `BuildFallback(GL gl, Wb.BindlessSupport? bindless = null)` and pass through. Find the call to `BuildFallback(gl)` inside `Build` and change to `BuildFallback(gl, bindless)`. - [ ] **Step 1.3: Add `GetBindlessHandles()` method** After the property declarations (around line 55), add: ```csharp /// /// Get 64-bit bindless handles for the terrain + alpha texture arrays. /// Throws if the atlas was constructed /// without a instance. Handles are generated /// lazily on first call and cached for the atlas's lifetime; both textures /// are made resident. /// public (ulong terrain, ulong alpha) GetBindlessHandles() { if (_bindless is null) throw new InvalidOperationException( "TerrainAtlas was constructed without BindlessSupport; cannot return bindless handles."); if (!_handlesGenerated) { _terrainHandle = _bindless.GetResidentHandle(GlTexture); _alphaHandle = _bindless.GetResidentHandle(GlAlphaTexture); _handlesGenerated = true; } return (_terrainHandle, _alphaHandle); } ``` - [ ] **Step 1.4: Update Dispose for two-phase bindless cleanup** Replace the existing `Dispose` method (line 381) with the two-phase pattern (mirror `TextureCache.Dispose` which is in N.5's spec section §2 Decision: "ALL MakeNonResident first, then ALL DeleteTexture"): ```csharp public void Dispose() { // Phase 1: release bindless residency BEFORE deleting textures. // ARB_bindless_texture requires this ordering; interleaving is UB. if (_handlesGenerated && _bindless is not null) { _bindless.MakeNonResident(_terrainHandle); _bindless.MakeNonResident(_alphaHandle); _handlesGenerated = false; } // Phase 2: delete the underlying GL textures. _gl.DeleteTexture(GlTexture); _gl.DeleteTexture(GlAlphaTexture); } ``` - [ ] **Step 1.5: Build green** Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo` Expected: `Build succeeded. 0 Warning(s) 0 Error(s)`. (TerrainAtlas's existing callers all pass `Build(gl, dats)` without the new optional parameter; the default `bindless = null` keeps them working.) - [ ] **Step 1.6: Commit** ```bash git add src/AcDream.App/Rendering/TerrainAtlas.cs git commit -m "$(cat <<'EOF' phase(N.5b) Task 1: TerrainAtlas bindless extension Add optional BindlessSupport ctor parameter + GetBindlessHandles() method that returns (terrainHandle, alphaHandle) ulongs with both textures made resident. Two-phase Dispose mirroring TextureCache (MakeNonResident before DeleteTexture per ARB_bindless_texture spec). Existing callers pass `Build(gl, dats)` unchanged; bindless = null default keeps them working until T6/T8 wires the renderer. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 2: TerrainSlotAllocator (CPU) + tests **Goal:** Pure-CPU class managing the slot allocator (free-list + capacity tracking) and the DEIC array builder. Unit-testable in isolation. **Files:** - Create: `src/AcDream.Core/Terrain/TerrainSlotAllocator.cs` - Create: `tests/AcDream.Core.Tests/Terrain/TerrainSlotAllocatorTests.cs` - [ ] **Step 2.1: Write the failing tests first (TDD)** Create `tests/AcDream.Core.Tests/Terrain/TerrainSlotAllocatorTests.cs`: ```csharp using AcDream.Core.Terrain; using Xunit; namespace AcDream.Core.Tests.Terrain; public class TerrainSlotAllocatorTests { [Fact] public void Allocate_FromFreshAllocator_ReturnsZero() { var alloc = new TerrainSlotAllocator(initialCapacity: 8); Assert.Equal(0, alloc.Allocate(out _)); } [Fact] public void Allocate_TwoTimes_ReturnsZeroThenOne() { var alloc = new TerrainSlotAllocator(initialCapacity: 8); Assert.Equal(0, alloc.Allocate(out _)); Assert.Equal(1, alloc.Allocate(out _)); } [Fact] public void FreeThenAllocate_ReusesFreedSlot() { var alloc = new TerrainSlotAllocator(initialCapacity: 8); var s0 = alloc.Allocate(out _); var s1 = alloc.Allocate(out _); alloc.Free(s0); Assert.Equal(s0, alloc.Allocate(out _)); } [Fact] public void FreeOrderedFreshAllocs_ReturnsInFifoOrder() { var alloc = new TerrainSlotAllocator(initialCapacity: 8); var s0 = alloc.Allocate(out _); var s1 = alloc.Allocate(out _); var s2 = alloc.Allocate(out _); alloc.Free(s0); alloc.Free(s2); // FIFO: s0 first because freed first. Assert.Equal(s0, alloc.Allocate(out _)); Assert.Equal(s2, alloc.Allocate(out _)); } [Fact] public void Allocate_BeyondInitialCapacity_SignalsNeedsGrow() { var alloc = new TerrainSlotAllocator(initialCapacity: 2); alloc.Allocate(out var grow0); alloc.Allocate(out var grow1); alloc.Allocate(out var grow2); // exceeds initial capacity Assert.False(grow0); Assert.False(grow1); Assert.True(grow2); } [Fact] public void GrowTo_DoublesCapacityCorrectly() { var alloc = new TerrainSlotAllocator(initialCapacity: 4); alloc.GrowTo(8); Assert.Equal(8, alloc.Capacity); alloc.GrowTo(64); Assert.Equal(64, alloc.Capacity); } [Fact] public void LoadedCount_TracksAllocAndFree() { var alloc = new TerrainSlotAllocator(initialCapacity: 8); Assert.Equal(0, alloc.LoadedCount); var s0 = alloc.Allocate(out _); var s1 = alloc.Allocate(out _); Assert.Equal(2, alloc.LoadedCount); alloc.Free(s0); Assert.Equal(1, alloc.LoadedCount); } [Fact] public void Free_TwiceForSameSlot_Throws() { var alloc = new TerrainSlotAllocator(initialCapacity: 8); var s0 = alloc.Allocate(out _); alloc.Free(s0); Assert.Throws(() => alloc.Free(s0)); } } ``` - [ ] **Step 2.2: Run tests to verify they fail** Run: `dotnet test --filter "FullyQualifiedName~TerrainSlotAllocatorTests" --nologo` Expected: build error — `TerrainSlotAllocator` type not found. - [ ] **Step 2.3: Implement TerrainSlotAllocator** Create `src/AcDream.Core/Terrain/TerrainSlotAllocator.cs`: ```csharp using System; using System.Collections.Generic; namespace AcDream.Core.Terrain; /// /// Pure-CPU slot allocator for the terrain modern dispatcher's global VBO/EBO. /// One slot = one landblock's worth of mesh data (384 verts + 384 indices). /// Uses a FIFO free-list for slot recycling and a monotonic counter for /// first-time growth, mirroring WorldBuilder's TerrainRenderManager pattern. /// All bookkeeping is CPU-side; the GPU buffer growth itself is performed /// by TerrainModernRenderer when sets needsGrow=true. /// public sealed class TerrainSlotAllocator { private readonly Queue _freeSlots = new(); private readonly HashSet _liveSlots = new(); private int _nextFreeSlot; private int _capacity; public TerrainSlotAllocator(int initialCapacity = 64) { if (initialCapacity <= 0) throw new ArgumentOutOfRangeException(nameof(initialCapacity), "must be > 0"); _capacity = initialCapacity; } /// Current capacity in slots. Growable via . public int Capacity => _capacity; /// Slots currently in use (allocated minus freed). public int LoadedCount => _liveSlots.Count; /// /// Allocate a slot index. Reuses a freed slot via FIFO if available, /// otherwise hands out the next monotonic index. Sets /// to true when the returned slot index is /// at or beyond current capacity — caller must /// before using the slot. /// public int Allocate(out bool needsGrow) { int slot; if (_freeSlots.TryDequeue(out var freed)) { slot = freed; } else { slot = _nextFreeSlot++; } _liveSlots.Add(slot); needsGrow = slot >= _capacity; return slot; } /// /// Return a slot to the free list. Throws if the slot wasn't currently /// allocated (catches double-free bugs). /// public void Free(int slot) { if (!_liveSlots.Remove(slot)) throw new InvalidOperationException( $"Slot {slot} was not allocated (double-free or unknown slot)."); _freeSlots.Enqueue(slot); } /// Update capacity counter after the caller has grown the GPU buffers. public void GrowTo(int newCapacity) { if (newCapacity < _capacity) throw new ArgumentException("Capacity can only grow", nameof(newCapacity)); _capacity = newCapacity; } } ``` - [ ] **Step 2.4: Run tests to verify all pass** Run: `dotnet test --filter "FullyQualifiedName~TerrainSlotAllocatorTests" --nologo` Expected: `Passed: 8, Failed: 0` in <1 second. - [ ] **Step 2.5: Commit** ```bash git add src/AcDream.Core/Terrain/TerrainSlotAllocator.cs tests/AcDream.Core.Tests/Terrain/TerrainSlotAllocatorTests.cs git commit -m "$(cat <<'EOF' phase(N.5b) Task 2: TerrainSlotAllocator + tests Pure-CPU slot allocator for the terrain modern dispatcher's global VBO/EBO. FIFO free-list + monotonic counter, mirroring WB's TerrainRenderManager pattern. Caller (TerrainModernRenderer) handles GPU buffer growth when Allocate sets needsGrow=true. 8 unit tests cover: fresh-allocator returns slot 0, sequential allocs, free+alloc reuse, FIFO ordering, needsGrow signaling on capacity overflow, GrowTo, LoadedCount tracking, and double-free detection. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 3: (merged into Task 2 above) — n/a (The spec listed this as a separate task; in practice TDD writes test+code together. Skipped.) --- ## Task 4: terrain_modern.vert **Goal:** Vertex shader for the modern terrain dispatcher. Bit-identical math to today's `terrain.vert` with one structural change: bindless `sampler2DArray` uniform (texture access syntactically unchanged in GLSL; bindless-ness is invisible at the shader level — the C# side sets the handle via `glProgramUniformHandleARB`). **Files:** - Create: `src/AcDream.App/Rendering/Shaders/terrain_modern.vert` **No unit tests** — shader correctness is verified at integration (Task 8). - [ ] **Step 4.1: Read today's `terrain.vert`** Read `src/AcDream.App/Rendering/Shaders/terrain.vert` end-to-end (147 lines). The new shader is a 1:1 port with two preamble changes: 1. `#version 460 core` (was 430) 2. `#extension GL_ARB_bindless_texture : require` added immediately after the version line Everything else stays bit-for-bit identical (vertex attribute layout, SceneLighting UBO, AdjustPlanes lighting bake, gl_VertexID corner mapping, etc.). - [ ] **Step 4.2: Write the new shader** Create `src/AcDream.App/Rendering/Shaders/terrain_modern.vert`: ```glsl #version 460 core #extension GL_ARB_bindless_texture : require // Phase N.5b: terrain shader on the modern bindless dispatcher. // Math identical to terrain.vert (Phase 3c per-cell mesh + Phase G AdjustPlanes // lighting). The only structural change is the version + bindless extension // — sampler access in the fragment stage is unchanged at the GLSL level. layout(location = 0) in vec3 aPos; layout(location = 1) in vec3 aNormal; layout(location = 2) in uvec4 aPacked0; layout(location = 3) in uvec4 aPacked1; layout(location = 4) in uvec4 aPacked2; layout(location = 5) in uvec4 aPacked3; uniform mat4 uView; uniform mat4 uProjection; struct Light { vec4 posAndKind; vec4 dirAndRange; vec4 colorAndIntensity; vec4 coneAngleEtc; }; layout(std140, binding = 1) uniform SceneLighting { Light uLights[8]; vec4 uCellAmbient; vec4 uFogParams; vec4 uFogColor; vec4 uCameraAndTime; }; out vec2 vBaseUV; out vec3 vWorldNormal; out vec3 vWorldPos; out vec3 vLightingRGB; out vec4 vOverlay0; out vec4 vOverlay1; out vec4 vOverlay2; out vec4 vRoad0; out vec4 vRoad1; flat out float vBaseTexIdx; const float MIN_FACTOR = 0.0; vec4 unpackOverlayLayer(uint texIdxU, uint alphaIdxU, uint rotIdx, vec2 baseUV) { float texIdx = float(texIdxU); float alphaIdx = float(alphaIdxU); if (texIdx >= 254.0) texIdx = -1.0; if (alphaIdx >= 254.0) alphaIdx = -1.0; vec2 rotatedUV = baseUV; if (rotIdx == 1u) rotatedUV = vec2(1.0 - baseUV.y, baseUV.x); else if (rotIdx == 2u) rotatedUV = vec2(1.0 - baseUV.x, 1.0 - baseUV.y); else if (rotIdx == 3u) rotatedUV = vec2( baseUV.y, 1.0 - baseUV.x); return vec4(rotatedUV.x, rotatedUV.y, texIdx, alphaIdx); } void main() { uint rotOvl0 = (aPacked3.x >> 2u) & 3u; uint rotOvl1 = (aPacked3.x >> 4u) & 3u; uint rotOvl2 = (aPacked3.x >> 6u) & 3u; uint rotRd0 = aPacked3.y & 3u; uint rotRd1 = (aPacked3.y >> 2u) & 3u; uint splitDir= (aPacked3.y >> 4u) & 1u; int vIdx = gl_VertexID % 6; int corner = 0; if (splitDir == 0u) { // SWtoNE order: BL, BR, TR, BL, TR, TL → corners 0, 1, 2, 0, 2, 3 if (vIdx == 0) corner = 0; else if (vIdx == 1) corner = 1; else if (vIdx == 2) corner = 2; else if (vIdx == 3) corner = 0; else if (vIdx == 4) corner = 2; else corner = 3; } else { // SEtoNW order: BL, BR, TL, BR, TR, TL → corners 0, 1, 3, 1, 2, 3 if (vIdx == 0) corner = 0; else if (vIdx == 1) corner = 1; else if (vIdx == 2) corner = 3; else if (vIdx == 3) corner = 1; else if (vIdx == 4) corner = 2; else corner = 3; } vec2 baseUV; if (corner == 0) baseUV = vec2(0.0, 1.0); else if (corner == 1) baseUV = vec2(1.0, 1.0); else if (corner == 2) baseUV = vec2(1.0, 0.0); else baseUV = vec2(0.0, 0.0); vBaseUV = baseUV; vWorldPos = aPos; vWorldNormal = normalize(aNormal); // Retail AdjustPlanes bake (terrain.vert:124-134 — identical math). vec3 sunDir = uLights[0].dirAndRange.xyz; vec3 sunCol = uLights[0].colorAndIntensity.xyz * uLights[0].colorAndIntensity.w; float L = max(dot(vWorldNormal, -sunDir), MIN_FACTOR); vLightingRGB = sunCol * L + uCellAmbient.xyz; float baseTex = float(aPacked0.x); if (baseTex >= 254.0) baseTex = -1.0; vBaseTexIdx = baseTex; vOverlay0 = unpackOverlayLayer(aPacked0.z, aPacked0.w, rotOvl0, baseUV); vOverlay1 = unpackOverlayLayer(aPacked1.x, aPacked1.y, rotOvl1, baseUV); vOverlay2 = unpackOverlayLayer(aPacked1.z, aPacked1.w, rotOvl2, baseUV); vRoad0 = unpackOverlayLayer(aPacked2.x, aPacked2.y, rotRd0, baseUV); vRoad1 = unpackOverlayLayer(aPacked2.z, aPacked2.w, rotRd1, baseUV); gl_Position = uProjection * uView * vec4(aPos, 1.0); } ``` - [ ] **Step 4.3: Verify the shader file ships with the project (build copy)** Look at `src/AcDream.App/AcDream.App.csproj`. If shader files use `` with `` or a Glob, the new file will be picked up automatically. If shaders are individually listed, add the new file there. Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo` Expected: 0 errors. (No code touched it; should compile clean.) - [ ] **Step 4.4: Commit** ```bash git add src/AcDream.App/Rendering/Shaders/terrain_modern.vert # Also add csproj if it was modified to include the file: # git add src/AcDream.App/AcDream.App.csproj git commit -m "$(cat <<'EOF' phase(N.5b) Task 4: terrain_modern.vert Vertex shader for the modern terrain dispatcher. Bit-identical math to today's terrain.vert (Phase 3c per-cell mesh + Phase G AdjustPlanes lighting). The only structural change is the version + bindless extension preamble — sampler access stays a regular sampler2DArray uniform; bindless-ness is invisible at the GLSL level. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 5: terrain_modern.frag **Goal:** Fragment shader for the modern terrain dispatcher. Bit-identical math to today's `terrain.frag` with the same bindless preamble change. **Files:** - Create: `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` - [ ] **Step 5.1: Read today's `terrain.frag`** Read `src/AcDream.App/Rendering/Shaders/terrain.frag` end-to-end (149 lines). The new shader is a 1:1 port with the same `#version 460 core` + `#extension GL_ARB_bindless_texture : require` preamble change. - [ ] **Step 5.2: Write the new shader** Create `src/AcDream.App/Rendering/Shaders/terrain_modern.frag`: ```glsl #version 460 core #extension GL_ARB_bindless_texture : require // Phase N.5b: terrain fragment shader on the modern bindless dispatcher. // Math identical to terrain.frag (Phase 3c per-cell maskBlend3 + // Phase G fog + lightning flash). uTerrain and uAlpha are bound via // glProgramUniformHandleARB on the C# side; GLSL sampling is unchanged. in vec2 vBaseUV; in vec3 vWorldNormal; in vec3 vWorldPos; in vec3 vLightingRGB; in vec4 vOverlay0; in vec4 vOverlay1; in vec4 vOverlay2; in vec4 vRoad0; in vec4 vRoad1; flat in float vBaseTexIdx; out vec4 fragColor; uniform sampler2DArray uTerrain; uniform sampler2DArray uAlpha; struct Light { vec4 posAndKind; vec4 dirAndRange; vec4 colorAndIntensity; vec4 coneAngleEtc; }; layout(std140, binding = 1) uniform SceneLighting { Light uLights[8]; vec4 uCellAmbient; vec4 uFogParams; vec4 uFogColor; vec4 uCameraAndTime; }; const float TILE = 1.0; vec4 maskBlend3(vec4 t0, vec4 t1, vec4 t2, float h0, float h1, float h2) { float a0 = h0 == 0.0 ? 1.0 : t0.a; float a1 = h1 == 0.0 ? 1.0 : t1.a; float a2 = h2 == 0.0 ? 1.0 : t2.a; float aR = 1.0 - (a0 * a1 * a2); float aRsafe = max(aR, 1e-6); a0 = 1.0 - a0; a1 = 1.0 - a1; a2 = 1.0 - a2; vec3 r0 = (a0 * t0.rgb + (1.0 - a0) * a1 * t1.rgb + (1.0 - a1) * a2 * t2.rgb); return vec4(r0 / aRsafe, aR); } vec4 combineOverlays(vec2 baseUV, vec4 pOverlay0, vec4 pOverlay1, vec4 pOverlay2) { float h0 = pOverlay0.z < 0.0 ? 0.0 : 1.0; float h1 = pOverlay1.z < 0.0 ? 0.0 : 1.0; float h2 = pOverlay2.z < 0.0 ? 0.0 : 1.0; vec4 t0 = vec4(0.0), t1 = vec4(0.0), t2 = vec4(0.0); if (h0 > 0.0) { t0 = texture(uTerrain, vec3(baseUV * TILE, pOverlay0.z)); if (pOverlay0.w >= 0.0) { vec4 a = texture(uAlpha, vec3(pOverlay0.xy, pOverlay0.w)); t0.a = a.a; } } if (h1 > 0.0) { t1 = texture(uTerrain, vec3(baseUV * TILE, pOverlay1.z)); if (pOverlay1.w >= 0.0) { vec4 a = texture(uAlpha, vec3(pOverlay1.xy, pOverlay1.w)); t1.a = a.a; } } if (h2 > 0.0) { t2 = texture(uTerrain, vec3(baseUV * TILE, pOverlay2.z)); if (pOverlay2.w >= 0.0) { vec4 a = texture(uAlpha, vec3(pOverlay2.xy, pOverlay2.w)); t2.a = a.a; } } return maskBlend3(t0, t1, t2, h0, h1, h2); } vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) { float h0 = pRoad0.z < 0.0 ? 0.0 : 1.0; float h1 = pRoad1.z < 0.0 ? 0.0 : 1.0; vec4 result = vec4(0.0); if (h0 > 0.0) { result = texture(uTerrain, vec3(baseUV * TILE, pRoad0.z)); if (pRoad0.w >= 0.0) { vec4 a0 = texture(uAlpha, vec3(pRoad0.xy, pRoad0.w)); result.a = 1.0 - a0.a; if (h1 > 0.0 && pRoad1.w >= 0.0) { vec4 a1 = texture(uAlpha, vec3(pRoad1.xy, pRoad1.w)); result.a = 1.0 - (a0.a * a1.a); } } } return result; } vec3 applyFog(vec3 lit, vec3 worldPos) { int mode = int(uFogParams.w); if (mode == 0) return lit; float d = length(worldPos - uCameraAndTime.xyz); float fogStart = uFogParams.x; float fogEnd = uFogParams.y; float span = max(1e-3, fogEnd - fogStart); float fog = clamp((d - fogStart) / span, 0.0, 1.0); return mix(lit, uFogColor.xyz, fog); } void main() { vec4 baseColor = vec4(0.0); if (vBaseTexIdx >= 0.0) { baseColor = texture(uTerrain, vec3(vBaseUV * TILE, vBaseTexIdx)); } vec4 overlays = vec4(0.0); if (vOverlay0.z >= 0.0) overlays = combineOverlays(vBaseUV, vOverlay0, vOverlay1, vOverlay2); vec4 roads = vec4(0.0); if (vRoad0.z >= 0.0) roads = combineRoad(vBaseUV, vRoad0, vRoad1); vec3 baseMasked = baseColor.rgb * ((1.0 - overlays.a) * (1.0 - roads.a)); vec3 ovlMasked = overlays.rgb * (overlays.a * (1.0 - roads.a)); vec3 roadMasked = roads.rgb * roads.a; vec3 rgb = clamp(baseMasked + ovlMasked + roadMasked, 0.0, 1.0); vec3 lit = rgb * min(vLightingRGB, vec3(1.0)); float flash = uFogParams.z; lit += flash * vec3(0.6, 0.6, 0.75); lit = applyFog(lit, vWorldPos); fragColor = vec4(lit, 1.0); } ``` - [ ] **Step 5.3: Build green** Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo` Expected: 0 errors. - [ ] **Step 5.4: Commit** ```bash git add src/AcDream.App/Rendering/Shaders/terrain_modern.frag # Add csproj if needed for shader copy git commit -m "$(cat <<'EOF' phase(N.5b) Task 5: terrain_modern.frag Fragment shader for the modern terrain dispatcher. Bit-identical math to today's terrain.frag (per-cell maskBlend3 + Phase G fog + lightning flash). Same #version 460 + GL_ARB_bindless_texture preamble change as terrain_modern.vert. Sampling syntax unchanged — the bindless-ness is invisible at the GLSL level. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 6: TerrainModernRenderer **Goal:** The dispatcher class. Wires `TerrainSlotAllocator` + GL state + bindless atlas handle uniforms + DEIC dispatch via `glMultiDrawElementsIndirect`. Replaces `TerrainChunkRenderer` (drop-in interface). **Files:** - Create: `src/AcDream.App/Rendering/TerrainModernRenderer.cs` **Depends on:** Task 1 (`TerrainAtlas.GetBindlessHandles`), Task 2 (`TerrainSlotAllocator`), Task 4 + 5 (shaders). - [ ] **Step 6.1: Skim existing pattern** Read these files for the pattern this code mirrors: - `src/AcDream.App/Rendering/TerrainChunkRenderer.cs` — current per-chunk pattern (the API surface to match) - `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` — N.5's modern dispatcher (the SSBO + indirect pattern) - `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TerrainRenderManager.cs` lines 645-902 — WB's terrain dispatcher (the slot allocator + multi-draw indirect pattern; GL calls match what we want) - [ ] **Step 6.2: Implement TerrainModernRenderer** Create `src/AcDream.App/Rendering/TerrainModernRenderer.cs`: ```csharp using System.Numerics; using AcDream.App.Rendering.Wb; using AcDream.Core.Terrain; using Silk.NET.OpenGL; namespace AcDream.App.Rendering; /// /// Phase N.5b modern terrain dispatcher. Single global VBO/EBO with a slot /// allocator (one slot per landblock, 384 verts × 40 bytes = 15,360 bytes /// per slot). Per-frame: build a DrawElementsIndirectCommand array from /// visible slots, upload, dispatch via glMultiDrawElementsIndirect. Atlas /// textures bound via bindless handles set per-frame as sampler uniforms. /// /// Total ~6-8 GL calls per frame for terrain regardless of visible /// landblock count. /// public sealed unsafe class TerrainModernRenderer : IDisposable { private const int VertsPerLandblock = LandblockMesh.VerticesPerLandblock; // 384 private const int IndicesPerLandblock = VertsPerLandblock; private const int VertexSize = 40; // sizeof(TerrainVertex) private const int IndexSize = sizeof(uint); private const float LandblockSize = LandblockMesh.LandblockSize; // 192 private readonly GL _gl; private readonly BindlessSupport _bindless; private readonly Shader _shader; private readonly TerrainAtlas _atlas; private readonly TerrainSlotAllocator _alloc; // Per-slot live data (index by slot integer; null entries are unused slots). private SlotData?[] _slots; // Reverse map: landblockId -> slot, for RemoveLandblock and replacement. private readonly Dictionary _idToSlot = new(); // GPU buffers. private uint _globalVao; private uint _globalVbo; private uint _globalEbo; private uint _indirectBuffer; private int _indirectCapacity; // Cached sampler-uniform locations (matrix uniforms are set by name via Shader.SetMatrix4). private int _uTerrainLoc; private int _uAlphaLoc; // Reusable per-frame buffers. private readonly List _visibleSlots = new(); private DrawElementsIndirectCommand[] _deicScratch = Array.Empty(); // Diag. public int LoadedSlots => _alloc.LoadedCount; public int VisibleSlots => _visibleSlots.Count; public int CapacitySlots => _alloc.Capacity; public TerrainModernRenderer( GL gl, BindlessSupport bindless, Shader shader, TerrainAtlas atlas, int initialSlotCapacity = 64) { _gl = gl; _bindless = bindless; _shader = shader; _atlas = atlas; _alloc = new TerrainSlotAllocator(initialSlotCapacity); _slots = new SlotData?[initialSlotCapacity]; _uTerrainLoc = _gl.GetUniformLocation(_shader.Program, "uTerrain"); _uAlphaLoc = _gl.GetUniformLocation(_shader.Program, "uAlpha"); _globalVao = _gl.GenVertexArray(); _globalVbo = _gl.GenBuffer(); _globalEbo = _gl.GenBuffer(); AllocateGpuBuffers(initialSlotCapacity); ConfigureVao(); _indirectBuffer = _gl.GenBuffer(); } public void AddLandblock(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin) { ArgumentNullException.ThrowIfNull(meshData); if (meshData.Vertices.Length != VertsPerLandblock) throw new ArgumentException( $"Expected {VertsPerLandblock} vertices, got {meshData.Vertices.Length}", nameof(meshData)); if (_idToSlot.ContainsKey(landblockId)) RemoveLandblock(landblockId); int slot = _alloc.Allocate(out var needsGrow); if (needsGrow) { int newCap = Math.Max(_alloc.Capacity * 2, slot + 1); EnsureCapacity(newCap); } // Bake worldOrigin into vertex positions; capture min/max Z for AABB. var bakedVerts = new TerrainVertex[VertsPerLandblock]; float zMin = float.MaxValue, zMax = float.MinValue; for (int i = 0; i < VertsPerLandblock; i++) { var v = meshData.Vertices[i]; var worldPos = v.Position + worldOrigin; bakedVerts[i] = new TerrainVertex(worldPos, v.Normal, v.Data0, v.Data1, v.Data2, v.Data3); if (worldPos.Z < zMin) zMin = worldPos.Z; if (worldPos.Z > zMax) zMax = worldPos.Z; } if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; } // Bake baseVertex into indices on the CPU side (driver-portable pattern). uint baseVertex = (uint)(slot * VertsPerLandblock); var bakedIndices = new uint[IndicesPerLandblock]; for (int i = 0; i < IndicesPerLandblock; i++) bakedIndices[i] = meshData.Indices[i] + baseVertex; // glBufferSubData into the slot's VBO + EBO regions. nint vboByteOffset = (nint)(slot * VertsPerLandblock * VertexSize); nint eboByteOffset = (nint)(slot * IndicesPerLandblock * IndexSize); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _globalVbo); fixed (TerrainVertex* p = bakedVerts) { _gl.BufferSubData(BufferTargetARB.ArrayBuffer, vboByteOffset, (nuint)(VertsPerLandblock * VertexSize), p); } _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _globalEbo); fixed (uint* p = bakedIndices) { _gl.BufferSubData(BufferTargetARB.ElementArrayBuffer, eboByteOffset, (nuint)(IndicesPerLandblock * IndexSize), p); } _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0); _slots[slot] = new SlotData { LandblockId = landblockId, WorldOrigin = worldOrigin, FirstIndex = (uint)(slot * IndicesPerLandblock), IndexCount = IndicesPerLandblock, AabbMin = new Vector3(worldOrigin.X, worldOrigin.Y, zMin), AabbMax = new Vector3(worldOrigin.X + LandblockSize, worldOrigin.Y + LandblockSize, zMax), }; _idToSlot[landblockId] = slot; } public void RemoveLandblock(uint landblockId) { if (!_idToSlot.TryGetValue(landblockId, out var slot)) return; _idToSlot.Remove(landblockId); _slots[slot] = null; _alloc.Free(slot); // No GPU clear: the per-frame DEIC array won't reference this slot. } public void Draw(ICamera camera, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null) { if (_alloc.LoadedCount == 0) return; // Build visible slot list with per-slot frustum cull. _visibleSlots.Clear(); for (int slot = 0; slot < _slots.Length; slot++) { var data = _slots[slot]; if (data is null) continue; if (frustum is not null && data.LandblockId != neverCullLandblockId) { if (!FrustumCuller.IsAabbVisible(frustum.Value, data.AabbMin, data.AabbMax)) continue; } _visibleSlots.Add(slot); } if (_visibleSlots.Count == 0) return; // Build DEIC array. if (_deicScratch.Length < _visibleSlots.Count) _deicScratch = new DrawElementsIndirectCommand[Math.Max(_visibleSlots.Count, 64)]; for (int i = 0; i < _visibleSlots.Count; i++) { var data = _slots[_visibleSlots[i]]!; _deicScratch[i] = new DrawElementsIndirectCommand { Count = (uint)data.IndexCount, InstanceCount = 1u, FirstIndex = data.FirstIndex, BaseVertex = 0, // baked into indices on upload BaseInstance = 0, }; } // Grow indirect buffer if needed. if (_visibleSlots.Count > _indirectCapacity) { _indirectCapacity = Math.Max(64, _visibleSlots.Count * 2); _gl.BindBuffer(GLEnum.DrawIndirectBuffer, _indirectBuffer); _gl.BufferData(GLEnum.DrawIndirectBuffer, (nuint)(_indirectCapacity * sizeof(DrawElementsIndirectCommand)), null, GLEnum.DynamicDraw); } else { _gl.BindBuffer(GLEnum.DrawIndirectBuffer, _indirectBuffer); } // Upload DEIC array. fixed (DrawElementsIndirectCommand* p = _deicScratch) { _gl.BufferSubData(GLEnum.DrawIndirectBuffer, 0, (nuint)(_visibleSlots.Count * sizeof(DrawElementsIndirectCommand)), p); } // Bind shader + uniforms + atlas handles. _shader.Use(); _shader.SetMatrix4("uView", camera.View); _shader.SetMatrix4("uProjection", camera.Projection); var (terrainHandle, alphaHandle) = _atlas.GetBindlessHandles(); _bindless.SetSamplerHandleUniform(_shader.Program, _uTerrainLoc, terrainHandle); _bindless.SetSamplerHandleUniform(_shader.Program, _uAlphaLoc, alphaHandle); _gl.BindVertexArray(_globalVao); _gl.MemoryBarrier(MemoryBarrierMask.CommandBarrierBit); _gl.MultiDrawElementsIndirect( PrimitiveType.Triangles, DrawElementsType.UnsignedInt, (void*)0, (uint)_visibleSlots.Count, (uint)sizeof(DrawElementsIndirectCommand)); _gl.BindVertexArray(0); _gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0); } public void Dispose() { _gl.DeleteVertexArray(_globalVao); _gl.DeleteBuffer(_globalVbo); _gl.DeleteBuffer(_globalEbo); _gl.DeleteBuffer(_indirectBuffer); } // ---------------------------------------------------------------- // Private helpers // ---------------------------------------------------------------- private void AllocateGpuBuffers(int capacitySlots) { nuint vboBytes = (nuint)(capacitySlots * VertsPerLandblock * VertexSize); nuint eboBytes = (nuint)(capacitySlots * IndicesPerLandblock * IndexSize); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _globalVbo); _gl.BufferData(BufferTargetARB.ArrayBuffer, vboBytes, null, BufferUsageARB.DynamicDraw); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _globalEbo); _gl.BufferData(BufferTargetARB.ElementArrayBuffer, eboBytes, null, BufferUsageARB.DynamicDraw); _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0); } private void ConfigureVao() { _gl.BindVertexArray(_globalVao); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _globalVbo); _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _globalEbo); uint stride = (uint)VertexSize; // location 0: Position _gl.EnableVertexAttribArray(0); _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0); // location 1: Normal _gl.EnableVertexAttribArray(1); _gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float))); // locations 2-5: Data0..Data3 (uvec4 byte attributes) nint dataOffset = 6 * sizeof(float); _gl.EnableVertexAttribArray(2); _gl.VertexAttribIPointer(2, 4, VertexAttribIType.UnsignedByte, stride, (void*)dataOffset); _gl.EnableVertexAttribArray(3); _gl.VertexAttribIPointer(3, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 4)); _gl.EnableVertexAttribArray(4); _gl.VertexAttribIPointer(4, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 8)); _gl.EnableVertexAttribArray(5); _gl.VertexAttribIPointer(5, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 12)); _gl.BindVertexArray(0); } private void EnsureCapacity(int newCapacity) { if (newCapacity <= _alloc.Capacity) return; // Allocate new VBO + EBO at new size; copy old contents; swap; recreate VAO. uint newVbo = _gl.GenBuffer(); uint newEbo = _gl.GenBuffer(); nuint newVboBytes = (nuint)(newCapacity * VertsPerLandblock * VertexSize); nuint newEboBytes = (nuint)(newCapacity * IndicesPerLandblock * IndexSize); nuint oldVboBytes = (nuint)(_alloc.Capacity * VertsPerLandblock * VertexSize); nuint oldEboBytes = (nuint)(_alloc.Capacity * IndicesPerLandblock * IndexSize); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, newVbo); _gl.BufferData(BufferTargetARB.ArrayBuffer, newVboBytes, null, BufferUsageARB.DynamicDraw); _gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalVbo); _gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newVbo); _gl.CopyBufferSubData(CopyBufferSubDataTarget.CopyReadBuffer, CopyBufferSubDataTarget.CopyWriteBuffer, 0, 0, oldVboBytes); _gl.DeleteBuffer(_globalVbo); _globalVbo = newVbo; _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, newEbo); _gl.BufferData(BufferTargetARB.ElementArrayBuffer, newEboBytes, null, BufferUsageARB.DynamicDraw); _gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalEbo); _gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newEbo); _gl.CopyBufferSubData(CopyBufferSubDataTarget.CopyReadBuffer, CopyBufferSubDataTarget.CopyWriteBuffer, 0, 0, oldEboBytes); _gl.DeleteBuffer(_globalEbo); _globalEbo = newEbo; // Recreate VAO with new buffer bindings. _gl.DeleteVertexArray(_globalVao); _globalVao = _gl.GenVertexArray(); ConfigureVao(); // Grow slot tracking array. Array.Resize(ref _slots, newCapacity); _alloc.GrowTo(newCapacity); } private sealed class SlotData { public uint LandblockId; public Vector3 WorldOrigin; public uint FirstIndex; public int IndexCount; public Vector3 AabbMin; public Vector3 AabbMax; } } ``` - [ ] **Step 6.3: Add `SetSamplerHandleUniform` helper to BindlessSupport** The renderer calls `_bindless.SetSamplerHandleUniform(...)` which doesn't exist yet. Add it to `src/AcDream.App/Rendering/Wb/BindlessSupport.cs`: After the `MakeNonResident` method (around line 46), add: ```csharp /// /// Set a sampler-typed uniform from a 64-bit bindless handle. Uses /// glProgramUniformHandleARB so it doesn't require the program to be bound. /// public void SetSamplerHandleUniform(uint program, int location, ulong handle) { _ext.ProgramUniformHandle(program, location, handle); } ``` - [ ] **Step 6.4: Build green** Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo` Expected: 0 errors. (`Silk.NET.OpenGL.Extensions.ARB.ArbBindlessTexture` already provides `ProgramUniformHandle`.) If the Silk.NET method name differs (e.g. `ProgramUniformHandleARB` vs `ProgramUniformHandle`), check `using Silk.NET.OpenGL.Extensions.ARB;` IntelliSense and use the correct name. - [ ] **Step 6.5: Commit** ```bash git add src/AcDream.App/Rendering/TerrainModernRenderer.cs src/AcDream.App/Rendering/Wb/BindlessSupport.cs git commit -m "$(cat <<'EOF' phase(N.5b) Task 6: TerrainModernRenderer The new terrain dispatcher. Single global VBO/EBO with a slot allocator (one slot per landblock, 384 verts × 40 bytes per slot). Per-frame: build DEIC array from visible slots, upload, dispatch via glMultiDrawElementsIndirect. Atlas textures bound via bindless handles set per-frame as sampler uniforms. Total ~6-8 GL calls per frame for terrain regardless of visible landblock count (vs today's per-LB binds at radius=2 → ~25 calls, radius=5 → ~121 calls). API mirrors TerrainChunkRenderer so GameWindow integration in T8 is a drop-in field+ctor swap. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 7: TerrainModernConformanceTests **Goal:** Z-conformance sentinel for issue #51's bug class. Sweeps ~10 representative landblocks × ~100 sample points; asserts `|meshTriZ - TerrainSurface.SampleZFromHeightmap| < 0.001m`. **Files:** - Create: `tests/AcDream.Core.Tests/Terrain/TerrainModernConformanceTests.cs` **Independence note:** This test uses `LandblockMesh.Build` directly (the source-of-truth generator that `TerrainModernRenderer` consumes internally). The test runs without GL and is independent of T6 — it can land in parallel with T1, T2, T4, T5. - [ ] **Step 7.1: Read the existing `ClientConformanceTests.cs` for the dat-loading pattern** Run: `cat tests/AcDream.Core.Tests/Terrain/ClientConformanceTests.cs | head -80` This shows the existing pattern for loading dat heightmap data in tests. Use the same `DatCollection` setup + `Region` fetch pattern. - [ ] **Step 7.2: Write the conformance test** Create `tests/AcDream.Core.Tests/Terrain/TerrainModernConformanceTests.cs`: ```csharp using System; using System.Collections.Generic; using System.IO; using System.Numerics; using AcDream.Core.Physics; using AcDream.Core.Terrain; using DatReaderWriter; using DatReaderWriter.DBObjs; using Xunit; using Xunit.Abstractions; namespace AcDream.Core.Tests.Terrain; /// /// Phase N.5b Z-conformance sentinel: proves that the visual terrain mesh /// produced by agrees with the physics-side /// at arbitrary (X, Y) /// within 1 mm. This is the exact bug class issue #51 names — if a future /// refactor silently changes formula or vertex layout in either path, /// this test fires before the player floats above (or sinks below) the /// visible ground. /// public class TerrainModernConformanceTests { private readonly ITestOutputHelper _out; public TerrainModernConformanceTests(ITestOutputHelper output) => _out = output; private static readonly (string name, uint lbX, uint lbY)[] RepresentativeLandblocks = { ("Holtburg flat 0xA9B0", 0xA9, 0xB0), ("Holtburg sloped 0xA9B1", 0xA9, 0xB1), ("Foundry-area 0x8080", 0x80, 0x80), ("Cragstone 0xCB99", 0xCB, 0x99), ("Direlands sample 0xC040", 0xC0, 0x40), ("MapOrigin 0x0000", 0x00, 0x00), ("Mid-map 0x7F7F", 0x7F, 0x7F), ("MapCorner 0xFEFE", 0xFE, 0xFE), ("Subway outdoor 0x0185", 0x01, 0x85), ("North continent 0x4D96", 0x4D, 0x96), // worst-case landblock from divergence test }; [Fact] public void VisualMeshZ_AgreesWith_PhysicsZ_WithinOneMillimeter() { var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Documents", "Asheron's Call"); if (!Directory.Exists(datDir)) { _out.WriteLine($"SKIP: dat directory not found at {datDir}"); return; } using var dats = new DatCollection(datDir); var region = dats.Get(0x13000000u); Assert.NotNull(region); var heightTable = region.LandDefs.LandHeightTable; long totalSamples = 0; long totalLandblocksTested = 0; double maxDelta = 0; (string name, uint lbX, uint lbY, float lx, float ly, float meshZ, float physicsZ) worstCase = default; var rng = new Random(seed: 42); // fixed seed for reproducible sample distribution foreach (var (name, lbX, lbY) in RepresentativeLandblocks) { uint landblockId = (lbX << 24) | (lbY << 16) | 0xFFFFu; var landblock = dats.Get(landblockId); if (landblock is null) { _out.WriteLine($" skipped {name}: dat not found (probably water-only)"); continue; } totalLandblocksTested++; // Compute mesh via the source-of-truth generator. Empty surfaceCache // is fine — test only cares about vertex Z values. var ctx = TerrainBlendingContext.Empty; // see Note below if this constructor doesn't exist var surfaceCache = new Dictionary(); var meshData = LandblockMesh.Build(landblock, lbX, lbY, heightTable, ctx, surfaceCache); // Sample 100 (localX, localY) points uniformly + edge cases. for (int s = 0; s < 100; s++) { float lx = (float)rng.NextDouble() * 192f; float ly = (float)rng.NextDouble() * 192f; float meshZ = SampleMeshZ(meshData, lx, ly); float physicsZ = TerrainSurface.SampleZFromHeightmap( landblock.Height, heightTable, lbX, lbY, lx, ly); double delta = Math.Abs(meshZ - physicsZ); if (delta > maxDelta) { maxDelta = delta; worstCase = (name, lbX, lbY, lx, ly, meshZ, physicsZ); } totalSamples++; Assert.True(delta < 0.001, $"Mesh Z disagrees with physics Z at lb=0x{lbX:X2}{lbY:X2} ({name}) " + $"local=({lx:F2},{ly:F2}): meshZ={meshZ:F4} physicsZ={physicsZ:F4} delta={delta:F4}m"); } } _out.WriteLine($"=== Phase N.5b conformance sweep ==="); _out.WriteLine($"Landblocks tested: {totalLandblocksTested}/{RepresentativeLandblocks.Length}"); _out.WriteLine($"Total samples: {totalSamples}"); _out.WriteLine($"Max |delta|: {maxDelta * 1000:F4} mm (tolerance: 1.0 mm)"); if (totalSamples > 0) _out.WriteLine($"Worst case: {worstCase.name} local=({worstCase.lx:F2},{worstCase.ly:F2}) " + $"meshZ={worstCase.meshZ:F4} physicsZ={worstCase.physicsZ:F4}"); Assert.True(totalLandblocksTested >= 5, $"Expected at least 5 representative landblocks loadable; got {totalLandblocksTested}."); } /// /// Sample the mesh's triangle-interpolated Z at (localX, localY). Walks /// the mesh's triangles (3 indices each), tests point-in-triangle in 2D, /// and barycentric-interpolates Z from the matching triangle's three Zs. /// private static float SampleMeshZ(LandblockMeshData mesh, float lx, float ly) { for (int triBase = 0; triBase < mesh.Indices.Length; triBase += 3) { var v0 = mesh.Vertices[mesh.Indices[triBase + 0]]; var v1 = mesh.Vertices[mesh.Indices[triBase + 1]]; var v2 = mesh.Vertices[mesh.Indices[triBase + 2]]; // Barycentric coords for (lx, ly) wrt triangle v0/v1/v2 in 2D. float denom = (v1.Position.Y - v2.Position.Y) * (v0.Position.X - v2.Position.X) + (v2.Position.X - v1.Position.X) * (v0.Position.Y - v2.Position.Y); if (Math.Abs(denom) < 1e-9f) continue; float a = ((v1.Position.Y - v2.Position.Y) * (lx - v2.Position.X) + (v2.Position.X - v1.Position.X) * (ly - v2.Position.Y)) / denom; float b = ((v2.Position.Y - v0.Position.Y) * (lx - v2.Position.X) + (v0.Position.X - v2.Position.X) * (ly - v2.Position.Y)) / denom; float c = 1f - a - b; // Inside test with epsilon for boundary stability. const float eps = 1e-4f; if (a >= -eps && b >= -eps && c >= -eps) return a * v0.Position.Z + b * v1.Position.Z + c * v2.Position.Z; } // Should not happen for valid mesh + in-bounds (lx, ly). throw new InvalidOperationException( $"No triangle found containing local=({lx:F2},{ly:F2}); mesh has {mesh.Indices.Length / 3} triangles."); } } ``` **Note on `TerrainBlendingContext.Empty`:** if this static doesn't exist, construct a minimal one: ```csharp var ctx = new TerrainBlendingContext( terrainTypeToLayer: new Dictionary(), cornerAlphaLayers: Array.Empty(), sideAlphaLayers: Array.Empty(), roadAlphaLayers: Array.Empty(), cornerAlphaTCodes: Array.Empty(), sideAlphaTCodes: Array.Empty(), roadAlphaRCodes: Array.Empty(), roadLayer: SurfaceInfo.None); ``` (Check `src/AcDream.Core/Terrain/TerrainBlendingContext.cs` for the actual signature.) - [ ] **Step 7.3: Run the conformance test** Run: `dotnet test --filter "FullyQualifiedName~TerrainModernConformanceTests" --nologo --logger "console;verbosity=detailed"` Expected outcomes: - If dat dir present: PASS with `Max |delta|: <1.0 mm` printed. - If dat dir absent: PASS with `SKIP: dat directory not found` (test gracefully skips). If the test FAILS with a delta > 1mm, the visual mesh and physics surface have drifted — investigate before proceeding. - [ ] **Step 7.4: Commit** ```bash git add tests/AcDream.Core.Tests/Terrain/TerrainModernConformanceTests.cs git commit -m "$(cat <<'EOF' phase(N.5b) Task 7: TerrainModernConformanceTests Z-conformance sentinel for issue #51's bug class. Sweeps 10 representative landblocks × 100 sample points (uniform random in local 0..192 with fixed seed). For each point: compute meshTriZ via barycentric interpolation in the matching triangle of the LandblockMesh.Build output; compute physicsZ via TerrainSurface.SampleZFromHeightmap; assert |delta| < 0.001m. Catches any silent formula or vertex-layout drift between the visual and physics paths. Skips gracefully if ACDREAM_DAT_DIR isn't set (CI without dat data). Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 8: GameWindow integration **Goal:** Swap `TerrainChunkRenderer` → `TerrainModernRenderer` at the field declaration + construction site. Wire `[TERRAIN-DIAG]` rollup callback. **Files:** - Modify: `src/AcDream.App/Rendering/GameWindow.cs` **Depends on:** Task 6. - [ ] **Step 8.1: Locate the field + ctor + diag wiring** ```bash grep -n "TerrainChunkRenderer\|_terrain" src/AcDream.App/Rendering/GameWindow.cs | head -20 ``` The field declaration is at line 21; the ctor is at line 1391. The diag rollup pattern lives near the existing `[WB-DIAG]` writes — search for `WB-DIAG`. - [ ] **Step 8.2: Swap field type** In `src/AcDream.App/Rendering/GameWindow.cs:21`, change: ```csharp private TerrainChunkRenderer? _terrain; ``` to: ```csharp private TerrainModernRenderer? _terrain; ``` - [ ] **Step 8.3: Swap ctor call (and pass BindlessSupport to TerrainAtlas)** At line 1391: ```csharp _terrain = new TerrainChunkRenderer(_gl, _shader, terrainAtlas); ``` Becomes: ```csharp _terrain = new TerrainModernRenderer(_gl, _bindless, _terrainModernShader, terrainAtlas); ``` (The `_bindless` field already exists from N.5; the shader field name may need to be created/loaded — see step 8.4.) You also need to ensure `terrainAtlas` was constructed with `BindlessSupport`. Find the `TerrainAtlas.Build(gl, dats)` call upstream and change to `TerrainAtlas.Build(gl, dats, _bindless)`. - [ ] **Step 8.4: Load the new shader** Find where `terrain.vert/.frag` are currently loaded into a `Shader` object. Add a parallel load for `terrain_modern.vert/.frag` into a new `_terrainModernShader` field. Pattern should mirror how `mesh_modern` shaders were loaded in N.5 (search GameWindow for `mesh_modern` to find the template). - [ ] **Step 8.5: Add `[TERRAIN-DIAG]` rollup** Find where `[WB-DIAG]` is logged. Add a parallel `[TERRAIN-DIAG]` line: ```csharp Console.WriteLine( $"[TERRAIN-DIAG] cpu_ms={terrainCpuMedianMs:F2}/{terrainCpu95thMs:F2} " + $"draws={_terrain?.VisibleSlots ?? 0}/frame " + $"visible={_terrain?.VisibleSlots ?? 0} " + $"loaded={_terrain?.LoadedSlots ?? 0} " + $"capacity={_terrain?.CapacitySlots ?? 0}"); ``` To capture `terrainCpuMedianMs` / `terrainCpu95thMs`, wrap the `_terrain.Draw(...)` call in a `Stopwatch` and accumulate samples into a 5-second rolling buffer. Mirror the existing `[WB-DIAG]` accumulator (search GameWindow for `Stopwatch` + `cpu_ms`). - [ ] **Step 8.6: Build + run the client** Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo` Expected: 0 errors. Launch the client (PowerShell): ```powershell $env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" $env:ACDREAM_LIVE = "1" $env:ACDREAM_TEST_HOST = "127.0.0.1" $env:ACDREAM_TEST_PORT = "9000" $env:ACDREAM_TEST_USER = "testaccount" $env:ACDREAM_TEST_PASS = "testpassword" $env:ACDREAM_WB_DIAG = "1" dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath launch.log ``` Wait ~10 seconds for in-world. Confirm: - Terrain renders (no black ground) - `launch.log` contains `[TERRAIN-DIAG]` lines If terrain is black or missing, check: - `[WB-DIAG]` — bindless capability detected? - Atlas handle nonzero? - `glGetError()` after `glMultiDrawElementsIndirect`? - [ ] **Step 8.7: Commit (initial integration; visual gate is next)** ```bash git add src/AcDream.App/Rendering/GameWindow.cs git commit -m "$(cat <<'EOF' phase(N.5b): wire TerrainModernRenderer into GameWindow Swap TerrainChunkRenderer → TerrainModernRenderer (drop-in: same AddLandblock/RemoveLandblock/Draw interface). Pass BindlessSupport to TerrainAtlas.Build so GetBindlessHandles() is callable. Load the new terrain_modern shader pair and pass to the renderer ctor. Add [TERRAIN-DIAG] rollup mirroring the existing [WB-DIAG] pattern. Visual verification at four scenes (Holtburg flat + sloped, Foundry, sloped landblock) is the next gate. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## USER VERIFICATION GATE — visual checks **Block here. Do not proceed to T9/T10 until the user confirms all checks at all four scenes.** User runs the client per the launch command in step 8.6, drives the character through: 1. **Holtburg town** (~0xA9B0) 2. **Holtburg sloped landblock** (~0xA9B1) 3. **Foundry-area** (~0x80xx) 4. **Any visibly-sloped outdoor landblock** At each scene confirm: 1. ✓ No cell-boundary wobble (load-bearing #51 sentinel) 2. ✓ No missing chunks / black holes 3. ✓ No texture seams at landblock edges 4. ✓ No z-fighting 5. ✓ `[TERRAIN-DIAG] visible=N` consistent with scene; renderer visibly using indirect dispatch (no per-LB calls) 6. ✓ `[TERRAIN-DIAG] cpu_ms` at radius=5 ≥10% lower than the recorded baseline If any check fails, fix in place, re-verify, repeat. Only after **all six checks pass at all four scenes** proceed to Tasks 9 + 10. --- ## Task 9: Delete legacy **Goal:** Remove the now-unused `TerrainChunkRenderer`, `TerrainRenderer`, and the old shader files. **Files:** - Delete: `src/AcDream.App/Rendering/TerrainChunkRenderer.cs` - Delete: `src/AcDream.App/Rendering/TerrainRenderer.cs` - Delete: `src/AcDream.App/Rendering/Shaders/terrain.vert` - Delete: `src/AcDream.App/Rendering/Shaders/terrain.frag` - [ ] **Step 9.1: Delete the files** ```bash git rm src/AcDream.App/Rendering/TerrainChunkRenderer.cs git rm src/AcDream.App/Rendering/TerrainRenderer.cs git rm src/AcDream.App/Rendering/Shaders/terrain.vert git rm src/AcDream.App/Rendering/Shaders/terrain.frag ``` - [ ] **Step 9.2: Build green (verify nothing else referenced these)** Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo` Expected: 0 errors. If references break in unexpected places, restore the files (`git checkout HEAD -- ...`) and find/delete the references first, then re-attempt. - [ ] **Step 9.3: Run the full N.5 + N.5b test filter to confirm nothing regressed** Run: ```bash dotnet test --filter "FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~TerrainBlending|FullyQualifiedName~LandblockMesh|FullyQualifiedName~SplitFormulaDivergence" --nologo ``` Expected: all green. - [ ] **Step 9.4: Commit** ```bash git commit -m "$(cat <<'EOF' phase(N.5b): retire legacy terrain renderers Deletes: - TerrainChunkRenderer.cs (454 lines, replaced by TerrainModernRenderer) - TerrainRenderer.cs (247 lines, older sibling, no production users) - terrain.vert / terrain.frag (replaced by terrain_modern.{vert,frag}) The modern path is now the only path. Mirror N.5's mandatory-modern amendment: missing GL_ARB_bindless_texture throws NotSupportedException at startup (already in place via the BindlessSupport.TryCreate gate). Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 10: Roadmap + ISSUES + memory + perf baseline **Goal:** Close out the phase. Update the roadmap, close issue #51, write the memory file, capture perf numbers in a baseline doc. **Files:** - Modify: `docs/plans/2026-04-11-roadmap.md` - Modify: `docs/ISSUES.md` - Modify: `CLAUDE.md` - Create: `docs/plans/2026-05-09-phase-n5b-perf-baseline.md` - Create: `~/.claude/projects/C--Users-erikn-source-repos-acdream/memory/project_phase_n5b_state.md` - [ ] **Step 10.1: Roadmap entry** Read `docs/plans/2026-04-11-roadmap.md`. Add an N.5b row to the "Shipped" table (mirror the N.5 row's format). Remove "terrain on modern path" from the N.6 scope notes. - [ ] **Step 10.2: Close issue #51** In `docs/ISSUES.md`, move issue #51 from the OPEN section to "Recently closed" with the SHIP commit SHA. Note: the resolution was Path C (kept retail's formula via `LandblockMesh.Build`; never adopted WB's formula). - [ ] **Step 10.3: Update CLAUDE.md "WB integration cribs"** Add an entry under the existing "WB integration cribs" bullet list: ```markdown - `src/AcDream.App/Rendering/TerrainModernRenderer.cs` — terrain dispatcher on N.5's modern primitives. Mirrors WB's `TerrainRenderManager` pattern (single global VBO/EBO + slot allocator + `glMultiDrawElementsIndirect`) but driven by acdream's `LandblockMesh.Build` so retail's `FSplitNESW` formula is preserved (issue #51). ~6-8 GL calls/frame for terrain regardless of scene size. ``` - [ ] **Step 10.4: Write the perf baseline doc** Create `docs/plans/2026-05-09-phase-n5b-perf-baseline.md` with the before/after numbers from the user verification gate: ```markdown # Phase N.5b — terrain perf baseline ## Test scene - Holtburg town (~0xA9B0), radius=5, default settings. - Captured 5-second `[TERRAIN-DIAG]` rollup median + 95th. ## Before (TerrainChunkRenderer) - Terrain GL calls / frame: - CPU dispatcher cpu_ms median: - CPU dispatcher cpu_ms 95th : ## After (TerrainModernRenderer) - Terrain GL calls / frame: - CPU dispatcher cpu_ms median: - CPU dispatcher cpu_ms 95th : ## Reduction - GL calls: (~Z% reduction) - CPU median: ms → ms (~Z% reduction) ## Acceptance - Acceptance criterion 5 (≥10% CPU reduction at radius=5): ``` - [ ] **Step 10.5: Write the memory file** Create `~/.claude/projects/C--Users-erikn-source-repos-acdream/memory/project_phase_n5b_state.md`: ```markdown --- name: "Project: Phase N.5b state (shipped 2026-MM-DD)" description: N.5b lifted terrain rendering onto bindless + multi-draw indirect via Path C (WB's renderer pattern, acdream's LandblockMesh.Build for retail formula compliance). ~6-8 GL calls/frame for terrain. Closes issue #51. type: project --- **Phase N.5b — Terrain on the Modern Rendering Path — shipped 2026-MM-DD.** `TerrainModernRenderer` replaces `TerrainChunkRenderer` (deleted along with `TerrainRenderer` + `terrain.vert/.frag`). Single global VBO/EBO with slot allocator (one slot per landblock); per-frame DEIC array upload + `glMultiDrawElementsIndirect`; bindless atlas handles set per-frame as sampler uniforms. **Path C** (chosen during brainstorm): mirror WB's renderer pattern but consume `LandblockMesh.Build` (which uses retail's `FSplitNESW` formula). Path A killed by 49.98% measured divergence between WB's formula and retail's at retail addr `00531d10`. Path B (fork-patch WB) rejected for permanent maintenance burden. Closes issue #51 (visual ↔ physics terrain Z agreement). **Why:** N.5b completes the rendering modernization for outdoor content. Together with N.5 entity rendering, every visible gameplay-area surface now flows through `glMultiDrawElementsIndirect`. EnvCells (interiors), sky, particles still on legacy renderers pending later phases. **How to apply:** when working on terrain rendering, the modern path is now the only path. The split formula is locked to retail's `FSplitNESW` via `TerrainBlending.CalculateSplitDirection`; do NOT substitute WB's `TerrainUtils.CalculateSplitDirection` (49.98% wrong per the divergence test). ## Gotchas surfaced during N.5b implementation (Fill in any high-value, non-obvious lessons that surfaced during implementation. If nothing surfaced beyond what N.5's gotchas already cover, note that explicitly.) ``` Then add a one-line entry to the memory index at `~/.claude/projects/C--Users-erikn-source-repos-acdream/memory/MEMORY.md`: ```markdown - [Project: Phase N.5b state](project_phase_n5b_state.md) — N.5b SHIPPED YYYY-MM-DD. Terrain on bindless + multi-draw indirect via Path C. Closes #51. ``` - [ ] **Step 10.6: Final SHIP commit** ```bash git add docs/plans/2026-04-11-roadmap.md docs/ISSUES.md CLAUDE.md docs/plans/2026-05-09-phase-n5b-perf-baseline.md # Memory file is outside the repo, skip git for it git commit -m "$(cat <<'EOF' phase(N.5b): SHIP — terrain on modern rendering path TerrainModernRenderer replaces TerrainChunkRenderer + TerrainRenderer. Single global VBO/EBO + slot allocator + glMultiDrawElementsIndirect + bindless atlas handles. ~6-8 GL calls/frame for terrain regardless of scene size. Path C: WB renderer pattern + acdream's LandblockMesh.Build (retail's FSplitNESW formula preserved per #51). Path A killed by 49.98% measured divergence vs retail; Path B (fork-patch WB) rejected for maintenance burden. Perf at radius=5 (Holtburg): . See docs/plans/2026-05-09-phase-n5b-perf-baseline.md. Visual verification: confirmed at 4 outdoor scenes (Holtburg flat + sloped, Foundry-area, sloped landblock). No cell-boundary wobble. Closes issue #51. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Self-review checklist After all tasks land, sanity-check: - [x] Build green: `dotnet build` - [x] All N.5 + N.5b tests green: 114/114 in the filter (Wb, MatrixComposition, TextureCacheBindless, TerrainSlot, TerrainModernConformance, TerrainBlending, LandblockMesh, SplitFormulaDivergence) - [x] Visual verification: terrain renders correctly in modern path (after the black-terrain hotfix at `da56063`) - [x] Issue #51 closed in `docs/ISSUES.md` (T10 commit `083c10c`) - [x] Roadmap shows N.5b in "Shipped" (T10 commit `083c10c`) - [x] Memory file written (`memory/project_phase_n5b_state.md` outside repo) - [x] Perf baseline doc has real before/after numbers (`docs/plans/2026-05-09-phase-n5b-perf-baseline.md`) - [N/A] **CPU dispatcher reduction ≥10% at radius=5** — captured measurement showed modern is ~4× SLOWER on CPU at radius=5 in Holtburg. The chunked legacy renderer collapsed radius=5 to one `glDrawElements` call, so the multi-draw indirect savings don't apply at this scene size. **Acceptance criterion #5 is amended via the perf baseline doc**: ship N.5b on visual identity + structural correctness rather than CPU savings. Architectural wins (zero `glBindTexture`/frame; constant-cost dispatch as A.5 raises radius) are real but only manifest at higher scene complexity. --- ## SHIP record — 2026-05-09 **Phase N.5b — Terrain on the Modern Rendering Path — SHIPPED.** ### Commit chain ``` 083c10c docs(N.5b T10): roadmap + ISSUES + CLAUDE.md + perf baseline updates 7dfa2af phase(N.5b): retire legacy terrain renderers da56063 fix(N.5b): black terrain — switch to uvec2 handle + sampler constructor 55e516c fix(N.5b T8): TerrainDiagMedian/P95 IndexOutOfRangeException on first flush 336ad34 chore(N.5b): TEMPORARY perf benchmark toggle for legacy↔modern terrain 75913c1 phase(N.5b): wire TerrainModernRenderer into GameWindow 3418f65 fix(N.5b T6): index-length validation + document VertsPerLandblock %6 invariant 0a77bd1 phase(N.5b) Task 6: TerrainModernRenderer 4ed7920 fix(N.5b T7): tighten conformance sample upper bound to 191.975f e54d5ca phase(N.5b) Task 7: TerrainModernConformanceTests 1ea00a0 phase(N.5b) Task 5: terrain_modern.frag 3c108a0 phase(N.5b) Task 4: terrain_modern.vert ba85299 phase(N.5b) Task 2: TerrainSlotAllocator + tests db0f010 phase(N.5b) Task 1: TerrainAtlas bindless extension 79367d4 plan(N.5b): implementation plan for terrain on modern path b35ddf3 spec(N.5b): design for terrain on the modern rendering path 47f2cea test(N.5b): quantify WB vs retail terrain split formula divergence ``` ### Captured perf numbers (radius=5, Holtburg town dueling field, 5+ rollups) | Renderer | cpu_us median | cpu_us p95 | draws/frame | Visible LBs | Loaded LBs | |---|---|---|---|---|---| | **Legacy** (`TerrainChunkRenderer`) | 1.5 | 3.0 | 1 (single chunk) | 132-143 (chunk grain) | 121-143 | | **Modern** (`TerrainModernRenderer`) | 6.4-7.0 | 9-14 | ~36-51 | 36-51 (per-LB cull) | 132-143 | Modern is ~4× slower on CPU at radius=5 because legacy's 16×16-LBs-per-chunk pattern already collapsed radius=5 to one `glDrawElements` call. The architectural wins (bindless atlas → zero `glBindTexture`/frame; constant-cost dispatch as radius grows) manifest at higher scene complexity (A.5 territory). Full writeup: `docs/plans/2026-05-09-phase-n5b-perf-baseline.md`. ### Plan amendments captured during execution | Task | Original framing | Issue | Resolution | |---|---|---|---| | 6 | "≥6-8 GL calls per frame for terrain" | Counted matrix-uniform calls would push it higher | Doc-comment overstated; actual ~13 GL calls/frame in modern. Architectural shape (one MDI per pass) preserved. Captured in T6 code review. | | 7 | Sample upper bound `* 192f` | Physics path clamps `localX/24` at 7.999 → effective 191.976. Sample > 191.976 makes physics + mesh disagree by up to 23 mm. | Tightened to `* 191.975f`. Verified test still passes (max ‖Δ‖ = 0.015 mm). | | 8 | "GL_TIME_ELAPSED query around the indirect dispatch" | Same single-frame poll bug as N.5 (`QueryResultAvailable=1` never appears) | Deferred GPU timer to N.6 perf polish, same as N.5. CPU stopwatch only for N.5b. | | 8 | Acceptance criterion 5: "≥10% lower CPU dispatcher" | At radius=5 / Holtburg, legacy was already ~1.5µs (one draw call); modern's per-frame slot-walk + DEIC build can't beat that | Criterion amended via perf baseline doc; ship N.5b on visual identity + structural correctness. | ### Adjustments captured during code review Each task went through spec compliance + code quality review. Notable adjustments: - T1 fixup: two-phase `Dispose` ordering (ALL `MakeNonResident` first, then ALL `DeleteTexture`) per ARB_bindless_texture spec. - T6 fixups (Important): `meshData.Indices.Length` validation in `AddLandblock`; documented `VertsPerLandblock % 6 == 0` load-bearing invariant for the shader's `gl_VertexID % 6` corner-table lookup. - T7 fixup (Important): tightened sample upper bound to `191.975f` to avoid the physics-clamp-vs-mesh-actual-position disagreement. ### Hotfixes after T8 ship T8 shipped with two latent bugs that surfaced during the perf-baseline measurement run: - `55e516c` — `MaybeFlushTerrainDiag` median calc underflow (`copy[N - nz/2]` → `copy[N]` when nz=1). - `da56063` — **black terrain in modern path.** Root cause: `uniform sampler2DArray` + `glProgramUniformHandleARB` is rejected with `GL_INVALID_OPERATION` on the NVIDIA Windows driver. Switched to N.5's mesh_modern pattern: `uniform uvec2 uTerrainHandle` + `sampler2DArray(handle)` constructor at use sites. The black-terrain bug ALSO surfaced a process flaw: the user-verification gate was claimed "passed" without actual visual confirmation. The bug masked itself for hours of perf-measurement work. Memory captures this as a third high-value gotcha for future phases. ### Out-of-scope — N.6 follow-ups - **GPU timer query double-buffering** — same as N.5; bring up alongside N.5's deferred fix. - **Persistent-mapped indirect buffer** — eliminates per-frame `glBufferSubData(DRAW_INDIRECT_BUFFER)`. Likely small win at radius=5 (~1KB upload), bigger at higher radii. - **GPU-side culling** (compute shader writing the DEIC array directly) — eliminates the CPU slot walk + DEIC build. N.6 or later. - **Re-baseline at higher radius** — once A.5 raises the streaming radius, the architectural wins of multi-draw indirect should manifest. Capture fresh perf numbers there. ### Memory `project_phase_n5b_state.md` captures three high-value gotchas for future bindless work: 1. `uniform sampler2DArray` + `glProgramUniformHandleARB` is unreliable; default to uvec2 handle + sampler-from-handle constructor. 2. Median-calc with `nz/2` underflows to out-of-range when nz<2; use `(nz-1)/2` form. 3. Visual-gate "go" doesn't equal "verified" — require actual visual confirmation, not just assent. ### Files added or deleted summary **Added:** - `src/AcDream.App/Rendering/TerrainModernRenderer.cs` - `src/AcDream.Core/Terrain/TerrainSlotAllocator.cs` - `src/AcDream.App/Rendering/Shaders/terrain_modern.vert` - `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` - `tests/AcDream.Core.Tests/Terrain/TerrainSlotAllocatorTests.cs` - `tests/AcDream.Core.Tests/Terrain/TerrainModernConformanceTests.cs` - `tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs` - `docs/plans/2026-05-09-phase-n5b-perf-baseline.md` - `docs/superpowers/specs/2026-05-09-phase-n5b-terrain-modern-design.md` - `docs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md` (this file) **Modified:** - `src/AcDream.App/Rendering/TerrainAtlas.cs` — bindless extension - `src/AcDream.App/Rendering/Wb/BindlessSupport.cs` — note about retired SetSamplerHandleUniform helper - `src/AcDream.App/Rendering/GameWindow.cs` — TerrainModernRenderer wiring + [TERRAIN-DIAG] rollup, then T9 cleanup - `CLAUDE.md` — N.5b entry in WB integration cribs - `docs/plans/2026-04-11-roadmap.md` — N.5b → Shipped - `docs/ISSUES.md` — issue #51 → Recently closed **Deleted:** - `src/AcDream.App/Rendering/TerrainChunkRenderer.cs` - `src/AcDream.App/Rendering/TerrainRenderer.cs` - `src/AcDream.App/Rendering/Shaders/terrain.vert` - `src/AcDream.App/Rendering/Shaders/terrain.frag`