fix(physics): correct terrain Z sampling triangle layout in TerrainSurface

The SampleZ method had the two triangle-boundary conditions swapped relative
to what the mesh index buffer actually renders, causing the physics Z to
sample from the wrong triangle on roughly half of all terrain cells. The
error could be up to 7.5 units on steep 24×24 cells, which manifested as
feet clipping into rising terrain on slopes.

Root cause (discovered via WorldBuilder-ACME-Edition exhaustive analysis):
  "SWtoNE cut" means BL and TR are the *isolated* vertices; the shared
  hypotenuse runs TL(0,1) → BR(1,0), so the correct dividing test is
  tx+ty=1, NOT ty=tx. The old code used ty≤tx for the SWtoNE branch
  (the BL→TR diagonal), which matches the SEtoNW mesh layout instead.

Fix: swap the boundary conditions for the two split cases so they match
LandblockMesh.cs's actual index buffer layout:
  SWtoNE: tx+ty ≤ 1 → BL+BR+TL, else TR+TL+BR
  SEtoNW: ty ≤ tx  → BL+BR+TR, else BL+TR+TL

Verified by running all three formulas (acdream, ACME GetHeight, ACME
HeightSampler) against the mesh index buffer at 50 interior points across
both split types: fixed version matches 0 errors, old version had 50.
All 283 tests still pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 22:25:32 +02:00
parent 78d43a0914
commit 3ae7f5e7ae

View file

@ -51,12 +51,15 @@ public sealed class TerrainSurface
/// 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.
/// Triangle layout (from LandblockMesh.cs index buffer):
/// SWtoNE: tri1 = {BL,TL,BR}, tri2 = {BR,TL,TR} — shared edge TL→BR (x+y=1 boundary)
/// SEtoNW: tri1 = {BL,TR,BR}, tri2 = {BL,TL,TR} — shared edge BL→TR (y=x boundary)
///
/// NOTE: The SWtoNE "cut" exposes the SW(BL) and NE(TR) corners as isolated
/// vertices — the hypotenuse runs NW(TL)→SE(BR), so the dividing test is
/// x+y=1 (not y=x). Confusing naming aside, the formula below matches
/// TerrainGeometryGenerator.GetHeight (ACME WorldBuilder-ACME-Edition) which
/// was verified against the mesh index buffer.
/// </summary>
public float SampleZ(float localX, float localY)
{
@ -83,17 +86,21 @@ public sealed class TerrainSurface
if (splitSWtoNE)
{
if (ty <= tx)
return hBL + (hBR - hBL) * tx + (hTR - hBR) * ty;
// Mesh: {BL,TL,BR} and {BR,TL,TR}. Shared hypotenuse = TL(0,1)→BR(1,0).
// Dividing line: tx + ty = 1.
if (tx + ty <= 1f)
return hBL + (hBR - hBL) * tx + (hTL - hBL) * ty; // BL+BR+TL triangle
else
return hBL + (hTR - hTL) * tx + (hTL - hBL) * ty;
return hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); // TR+TL+BR triangle
}
else
{
if (ty <= 1f - tx)
return hBL + (hBR - hBL) * tx + (hTL - hBL) * ty;
// Mesh: {BL,TR,BR} and {BL,TL,TR}. Shared hypotenuse = BL(0,0)→TR(1,1).
// Dividing line: ty = tx.
if (ty <= tx)
return hBL + (hBR - hBL) * tx + (hTR - hBR) * ty; // BL+BR+TR triangle
else
return hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty);
return hBL + (hTR - hTL) * tx + (hTL - hBL) * ty; // BL+TR+TL triangle
}
}