using System; namespace AcDream.Core.Physics; /// /// Outdoor terrain height resolver for a single landblock. Performs /// per-triangle barycentric Z interpolation matching the visual terrain /// mesh's triangle split direction (AC2D's FSplitNESW formula). /// /// /// Each cell (24×24 units) is split into two triangles along either the /// SW→NE or SE→NW diagonal. The split direction is determined by the /// same formula the render mesh uses (0x0CCAC033 constants from AC2D), /// so the Z this class produces matches the visual terrain surface exactly. /// /// public sealed class TerrainSurface { private const int HeightmapSide = 9; private const float CellSize = 24f; private const int CellsPerSide = 8; // 192 / 24 private readonly float[,] _z; // pre-resolved heights [x, y] private readonly uint _landblockX; private readonly uint _landblockY; public TerrainSurface(byte[] heights, float[] heightTable, uint landblockX = 0, uint landblockY = 0) { ArgumentNullException.ThrowIfNull(heights); ArgumentNullException.ThrowIfNull(heightTable); if (heights.Length < 81) throw new ArgumentException("heights must have 81 entries", nameof(heights)); if (heightTable.Length < 256) throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable)); _landblockX = landblockX; _landblockY = landblockY; // Pre-resolve all 81 heights so SampleZ is a pure lookup + lerp. _z = new float[HeightmapSide, HeightmapSide]; for (int x = 0; x < HeightmapSide; x++) for (int y = 0; y < HeightmapSide; y++) _z[x, y] = heightTable[heights[x * HeightmapSide + y]]; } /// /// Triangle-aware terrain Z at (localX, localY) in landblock-local /// coordinates (0..192 range). Uses the AC2D FSplitNESW formula to /// determine which triangle the point falls in, then does barycentric /// interpolation within that triangle. This matches the visual terrain /// mesh exactly. /// /// Triangle layout (from LandblockMesh.cs index buffer): /// SWtoNE: tri1 = {BL,TL,BR}, tri2 = {BR,TL,TR} — shared edge TL→BR (x+y=1 boundary) /// SEtoNW: tri1 = {BL,TR,BR}, tri2 = {BL,TL,TR} — shared edge BL→TR (y=x boundary) /// /// NOTE: The SWtoNE "cut" exposes the SW(BL) and NE(TR) corners as isolated /// vertices — the hypotenuse runs NW(TL)→SE(BR), so the dividing test is /// x+y=1 (not y=x). Confusing naming aside, the formula below matches /// TerrainGeometryGenerator.GetHeight (ACME WorldBuilder-ACME-Edition) which /// was verified against the mesh index buffer. /// public float SampleZ(float localX, float localY) { // Which cell? float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f); float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f); int cx = (int)fx; int cy = (int)fy; cx = Math.Clamp(cx, 0, CellsPerSide - 1); cy = Math.Clamp(cy, 0, CellsPerSide - 1); // Fractional position within the cell [0, 1] float tx = fx - cx; float ty = fy - cy; // Four corner heights (BL, BR, TR, TL) float hBL = _z[cx, cy ]; float hBR = _z[cx + 1, cy ]; float hTR = _z[cx + 1, cy + 1]; float hTL = _z[cx, cy + 1]; // Split direction using the AC2D render formula bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy); if (splitSWtoNE) { // Mesh: {BL,TL,BR} and {BR,TL,TR}. Shared hypotenuse = TL(0,1)→BR(1,0). // Dividing line: tx + ty = 1. if (tx + ty <= 1f) return hBL + (hBR - hBL) * tx + (hTL - hBL) * ty; // BL+BR+TL triangle else return hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); // TR+TL+BR triangle } else { // Mesh: {BL,TR,BR} and {BL,TL,TR}. Shared hypotenuse = BL(0,0)→TR(1,1). // Dividing line: ty = tx. if (ty <= tx) return hBL + (hBR - hBL) * tx + (hTR - hBR) * ty; // BL+BR+TR triangle else return hBL + (hTR - hTL) * tx + (hTL - hBL) * ty; // BL+TR+TL triangle } } /// /// Compute the outdoor cell ID for the given landblock-local position. /// public uint ComputeOutdoorCellId(float localX, float localY) { int cx = Math.Clamp((int)(localX / CellSize), 0, CellsPerSide - 1); int cy = Math.Clamp((int)(localY / CellSize), 0, CellsPerSide - 1); return (uint)(1 + cx * CellsPerSide + cy); } /// /// AC2D's FSplitNESW render formula. Returns true for SW→NE diagonal. /// Uses global cell coordinates (landblockX*8+cellX, landblockY*8+cellY). /// private static bool IsSplitSWtoNE(uint landblockX, uint cellX, uint landblockY, uint cellY) { uint x = landblockX * 8 + cellX; uint y = landblockY * 8 + cellY; uint dw = unchecked(x * y * 0x0CCAC033u - x * 0x421BE3BDu + y * 0x6C1AC587u - 0x519B8F25u); return (dw & 0x80000000u) != 0; } }