feat(core+app): per-cell terrain texture blending (Phase 3c.4)

The visual-win commit that wires up the Phase 3c.1/.2/.3 building blocks:
Holtburg's terrain now uses AC's real per-cell texture-merge blend
(base + up to 3 terrain overlays + up to 2 road overlays, with alpha
masks from the alpha atlas) instead of the flat per-vertex single-layer
atlas lookup that preceded it.

Geometry rewrite:
  - New TerrainVertex struct (40 bytes): Position(vec3) + Normal(vec3) +
    Data0..3 (4x uint32 packed blend recipe)
  - LandblockMesh.Build is now cell-based: iterates 8x8 cells instead of
    the old 9x9 vertex grid, emits 6 vertices per cell (two triangles),
    384 total vertices per landblock
  - For each cell: extract 4-corner terrain/road values → GetPalCode →
    BuildSurface (cached across landblocks via a shared surfaceCache) →
    FillCellData → split direction from CalculateSplitDirection → emit
    6 vertices in the exact gl_VertexID % 6 order WorldBuilder's vertex
    shader expects
  - Per-vertex normals preserved via Phase 3b central-difference
    precomputation on the 9x9 heightmap, interpolated smoothly across
    the cell (we deliberately didn't adopt WorldBuilder's dFdx/dFdy
    flat-shade approach — Phase 3a/3b user-tuned lighting was worth
    keeping)

Renderer rewrite:
  - TerrainRenderer VAO: vec3 Position, vec3 Normal, 4x uvec4 byte
    attributes for Data0..3. The uvec4-of-bytes read pattern matches
    Landscape.vert so the ported shader math stays byte-for-byte
    identical to WorldBuilder's.
  - Binds both atlases: terrain atlas on unit 0 (uTerrain), alpha atlas
    on unit 1 (uAlpha)

Shader rewrite (ports of WorldBuilder Landscape.vert/.frag, trimmed):
  - terrain.vert: unpacks the 4 data bytes + rotation bits, derives the
    cell corner from gl_VertexID % 6 + splitDir, rotates the cell-local
    UV per overlay's rotation field, and computes world-space normal
    for the fragment shader
  - terrain.frag: maskBlend3 three-layer alpha-weighted composite for
    terrain overlays, inverted-alpha road combine, final composite
    base * (1-ovlA)*(1-rdA) + ovl * ovlA*(1-rdA) + road * rdA. Phase
    3a/3b directional lighting applied on top (SUN_DIR, AMBIENT=0.25,
    DIFFUSE=0.75, in sync with mesh.frag).
  - Editor uniforms (grid, brush, unwalkable slopes) deliberately
    omitted — not applicable to a game client
  - Per-texture tiling factor hardcoded to 1.0 for now (WorldBuilder
    reads it from uTexTiling[36] uploaded from the dats); one tile per
    cell = 8 tiles per landblock-side, slightly coarser than the old
    ~2x-per-cell tiling. Tunable via the TILE constant if needed.

TerrainAtlas grew parallel TCode/RCode lists (CornerAlphaTCodes,
SideAlphaTCodes, RoadAlphaRCodes) so TerrainBlendingContext can be
built without the mesh loader touching the dats directly.

GameWindow builds a TerrainBlendingContext once, shares a Dictionary
<uint, SurfaceInfo> surfaceCache across all 9 landblocks. Output:
"terrain: 137 unique palette codes across 9 landblocks" — avg ~15
unique per landblock, cache reuse healthy.

LandblockMeshTests rewritten for 384-vertex layout. 77/77 tests green.
Visual smoke run launches clean: no shader compile/link errors, no
GL warnings, terrain renders to the screen.

User visual verification is the final acceptance gate for Phase 3c.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 14:02:15 +02:00
parent a6cd56663f
commit e0dfecdf23
8 changed files with 610 additions and 170 deletions

View file

