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>
75 KiB
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'sterrain.vertwith bindless preamble (~150 lines).src/AcDream.App/Rendering/Shaders/terrain_modern.frag— port of today'sterrain.fragwith 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— addBindlessSupport? bindlessctor 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.cssrc/AcDream.App/Rendering/TerrainRenderer.cssrc/AcDream.App/Rendering/Shaders/terrain.vertsrc/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
- Read the spec section the task implements.
- For TDD-friendly tasks (T2 slot allocator, T7 conformance): write the failing test → run → verify failure → implement → run → verify pass → commit.
- 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.)
- 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 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:
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
BuildandBuildFallbackto 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:
#version 460 core(was 430)#extension GL_ARB_bindless_texture : requireadded 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.cslines 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
SetSamplerHandleUniformhelper 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.csfor 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 mmprinted. - 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 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
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.logcontains[TERRAIN-DIAG]lines
If terrain is black or missing, check:
-
[WB-DIAG]— bindless capability detected? -
Atlas handle nonzero?
-
glGetError()afterglMultiDrawElementsIndirect? -
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:
- Holtburg town (~0xA9B0)
- Holtburg sloped landblock (~0xA9B1)
- Foundry-area (~0x80xx)
- Any visibly-sloped outdoor landblock
At each scene confirm:
- ✓ No cell-boundary wobble (load-bearing #51 sentinel)
- ✓ No missing chunks / black holes
- ✓ No texture seams at landblock edges
- ✓ No z-fighting
- ✓
[TERRAIN-DIAG] visible=Nconsistent with scene; renderer visibly using indirect dispatch (no per-LB calls) - ✓
[TERRAIN-DIAG] cpu_msat 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 commit083c10c) - Roadmap shows N.5b in "Shipped" (T10 commit
083c10c) - Memory file written (
memory/project_phase_n5b_state.mdoutside 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
glDrawElementscall, 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 (zeroglBindTexture/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
Disposeordering (ALLMakeNonResidentfirst, then ALLDeleteTexture) per ARB_bindless_texture spec. - T6 fixups (Important):
meshData.Indices.Lengthvalidation inAddLandblock; documentedVertsPerLandblock % 6 == 0load-bearing invariant for the shader'sgl_VertexID % 6corner-table lookup. - T7 fixup (Important): tightened sample upper bound to
191.975fto 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—MaybeFlushTerrainDiagmedian calc underflow (copy[N - nz/2]→copy[N]when nz=1).da56063— black terrain in modern path. Root cause:uniform sampler2DArray+glProgramUniformHandleARBis rejected withGL_INVALID_OPERATIONon 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:
uniform sampler2DArray+glProgramUniformHandleARBis unreliable; default to uvec2 handle + sampler-from-handle constructor.- Median-calc with
nz/2underflows to out-of-range when nz<2; use(nz-1)/2form. - 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.cssrc/AcDream.Core/Terrain/TerrainSlotAllocator.cssrc/AcDream.App/Rendering/Shaders/terrain_modern.vertsrc/AcDream.App/Rendering/Shaders/terrain_modern.fragtests/AcDream.Core.Tests/Terrain/TerrainSlotAllocatorTests.cstests/AcDream.Core.Tests/Terrain/TerrainModernConformanceTests.cstests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.csdocs/plans/2026-05-09-phase-n5b-perf-baseline.mddocs/superpowers/specs/2026-05-09-phase-n5b-terrain-modern-design.mddocs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md(this file)
Modified:
src/AcDream.App/Rendering/TerrainAtlas.cs— bindless extensionsrc/AcDream.App/Rendering/Wb/BindlessSupport.cs— note about retired SetSamplerHandleUniform helpersrc/AcDream.App/Rendering/GameWindow.cs— TerrainModernRenderer wiring + [TERRAIN-DIAG] rollup, then T9 cleanupCLAUDE.md— N.5b entry in WB integration cribsdocs/plans/2026-04-11-roadmap.md— N.5b → Shippeddocs/ISSUES.md— issue #51 → Recently closed
Deleted:
src/AcDream.App/Rendering/TerrainChunkRenderer.cssrc/AcDream.App/Rendering/TerrainRenderer.cssrc/AcDream.App/Rendering/Shaders/terrain.vertsrc/AcDream.App/Rendering/Shaders/terrain.frag