diff --git a/docs/plans/2026-04-10-phase-3c-terrain-blending-plan.md b/docs/plans/2026-04-10-phase-3c-terrain-blending-plan.md new file mode 100644 index 0000000..e005c32 --- /dev/null +++ b/docs/plans/2026-04-10-phase-3c-terrain-blending-plan.md @@ -0,0 +1,497 @@ +# Phase 3c: Terrain Texture Blending + +**Status:** Ready for review +**Date:** 2026-04-10 +**Prerequisites:** Phases 1-3a/3b merged (lighting + per-vertex terrain normals) +**Primary reference:** `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/LandSurfaceManager.cs` + `Shaders/Landscape.{vert,frag}` +**Deferred out:** Chunking refactor (moved to a hypothetical Phase 3d when streaming actually matters) + +--- + +## 1. Goal + +Replace acdream's current "one terrain texture type per vertex, pick an atlas layer" with AC's real texture-merge blending: per-cell palette codes + alpha masks + up to 3 terrain overlays + up to 2 road overlays composited in the fragment shader. End result: no more jagged grass/dirt/sand seams, terrain looks like real AC. + +**Explicitly NOT in scope:** +- Chunking / global VBO slot pools (zero visual benefit until we stream more landblocks) +- Streaming, frustum culling, multi-draw indirect +- Minimap / grid overlay / brush visualization (editor features) +- Collision queries + +Keep the existing "one VBO per landblock" render structure. All the changes are inside how each landblock is meshed and shaded. + +--- + +## 2. What changes architecturally + +### Current (post-Phase 3b) +``` +LandblockMesh.Build → 81 vertices (9×9 grid) + each vertex: Position, Normal, TexCoord, TerrainLayer(uint) +TerrainRenderer: one VBO per landblock +Fragment shader: single sampler2DArray lookup +``` + +### After Phase 3c +``` +LandblockMesh.Build → 384 vertices (64 cells × 6 vertices) + each vertex: Position, Normal, TexCoord, Data0, Data1, Data2, Data3 + where Data0..3 encode: base + 3 overlays + 2 roads + rotations + split direction +TerrainRenderer: one VBO per landblock (unchanged) +Fragment shader: two sampler2DArray lookups (terrain atlas + alpha atlas), + layered alpha-weighted composite +``` + +**Per-cell, not per-vertex:** every 6 vertices sharing a cell carry identical `Data0..3`. The vertex shader uses them to compute UVs + rotations for the fragment shader, which does the actual blend. + +--- + +## 3. Core algorithm port + +All of these are **pure functions** lifted (with attribution) from WorldBuilder's `LandSurfaceManager.cs`. Live in a new file `src/AcDream.Core/Terrain/TerrainBlending.cs`. + +### 3.1 Palette code +```csharp +// Encodes 4 corner terrain types (5 bits each) + 4 road flags (2 bits each) +// into a 32-bit hash. Same corners → same palCode → same blending choices. +public static uint GetPalCode(int r1, int r2, int r3, int r4, + int t1, int t2, int t3, int t4) +{ + uint terrainBits = (uint)((t1 << 15) | (t2 << 10) | (t3 << 5) | t4); + uint roadBits = (uint)((r1 << 26) | (r2 << 24) | (r3 << 22) | (r4 << 20)); + uint sizeBits = 1u << 28; + return sizeBits | roadBits | terrainBits; +} +``` + +### 3.2 SurfaceInfo (derived from palCode via `BuildSurface`) +```csharp +public readonly record struct SurfaceInfo( + byte BaseTexIdx, // 0..35, terrain atlas layer + byte Ovl0TexIdx, byte Ovl0AlphaIdx, byte Ovl0Rotation, // 255 idx = unused; rot 0..3 + byte Ovl1TexIdx, byte Ovl1AlphaIdx, byte Ovl1Rotation, + byte Ovl2TexIdx, byte Ovl2AlphaIdx, byte Ovl2Rotation, + byte Road0TexIdx, byte Road0AlphaIdx, byte Road0Rotation, + byte Road1TexIdx, byte Road1AlphaIdx, byte Road1Rotation); + +// Ports WorldBuilder's BuildTexture + FindTerrainAlpha + FindRoadAlpha +public static SurfaceInfo BuildSurface( + uint palCode, + TexMergeInfo texMerge, // from Region.TerrainInfo.LandSurfaces.TexMerge + IReadOnlyDictionary terrainTypeToLayer, + IReadOnlyList cornerAlphaLayers, // atlas layers 0..3 + IReadOnlyList sideAlphaLayers, // atlas layers 4..7 + IReadOnlyList roadAlphaLayers); // atlas layers 5..14 +``` + +### 3.3 FillCellData (pack into 4 uints) +```csharp +// Per-cell shared-among-6-vertices data. Bit layout matches WorldBuilder's +// Landscape.vert unpacking so the shader stays as a straight port. +public static (uint d0, uint d1, uint d2, uint d3) FillCellData( + SurfaceInfo s, CellSplitDirection split) +{ + uint d0 = (uint)(s.BaseTexIdx | (0 /*base alpha unused*/ << 8) + | ((s.Ovl0TexIdx | (s.Ovl0AlphaIdx << 8)) << 16)); + uint d1 = (uint)( (s.Ovl1TexIdx | (s.Ovl1AlphaIdx << 8)) + | ((s.Ovl2TexIdx | (s.Ovl2AlphaIdx << 8)) << 16)); + uint d2 = (uint)( (s.Road0TexIdx | (s.Road0AlphaIdx << 8)) + | ((s.Road1TexIdx | (s.Road1AlphaIdx << 8)) << 16)); + uint d3 = (uint)(/*base rot=0*/ + | (s.Ovl0Rotation << 2) | (s.Ovl1Rotation << 4) | (s.Ovl2Rotation << 6) + | (s.Road0Rotation << 8) | (s.Road1Rotation << 10) + | (((int)split & 1) << 12)); + return (d0, d1, d2, d3); +} +``` + +### 3.4 Cell split direction (deterministic hash) +```csharp +public enum CellSplitDirection { SwToNe = 0, NwToSe = 1 } + +// Ports WorldBuilder TerrainUtils.GetCellSplitDirection — the magic constants +// must match exactly or our splits won't line up with server collision physics. +public static CellSplitDirection GetCellSplitDirection( + int landblockX, int landblockY, int cellX, int cellY); +``` + +### 3.5 PseudoRandomIndex +```csharp +// Used by BuildSurface to pick which of several alpha variants to use for a +// given palCode, so visually similar cells don't all use identical blend masks. +public static int PseudoRandomIndex(uint palCode, int max); +``` + +Every function above is **trivially unit-testable** with canned inputs. We'll seed golden values from an instrumented WorldBuilder run. + +--- + +## 4. Per-cell mesh generation + +Rewrite `LandblockMesh.Build` to the new per-cell layout. Still one landblock → one `LandblockMeshData` → one VBO (so `TerrainRenderer` stays unchanged at the draw-call level). + +```csharp +public static LandblockMeshData Build( + LandBlock block, + int landblockX, int landblockY, // for split-direction hashing + float[] heightTable, + TexMergeInfo texMerge, + IReadOnlyDictionary terrainTypeToLayer, + IReadOnlyList cornerAlphaLayers, + IReadOnlyList sideAlphaLayers, + IReadOnlyList roadAlphaLayers, + Dictionary surfaceCache) // palCode → SurfaceInfo, shared across landblocks +{ + // Pre-sample the 9×9 heightmap (existing Phase 3b logic, unchanged) + var heights = new float[9, 9]; + // ... + + var vertices = new List(64 * 6); + var indices = new List(64 * 6); + + for (int cy = 0; cy < 8; cy++) + { + for (int cx = 0; cx < 8; cx++) + { + // 1. Gather 4 corner TerrainInfos (x-major, block.Terrain[x*9+y]) + var tBL = block.Terrain[cx * 9 + cy]; + var tBR = block.Terrain[(cx + 1) * 9 + cy]; + var tTR = block.Terrain[(cx + 1) * 9 + (cy + 1)]; + var tTL = block.Terrain[cx * 9 + (cy + 1)]; + + // 2. palCode + SurfaceInfo (cached) + uint palCode = TerrainBlending.GetPalCode( + tBL.Road, tBR.Road, tTR.Road, tTL.Road, + (int)tBL.Type, (int)tBR.Type, (int)tTR.Type, (int)tTL.Type); + if (!surfaceCache.TryGetValue(palCode, out var surf)) + { + surf = TerrainBlending.BuildSurface( + palCode, texMerge, terrainTypeToLayer, + cornerAlphaLayers, sideAlphaLayers, roadAlphaLayers); + surfaceCache[palCode] = surf; + } + + // 3. Cell data (shared across all 6 vertices of the cell) + var split = TerrainBlending.GetCellSplitDirection(landblockX, landblockY, cx, cy); + var (d0, d1, d2, d3) = TerrainBlending.FillCellData(surf, split); + + // 4. 4 corner positions + per-corner central-difference normals + // (Phase 3b logic lifted into a helper) + var posBL = CornerPosition(cx, cy, heights); + var posBR = CornerPosition(cx + 1, cy, heights); + var posTR = CornerPosition(cx + 1, cy + 1, heights); + var posTL = CornerPosition(cx, cy + 1, heights); + var nBL = CentralDiffNormal(heights, cx, cy); + // ...same for BR, TR, TL + + // 5. Per-corner UVs in cell-local [0,1] space + var uvBL = new Vector2(0, 0); + var uvBR = new Vector2(1, 0); + var uvTR = new Vector2(1, 1); + var uvTL = new Vector2(0, 1); + + // 6. Two triangles per split direction + // SwToNe: (BL,BR,TR), (BL,TR,TL) + // NwToSe: (BL,BR,TL), (BR,TR,TL) + void Emit(Vector3 p, Vector3 n, Vector2 uv) + { + uint idx = (uint)vertices.Count; + vertices.Add(new Vertex(p, n, uv, d0, d1, d2, d3)); + indices.Add(idx); + } + + if (split == CellSplitDirection.SwToNe) + { + Emit(posBL, nBL, uvBL); Emit(posBR, nBR, uvBR); Emit(posTR, nTR, uvTR); + Emit(posBL, nBL, uvBL); Emit(posTR, nTR, uvTR); Emit(posTL, nTL, uvTL); + } + else + { + Emit(posBL, nBL, uvBL); Emit(posBR, nBR, uvBR); Emit(posTL, nTL, uvTL); + Emit(posBR, nBR, uvBR); Emit(posTR, nTR, uvTR); Emit(posTL, nTL, uvTL); + } + } + } + + return new LandblockMeshData(vertices.ToArray(), indices.ToArray()); +} +``` + +384 vertices, 384 indices, 64 cells per landblock. + +The `surfaceCache` is passed in from `GameWindow` so all 9 landblocks share the same palette → SurfaceInfo cache. WorldBuilder does this too — palCode hashing is deterministic so two different landblocks with the same corner config hit the same cached SurfaceInfo. + +--- + +## 5. Vertex format change + +`src/AcDream.Core/Terrain/Vertex.cs`: +```csharp +public readonly record struct Vertex( + Vector3 Position, // 12 + Vector3 Normal, // 12 + Vector2 TexCoord, // 8 + uint Data0, // 4 + uint Data1, // 4 + uint Data2, // 4 + uint Data3); // 4 +// Total: 48 bytes (was 36) +``` + +Drops `TerrainLayer` since the shader now reads layer info from `Data0`. + +**Breaking change for `GfxObjMesh`:** static meshes also use `Vertex`. Their normals etc. stay put, but they now emit `0, 0, 0, 0` for `Data0..3`. The `mesh.vert`/`mesh.frag` static-mesh shaders don't reference those attributes so they're ignored — but the VAO binding layout must include them (stride = 48, attribute 3-6 still enabled, they just carry dead bytes). + +Alternative: give static meshes their own vertex struct so we don't waste 16 bytes per mesh vertex. Deferred — Phase 2+ mesh memory isn't a bottleneck, and a unified struct is simpler. + +--- + +## 6. TerrainAtlas grows an alpha atlas + +`src/AcDream.App/Rendering/TerrainAtlas.cs`: +```csharp +public sealed class TerrainAtlas : IDisposable +{ + // Existing + public uint GlTerrainTexture { get; } // 512×512 × 36 layers, RGBA8 + public IReadOnlyDictionary TerrainTypeToLayer { get; } + + // New + public uint GlAlphaTexture { get; } // 512×512 × 16 layers, RGBA8 (R=G=B=A=alpha) + public IReadOnlyList CornerAlphaLayers { get; } // layers 0..3 + public IReadOnlyList SideAlphaLayers { get; } // layers 4..7 + public IReadOnlyList RoadAlphaLayers { get; } // layers 5..14 (up to 10) +} +``` + +Loading procedure (`Build` grows): +1. Same as today for the 36 terrain layers. +2. Iterate `Region.TerrainInfo.LandSurfaces.TexMerge.CornerTerrainMaps` — for each, load the referenced `SurfaceTexture` dat, decode to grayscale (single channel), upload as layer N of the alpha atlas where N = insertion order. +3. Same for `SideTerrainMaps` (appended after corners) and `RoadMaps` (appended after sides; layers may overlap per WorldBuilder's `5 + index` convention). + +--- + +## 7. TerrainRenderer VAO update + +`src/AcDream.App/Rendering/TerrainRenderer.cs`: +```csharp +uint stride = (uint)sizeof(Vertex); // now 48 +// aPos +_gl.EnableVertexAttribArray(0); +_gl.VertexAttribPointer (0, 3, Float, false, stride, (void*)0); +// aNormal +_gl.EnableVertexAttribArray(1); +_gl.VertexAttribPointer (1, 3, Float, false, stride, (void*)(3 * sizeof(float))); +// aTex +_gl.EnableVertexAttribArray(2); +_gl.VertexAttribPointer (2, 2, Float, false, stride, (void*)(6 * sizeof(float))); +// aData0..3 (uint attributes need VertexAttribIPointer) +_gl.EnableVertexAttribArray(3); +_gl.VertexAttribIPointer(3, 1, UnsignedInt, stride, (void*)(8 * sizeof(float))); +_gl.EnableVertexAttribArray(4); +_gl.VertexAttribIPointer(4, 1, UnsignedInt, stride, (void*)(8 * sizeof(float) + 4)); +_gl.EnableVertexAttribArray(5); +_gl.VertexAttribIPointer(5, 1, UnsignedInt, stride, (void*)(8 * sizeof(float) + 8)); +_gl.EnableVertexAttribArray(6); +_gl.VertexAttribIPointer(6, 1, UnsignedInt, stride, (void*)(8 * sizeof(float) + 12)); +``` + +Binds both texture arrays before drawing: +```csharp +_gl.ActiveTexture(Texture0); +_gl.BindTexture(Texture2DArray, _atlas.GlTerrainTexture); +_gl.ActiveTexture(Texture1); +_gl.BindTexture(Texture2DArray, _atlas.GlAlphaTexture); +_shader.SetInt("uTerrain", 0); +_shader.SetInt("uAlpha", 1); +``` + +`StaticMeshRenderer` bumps to the same 7-attribute layout even though mesh.vert/frag don't use attribs 3-6 (they'll just sit ignored). + +--- + +## 8. Shader rewrite + +### terrain.vert +```glsl +#version 430 core +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in vec2 aTex; +layout(location = 3) in uint aData0; +layout(location = 4) in uint aData1; +layout(location = 5) in uint aData2; +layout(location = 6) in uint aData3; + +uniform mat4 uModel, uView, uProjection; + +out vec2 vBaseUV; +out vec3 vWorldNormal; +flat out uvec4 vPacked0; // base(tex,alpha) ovl0(tex,alpha) -- each 8 bits +flat out uvec4 vPacked1; // ovl1(tex,alpha) ovl2(tex,alpha) +flat out uvec4 vPacked2; // road0(tex,alpha) road1(tex,alpha) +flat out uvec4 vPacked3; // rotations + split direction + +void main() { + vBaseUV = aTex; + vWorldNormal = normalize(mat3(uModel) * aNormal); + + vPacked0 = uvec4( aData0 & 0xFFu, (aData0 >> 8) & 0xFFu, + (aData0 >> 16) & 0xFFu, (aData0 >> 24) & 0xFFu); + vPacked1 = uvec4( aData1 & 0xFFu, (aData1 >> 8) & 0xFFu, + (aData1 >> 16) & 0xFFu, (aData1 >> 24) & 0xFFu); + vPacked2 = uvec4( aData2 & 0xFFu, (aData2 >> 8) & 0xFFu, + (aData2 >> 16) & 0xFFu, (aData2 >> 24) & 0xFFu); + vPacked3 = uvec4( aData3 & 0xFFu, (aData3 >> 8) & 0xFFu, + (aData3 >> 16) & 0xFFu, (aData3 >> 24) & 0xFFu); + + gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0); +} +``` + +### terrain.frag (sketch — exact compositing math is 1:1 from WorldBuilder's `Landscape.frag`) +```glsl +#version 430 core +in vec2 vBaseUV; +in vec3 vWorldNormal; +flat in uvec4 vPacked0, vPacked1, vPacked2, vPacked3; + +out vec4 fragColor; + +uniform sampler2DArray uTerrain; // 36 layers +uniform sampler2DArray uAlpha; // 16 layers + +// Lighting (unchanged from Phase 3a tune) +const vec3 SUN_DIR = normalize(vec3(0.5, 0.4, 0.6)); +const float AMBIENT = 0.25; +const float DIFFUSE = 0.75; + +vec4 sampleT(uint layer, vec2 uv) { + // Uniform tiling for now; can add per-layer uTexTiling[] later if needed. + return texture(uTerrain, vec3(uv, float(layer))); +} + +vec4 sampleA(uint layer, vec2 uv) { + return texture(uAlpha, vec3(uv, float(layer))); +} + +// Port of WorldBuilder maskBlend3: three-layer alpha-weighted composite. +vec4 combineOverlays(vec2 uv, + uint t0, uint a0, + uint t1, uint a1, + uint t2, uint a2) { + // ... see references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/Landscape.frag + // for the exact math; port verbatim with attribution in a comment +} + +vec4 combineRoads(vec2 uv, uint t0, uint a0, uint t1, uint a1) { + // Port of WorldBuilder combineRoad: inverted-alpha multiply +} + +void main() { + uint baseTex = vPacked0.x; + vec4 baseColor = sampleT(baseTex, vBaseUV); + + vec4 overlays = vec4(0.0); + if (vPacked0.z != 255u) // ovl0 present + overlays = combineOverlays(vBaseUV, + vPacked0.z, vPacked0.w, vPacked1.x, vPacked1.y, vPacked1.z, vPacked1.w); + + vec4 roads = vec4(0.0); + if (vPacked2.x != 255u) // road0 present + roads = combineRoads(vBaseUV, vPacked2.x, vPacked2.y, vPacked2.z, vPacked2.w); + + // Composite: base × (1-ovlA) × (1-rdA) + ovl × ovlA × (1-rdA) + road × rdA + vec3 b = baseColor.rgb * ((1.0 - overlays.a) * (1.0 - roads.a)); + vec3 o = overlays.rgb * ( overlays.a * (1.0 - roads.a)); + vec3 r = roads.rgb * roads.a; + vec3 rgb = b + o + r; + + // Phase 3a lighting + float ndotl = max(dot(normalize(vWorldNormal), SUN_DIR), 0.0); + float lighting = AMBIENT + DIFFUSE * ndotl; + fragColor = vec4(rgb * lighting, 1.0); +} +``` + +--- + +## 9. Tests + +### 9.1 Pure logic (`TerrainBlendingTests`) +- `GetPalCode_AllGrass_MatchesGolden` — hand-computed bit value +- `GetPalCode_DeterministicFromInputs` +- `GetCellSplitDirection_Deterministic` +- `GetCellSplitDirection_ProducesKnownValueForHoltburgCell` — golden from WorldBuilder instrumented +- `FillCellData_AllGrass_EmitsBaseOnly` — `d0.baseTex = grass, d0.ovl0Tex = 255` +- `FillCellData_GrassBorderingDirt_EmitsOverlay` +- `PseudoRandomIndex_InRange` + +### 9.2 Mesh generation (`LandblockMeshTests` rewritten) +- Old "81 vertices per landblock" asserts replaced with "384 vertices per landblock" +- `AllSixVerticesOfACellShareData0123` — within each 6-vertex stride, Data0..3 are identical +- `SplitDirectionFlipsTriangleOrder` — with a stub cell that hashes NwToSe, the emitted triangle corners differ from SwToNe +- Flat landblock still produces coherent mesh (no NaN normals, positions in expected range) + +### 9.3 Atlas build (`TerrainAtlasTests`) +- Extend existing tests: alpha atlas has ≥ 14 layers, each non-empty +- Corner/Side/Road layer index lists have expected sizes + +### 9.4 Visual smoke (no automated harness) +- Fly around Holtburg; terrain type boundaries should blend smoothly +- If roads exist in the loaded landblocks, they should overlay terrain correctly +- Lighting still works (Phase 3a/3b preserved in the rewritten fragment shader) + +--- + +## 10. Execution order (best sequence to catch bugs early) + +1. **Palette math, pure CPU** — write `TerrainBlending` + unit tests first. No GL, no visuals. Just prove the palCode/FillCellData math is byte-identical to WorldBuilder via golden values. Commit as `feat(core): terrain palette blending math (Phase 3c.1)`. + +2. **Alpha atlas loading** — extend `TerrainAtlas` to load the alpha maps from the Region dat. Add a debug output mode (optional: render alpha atlas full-screen as a grid) to verify the loaded masks look right. Commit as `feat(app): alpha atlas texture array (Phase 3c.2)`. + +3. **Vertex format + mesh generation** — rewrite `Vertex` struct, `LandblockMesh.Build`, and the VAO bindings in both `TerrainRenderer` and `StaticMeshRenderer`. The old terrain.frag will still render (reads only baseTex from Data0, ignores overlays) so the world should still look recognizable — just with per-cell texture choices instead of per-vertex. Commit as `refactor(core+app): per-cell terrain vertex layout (Phase 3c.3)`. + +4. **Shader rewrite** — the new terrain.vert/.frag with full blending. This is the visual-win commit. Iterate with you watching. Commit as `feat(app): per-cell terrain blending shader (Phase 3c.4)`. + +5. **Memory + state doc** — update `MEMORY.md`, write `project_phase_3c_state.md`. + +Each step is ~a few hundred lines, testable in isolation, and preserves a runnable program after commit. If step 4 looks wrong, we haven't broken steps 1-3. + +--- + +## 11. Risks + +| Risk | Mitigation | +|---|---| +| Bit layout in `FillCellData` drifts from the shader's `uvec4` unpack | Unit-test the CPU packer round-trip: `Pack → Unpack (same shifts as GLSL) → SurfaceInfo` and assert equality | +| `GetCellSplitDirection` magic constants copied wrong → wrong triangles | Golden-value test for ~5 known (lb,cell) → split from a WorldBuilder instrumented run | +| Triangle winding flipped → culled terrain (screen goes dark) | Disable face culling for the bring-up; enable once visually correct | +| Alpha atlas channel swizzle wrong (stored red but shader reads alpha) | Dedicated debug shader output mode showing only the alpha sample; short iterative loop | +| `TexMerge.TerrainDesc[i].MTileSize` unavailable in our DatReaderWriter version | Grep the reference crate first; fall back to uniform 1.0 tiling if missing | +| `StaticMeshRenderer` and `TerrainRenderer` VAO layouts drift apart after shared Vertex extension | Keep attribute enables identical in both; add a test that both call the same attribute-setup helper if drift becomes a concern | +| Visual iteration on blend math takes many runs | Leave the grayscale-lighting debug toggle easy to re-enable; have a "show alpha only" flag ready | + +--- + +## 12. Deliverables checklist + +- [ ] `src/AcDream.Core/Terrain/TerrainBlending.cs` + `SurfaceInfo` + `CellSplitDirection` +- [ ] `src/AcDream.Core.Tests/Terrain/TerrainBlendingTests.cs` with golden values +- [ ] `Vertex` struct extended (+16 bytes) +- [ ] `LandblockMesh.Build` rewritten to 64-cell layout +- [ ] `LandblockMeshTests` migrated to 384-vertex expectations +- [ ] `TerrainAtlas.Build` loads corner/side/road alpha maps, exposes `GlAlphaTexture` + index lists +- [ ] `TerrainRenderer` + `StaticMeshRenderer` VAO bindings extended (stride 48, 7 attributes) +- [ ] `terrain.vert` + `terrain.frag` rewritten with blending +- [ ] Visual smoke: Holtburg terrain boundaries blend smoothly +- [ ] Memory update: `project_phase_3c_state.md` + `MEMORY.md` index entry + +--- + +## 13. Not in 3c, tracked for later + +- **Phase 3d (if/when we need it):** chunking — global VBO slot pool, `TerrainChunk`, `TerrainGeometryGenerator` stateless chunk builder, multi-draw indirect. Pays off once we stream >20 landblocks. +- **Phase 4:** networking (design doc already at `docs/plans/2026-04-10-phase-4-networking-design.md`). Foundry statue comes online here. +- **Phase 5:** character appearance + animation. +- **Phase 6:** collision / physics (port ACViewer `Physics/`).