acdream/docs/research/2026-05-09-phase-n5b-handoff.md
Erik 380922cdbe docs(N.5b): cold-start handoff for next session
Captures everything a fresh agent needs to pick up Phase N.5b (Terrain
on the Modern Rendering Path) without spelunking through the N.5
session history.

Front-loads the load-bearing constraint: issue #51 (WB's terrain split
formula diverges from retail's FSplitNESW). Lays out three viable
design paths (A: adopt WB's formula everywhere; B: keep retail's
formula and fork-patch WB; C: WB mesh layout but our formula). The
brainstorm needs to pick one, informed by quantified divergence rate
across representative landblocks.

Includes file-by-file inventory of acdream's terrain stack (1383 lines
across TerrainRenderer + TerrainChunkRenderer + TerrainAtlas + shaders)
vs WB's (1937 lines across TerrainRenderManager + TerrainGeometryGenerator
+ LandSurfaceManager). Eight brainstorm questions covering atlas model,
mesh ownership, index format, shader unification, streaming integration,
conformance test, and visual verification gate.

Mirrors the N.5 handoff structure that worked well last session:
TL;DR + where N.5 left things + what N.5b inherits + technical detail
+ files to read + brainstorm questions + acceptance criteria + first
30 minutes + things to NOT do.

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

21 KiB

Phase N.5b — Terrain on the Modern Rendering Path — Cold-Start Handoff

Created: 2026-05-09, immediately after N.5 ship + roadmap A.5 addition. Audience: the next agent picking up terrain rendering work. Purpose: give you everything you need to start N.5b cold, without spelunking through the N.5 session's history.


TL;DR

N.5 just shipped: WbDrawDispatcher lifts entity rendering onto bindless textures + glMultiDrawElementsIndirect. CPU dispatcher 1.23 ms / frame median at Holtburg courtyard, ~810 fps sustained. Entities only — terrain is still on a separate legacy renderer.

N.5b's job: port terrain rendering onto the same modern primitives that N.5 just delivered. Concretely:

  1. Replace TerrainRenderer + TerrainChunkRenderer (per-landblock VAO, glDrawElements, sampler2D atlases) with a multi-draw-indirect dispatcher analogous to WbDrawDispatcher, sharing the modern path's bindless texture infrastructure where it makes sense.
  2. Keep terrain visually identical to today. The legacy TerrainAtlas + terrain.vert/.frag already render correctly; don't introduce visual regressions.
  3. Resolve issue #51 (WB's terrain split formula diverges from retail's FSplitNESW) — see "Load-bearing constraint" below.

The roadmap estimate is ~1 week because the modern-path primitives are already built. The actual work is porting + bridging + a real correctness decision on the split formula.


Load-bearing constraint: Issue #51 (terrain split formula)

This is the design decision that will dominate the brainstorm. Read docs/ISSUES.md issue #51 in full before brainstorming.

The short version:

  • acdream's terrain split formula is the retail-decomp FSplitNESW (constants 0x0CCAC033 / 0x421BE3BD / 0x6C1AC587 / 0x519B8F25). Documented in CLAUDE.md as the real AC formula. Ours is degree-2 polynomial in (x,y). Used by:
    • src/AcDream.Core/Physics/TerrainSurface.cs:113-120 (physics — IsSplitSWtoNE)
    • src/AcDream.Core/World/TerrainBlending.cs (visual mesh)
  • WB's terrain split formula in references/WorldBuilder/WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs:44 is LINEAR in (x,y). Different math; they cannot be algebraically equivalent. They disagree on a meaningful fraction of cells — up to ~2m height delta on sloped cells.
  • WB's TerrainGeometryGenerator (the obvious adoption target for N.5b's mesh path) uses WB's formula. If we adopt it wholesale, our visual terrain disagrees with our physics (which uses retail's formula). Player floats / sinks. Already-fixed bug class returns.

