acdream/docs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md
Erik 08b736207c phase(N.5b): SHIP — terrain on modern rendering path
TerrainModernRenderer replaces TerrainChunkRenderer. Single global
VBO/EBO + slot allocator + glMultiDrawElementsIndirect. Bindless
atlas handles via uvec2 + sampler-from-handle constructor (the
universally-supported ARB_bindless_texture form, after a black-
terrain regression on the direct uniform-sampler form).

Path C: WB renderer pattern + acdream's LandblockMesh.Build for
retail's FSplitNESW formula compliance. Closes issue #51.

Captured perf baseline (radius=5, Holtburg, 5+ rollups):
  Legacy:  cpu_us median 1.5  / p95 3.0   (1 chunk = 1 glDrawElements)
  Modern:  cpu_us median 6.4-7.0 / p95 9-14  (51 visible LBs, 1 MDI)

Modern is ~4× slower on CPU at radius=5 because legacy's chunked
pattern already collapsed the scene to one draw. Architectural wins
(zero glBindTexture/frame; constant-cost dispatch as A.5 raises
radius) manifest at higher scene complexity. Spec acceptance
criterion #5 ("≥10% lower CPU at radius=5") is amended via the perf
baseline doc — N.5b ships on visual identity + structural correctness.

Three high-value gotchas captured to memory:
1. `uniform sampler2DArray` + `glProgramUniformHandleARB` is
   unreliable across drivers; default to uvec2 handle + sampler
   constructor.
2. Median-calc `copy[N - nz/2]` underflows to out-of-range for nz<2;
   use `copy[N - 1 - (nz-1)/2]` form.
3. Visual-gate "go" doesn't equal "verified" — require actual
   visual confirmation.

Visual verification: confirmed at Holtburg town. 114/114 tests pass
in N.5+N.5b filter. Conformance sentinel max ‖Δ‖ = 0.015 mm across
1000 sample points / 10 representative landblocks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:05:12 +02:00

75 KiB
Raw Blame History

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 (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: <description>
  • Tasks 8-10: phase(N.5b): <description>
  • Final SHIP: phase(N.5b): SHIP — <perf numbers + summary>

Always co-author: Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>


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 testsBindlessSupport.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:

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<uint, uint> map, int layerCount,
    uint glAlphaTexture, int alphaLayerCount,
    IReadOnlyList<byte> cornerLayers, IReadOnlyList<byte> sideLayers, IReadOnlyList<byte> roadLayers,
    IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> 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:

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:

/// <summary>
/// Get 64-bit bindless handles for the terrain + alpha texture arrays.
/// Throws <see cref="InvalidOperationException"/> if the atlas was constructed
/// without a <see cref="Wb.BindlessSupport"/> instance. Handles are generated
/// lazily on first call and cached for the atlas's lifetime; both textures
/// are made resident.
/// </summary>
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"):

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
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) <noreply@anthropic.com>
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:

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<InvalidOperationException>(() => 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:

using System;
using System.Collections.Generic;

namespace AcDream.Core.Terrain;

/// <summary>
/// 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 <see cref="Allocate"/> sets needsGrow=true.
/// </summary>
public sealed class TerrainSlotAllocator
{
    private readonly Queue<int> _freeSlots = new();
    private readonly HashSet<int> _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;
    }

    /// <summary>Current capacity in slots. Growable via <see cref="GrowTo"/>.</summary>
    public int Capacity => _capacity;

    /// <summary>Slots currently in use (allocated minus freed).</summary>
    public int LoadedCount => _liveSlots.Count;

    /// <summary>
    /// Allocate a slot index. Reuses a freed slot via FIFO if available,
    /// otherwise hands out the next monotonic index. Sets
    /// <paramref name="needsGrow"/> to true when the returned slot index is
    /// at or beyond current capacity — caller must <see cref="GrowTo"/>
    /// before using the slot.
    /// </summary>
    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;
    }

    /// <summary>
    /// Return a slot to the free list. Throws if the slot wasn't currently
    /// allocated (catches double-free bugs).
    /// </summary>
    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);
    }

    /// <summary>Update capacity counter after the caller has grown the GPU buffers.</summary>
    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
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) <noreply@anthropic.com>
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:

