acdream/src/AcDream.Core/Terrain/LandblockMesh.cs
Erik 4be392b361 refactor(A.5 T9): _surfaceCache -> ConcurrentDictionary for off-thread mesh build
Widens LandblockMesh.Build's surfaceCache parameter from Dictionary to
IDictionary so any IDictionary implementation compiles at call sites.
Switches GameWindow._surfaceCache from Dictionary to ConcurrentDictionary
so T11's streaming worker can call Build off the render thread without
a lock.

The TryGetValue+assign lookup inside Build is not atomic, but BuildSurface
is deterministic (same palCode -> same SurfaceInfo), making last-write-wins
under concurrent access benign. Comment added at the pattern site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:55:53 +02:00

193 lines
9.3 KiB
C#
Raw 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.

using System.Numerics;
using DatReaderWriter.DBObjs;
namespace AcDream.Core.Terrain;
/// <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
{
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,
TerrainBlendingContext ctx,
System.Collections.Generic.IDictionary<uint, SurfaceInfo> surfaceCache)
{
ArgumentNullException.ThrowIfNull(block);
ArgumentNullException.ThrowIfNull(heightTable);
ArgumentNullException.ThrowIfNull(ctx);
ArgumentNullException.ThrowIfNull(surfaceCache);
if (heightTable.Length < 256)
throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));
// 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]];
// 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++)
{
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++)
{
// 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)];
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;
// WorldBuilder's palCode convention: t1=BL, t2=BR, t3=TR, t4=TL.
uint palCode = TerrainBlending.GetPalCode(
rBL, rBR, rTR, rTL, ttBL, ttBR, ttTR, ttTL);
// Lookup-or-build pattern. Not atomic under concurrent access
// (TryGetValue then assign), but BuildSurface is deterministic —
// two workers building the same palCode produce equal SurfaceInfo,
// last-write-wins is benign.
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 an order matching ACE's
// LandblockStruct.ConstructPolygons triangulation. The vertex
// shader maps gl_VertexID % 6 → corner index for UV lookup,
// so the CPU order and shader table must stay in lockstep.
//
// SWtoNE (splitDir=0, SWtoNEcut=true): diagonal BL → TR.
// Triangles: {BL,BR,TR} + {BL,TR,TL} (shared edge BL-TR)
// vIdx: 0 1 2 3 4 5
// corner: 0 1 2 0 2 3
// BL BR TR BL TR TL
// SEtoNW (splitDir=1, SWtoNEcut=false): diagonal BR → TL.
// Triangles: {BL,BR,TL} + {BR,TR,TL} (shared edge BR-TL)
// vIdx: 0 1 2 3 4 5
// corner: 0 1 3 1 2 3
// BL BR TL BR TR TL
//
// 2026-04-21 fix: previous mapping had the enum→geometry
// inversion — SWtoNE built NW-SE-diagonal triangles (ACE's
// SEtoNW geometry) and vice versa, causing remote players
// to hover/clip on sloped cells by up to ~1m.
if (split == CellSplitDirection.SWtoNE)
{
WriteCell(vertices, ref vi, d0, d1, d2, d3,
posBL, nBL, posBR, nBR, posTR, nTR,
posBL, nBL, posTR, nTR, posTL, nTL);
}
else
{
WriteCell(vertices, ref vi, d0, d1, d2, d3,
posBL, nBL, posBR, nBR, posTL, nTL,
posBR, nBR, posTR, nTR, posTL, nTL);
}
}
}
// 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);
}
}