Three viable design paths (the brainstorm has to pick one):

  • Path A — Adopt WB's formula everywhere. Switch both physics AND visual mesh to WB's CalculateSplitDirection. Use WB's TerrainGeometryGenerator directly. Visual + physics stay synced. Risk: physics now disagrees with retail server-authoritative Z by up to ~2m on sloped cells. Server-side validation (if any) might reject movements; the player might "snap" to server's Z when packets land. Need to confirm whether ACE actually validates Z or trusts the client. Lowest implementation effort.

  • Path B — Keep retail's formula; fork-patch WB. Patch references/WorldBuilder/.../TerrainUtils.cs to use retail's formula in our fork. Push the patch to the acdream branch of the fork (per the WB submodule plumbing fixed in the previous session). Submit upstream PR if Chorizite wants it. Most retail-faithful. Implementation effort: medium. Coordination overhead with upstream.

  • Path C — Use WB's mesh layout but our formula. Don't use WB's TerrainGeometryGenerator directly. Instead port WB's mesh layout (vertex buffer shape, index buffer per landblock, atlas integration) into a new acdream-side TerrainGeometryGenerator that uses retail's formula. Highest effort but cleanest separation — no fork patches.

Recommendation in the brainstorm: probably Path A if quantification shows ACE doesn't validate Z aggressively (retail's network protocol is "client tells server position; server trusts within sanity bounds"), otherwise Path B. Path C is overengineered for the level of divergence.

Step 1 of the brainstorm: quantify the divergence. Run WB's formula

  • retail's formula across all (lbX, lbY, cellX, cellY) tuples for several representative landblocks (Holtburg, Foundry, open landscape, some sloped terrain like Direlands). Record disagreement rate. If <5% of cells disagree, Path A's risk is bounded; if >20%, Path B becomes more attractive.

Where N.5 left things

Branch state

After last session:

  • main is at a64cd11 ("docs(roadmap): add A.5 — two-tier streaming")
  • N.5 SHIP at 27eaf4e (merge commit)
  • N.5 ship-amendment at e0dbc9c (legacy renderers retired)
  • Legacy InstancedMeshRenderer + StaticMeshRenderer + WbFoundationFlag ARE GONE. Bindless is mandatory; missing extensions throws NotSupportedException at startup.

What works in N.5

  • Entity rendering: WbDrawDispatcher does ~12-15 GL calls per frame for all visible entities regardless of scene complexity. Three SSBO uploads (instance matrices @ binding=0, batch data @ binding=1, indirect commands) + 2 glMultiDrawElementsIndirect calls (opaque + transparent passes).
  • Bindless texture infrastructure: BindlessSupport wrapper + TextureCache parallel UploadRgba8AsLayer1Array path + three Bindless* GetOrUpload methods + two-phase Dispose. All textures on the WB modern path are 1-layer Texture2DArray + sampler2DArray.
  • mesh_modern.vert/.frag preserves the full SceneLighting UBO (8 lights + fog + lightning flash + per-channel clamp) — visual identity to N.4 confirmed at user gates.
  • Diagnostic: CPU stopwatch + GL_TIME_ELAPSED queries logged via [WB-DIAG] (GPU timing currently shows 0/0 — query polling needs double-buffering, deferred to N.6).

What N.5b inherits

These are levers N.5b will pull on:

  • BindlessSupport at src/AcDream.App/Rendering/Wb/BindlessSupport.cs — already wraps ArbBindlessTexture. Reusable for terrain textures.
  • DrawElementsIndirectCommand struct at src/AcDream.App/Rendering/Wb/DrawElementsIndirectCommand.cs — 20-byte layout, ready to populate per-landblock terrain commands.
  • BuildIndirectArrays helper at src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs — pure CPU layout helper, currently scoped to entities; could generalize for terrain.
  • TextureCache with parallel Texture2DArray bindless cache — but terrain has its own TerrainAtlas (multi-layer texture array for splat blending). N.5b decides whether to integrate or keep separate.
  • SceneLightingUbo at binding=1 — terrain.frag already consumes it; the new modern terrain shader continues that.
  • Retail's FSplitNESW in src/AcDream.Core/World/TerrainBlending.cs — the formula to preserve (or replace, per Path A/B/C decision).