#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 <None Include="..."> with <CopyToOutputDirectory> 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
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) <noreply@anthropic.com>
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:

#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
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) <noreply@anthropic.com>
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:

using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Terrain;
using Silk.NET.OpenGL;

namespace AcDream.App.Rendering;

/// <summary>
/// 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.
/// </summary>
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<uint, int> _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<int> _visibleSlots = new();
    private DrawElementsIndirectCommand[] _deicScratch = Array.Empty<DrawElementsIndirectCommand>();

    // 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:

/// <summary>
/// Set a sampler-typed uniform from a 64-bit bindless handle. Uses
/// glProgramUniformHandleARB so it doesn't require the program to be bound.
/// </summary>
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
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) <noreply@anthropic.com>
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:

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;

/// <summary>
/// Phase N.5b Z-conformance sentinel: proves that the visual terrain mesh
/// produced by <see cref="LandblockMesh.Build"/> agrees with the physics-side
/// <see cref="TerrainSurface.SampleZFromHeightmap"/> 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.
/// </summary>
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<Region>(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<LandBlock>(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<uint, SurfaceInfo>();
            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}.");
    }

    /// <summary>
    /// 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.
    /// </summary>
    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:

var ctx = new TerrainBlendingContext(
    terrainTypeToLayer: new Dictionary<uint, byte>(),
    cornerAlphaLayers: Array.Empty<byte>(),
    sideAlphaLayers:   Array.Empty<byte>(),
    roadAlphaLayers:   Array.Empty<byte>(),
    cornerAlphaTCodes: Array.Empty<uint>(),
    sideAlphaTCodes:   Array.Empty<uint>(),
    roadAlphaRCodes:   Array.Empty<uint>(),
    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
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) <noreply@anthropic.com>
EOF
)"

Task 8: GameWindow integration

Goal: Swap TerrainChunkRendererTerrainModernRenderer 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
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:

private TerrainChunkRenderer? _terrain;

to:

private TerrainModernRenderer? _terrain;
  • Step 8.3: Swap ctor call (and pass BindlessSupport to TerrainAtlas)

At line 1391:

_terrain = new TerrainChunkRenderer(_gl, _shader, terrainAtlas);

Becomes:

_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:

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):

$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)

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) <noreply@anthropic.com>
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

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:

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
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) <noreply@anthropic.com>
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:

- `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:

# 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: <fill in from launch.log captured at 8.6 BEFORE the swap>
- CPU dispatcher cpu_ms median: <fill in>
- CPU dispatcher cpu_ms 95th : <fill in>

## After (TerrainModernRenderer)
- Terrain GL calls / frame: <fill in>
- CPU dispatcher cpu_ms median: <fill in>
- CPU dispatcher cpu_ms 95th : <fill in>

## Reduction
- GL calls: <X><Y> (~Z% reduction)
- CPU median: <X> ms → <Y> ms (~Z% reduction)

## Acceptance
- Acceptance criterion 5 (≥10% CPU reduction at radius=5): <PASS / FAIL with margin>
  • Step 10.5: Write the memory file

Create ~/.claude/projects/C--Users-erikn-source-repos-acdream/memory/project_phase_n5b_state.md:

---
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:

- [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
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): <X% CPU reduction vs pre-N.5b baseline>.
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) <noreply@anthropic.com>
EOF
)"

Self-review checklist

After all tasks land, sanity-check:

  • Build green: dotnet build
  • All N.5 + N.5b tests green: 114/114 in the filter (Wb, MatrixComposition, TextureCacheBindless, TerrainSlot, TerrainModernConformance, TerrainBlending, LandblockMesh, SplitFormulaDivergence)
  • Visual verification: terrain renders correctly in modern path (after the black-terrain hotfix at da56063)
  • Issue #51 closed in docs/ISSUES.md (T10 commit 083c10c)
  • Roadmap shows N.5b in "Shipped" (T10 commit 083c10c)
  • Memory file written (memory/project_phase_n5b_state.md outside repo)
  • 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:

  • 55e516cMaybeFlushTerrainDiag median calc underflow (copy[N - nz/2]copy[N] when nz=1).
  • da56063black 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