acdream/src/AcDream.Core/Physics/TerrainSurface.cs
Erik 261322b48e fix(physics): #32 L.2c precipice edge-slide context
Port the first retail precipice-slide slice from named retail/ACE: terrain and BSP walkable hits now preserve polygon vertices, failed step-down edges back-probe to rediscover the walkable polygon, and edge-slide can run precipice/cliff slide instead of only hard-stopping.

Adds pseudocode anchors plus regression coverage for terrain polygon context and loaded-terrain boundary edge-slide.

Co-authored-by: Codex <codex@openai.com>
2026-04-30 08:04:37 +02:00

395 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Numerics;
namespace AcDream.Core.Physics;
public readonly record struct TerrainSurfacePolygon(
float Z,
Vector3 Normal,
Vector3[] Vertices);
/// <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 bool[,] _cornerIsWater; // per-VERTEX water flag [x, y] — SurfChar[(type >> 2) & 0x1F]
private readonly byte[,] _cellWaterType; // per-CELL 0=NotWater, 1=Partially, 2=Entirely [cx, cy]
private readonly uint _landblockX;
private readonly uint _landblockY;
public TerrainSurface(byte[] heights, float[] heightTable,
uint landblockX = 0, uint landblockY = 0,
byte[]? terrainTypes = null)
{
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]];
// Per-vertex water flag. TerrainType lives in bits 2-6 of each
// TerrainInfo byte; water is types 0x10-0x14 inclusive (per
// LandDefs.TerrainType enum: WaterRunning..WaterDeepSea).
// Retail's SurfChar[32] lookup marks those 5 indices as water.
// If terrainTypes is not provided (tests / legacy callers) we
// default to "no water anywhere" which preserves old behavior.
_cornerIsWater = new bool[HeightmapSide, HeightmapSide];
if (terrainTypes is not null && terrainTypes.Length >= 81)
{
for (int x = 0; x < HeightmapSide; x++)
for (int y = 0; y < HeightmapSide; y++)
{
int typeBits = (terrainTypes[x * HeightmapSide + y] >> 2) & 0x1F;
_cornerIsWater[x, y] = typeBits >= 0x10 && typeBits <= 0x14;
}
}
// Per-cell water classification (mirrors ACE
// LandblockStruct.CalcCellWater / CalcWater). A cell has 4
// vertex corners; count how many are water type.
_cellWaterType = new byte[CellsPerSide, CellsPerSide];
for (int cx = 0; cx < CellsPerSide; cx++)
for (int cy = 0; cy < CellsPerSide; cy++)
{
int waterCorners = 0;
if (_cornerIsWater[cx, cy ]) waterCorners++;
if (_cornerIsWater[cx + 1, cy ]) waterCorners++;
if (_cornerIsWater[cx + 1, cy + 1]) waterCorners++;
if (_cornerIsWater[cx, cy + 1]) waterCorners++;
_cellWaterType[cx, cy] = waterCorners switch
{
0 => 0, // NotWater
4 => 2, // EntirelyWater
_ => 1, // PartiallyWater
};
}
}
/// <summary>
/// Triangle-aware terrain Z at (localX, localY) in landblock-local
/// coordinates (0..192 range). Uses the decompiled retail client formula
/// (FUN_00532a50 / ACE LandblockStruct.ConstructPolygons) to pick one of
/// two diagonals, then does barycentric interpolation inside the chosen
/// triangle. Cross-verified against ACE's <c>LandCell.find_terrain_poly</c>
/// (plane-equation based), both produce identical Z for every (localX,localY).
///
/// <para>
/// Triangle layout matches ACE's ConstructPolygons (lines 221-244):
/// <b>SWtoNE</b> (bit31 set, <c>SWtoNEcut = true</c>): diagonal runs
/// <b>BL → TR</b> (line y = x). Triangles: {BL,BR,TR} below,
/// {BL,TR,TL} above. Dividing test: <c>tx &gt; ty</c>.
/// <b>SEtoNW</b> (bit31 clear, <c>SWtoNEcut = false</c>): diagonal runs
/// <b>BR → TL</b> (line x + y = 1). Triangles: {BL,BR,TL} below,
/// {BR,TR,TL} above. Dividing test: <c>tx + ty &lt;= 1</c>.
/// </para>
///
/// <para>
/// Diagnosed 2026-04-21: previous version had the two enum branches'
/// geometry inverted — when <c>splitSWtoNE</c> was <c>true</c> we
/// interpolated across the NW-SE diagonal (ACE's SEtoNW geometry) and
/// vice versa. Symptom: remote players drawn at server Z hovered up to
/// ~1m above or clipped into the rendered ground on sloped cells
/// because our surface Z came from the wrong triangle of the cell quad.
/// Flat cells masked the bug because all four corners shared one Z.
/// </para>
/// </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=SW, BR=SE, TR=NE, TL=NW)
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 — same formula as TerrainBlending.CalculateSplitDirection
// and ACE's LandblockStruct.ConstructPolygons.
bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy);
if (splitSWtoNE)
{
// Diagonal BL(0,0) → TR(1,1) — line y = x.
// Triangles: {BL,BR,TR} below (tx > ty), {BL,TR,TL} above.
if (tx > ty)
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
}
else
{
// Diagonal BR(1,0) → TL(0,1) — line x + y = 1.
// Triangles: {BL,BR,TL} below (tx+ty <= 1), {BR,TR,TL} above.
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); // BR+TR+TL triangle
}
}
/// <summary>
/// Sample both the terrain Z and the triangle-plane surface normal at
/// (localX, localY). The normal is derived from the gradient of the same
/// triangle <see cref="SampleZ"/> interpolates across, so returned
/// <c>(Z, Normal)</c> is exactly the sloped plane the physics should
/// contact against.
///
/// <para>
/// This matters for <see cref="Transition"/>: when <c>AdjustOffset</c>
/// projects the per-step movement offset onto the contact plane, a flat
/// plane (<c>Normal = (0,0,1)</c>) cannot impart any Z component to a
/// horizontal velocity — the character walks off a slope and the
/// per-frame step-down budget (~4 cm) can't catch up with the slope
/// descent rate, so the sphere floats above the terrain. A SLOPED plane
/// gives AdjustOffset the info it needs to produce slope-aligned motion
/// with the correct Z component baked in.
/// </para>
///
/// <para>
/// Retail does this via <c>LandCell.find_terrain_poly → walkable.Plane</c>
/// (ACE <c>Landblock.cs:125-137</c>). We derive the equivalent plane
/// analytically from the chosen triangle's three corner heights.
/// </para>
/// </summary>
public (float Z, System.Numerics.Vector3 Normal) SampleSurface(float localX, float localY)
{
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);
float tx = fx - cx;
float ty = fy - cy;
float hBL = _z[cx, cy ];
float hBR = _z[cx + 1, cy ];
float hTR = _z[cx + 1, cy + 1];
float hTL = _z[cx, cy + 1];
bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy);
// The SampleZ formula for each triangle is linear in (tx, ty):
// Z = a + b * tx + c * ty
// so dZ/dLocalX = b / CellSize, dZ/dLocalY = c / CellSize.
// Surface normal = normalize((-dZ/dX, -dZ/dY, 1)) — a well-known
// identity for a height-field plane.
float z, dzdx, dzdy;
if (splitSWtoNE)
{
// Diagonal BL(0,0) → TR(1,1). Triangles: {BL,BR,TR} / {BL,TR,TL}.
if (tx > ty)
{
// {BL,BR,TR}: Z = hBL + (hBR-hBL)·tx + (hTR-hBR)·ty
z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty;
dzdx = (hBR - hBL) / CellSize;
dzdy = (hTR - hBR) / CellSize;
}
else
{
// {BL,TR,TL}: Z = hBL + (hTR-hTL)·tx + (hTL-hBL)·ty
z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty;
dzdx = (hTR - hTL) / CellSize;
dzdy = (hTL - hBL) / CellSize;
}
}
else
{
// Diagonal BR(1,0) → TL(0,1). Triangles: {BL,BR,TL} / {BR,TR,TL}.
if (tx + ty <= 1f)
{
// {BL,BR,TL}: Z = hBL + (hBR-hBL)·tx + (hTL-hBL)·ty
z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty;
dzdx = (hBR - hBL) / CellSize;
dzdy = (hTL - hBL) / CellSize;
}
else
{
// {BR,TR,TL}: Z = hTR + (hTL-hTR)(1-tx) + (hBR-hTR)(1-ty)
// Equivalent linear form: Z = [hBR+hTL-hTR] + (hTR-hTL)·tx + (hTR-hBR)·ty
z = hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty);
dzdx = (hTR - hTL) / CellSize;
dzdy = (hTR - hBR) / CellSize;
}
}
var normal = System.Numerics.Vector3.Normalize(
new System.Numerics.Vector3(-dzdx, -dzdy, 1f));
return (z, normal);
}
/// <summary>
/// Sample the terrain triangle at (localX, localY), including the three
/// local-space vertices that bound the sampled point. Edge-slide needs
/// these vertices so the retail crossed-edge test can identify which edge
/// the sphere left when a step-down probe fails.
/// </summary>
public TerrainSurfacePolygon SampleSurfacePolygon(float localX, float localY)
{
float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f);
float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f);
int cx = Math.Clamp((int)fx, 0, CellsPerSide - 1);
int cy = Math.Clamp((int)fy, 0, CellsPerSide - 1);
float tx = fx - cx;
float ty = fy - cy;
float hBL = _z[cx, cy ];
float hBR = _z[cx + 1, cy ];
float hTR = _z[cx + 1, cy + 1];
float hTL = _z[cx, cy + 1];
bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy);
Vector3 bl = new(cx * CellSize, cy * CellSize, hBL);
Vector3 br = new((cx + 1) * CellSize, cy * CellSize, hBR);
Vector3 tr = new((cx + 1) * CellSize, (cy + 1) * CellSize, hTR);
Vector3 tl = new(cx * CellSize, (cy + 1) * CellSize, hTL);
float z;
Vector3[] vertices;
if (splitSWtoNE)
{
if (tx > ty)
{
z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty;
vertices = new[] { bl, br, tr };
}
else
{
z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty;
vertices = new[] { bl, tr, tl };
}
}
else
{
if (tx + ty <= 1f)
{
z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty;
vertices = new[] { bl, br, tl };
}
else
{
z = hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty);
vertices = new[] { br, tr, tl };
}
}
var normal = Vector3.Normalize(
Vector3.Cross(vertices[1] - vertices[0], vertices[2] - vertices[0]));
if (normal.Z < 0f)
normal = -normal;
return new TerrainSurfacePolygon(z, normal, vertices);
}
/// <summary>
/// Retail per-point water depth in meters — the amount the character's
/// feet are allowed to sink below the contact plane before the
/// push-up fires. <see cref="ValidateWalkable"/> adds this to the
/// signed-distance check so water cells visually submerge the
/// character while dry terrain keeps feet exactly on the plane.
///
/// <para>
/// Ported from ACE <c>ObjCell.get_water_depth</c> and
/// <c>LandblockStruct.calc_water_depth</c>, except that the retail
/// 0.1 fallback for "dry corner of a partially-water cell" is
/// collapsed to 0. The 0.1 offset destabilizes the "feet exactly on
/// plane" contact-touch check (dist > EPSILON, so SetContactPlane
/// doesn't fire, ValidateTransition clears OnWalkable, gravity
/// applies, character floats/falls each frame). The visible effect
/// in retail is subtle (~10 cm natural sink-in) so dropping it to 0
/// is indistinguishable.
/// </para>
/// <list type="bullet">
/// <item>NotWater cell → <c>0.0f</c></item>
/// <item>EntirelyWater cell → <c>0.9f</c> (fully submerged)</item>
/// <item>PartiallyWater cell, nearest-corner water type → <c>0.45f</c></item>
/// <item>PartiallyWater cell, nearest-corner non-water → <c>0.0f</c>
/// (retail uses 0.1, we use 0 to avoid the above-EPSILON dist bug)</item>
/// </list>
/// </summary>
public float SampleWaterDepth(float localX, float localY)
{
float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f);
float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f);
int cx = Math.Clamp((int)fx, 0, CellsPerSide - 1);
int cy = Math.Clamp((int)fy, 0, CellsPerSide - 1);
byte waterType = _cellWaterType[cx, cy];
if (waterType == 0) return 0f; // NotWater
if (waterType == 2) return 0.9f; // EntirelyWater
// PartiallyWater — resolve to nearest corner (retail picks the
// terrain-vertex at the "+12" half of each axis, i.e. >= 12m into
// a 24m cell rounds up). Return 0.45 for water corner, 0 for dry
// (retail uses 0.1 for dry corners; see note above).
float tx = fx - cx;
float ty = fy - cy;
int vx = cx + (tx >= 0.5f ? 1 : 0);
int vy = cy + (ty >= 0.5f ? 1 : 0);
return _cornerIsWater[vx, vy] ? 0.45f : 0f;
}
/// <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;
}
}