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>
22 KiB
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):
- Same as today for the 36 terrain layers.
- Iterate
Region.TerrainInfo.LandSurfaces.TexMerge.CornerTerrainMaps— for each, load the referencedSurfaceTexturedat, decode to grayscale (single channel), upload as layer N of the alpha atlas where N = insertion order. - Same for
SideTerrainMaps(appended after corners) andRoadMaps(appended after sides; layers may overlap per WorldBuilder's5 + indexconvention).
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 valueGetPalCode_DeterministicFromInputsGetCellSplitDirection_DeterministicGetCellSplitDirection_ProducesKnownValueForHoltburgCell— golden from WorldBuilder instrumentedFillCellData_AllGrass_EmitsBaseOnly—d0.baseTex = grass, d0.ovl0Tex = 255FillCellData_GrassBorderingDirt_EmitsOverlayPseudoRandomIndex_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 identicalSplitDirectionFlipsTriangleOrder— 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)
-
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 asfeat(core): terrain palette blending math (Phase 3c.1). -
Alpha atlas loading — extend
TerrainAtlasto 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 asfeat(app): alpha atlas texture array (Phase 3c.2). -
Vertex format + mesh generation — rewrite
Vertexstruct,LandblockMesh.Build, and the VAO bindings in bothTerrainRendererandStaticMeshRenderer. 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 asrefactor(core+app): per-cell terrain vertex layout (Phase 3c.3). -
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). -
Memory + state doc — update
MEMORY.md, writeproject_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+CellSplitDirectionsrc/AcDream.Core.Tests/Terrain/TerrainBlendingTests.cswith golden valuesVertexstruct extended (+16 bytes)LandblockMesh.Buildrewritten to 64-cell layoutLandblockMeshTestsmigrated to 384-vertex expectationsTerrainAtlas.Buildloads corner/side/road alpha maps, exposesGlAlphaTexture+ index listsTerrainRenderer+StaticMeshRendererVAO bindings extended (stride 48, 7 attributes)terrain.vert+terrain.fragrewritten with blending- Visual smoke: Holtburg terrain boundaries blend smoothly
- Memory update:
project_phase_3c_state.md+MEMORY.mdindex entry
13. Not in 3c, tracked for later
- Phase 3d (if/when we need it): chunking — global VBO slot pool,
TerrainChunk,TerrainGeometryGeneratorstateless 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/).