# 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.