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:
parent
a6cd56663f
commit
e0dfecdf23
8 changed files with 610 additions and 170 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28
src/AcDream.Core/Terrain/TerrainVertex.cs
Normal file
28
src/AcDream.Core/Terrain/TerrainVertex.cs
Normal 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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue