acdream/docs/plans/2026-04-10-phase-3c-terrain-blending-plan.md
Erik 3fb6b67735 docs(plan): Phase 3c terrain blending plan
Blending-focused plan for Phase 3c: port WorldBuilder's per-cell
palette-code + alpha-atlas terrain texture merge scheme so terrain
type boundaries blend smoothly instead of stair-stepping.

Scope deliberately excludes chunking (deferred to a possible Phase 3d
when streaming actually matters) so the work stays focused on the
visible win.

Execution split into 4 small commits:
  3c.1 palette math (pure CPU, unit tested against golden values)
  3c.2 alpha atlas loading
  3c.3 per-cell vertex layout refactor
  3c.4 shader rewrite (the visual-win commit)

Each step is runnable on its own so if 3c.4 looks wrong we know the
earlier steps were fine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:43:04 +02:00

22 KiB
Raw Permalink Blame History

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

// 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)

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<uint, byte> terrainTypeToLayer,
    IReadOnlyList<byte> cornerAlphaLayers,  // atlas layers 0..3
    IReadOnlyList<byte> sideAlphaLayers,    // atlas layers 4..7
    IReadOnlyList<byte> roadAlphaLayers);   // atlas layers 5..14

3.3 FillCellData (pack into 4 uints)

// 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)

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

// 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).

public static LandblockMeshData Build(
    LandBlock block,
    int landblockX, int landblockY,          // for split-direction hashing
    float[] heightTable,
    TexMergeInfo texMerge,
    IReadOnlyDictionary<uint, byte> terrainTypeToLayer,
    IReadOnlyList<byte> cornerAlphaLayers,
    IReadOnlyList<byte> sideAlphaLayers,
    IReadOnlyList<byte> roadAlphaLayers,
    Dictionary<uint, SurfaceInfo> 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<Vertex>(64 * 6);
    var indices  = new List<uint>(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:

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:

public sealed class TerrainAtlas : IDisposable
{
    // Existing
    public uint GlTerrainTexture { get; }  // 512×512 × 36 layers, RGBA8
    public IReadOnlyDictionary<uint, byte> TerrainTypeToLayer { get; }

    // New
    public uint GlAlphaTexture { get; }    // 512×512 × 16 layers, RGBA8 (R=G=B=A=alpha)
    public IReadOnlyList<byte> CornerAlphaLayers { get; }  // layers 0..3
    public IReadOnlyList<byte> SideAlphaLayers { get; }    // layers 4..7
    public IReadOnlyList<byte> 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:

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:

_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

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

#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_EmitsBaseOnlyd0.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/).