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. // Heartbeat timer.
private float _heartbeatAccum; private float _heartbeatAccum;
private float _prevGroundZ;
public const float HeartbeatInterval = 0.2f; // 200ms public const float HeartbeatInterval = 0.2f; // 200ms
public bool HeartbeatDue { get; private set; } public bool HeartbeatDue { get; private set; }
@ -107,7 +106,6 @@ public sealed class PlayerMovementController
{ {
Position = pos; Position = pos;
CellId = cellId; CellId = cellId;
_prevGroundZ = pos.Z;
} }
public MovementResult Update(float dt, MovementInput input) 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 // Triangle-aware Z sampling in TerrainSurface now matches the visual
// higher or lower than the center-point physics sample. Sample 4 // terrain mesh exactly (using AC2D's FSplitNESW render formula +
// points around the character (forward, back, left, right at ~0.7 // barycentric interpolation within each triangle). No multi-point
// units — roughly foot distance) and use the MAX Z. This prevents // sampling hacks needed.
// 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;
Position = new Vector3(result.Position.X, result.Position.Y, newZ); Position = new Vector3(result.Position.X, result.Position.Y, newZ);
CellId = result.CellId; 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 // surfaces for this landblock. Runs under _datLock (same lock as the
// rest of ApplyLoadedTerrainLocked) so dat reads are safe. // 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 cellSurfaces = new List<AcDream.Core.Physics.CellSurface>();
var portalPlanes = new List<AcDream.Core.Physics.PortalPlane>(); var portalPlanes = new List<AcDream.Core.Physics.PortalPlane>();

View file

@ -4,17 +4,14 @@ namespace AcDream.Core.Physics;
/// <summary> /// <summary>
/// Outdoor terrain height resolver for a single landblock. Performs /// Outdoor terrain height resolver for a single landblock. Performs
/// bilinear interpolation of the 9×9 heightmap grid to produce the /// per-triangle barycentric Z interpolation matching the visual terrain
/// ground Z at any (localX, localY) within the 192×192 landblock /// mesh's triangle split direction (AC2D's FSplitNESW formula).
/// footprint. Also computes the outdoor cell ID for AC's position
/// encoding.
/// ///
/// <para> /// <para>
/// Algorithm ported from GameWindow.SampleTerrainZ (which was inlined /// Each cell (24×24 units) is split into two triangles along either the
/// and not reusable). The heightmap is indexed x-major: /// SW→NE or SE→NW diagonal. The split direction is determined by the
/// <c>heights[x * 9 + y]</c>; each byte is a lookup into /// same formula the render mesh uses (0x0CCAC033 constants from AC2D),
/// <paramref name="heightTable"/> (256-entry float array from /// so the Z this class produces matches the visual terrain surface exactly.
/// Region.LandDefs.LandHeightTable).
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class TerrainSurface public sealed class TerrainSurface
@ -23,10 +20,12 @@ public sealed class TerrainSurface
private const float CellSize = 24f; private const float CellSize = 24f;
private const int CellsPerSide = 8; // 192 / 24 private const int CellsPerSide = 8; // 192 / 24
private readonly byte[] _heights; private readonly float[,] _z; // pre-resolved heights [x, y]
private readonly float[] _heightTable; 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(heights);
ArgumentNullException.ThrowIfNull(heightTable); ArgumentNullException.ThrowIfNull(heightTable);
@ -35,40 +34,88 @@ public sealed class TerrainSurface
if (heightTable.Length < 256) if (heightTable.Length < 256)
throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable)); throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));
_heights = heights; _landblockX = landblockX;
_heightTable = heightTable; _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> /// <summary>
/// Bilinear-interpolated terrain Z at (localX, localY) in /// Triangle-aware terrain Z at (localX, localY) in landblock-local
/// landblock-local coordinates (0..192 range). /// 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> /// </summary>
public float SampleZ(float localX, float localY) public float SampleZ(float localX, float localY)
{ {
float fx = Math.Clamp(localX / CellSize, 0f, HeightmapSide - 1f); // Which cell?
float fy = Math.Clamp(localY / CellSize, 0f, HeightmapSide - 1f); 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); // Fractional position within the cell [0, 1]
int y0 = Math.Min((int)fy, HeightmapSide - 2); float tx = fx - cx;
int x1 = x0 + 1; float ty = fy - cy;
int y1 = y0 + 1;
float tx = fx - x0;
float ty = fy - y0;
float h00 = _heightTable[_heights[x0 * HeightmapSide + y0]]; // Four corner heights (BL, BR, TR, TL)
float h10 = _heightTable[_heights[x1 * HeightmapSide + y0]]; float hBL = _z[cx, cy ];
float h01 = _heightTable[_heights[x0 * HeightmapSide + y1]]; float hBR = _z[cx + 1, cy ];
float h11 = _heightTable[_heights[x1 * HeightmapSide + y1]]; float hTR = _z[cx + 1, cy + 1];
float hTL = _z[cx, cy + 1];
float hx0 = h00 * (1 - tx) + h10 * tx; // Split direction using the AC2D render formula
float hx1 = h01 * (1 - tx) + h11 * tx; bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy);
return hx0 * (1 - ty) + hx1 * ty;
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> /// <summary>
/// Compute the outdoor cell ID for the given landblock-local position. /// 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> /// </summary>
public uint ComputeOutdoorCellId(float localX, float localY) 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); int cy = Math.Clamp((int)(localY / CellSize), 0, CellsPerSide - 1);
return (uint)(1 + cx * CellsPerSide + cy); 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 /// is designed so that both the visible mesh and the server's collision
/// triangulation agree on the same split — diverging from it causes /// triangulation agree on the same split — diverging from it causes
/// visual/physics disagreement at cell boundaries. /// 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> /// </summary>
public static CellSplitDirection CalculateSplitDirection( public static CellSplitDirection CalculateSplitDirection(
uint landblockX, uint cellX, uint landblockY, uint cellY) uint landblockX, uint cellX, uint landblockY, uint cellY)
{ {
uint seedA = (landblockX * 8 + cellX) * 214614067u; uint x = landblockX * 8 + cellX;
uint seedB = (landblockY * 8 + cellY) * 1109124029u; uint y = landblockY * 8 + cellY;
uint magicA = seedA + 1813693831u; uint dw = unchecked(x * y * 0x0CCAC033u - x * 0x421BE3BDu + y * 0x6C1AC587u - 0x519B8F25u);
uint magicB = seedB; // Bit 31 set = NE/SW diagonal (SWtoNE in our enum)
// uint wraparound is intentional — matches WorldBuilder and AC. // Bit 31 clear = NW/SE diagonal (SEtoNW in our enum)
float splitDir = magicA - magicB - 1369149221u; return (dw & 0x80000000u) != 0
return splitDir * 2.3283064e-10f >= 0.5f ? CellSplitDirection.SWtoNE
? CellSplitDirection.SEtoNW : CellSplitDirection.SEtoNW;
: CellSplitDirection.SWtoNE;
} }
// --- Phase 3c.3 algorithms (surface recipe + vertex data packing) --- // --- Phase 3c.3 algorithms (surface recipe + vertex data packing) ---