acdream/src/AcDream.Core/Physics/TerrainSurface.cs
Erik f562215e6c fix(physics): water depth submersion + sphere-safety-push steep-slope correction
Two coupled physics fixes that together resolve "+Acdream walks on top of
water instead of submerged" and "brief Falling animation when running up
steep hills".

## 1. Water depth = physics adjustment, not rendering

Retail has NO separate water surface mesh. Characters visually submerge
in water because ValidateWalkable adds `waterDepth` to its signed-distance
check (ACE ObjectInfo.cs:124), letting the character's feet sit below the
terrain plane by that amount before the push-up fires. Rendered character
below rendered terrain = looks submerged.

Our ValidateWalkable didn't carry a waterDepth, so feet were always
snapped exactly to the plane. Water cells looked like walking on water.

Added:
 - TerrainSurface now carries per-vertex water flags (bits 2-6 of
   TerrainInfo → SurfChar lookup) and per-cell classification.
 - TerrainSurface.SampleWaterDepth(localX, localY) returns 0.0 (dry),
   0.45 (partial-water near water corner), 0.9 (entirely water). Deviates
   from retail's 0.1 fallback for "dry corner of partial-water cell" —
   that 0.1 destabilizes the "feet exactly on plane" contact-touch check
   in ValidateWalkable (dist > EPSILON, SetContactPlane skipped,
   ValidateTransition clears OnWalkable, gravity applies, character
   micro-falls each frame).
 - PhysicsEngine.SampleWaterDepth is the world-space wrapper.
 - FindEnvCollisions samples the per-point depth and forwards it.
 - ValidateWalkable adds +waterDepth to the signed-distance check (this
   is the ACE-line-124 port).

GameWindow.ApplyLoadedTerrain extracts the low byte of each TerrainInfo
ushort and passes it to the TerrainSurface ctor so classification works.

## 2. AdjustOffset safety-push threshold on sloped planes

The LocalSphere is positioned at `(0, 0, radius)` — center along world
+Z from the character root. On a tilted plane the sphere center's
perpendicular distance to that plane is `radius * Normal.Z`, NOT
`radius`. The original threshold `dist < radius - EPS` therefore fires
spuriously on every slope and the follow-up push-up lifts feet by
`radius * (sec θ - 1)` — 7 cm at 30°, 20 cm at 45°, 48 cm at 60°.

The steep-slope lift is large enough to break ValidateWalkable's
contact-touch check, ValidateTransition then clears OnWalkable,
calc_acceleration applies gravity, and the character flickers into the
Falling animation for ~0.3s while running uphill. User-observed on steep
hills after today's water-depth work made the artifact visible (before
that, general hover masked it).

Fix: the threshold is `radius * Normal.Z` (the natural resting distance
of a Z-axis sphere on the plane). The push fires only when feet are
actually penetrating below natural resting, not on any sloped plane.
ACE's Transition.cs AdjustOffset has the original threshold but the bug
is invisible server-side.

All 717 tests green. Water submersion + steep-slope running both
user-visually verified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:56:46 +02:00

323 lines
14 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;
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 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>
/// 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;
}
}