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

497 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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