@ -3,87 +3,179 @@ using DatReaderWriter.DBObjs;
namespace AcDream.Core.Terrain;
public sealed record LandblockMeshData(Vertex[] Vertices, uint[] Indices);
/// <summary>
/// Terrain mesh data for a single landblock: 64 cells × 6 vertices per cell =
/// 384 vertices. All 6 vertices of a cell share the same <c>Data0..Data3</c>
/// blend recipe; the vertex shader derives UVs and corner index from
/// <c>gl_VertexID % 6</c> plus the split-direction bit.
/// </summary>
public sealed record LandblockMeshData(TerrainVertex[] Vertices, uint[] Indices);
public static class LandblockMesh
{
private const int VerticesPerSide = 9;
private const int CellsPerSide = VerticesPerSide - 1;
private const float CellSize = 24.0f;
// Phase 2b: tile terrain textures ~4x per landblock instead of stretching
// a single texture across the whole 192-unit patch.
private const float TexCoordDivisor = CellsPerSide / 4.0f;
public const int HeightmapSide = 9; // 9×9 heightmap samples
public const int CellsPerSide = 8; // 8×8 cells per landblock
public const int VerticesPerCell = 6; // two triangles
public const int VerticesPerLandblock = CellsPerSide * CellsPerSide * VerticesPerCell; // 384
public const float CellSize = 24.0f;
public const float LandblockSize = CellsPerSide * CellSize; // 192
// TerrainInfo bit layout: bits 0-1 Road, bits 2-6 Type (5-bit
// TerrainTextureType), bits 11-15 Scenery. Road flag is the 2-bit field
// at the LSB; AC's per-corner road value for GetPalCode takes the mask
// as an int 0..3.
private const int RoadMask = 0x3;
private const int TypeShift = 2;
private const int TypeMask = 0x1F;
/// <summary>
/// Build a per-cell terrain mesh for one landblock. Each cell is looked
/// up in the shared <paramref name="surfaceCache"/> by palette code; only
/// palette codes not yet seen in this scene call
/// <see cref="TerrainBlending.BuildSurface"/>.
/// </summary>
/// <param name="block">Landblock dat record (heightmap + terrain info).</param>
/// <param name="landblockX">Landblock X coord (high byte of landblock id) for split-direction hashing.</param>
/// <param name="landblockY">Landblock Y coord (second byte of landblock id).</param>
/// <param name="heightTable">Region.LandDefs.LandHeightTable — 256 float heights.</param>
/// <param name="ctx">TerrainAtlas-derived blending inputs.</param>
/// <param name="surfaceCache">Shared SurfaceInfo cache keyed by palette code.</param>
public static LandblockMeshData Build(
LandBlock block,
uint landblockX,
uint landblockY,
float[] heightTable,
IReadOnlyDictionary<uint, uint> terrainTypeToLayer)
TerrainBlendingContext ctx,
Dictionary<uint, SurfaceInfo> surfaceCache)
{
ArgumentNullException.ThrowIfNull(block);
ArgumentNullException.ThrowIfNull(heightTable);
ArgumentNullException.ThrowIfNull(terrainTypeToLayer);
ArgumentNullException.ThrowIfNull(ctx);
ArgumentNullException.ThrowIfNull(surfaceCache);
if (heightTable.Length < 256)
throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));
// Precompute all 81 heights in (x,y) grid order so we can do cheap
// neighbor lookups when computing per-vertex normals by central differences.
// Heights are packed x-major (Height[x*9+y]) matching the fix in cc55c3f.
var heights = new float[VerticesPerSide, VerticesPerSide];
for (int x = 0; x < VerticesPerSide; x++)
for (int y = 0; y < VerticesPerSide; y++)
heights[x, y] = heightTable[block.Height[x * VerticesPerSide + y]];
// Pre-sample all 81 heights into a 2D array (x-major indexing). This
// doubles as the source for per-vertex normals via central differences
// (Phase 3b lighting, preserved through the per-cell refactor).
var heights = new float[HeightmapSide, HeightmapSide];
for (int x = 0; x < HeightmapSide; x++)
for (int y = 0; y < HeightmapSide; y++)
heights[x, y] = heightTable[block.Height[x * HeightmapSide + y]];
var vertices = new Vertex[VerticesPerSide * VerticesPerSide];
for (int y = 0; y < VerticesPerSide; y++)
// Pre-compute all 81 vertex normals so the inner cell loop is a pure
// lookup. Central differences on the heightmap → smooth normal field.
var normals = new Vector3[HeightmapSide, HeightmapSide];
for (int x = 0; x < HeightmapSide; x++)
for (int y = 0; y < HeightmapSide; y++)
{
for (int x = 0; x < VerticesPerSide; x++)
int xL = Math.Max(x - 1, 0);
int xR = Math.Min(x + 1, HeightmapSide - 1);
int yD = Math.Max(y - 1, 0);
int yU = Math.Min(y + 1, HeightmapSide - 1);
float dx = (heights[xR, y] - heights[xL, y]) / ((xR - xL) * CellSize);
float dy = (heights[x, yU] - heights[x, yD]) / ((yU - yD) * CellSize);
normals[x, y] = Vector3.Normalize(new Vector3(-dx, -dy, 1f));
}
var vertices = new TerrainVertex[VerticesPerLandblock];
var indices = new uint[VerticesPerLandblock]; // 1 index per vertex (no deduplication)
int vi = 0;
for (int cy = 0; cy < CellsPerSide; cy++)
{
for (int cx = 0; cx < CellsPerSide; cx++)
{
int vi = y * VerticesPerSide + x;
int hi = x * VerticesPerSide + y;
float height = heights[x, y];
// Four corner TerrainInfo samples (x-major block.Terrain[x*9+y]).
var tBL = block.Terrain[cx * HeightmapSide + cy];
var tBR = block.Terrain[(cx + 1) * HeightmapSide + cy];
var tTR = block.Terrain[(cx + 1) * HeightmapSide + (cy + 1)];
var tTL = block.Terrain[cx * HeightmapSide + (cy + 1)];
// TerrainInfo is bit-packed: bits 0-1 Road, bits 2-6 Type (5-bit
// TerrainTextureType enum), bits 11-15 Scenery. The atlas keys on
// Type only, matching Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc
// which lists SurfaceTexture ids per TerrainTextureType.
uint terrainType = (uint)block.Terrain[hi].Type;
if (!terrainTypeToLayer.TryGetValue(terrainType, out var layer))
layer = 0;
int rBL = tBL.Road & RoadMask;
int rBR = tBR.Road & RoadMask;
int rTR = tTR.Road & RoadMask;
int rTL = tTL.Road & RoadMask;
int ttBL = (int)tBL.Type & TypeMask;
int ttBR = (int)tBR.Type & TypeMask;
int ttTR = (int)tTR.Type & TypeMask;
int ttTL = (int)tTL.Type & TypeMask;
// Per-vertex normal from central differences on the heightmap.
// Surface is z=h(x,y); tangents Sx=(1,0,dh/dx), Sy=(0,1,dh/dy);
// normal = Sx x Sy = (-dh/dx, -dh/dy, 1), normalized.
// Edge vertices use forward/backward difference instead of central.
int xL = Math.Max(x - 1, 0);
int xR = Math.Min(x + 1, VerticesPerSide - 1);
int yD = Math.Max(y - 1, 0);
int yU = Math.Min(y + 1, VerticesPerSide - 1);
float dx = (heights[xR, y] - heights[xL, y]) / ((xR - xL) * CellSize);
float dy = (heights[x, yU] - heights[x, yD]) / ((yU - yD) * CellSize);
var normal = Vector3.Normalize(new Vector3(-dx, -dy, 1f));
// WorldBuilder's palCode convention: t1=BL, t2=BR, t3=TR, t4=TL.
uint palCode = TerrainBlending.GetPalCode(
rBL, rBR, rTR, rTL, ttBL, ttBR, ttTR, ttTL);
vertices[vi] = new Vertex(
Position: new Vector3(x * CellSize, y * CellSize, height),
Normal: normal,
TexCoord: new Vector2(x / TexCoordDivisor, y / TexCoordDivisor),
TerrainLayer: layer);
if (!surfaceCache.TryGetValue(palCode, out var surf))
{
surf = TerrainBlending.BuildSurface(palCode, ctx);
surfaceCache[palCode] = surf;
}
var split = TerrainBlending.CalculateSplitDirection(
landblockX, (uint)cx, landblockY, (uint)cy);
var (d0, d1, d2, d3) = TerrainBlending.FillCellData(surf, split);
// Corner positions in landblock-local space.
var posBL = new Vector3( cx * CellSize, cy * CellSize, heights[cx, cy ]);
var posBR = new Vector3((cx + 1) * CellSize, cy * CellSize, heights[cx + 1, cy ]);
var posTR = new Vector3((cx + 1) * CellSize, (cy + 1) * CellSize, heights[cx + 1, cy + 1]);
var posTL = new Vector3( cx * CellSize, (cy + 1) * CellSize, heights[cx, cy + 1]);
var nBL = normals[cx, cy];
var nBR = normals[cx + 1, cy];
var nTR = normals[cx + 1, cy + 1];
var nTL = normals[cx, cy + 1];
// Emit 6 vertices in the exact order WorldBuilder's Landscape.vert
// expects. The vertex shader maps gl_VertexID % 6 → corner index
// for UV lookup, so the CPU order must match.
//
// SWtoNE (splitDir=0):
// vIdx: 0 1 2 3 4 5
// corner: 0 3 1 1 3 2
// BL TL BR BR TL TR
// SEtoNW (splitDir=1):
// vIdx: 0 1 2 3 4 5
// corner: 0 2 1 0 3 2
// BL TR BR BL TL TR
if (split == CellSplitDirection.SWtoNE)
{
WriteCell(vertices, ref vi, d0, d1, d2, d3,
posBL, nBL, posTL, nTL, posBR, nBR,
posBR, nBR, posTL, nTL, posTR, nTR);
}
else
{
WriteCell(vertices, ref vi, d0, d1, d2, d3,
posBL, nBL, posTR, nTR, posBR, nBR,
posBL, nBL, posTL, nTL, posTR, nTR);
}
}
}
var indices = new uint[CellsPerSide * CellsPerSide * 6];
int idx = 0;
for (int y = 0; y < CellsPerSide; y++)
{
for (int x = 0; x < CellsPerSide; x++)
{
uint a = (uint)(y * VerticesPerSide + x);
uint b = (uint)(y * VerticesPerSide + x + 1);
uint c = (uint)((y + 1) * VerticesPerSide + x);
uint d = (uint)((y + 1) * VerticesPerSide + x + 1);
indices[idx++] = a; indices[idx++] = b; indices[idx++] = d;
indices[idx++] = a; indices[idx++] = d; indices[idx++] = c;
}
}
// Indices are trivial 0..383 since we don't deduplicate verts.
for (uint i = 0; i < VerticesPerLandblock; i++)
indices[i] = i;
return new LandblockMeshData(vertices, indices);
}
private static void WriteCell(
TerrainVertex[] verts, ref int vi,
uint d0, uint d1, uint d2, uint d3,
Vector3 p0, Vector3 n0,
Vector3 p1, Vector3 n1,
Vector3 p2, Vector3 n2,
Vector3 p3, Vector3 n3,
Vector3 p4, Vector3 n4,
Vector3 p5, Vector3 n5)
{
verts[vi++] = new TerrainVertex(p0, n0, d0, d1, d2, d3);
verts[vi++] = new TerrainVertex(p1, n1, d0, d1, d2, d3);
verts[vi++] = new TerrainVertex(p2, n2, d0, d1, d2, d3);
verts[vi++] = new TerrainVertex(p3, n3, d0, d1, d2, d3);
verts[vi++] = new TerrainVertex(p4, n4, d0, d1, d2, d3);
verts[vi++] = new TerrainVertex(p5, n5, d0, d1, d2, d3);
}
}

View file

@ -0,0 +1,28 @@
using System.Numerics;
namespace AcDream.Core.Terrain;
/// <summary>
/// Per-vertex terrain geometry + blend data. Terrain uses a cell-based
/// layout: 64 cells per landblock, 6 vertices per cell (two triangles),
/// 384 vertices per landblock total. All 6 vertices in a cell carry
/// identical <c>Data0..3</c> (the cell's blend recipe from
/// <see cref="TerrainBlending.FillCellData"/>); the vertex shader derives
/// which of the 4 cell corners a given vertex represents from
/// <c>gl_VertexID % 6</c> plus the split direction bit.
///
/// Normal is stored per vertex via Phase 3b's central-difference scheme on
/// the 9×9 heightmap — this lets the fragment shader interpolate a smooth
/// normal across triangles (softer than WorldBuilder's <c>dFdx</c>/<c>dFdy</c>
/// flat-shaded approach). UVs are derived from the corner index in the
/// vertex shader — not stored here.
///
/// Size: 12 (position) + 12 (normal) + 4*4 (Data0..3) = 40 bytes.
/// </summary>
public readonly record struct TerrainVertex(
Vector3 Position,
Vector3 Normal,
uint Data0,
uint Data1,
uint Data2,
uint Data3);