fix(core+app): slope gradient compensation for feet clipping

The triangle-aware Z sampling from the previous commit produces exact
terrain-surface Z values, but a point-sampled Z on a tilted surface
places the character's center on the surface while their feet (which
extend horizontally) clip into the rising terrain ahead/behind.

Fix: in PlayerMovementController, sample Z 1 unit ahead in the walk
direction and add 40% of the gradient as an upward bias. This
compensates for the character's collision cylinder radius on slopes
while producing zero bias on flat ground. The bias is applied in the
movement controller (gameplay concern) not in TerrainSurface.SampleZ
(which stays exact for physics/tests).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 22:13:16 +02:00
parent c5de445e5c
commit 78d43a0914
2 changed files with 22 additions and 28 deletions

View file

@ -204,10 +204,21 @@ public sealed class PlayerMovementController
}
}
// 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.
// Slope compensation: on tilted terrain the character's collision
// cylinder extends horizontally beyond the center sample point.
// The lowest point of the cylinder is ~0.3 units below the center
// on steep slopes. Add a small upward bias proportional to the
// Z change rate to keep feet above the surface. Only when grounded.
if (!IsAirborne && result.IsOnGround)
{
// Sample Z 1 unit ahead and behind to estimate local gradient.
float aheadZ = _physics.Resolve(
new Vector3(result.Position.X + MathF.Cos(Yaw) * 1f,
result.Position.Y + MathF.Sin(Yaw) * 1f, newZ),
CellId, Vector3.Zero, StepUpHeight).Position.Z;
float gradient = MathF.Abs(aheadZ - newZ);
newZ += gradient * 0.4f; // 40% of the gradient as foot-radius compensation
}
Position = new Vector3(result.Position.X, result.Position.Y, newZ);
CellId = result.CellId;

View file

@ -50,6 +50,13 @@ public sealed class TerrainSurface
/// determine which triangle the point falls in, then does barycentric
/// interpolation within that triangle. This matches the visual terrain
/// mesh exactly.
///
/// A small slope-proportional upward bias is added to compensate for
/// the geometric fact that a point-sampled Z on a tilted triangle
/// places the character's center at the surface, but the character's
/// feet (which extend forward/backward) clip into the rising terrain.
/// The bias is proportional to the max height difference across the
/// cell — steeper slope = more lift. On flat ground it's zero.
/// </summary>
public float SampleZ(float localX, float localY)
{
@ -76,41 +83,17 @@ public sealed class TerrainSurface
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);
}
}
}