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>
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:
- Replace
TerrainRenderer+TerrainChunkRenderer(per-landblock VAO,glDrawElements,sampler2Datlases) with a multi-draw-indirect dispatcher analogous toWbDrawDispatcher, sharing the modern path's bindless texture infrastructure where it makes sense. - Keep terrain visually identical to today. The legacy
TerrainAtlas+terrain.vert/.fragalready render correctly; don't introduce visual regressions. - 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(constants0x0CCAC033/0x421BE3BD/0x6C1AC587/0x519B8F25). Documented inCLAUDE.mdas 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:44is 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'sTerrainGeometryGeneratordirectly. 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.csto use retail's formula in our fork. Push the patch to theacdreambranch 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
TerrainGeometryGeneratordirectly. Instead port WB's mesh layout (vertex buffer shape, index buffer per landblock, atlas integration) into a new acdream-sideTerrainGeometryGeneratorthat 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:
mainis ata64cd11("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+WbFoundationFlagARE GONE. Bindless is mandatory; missing extensions throwsNotSupportedExceptionat startup.
What works in N.5
- Entity rendering:
WbDrawDispatcherdoes ~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) + 2glMultiDrawElementsIndirectcalls (opaque + transparent passes). - Bindless texture infrastructure:
BindlessSupportwrapper +TextureCacheparallelUploadRgba8AsLayer1Arraypath + threeBindless*GetOrUploadmethods + two-phaseDispose. All textures on the WB modern path are 1-layerTexture2DArray+sampler2DArray. - mesh_modern.vert/.frag preserves the full
SceneLightingUBO (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:
BindlessSupportatsrc/AcDream.App/Rendering/Wb/BindlessSupport.cs— already wrapsArbBindlessTexture. Reusable for terrain textures.DrawElementsIndirectCommandstruct atsrc/AcDream.App/Rendering/Wb/DrawElementsIndirectCommand.cs— 20-byte layout, ready to populate per-landblock terrain commands.BuildIndirectArrayshelper atsrc/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs— pure CPU layout helper, currently scoped to entities; could generalize for terrain.TextureCachewith parallel Texture2DArray bindless cache — but terrain has its ownTerrainAtlas(multi-layer texture array for splat blending). N.5b decides whether to integrate or keep separate.SceneLightingUboat binding=1 — terrain.frag already consumes it; the new modern terrain shader continues that.- Retail's
FSplitNESWinsrc/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'sWbDrawDispatcherusesUnsignedShortfor entities. - WB packs all visible terrain into shared mesh buffers + dispatches
via
glMultiDrawElementsIndirect. We can mirror that pattern. - WB's
LandSurfaceManagerbuilds per-landblock alpha masks for splat blending; this is the bulk of its 588 lines. Different model from ourTerrainAtlaswhich uses palCode-based blending in the fragment shader.
What N.5b actually does
Roughly four sub-pieces:
- 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
DrawElementsIndirectCommandrecords withfirstIndex+baseVertexoffsets. OneglMultiDrawElementsIndirectcall per pass.
- index buffer. Per-landblock entries become
- Bindless terrain atlas. Either (a) port
TerrainAtlasto use bindless handles + sampler2DArray (small change, keeps current blending math), or (b) adopt WB'sLandSurfaceManager(bigger change, switches to alpha-mask blending). Brainstorm decides. - New shader
terrain_modern.vert/.fragthat:- Reads per-landblock data from an SSBO (analogous to
mesh_modern's
Batches[]) - Samples the terrain atlas via bindless
sampler2DArrayhandle - Continues to consume
SceneLightingUBO @ binding=1 (no visual identity regression vs N.4 — same lighting math)
- Reads per-landblock data from an SSBO (analogous to
mesh_modern's
- 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:
docs/ISSUES.mdissue #51 (49-103). Load-bearing constraint.CLAUDE.mdthe "Reference hierarchy by domain" terrain row + "Reference repos: check ALL FOUR" — terrain math is one of the places where checking multiple references matters most.- 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)
- 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 ourTerrainAtlas)references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/Landscape.vert/.fragreferences/WorldBuilder/WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs:44(CalculateSplitDirection — WB's formula)
- 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)
- 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:
-
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).
-
Atlas model. Keep
TerrainAtlas(palCode-based fragment shader blending) and just bindless-ify it, or adopt WB'sLandSurfaceManager(alpha-mask blending)? Tradeoff: minimal change vs alignment with WB. Visual outcome should be identical either way. -
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.
-
Index format. N.5 uses
UnsignedShort(max 64K verts per draw). Terrain landblocks have many more verts than that. WB usesUnsignedInt. Just commit toUnsignedIntfor terrain? -
Shader unification. Separate
terrain_modern.vert/.fragor merge withmesh_modern.vert/.fragvia uniforms? Probably separate since the vertex layouts differ (terrain has palCode; entities have UV). -
Streaming integration. Today's
TerrainChunkRendererintegrates 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? -
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. -
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.GetHeightwithin 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
- Read this handoff in full.
- Read
docs/ISSUES.mdissue #51 in full. - Read CLAUDE.md "Reference hierarchy by domain" terrain row.
- Read
TerrainRenderer.cs+TerrainChunkRenderer.csend-to-end. - Skim
TerrainRenderManager.cs(WB's) — at least the multi-draw indirect dispatch section. - Verify build is green:
dotnet build. - Verify N.5 ship is intact:
dotnet test --filter "FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless"should produce 71 passing tests, 0 failures. - 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.
- Invoke the
superpowers:brainstormingskill with the user. Walk through the 8 brainstorm questions above. Bring the formula divergence number to inform the Path A/B/C decision. - Write the spec.
- Write the plan.
- 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
FSplitNESWif 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.