What still uses the legacy path (NOT N.5b's job)

  • Sky rendering (SkyRenderer.cs) — N.8 territory.
  • Particles (ParticleRenderer.cs) — N.8 territory.
  • Debug lines (DebugLineRenderer.cs) — fine as-is.
  • UI / text (TextRenderer.cs + ImGui) — fine as-is; ImGui has its own backend.

What N.5b is — technical detail

Today's terrain stack (1383 lines acdream + ~140 lines shaders)

File Lines Role
src/AcDream.App/Rendering/TerrainRenderer.cs 247 Top-level orchestration; per-landblock cull + draw
src/AcDream.App/Rendering/TerrainChunkRenderer.cs 454 Per-landblock VAO + IBO management; glDrawElements per visible chunk
src/AcDream.App/Rendering/TerrainAtlas.cs 386 Multi-layer Texture2DArray atlas for terrain splat textures
src/AcDream.App/Rendering/Shaders/terrain.vert 147 Per-vertex world position, normal, UV, palCode
src/AcDream.App/Rendering/Shaders/terrain.frag 149 Splat blending across 4 corner textures

Per-frame today: for each visible landblock, bind its VAO + IBO, bind the terrain texture atlas, set per-landblock uniforms, issue glDrawElements. With 25 landblocks at default radius=2, that's ~25 draw calls per frame for terrain (cheap, but doesn't scale).

WB's terrain stack (1937 lines + ~200 lines shaders)

