fix(core): AC2D render split formula + triangle-aware Z sampling

Two fundamental terrain fixes based on the AC2D + holtburger deep dive:

1. Terrain split formula: replaced WorldBuilder's physics-path formula
   (214614067/1813693831) with AC2D's render-path formula (0x0CCAC033,
   0x421BE3BD, 0x6C1AC587, 0x519B8F25). The two produce different splits
   for some cells. Since the render mesh uses this formula, the physics
   Z sampler must match it to avoid misalignment on slopes.

2. Triangle-aware Z: replaced bilinear interpolation in TerrainSurface
   with per-triangle barycentric interpolation. Each cell is split into
   two triangles (using the same AC2D formula). SampleZ determines which
   triangle the query point falls in, then interpolates within that
   triangle. This produces Z values that exactly match the visual terrain
   mesh — no more slope clipping.

Removes the multi-point Z sampling hack from PlayerMovementController
(no longer needed with exact triangle Z).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 22:08:42 +02:00
parent 5cd776914a
commit c5de445e5c
4 changed files with 116 additions and 72 deletions

View file

@ -94,7 +94,6 @@ public sealed class PlayerMovementController
// Heartbeat timer.
private float _heartbeatAccum;
private float _prevGroundZ;
public const float HeartbeatInterval = 0.2f; // 200ms
public bool HeartbeatDue { get; private set; }
@ -107,7 +106,6 @@ public sealed class PlayerMovementController
{
Position = pos;
CellId = cellId;
_prevGroundZ = pos.Z;
}
public MovementResult Update(float dt, MovementInput input)
@ -206,31 +204,10 @@ public sealed class PlayerMovementController
}
}
// Multi-point Z sampling: on slopes the visual terrain mesh can be
// higher or lower than the center-point physics sample. Sample 4
// points around the character (forward, back, left, right at ~0.7
// units — roughly foot distance) and use the MAX Z. This prevents
// feet clipping on both uphill and downhill slopes.
if (!IsAirborne)
{
const float sampleDist = 0.7f;
ReadOnlySpan<(float ox, float oy)> offsets = stackalloc (float, float)[]
{
( forwardX * sampleDist, forwardY * sampleDist), // forward
(-forwardX * sampleDist, -forwardY * sampleDist), // back
( forwardY * sampleDist, -forwardX * sampleDist), // right
(-forwardY * sampleDist, forwardX * sampleDist), // left
};
foreach (var (ox, oy) in offsets)
{
var sampleResult = _physics.Resolve(
new Vector3(result.Position.X + ox, result.Position.Y + oy, newZ),
CellId, Vector3.Zero, StepUpHeight);
if (sampleResult.IsOnGround && sampleResult.Position.Z > newZ)
newZ = sampleResult.Position.Z;
}
}
_prevGroundZ = newZ;
// Triangle-aware Z sampling in TerrainSurface now matches the visual
// terrain mesh exactly (using AC2D's FSplitNESW render formula +
// barycentric interpolation within each triangle). No multi-point
// sampling hacks needed.
Position = new Vector3(result.Position.X, result.Position.Y, newZ);
CellId = result.CellId;

View file

