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>
193 lines
9.3 KiB
C#
193 lines
9.3 KiB
C#
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);
|
||
}
|
||
}
|