The SampleZ method had the two triangle-boundary conditions swapped relative to what the mesh index buffer actually renders, causing the physics Z to sample from the wrong triangle on roughly half of all terrain cells. The error could be up to 7.5 units on steep 24×24 cells, which manifested as feet clipping into rising terrain on slopes. Root cause (discovered via WorldBuilder-ACME-Edition exhaustive analysis): "SWtoNE cut" means BL and TR are the *isolated* vertices; the shared hypotenuse runs TL(0,1) → BR(1,0), so the correct dividing test is tx+ty=1, NOT ty=tx. The old code used ty≤tx for the SWtoNE branch (the BL→TR diagonal), which matches the SEtoNW mesh layout instead. Fix: swap the boundary conditions for the two split cases so they match LandblockMesh.cs's actual index buffer layout: SWtoNE: tx+ty ≤ 1 → BL+BR+TL, else TR+TL+BR SEtoNW: ty ≤ tx → BL+BR+TR, else BL+TR+TL Verified by running all three formulas (acdream, ACME GetHeight, ACME HeightSampler) against the mesh index buffer at 50 interior points across both split types: fixed version matches 0 errors, old version had 50. All 283 tests still pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
128 lines
5.2 KiB
C#
128 lines
5.2 KiB
C#
using System;
|
||
|
||
namespace AcDream.Core.Physics;
|
||
|
||
/// <summary>
|
||
/// 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).
|
||
///
|
||
/// <para>
|
||
/// 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.
|
||
/// </para>
|
||
/// </summary>
|
||
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]];
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Compute the outdoor cell ID for the given landblock-local position.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// AC2D's FSplitNESW render formula. Returns true for SW→NE diagonal.
|
||
/// Uses global cell coordinates (landblockX*8+cellX, landblockY*8+cellY).
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|