@ -1419,7 +1419,10 @@ public sealed class GameWindow : IDisposable
// surfaces for this landblock. Runs under _datLock (same lock as the
// rest of ApplyLoadedTerrainLocked) so dat reads are safe.
{
var terrainSurface = new AcDream.Core.Physics.TerrainSurface(lb.Heightmap.Height, _heightTable);
uint lbPhysX = (lb.LandblockId >> 24) & 0xFFu;
uint lbPhysY = (lb.LandblockId >> 16) & 0xFFu;
var terrainSurface = new AcDream.Core.Physics.TerrainSurface(
lb.Heightmap.Height, _heightTable, lbPhysX, lbPhysY);
var cellSurfaces = new List<AcDream.Core.Physics.CellSurface>();
var portalPlanes = new List<AcDream.Core.Physics.PortalPlane>();

View file

@ -4,17 +4,14 @@ namespace AcDream.Core.Physics;
/// <summary>
/// Outdoor terrain height resolver for a single landblock. Performs
/// bilinear interpolation of the 9×9 heightmap grid to produce the
/// ground Z at any (localX, localY) within the 192×192 landblock
/// footprint. Also computes the outdoor cell ID for AC's position
/// encoding.
/// per-triangle barycentric Z interpolation matching the visual terrain
/// mesh's triangle split direction (AC2D's FSplitNESW formula).
///
/// <para>
/// Algorithm ported from GameWindow.SampleTerrainZ (which was inlined
/// and not reusable). The heightmap is indexed x-major:
/// <c>heights[x * 9 + y]</c>; each byte is a lookup into
/// <paramref name="heightTable"/> (256-entry float array from
/// Region.LandDefs.LandHeightTable).
/// 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
@ -23,10 +20,12 @@ public sealed class TerrainSurface
private const float CellSize = 24f;
private const int CellsPerSide = 8; // 192 / 24
private readonly byte[] _heights;
private readonly float[] _heightTable;
private readonly float[,] _z; // pre-resolved heights [x, y]
private readonly uint _landblockX;
private readonly uint _landblockY;
public TerrainSurface(byte[] heights, float[] heightTable)
public TerrainSurface(byte[] heights, float[] heightTable,
uint landblockX = 0, uint landblockY = 0)
{
ArgumentNullException.ThrowIfNull(heights);
ArgumentNullException.ThrowIfNull(heightTable);
@ -35,40 +34,88 @@ public sealed class TerrainSurface
if (heightTable.Length < 256)
throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));
_heights = heights;
_heightTable = 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>
/// Bilinear-interpolated terrain Z at (localX, localY) in
/// landblock-local coordinates (0..192 range).
/// 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.
/// </summary>
public float SampleZ(float localX, float localY)
{
float fx = Math.Clamp(localX / CellSize, 0f, HeightmapSide - 1f);
float fy = Math.Clamp(localY / CellSize, 0f, HeightmapSide - 1f);
// 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);
int x0 = Math.Min((int)fx, HeightmapSide - 2);
int y0 = Math.Min((int)fy, HeightmapSide - 2);
int x1 = x0 + 1;
int y1 = y0 + 1;
float tx = fx - x0;
float ty = fy - y0;
// Fractional position within the cell [0, 1]
float tx = fx - cx;
float ty = fy - cy;
float h00 = _heightTable[_heights[x0 * HeightmapSide + y0]];
float h10 = _heightTable[_heights[x1 * HeightmapSide + y0]];
float h01 = _heightTable[_heights[x0 * HeightmapSide + y1]];
float h11 = _heightTable[_heights[x1 * HeightmapSide + y1]];
// 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];
float hx0 = h00 * (1 - tx) + h10 * tx;
float hx1 = h01 * (1 - tx) + h11 * tx;
return hx0 * (1 - ty) + hx1 * ty;
// Split direction using the AC2D render formula
bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy);
if (splitSWtoNE)
{
// Diagonal from BL(0,0) to TR(1,1)
// Triangle 1: BL, BR, TR (below the diagonal: tx >= ty... wait)
// Actually: the diagonal goes from (0,0) to (1,1).
// Points where ty <= tx are in the bottom-right triangle (BL, BR, TR).
// Points where ty > tx are in the top-left triangle (BL, TL, TR).
if (ty <= tx)
{
// Bottom-right triangle: BL(0,0), BR(1,0), TR(1,1)
// Z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty
return hBL + (hBR - hBL) * tx + (hTR - hBR) * ty;
}
else
{
// Top-left triangle: BL(0,0), TL(0,1), TR(1,1)
// Z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty
return hBL + (hTR - hTL) * tx + (hTL - hBL) * ty;
}
}
else
{
// Diagonal from BR(1,0) to TL(0,1)
// Points where ty <= (1 - tx) are in the bottom-left triangle (BL, BR, TL).
// Points where ty > (1 - tx) are in the top-right triangle (BR, TL, TR).
if (ty <= 1f - tx)
{
// Bottom-left triangle: BL(0,0), BR(1,0), TL(0,1)
// Z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty
return hBL + (hBR - hBL) * tx + (hTL - hBL) * ty;
}
else
{
// Top-right triangle: BR(1,0), TL(0,1), TR(1,1)
// Z = hTR + (hTL - hTR) * (1 - tx) + (hBR - hTR) * (1 - ty)
return hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty);
}
}
}
/// <summary>
/// Compute the outdoor cell ID for the given landblock-local position.
/// Outdoor cells are an 8×8 grid of 24×24-unit cells numbered
/// 0x0001..0x0040. Cell (0,0) at position (0,0) is 0x0001.
/// </summary>
public uint ComputeOutdoorCellId(float localX, float localY)
{
@ -76,4 +123,16 @@ public sealed class TerrainSurface
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;
}
}

View file

@ -44,21 +44,26 @@ public static class TerrainBlending
/// is designed so that both the visible mesh and the server's collision
/// triangulation agree on the same split — diverging from it causes
/// visual/physics disagreement at cell boundaries.
/// Ported verbatim from WorldBuilder TerrainUtils.CalculateSplitDirection;
/// the magic constants must stay exact or AC cells won't split correctly.
///
/// Uses the RENDER-PATH formula from AC2D and ACViewer's
/// LandblockMesh.cs (constants 0x0CCAC033, 0x421BE3BD, 0x6C1AC587,
/// 0x519B8F25), NOT the physics-path formula from WorldBuilder
/// (214614067 / 1813693831). The two produce different splits for
/// some cells, and the render formula is what the real AC client
/// uses for the visible terrain mesh. See
/// docs/research/2026-04-12-movement-deep-dive.md Part 3.
/// </summary>
public static CellSplitDirection CalculateSplitDirection(
uint landblockX, uint cellX, uint landblockY, uint cellY)
{
uint seedA = (landblockX * 8 + cellX) * 214614067u;
uint seedB = (landblockY * 8 + cellY) * 1109124029u;
uint magicA = seedA + 1813693831u;
uint magicB = seedB;
// uint wraparound is intentional — matches WorldBuilder and AC.
float splitDir = magicA - magicB - 1369149221u;
return splitDir * 2.3283064e-10f >= 0.5f
? CellSplitDirection.SEtoNW
: CellSplitDirection.SWtoNE;
uint x = landblockX * 8 + cellX;
uint y = landblockY * 8 + cellY;
uint dw = unchecked(x * y * 0x0CCAC033u - x * 0x421BE3BDu + y * 0x6C1AC587u - 0x519B8F25u);
// Bit 31 set = NE/SW diagonal (SWtoNE in our enum)
// Bit 31 clear = NW/SE diagonal (SEtoNW in our enum)
return (dw & 0x80000000u) != 0
? CellSplitDirection.SWtoNE
: CellSplitDirection.SEtoNW;
}
// --- Phase 3c.3 algorithms (surface recipe + vertex data packing) ---