File Lines Role
references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TerrainRenderManager.cs 1023 Top-level coordinator; uses multi-draw-indirect already
references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TerrainGeometryGenerator.cs 326 Mesh generation per landblock (uses WB's split formula — see #51)
references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/LandSurfaceManager.cs 588 Texture atlas management + alpha mask generation for splat blending
references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/Landscape.vert/.frag ~200 Modern shader; consumes SSBO instance data + bindless atlas handle

WB's renderer is structurally close to what N.5b targets. Key differences from acdream:

  • WB uses uint32 indices (DrawElementsType.UnsignedInt) for terrain — landblocks have more vertices than fit in ushort range. N.5's WbDrawDispatcher uses UnsignedShort for entities.
  • WB packs all visible terrain into shared mesh buffers + dispatches via glMultiDrawElementsIndirect. We can mirror that pattern.
  • WB's LandSurfaceManager builds per-landblock alpha masks for splat blending; this is the bulk of its 588 lines. Different model from our TerrainAtlas which uses palCode-based blending in the fragment shader.

What N.5b actually does

Roughly four sub-pieces:

  1. Terrain mesh on global VBO/IBO. Following N.5's pattern, all visible terrain landblocks pack into a single global vertex buffer
    • index buffer. Per-landblock entries become DrawElementsIndirectCommand records with firstIndex + baseVertex offsets. One glMultiDrawElementsIndirect call per pass.
  2. Bindless terrain atlas. Either (a) port TerrainAtlas to use bindless handles + sampler2DArray (small change, keeps current blending math), or (b) adopt WB's LandSurfaceManager (bigger change, switches to alpha-mask blending). Brainstorm decides.
  3. New shader terrain_modern.vert/.frag that:
    • Reads per-landblock data from an SSBO (analogous to mesh_modern's Batches[])
    • Samples the terrain atlas via bindless sampler2DArray handle
    • Continues to consume SceneLighting UBO @ binding=1 (no visual identity regression vs N.4 — same lighting math)
  4. Resolve issue #51 per Path A/B/C decision in the brainstorm.

Per-frame target shape

// Once at init:
Build global terrain VAO + VBO + IBO (resizable; grows as landblocks stream in)
Generate bindless handles for terrain atlas

// Per frame:
1. Frustum cull landblocks (existing per-landblock AABB test)
2. Build per-visible-landblock IndirectGroupInput list
3. Upload _terrainBatchSsbo + _terrainIndirectBuffer
4. glBindVertexArray(globalTerrainVao)
5. glBindBufferBase(SHADER_STORAGE_BUFFER, 1, _terrainBatchSsbo)
6. glBindBuffer(DRAW_INDIRECT_BUFFER, _terrainIndirectBuffer)
7. glMultiDrawElementsIndirect(...)  // ONCE per pass — opaque pass
   (terrain has no transparent; one indirect call total)

Total ~6-8 GL calls per frame for terrain regardless of scene size. At radius=5 (121 landblocks) this is the same number of GL calls as at radius=2 (25 landblocks).


Files to read before brainstorming

In rough order:

  1. docs/ISSUES.md issue #51 (49-103). Load-bearing constraint.
  2. CLAUDE.md the "Reference hierarchy by domain" terrain row + "Reference repos: check ALL FOUR" — terrain math is one of the places where checking multiple references matters most.
  3. acdream terrain stack:
    • src/AcDream.App/Rendering/TerrainRenderer.cs (247 lines, easy read)
    • src/AcDream.App/Rendering/TerrainChunkRenderer.cs (454 lines — this is the per-landblock GL plumbing that goes away in N.5b)
    • src/AcDream.App/Rendering/TerrainAtlas.cs (386 lines — multi-layer atlas)
    • src/AcDream.App/Rendering/Shaders/terrain.vert/.frag (~300 lines combined)
    • src/AcDream.Core/World/TerrainBlending.cs (the FSplitNESW side; preserve or replace)
  4. WB terrain stack:
    • references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TerrainRenderManager.cs (1023 lines — the model to mirror; multi-draw indirect already in place)
    • references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TerrainGeometryGenerator.cs (326 lines — uses WB's split formula; per #51)
    • references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/LandSurfaceManager.cs (588 lines — alpha-mask atlas; alternative to our TerrainAtlas)
    • references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/Landscape.vert/.frag
    • references/WorldBuilder/WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs:44 (CalculateSplitDirection — WB's formula)
  5. N.5 plan + spec (cribs for the modern-path pattern):
    • docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md (what we did, including amendments)
    • docs/superpowers/specs/2026-05-08-phase-n5-modern-rendering-design.md (decisions log)
  6. Memory: project_phase_n5_state.md — three high-value gotchas from N.5 (texture target lock-in, bindless Dispose order, GL_TIME_ELAPSED double-buffering). All apply to N.5b.

Brainstorm questions

These are the questions to resolve in the brainstorm step. Don't prejudge — bring them to the user with options + recommendation:

  1. Path A vs B vs C for issue #51 (the terrain split formula). The biggest decision; everything else flows from it. Should be informed by quantifying the divergence rate first (run both formulas across representative landblocks).

  2. Atlas model. Keep TerrainAtlas (palCode-based fragment shader blending) and just bindless-ify it, or adopt WB's LandSurfaceManager (alpha-mask blending)? Tradeoff: minimal change vs alignment with WB. Visual outcome should be identical either way.

  3. Mesh ownership. Use a single global VBO/IBO for all terrain (mirror N.5's pattern), or per-landblock VBO/IBO with multi-draw indirect over them? Single global is more cache-friendly + more like N.5, but requires resizable buffer management. Per-landblock is simpler but doesn't share the IBO across draws.

  4. Index format. N.5 uses UnsignedShort (max 64K verts per draw). Terrain landblocks have many more verts than that. WB uses UnsignedInt. Just commit to UnsignedInt for terrain?

  5. Shader unification. Separate terrain_modern.vert/.frag or merge with mesh_modern.vert/.frag via uniforms? Probably separate since the vertex layouts differ (terrain has palCode; entities have UV).

  6. Streaming integration. Today's TerrainChunkRenderer integrates with the streaming loader (landblocks come and go). N.5b's global buffer model needs a strategy for adding/removing landblocks from the global VBO/IBO without per-add reallocation. Free-list / compaction / fixed-slot allocator?

  7. Conformance test. Per the lessons from N.2, "WB's terrain formula differs from retail" — we need a test that proves our visual terrain matches our physics terrain (i.e., visual mesh Z at any (X,Y) equals TerrainSurface.GetHeight(X,Y)). Run a sweep across ~1M (X,Y) points; assert |delta| < epsilon.

  8. Visual verification gate. Holtburg + Foundry + sloped terrain (Direlands?) + cell transitions. The split-formula-disagreement bug class shows up as terrain "wobble" at cell boundaries — that's the specific thing to look for.


Acceptance criteria for the whole phase

  • Visual terrain identical to current legacy path (no missing chunks, no z-fighting at cell boundaries, no texture seams)
  • [WB-DIAG] shows terrain accounting for ~6-8 GL calls per frame regardless of scene size (currently scales with visible landblock count, ~25-121 calls)
  • Frame time measurably lower in dense-terrain scenes (specify scenes in the spec — probably radius=5 outdoor roaming)
  • Conformance test: visual mesh Z agrees with TerrainSurface.GetHeight within epsilon across a 1M-point sweep
  • All existing tests still green
  • The split-formula decision (#51) is resolved with a clear writeup in the spec

What you'll be doing in the first 30 minutes

  1. Read this handoff in full.
  2. Read docs/ISSUES.md issue #51 in full.
  3. Read CLAUDE.md "Reference hierarchy by domain" terrain row.
  4. Read TerrainRenderer.cs + TerrainChunkRenderer.cs end-to-end.
  5. Skim TerrainRenderManager.cs (WB's) — at least the multi-draw indirect dispatch section.
  6. Verify build is green: dotnet build.
  7. Verify N.5 ship is intact: dotnet test --filter "FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless" should produce 71 passing tests, 0 failures.
  8. Quantify the formula divergence (Path A/B/C decision input): write a one-shot test that runs both formulas across all (lbX, lbY, cellX, cellY) tuples for ~10 representative landblocks and reports disagreement rate.
  9. Invoke the superpowers:brainstorming skill with the user. Walk through the 8 brainstorm questions above. Bring the formula divergence number to inform the Path A/B/C decision.
  10. Write the spec.
  11. Write the plan.
  12. Begin Week 1 implementation per the plan.

Don't skip the brainstorm. The terrain split formula decision (Path A/B/C) has real downstream consequences — physics, server-Z agreement, fork-patching of WB. Needs explicit user input, not "the agent makes a call and goes." This phase is structurally the same shape as N.5 — brainstorm → spec → plan → tasks-with-checkboxes → commits-update-checkboxes → final SHIP commit.


Things to NOT do

  • Don't adopt WB's terrain code wholesale without resolving #51 first. The split formula decision affects the entire pipeline; patching it after-the-fact requires re-doing visual + physics + the TerrainGeometryGenerator port.
  • Don't introduce a per-cell wobble at landblock boundaries. That's the visible signature of the formula disagreement. If you see it during visual verification, the formula isn't aligned between your physics and visual paths.
  • Don't break the existing [WB-DIAG] instrumentation. Add a separate counter for terrain (terrainDrawsIssued) so the entity
    • terrain perf can be observed independently.
  • Don't bundle A.5 (two-tier streaming + horizon LOD) into this phase. N.5b is "terrain on modern path"; A.5 is "split the radius
    • LOD." Different scopes, different brainstorms. A.5 might become natural to pick up next once N.5b lands.
  • Don't try to re-port FSplitNESW if you're going Path A. The whole point of Path A is to commit to WB's formula. If you keep retail's formula via Path B/C, do it once, definitively.
  • Don't skip the formula-divergence quantification. Step 8 of the first 30 minutes. The Path decision should be data-informed, not gut-feel. <5% divergence makes Path A bounded-risk; >20% makes Path B/C more attractive.
  • Don't skip visual verification. The split-formula bug class shows up as cell-boundary wobble that's hard to spot in screenshots but obvious in motion. Walk a sloped landblock during verification.
  • Don't extend the phase scope. N.5b is "terrain on modern path." Sky, particles, EnvCells — all subsequent phases. If the brainstorm tries to expand, push back.

Reference: the N.5 dispatcher flow you're mirroring

WbDrawDispatcher.Draw(...) {
  // Phase 1: walk entities, build groups
  // Phase 2: lay matrices contiguously
  // Phase 3: build BatchData + DEIC arrays via BuildIndirectArrays
  // Phase 4: upload 3 SSBOs (instances, batches, indirect)
  // Phase 5: bind global VAO + SSBOs
  // Phase 6: opaque pass — glMultiDrawElementsIndirect
  // Phase 7: transparent pass — glMultiDrawElementsIndirect
}

For terrain the shape is similar but simpler:

TerrainModernDispatcher.Draw(...) {
  // Phase 1: walk visible landblocks, frustum cull
  // Phase 2: build per-landblock IndirectGroupInput list
  //          (one entry per visible landblock — typically 25-121)
  // Phase 3: upload 2 SSBOs (terrain batch data, indirect commands)
  //          (no per-instance buffer needed — terrain isn't instanced)
  // Phase 4: bind global terrain VAO + SSBOs
  // Phase 5: opaque pass ONLY — glMultiDrawElementsIndirect
}

Total ~6-8 GL calls per frame for terrain. That's the destination.

Good luck. The split-formula decision is the only really hard call; everything else is mechanical port work on top of N.5's substrate. Holler at the user if anything in #51's three paths feels genuinely ambiguous after reading the references.