using System.Numerics; using DatReaderWriter.DBObjs; namespace AcDream.Core.Terrain; /// /// Terrain mesh data for a single landblock: 64 cells × 6 vertices per cell = /// 384 vertices. All 6 vertices of a cell share the same Data0..Data3 /// blend recipe; the vertex shader derives UVs and corner index from /// gl_VertexID % 6 plus the split-direction bit. /// 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; /// /// Build a per-cell terrain mesh for one landblock. Each cell is looked /// up in the shared by palette code; only /// palette codes not yet seen in this scene call /// . /// /// Landblock dat record (heightmap + terrain info). /// Landblock X coord (high byte of landblock id) for split-direction hashing. /// Landblock Y coord (second byte of landblock id). /// Region.LandDefs.LandHeightTable — 256 float heights. /// TerrainAtlas-derived blending inputs. /// Shared SurfaceInfo cache keyed by palette code. public static LandblockMeshData Build( LandBlock block, uint landblockX, uint landblockY, float[] heightTable, TerrainBlendingContext ctx, System.Collections.Generic.IDictionary 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); } }