fix(terrain): align per-cell triangle geometry with ACE's ConstructPolygons convention

Our LandblockMesh, terrain.vert corner tables, and TerrainSurface.SampleZ
used the OPPOSITE diagonal for each CellSplitDirection enum value from
what ACE (and the decompiled retail client at FUN_00532a50) picks for the
same sign bit. Same formula, same sign-bit mapping, inverted geometry.

Symptom: remote players rendered at server-broadcast Z hovered or clipped
by up to ~1m on sloped cells. Flat cells masked the bug because all four
corner heights were equal so any triangle pair returned the same Z. Live
diagnostic confirmed +0.79m hover on cell (7,5) at lb(AA,B4) — a ~20°
slope — while flat neighbors agreed to floating-point noise.

Three coordinated edits so CPU mesh + GPU corner lookup + CPU sampler all
agree on the retail geometry:
 - LandblockMesh: SWtoNE branch now emits {BL,BR,TR}+{BL,TR,TL} (y=x cut),
   SEtoNW emits {BL,BR,TL}+{BR,TR,TL} (x+y=1 cut).
 - terrain.vert: corner-index tables updated to match.
 - TerrainSurface.SampleZ: swapped the two branches' interpolation.

After the fix, 19 live DIAG samples across flat + two slope transitions
all land within 0.01m of server Z. Staircase pattern during remote motion
on slopes is a separate bug (no per-frame collision resolution) and will
be addressed via the transition/FindValidPosition port.

Cross-verified against: ACE LandblockStruct.ConstructPolygons lines 221-
244, decompiled retail FUN_00532a50 (chunk_00530000.c:2235), ClientReference
IsSWtoNECut (tests/AcDream.Core.Tests/Terrain/ClientReference.cs).

Updated test SplitDirection_TerrainSurface_AgreesWith_TerrainBlending
with corrected expectations (Z values swap between the two branches).
All 717 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-21 13:20:59 +02:00
parent beffdf477e
commit 56975f8919
5 changed files with 96 additions and 71 deletions

View file

@ -1970,7 +1970,7 @@ public sealed class GameWindow : IDisposable
entity.Position = worldPos; entity.Position = worldPos;
entity.Rotation = rot; entity.Rotation = rot;
// Track remote-entity motion for stop detection. Only record the // Track remote-entity motion for stop detection. Only record the
// timestamp when position moved MEANINGFULLY (> 0.05m). Updates // timestamp when position moved MEANINGFULLY (> 0.05m). Updates
// that report the same position keep the old Time, so the // that report the same position keep the old Time, so the
// TickAnimations check can see when motion last changed. // TickAnimations check can see when motion last changed.

View file

@ -76,26 +76,30 @@ void main() {
// Derive which of the 4 cell corners this vertex represents from // Derive which of the 4 cell corners this vertex represents from
// gl_VertexID % 6. The CPU-side LandblockMesh emits vertices in a // gl_VertexID % 6. The CPU-side LandblockMesh emits vertices in a
// specific order for each split direction; the table below must stay // specific order for each split direction; the tables below must stay
// in lockstep with LandblockMesh.Build's SWtoNE/SEtoNW branches. // in lockstep with LandblockMesh.Build's SWtoNE/SEtoNW branches.
// 2026-04-21 fix: geometry re-derived to match ACE's ConstructPolygons
// convention. SWtoNE (cut BL→TR, y=x diagonal) now maps to the {BL,BR,TR}
// + {BL,TR,TL} triangle pair; SEtoNW (cut BR→TL, x+y=1 diagonal) maps to
// {BL,BR,TL} + {BR,TR,TL}.
int vIdx = gl_VertexID % 6; int vIdx = gl_VertexID % 6;
int corner = 0; int corner = 0;
if (splitDir == 0u) { if (splitDir == 0u) {
// SWtoNE order: BL, TL, BR, BR, TL, TR → corners 0, 3, 1, 1, 3, 2 // SWtoNE order: BL, BR, TR, BL, TR, TL → corners 0, 1, 2, 0, 2, 3
if (vIdx == 0) corner = 0; if (vIdx == 0) corner = 0;
else if (vIdx == 1) corner = 3; else if (vIdx == 1) corner = 1;
else if (vIdx == 2) corner = 1; else if (vIdx == 2) corner = 2;
else if (vIdx == 3) corner = 1;
else if (vIdx == 4) corner = 3;
else corner = 2;
} else {
// SEtoNW order: BL, TR, BR, BL, TL, TR → corners 0, 2, 1, 0, 3, 2
if (vIdx == 0) corner = 0;
else if (vIdx == 1) corner = 2;
else if (vIdx == 2) corner = 1;
else if (vIdx == 3) corner = 0; else if (vIdx == 3) corner = 0;
else if (vIdx == 4) corner = 3; else if (vIdx == 4) corner = 2;
else corner = 2; else corner = 3;
} else {
// SEtoNW order: BL, BR, TL, BR, TR, TL → corners 0, 1, 3, 1, 2, 3
if (vIdx == 0) corner = 0;
else if (vIdx == 1) corner = 1;
else if (vIdx == 2) corner = 3;
else if (vIdx == 3) corner = 1;
else if (vIdx == 4) corner = 2;
else corner = 3;
} }
vec2 baseUV; vec2 baseUV;

View file

@ -46,20 +46,31 @@ public sealed class TerrainSurface
/// <summary> /// <summary>
/// Triangle-aware terrain Z at (localX, localY) in landblock-local /// Triangle-aware terrain Z at (localX, localY) in landblock-local
/// coordinates (0..192 range). Uses the AC2D FSplitNESW formula to /// coordinates (0..192 range). Uses the decompiled retail client formula
/// determine which triangle the point falls in, then does barycentric /// (FUN_00532a50 / ACE LandblockStruct.ConstructPolygons) to pick one of
/// interpolation within that triangle. This matches the visual terrain /// two diagonals, then does barycentric interpolation inside the chosen
/// mesh exactly. /// triangle. Cross-verified against ACE's <c>LandCell.find_terrain_poly</c>
/// (plane-equation based), both produce identical Z for every (localX,localY).
/// ///
/// Triangle layout (from LandblockMesh.cs index buffer): /// <para>
/// SWtoNE: tri1 = {BL,TL,BR}, tri2 = {BR,TL,TR} — shared edge TL→BR (x+y=1 boundary) /// Triangle layout matches ACE's ConstructPolygons (lines 221-244):
/// SEtoNW: tri1 = {BL,TR,BR}, tri2 = {BL,TL,TR} — shared edge BL→TR (y=x boundary) /// <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>
/// ///
/// NOTE: The SWtoNE "cut" exposes the SW(BL) and NE(TR) corners as isolated /// <para>
/// vertices — the hypotenuse runs NW(TL)→SE(BR), so the dividing test is /// Diagnosed 2026-04-21: previous version had the two enum branches'
/// x+y=1 (not y=x). Confusing naming aside, the formula below matches /// geometry inverted — when <c>splitSWtoNE</c> was <c>true</c> we
/// TerrainGeometryGenerator.GetHeight (ACME WorldBuilder-ACME-Edition) which /// interpolated across the NW-SE diagonal (ACE's SEtoNW geometry) and
/// was verified against the mesh index buffer. /// 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> /// </summary>
public float SampleZ(float localX, float localY) public float SampleZ(float localX, float localY)
{ {
@ -75,32 +86,33 @@ public sealed class TerrainSurface
float tx = fx - cx; float tx = fx - cx;
float ty = fy - cy; float ty = fy - cy;
// Four corner heights (BL, BR, TR, TL) // Four corner heights (BL=SW, BR=SE, TR=NE, TL=NW)
float hBL = _z[cx, cy ]; float hBL = _z[cx, cy ];
float hBR = _z[cx + 1, cy ]; float hBR = _z[cx + 1, cy ];
float hTR = _z[cx + 1, cy + 1]; float hTR = _z[cx + 1, cy + 1];
float hTL = _z[cx, cy + 1]; float hTL = _z[cx, cy + 1];
// Split direction using the AC2D render formula // Split direction — same formula as TerrainBlending.CalculateSplitDirection
// and ACE's LandblockStruct.ConstructPolygons.
bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy); bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy);
if (splitSWtoNE) if (splitSWtoNE)
{ {
// Mesh: {BL,TL,BR} and {BR,TL,TR}. Shared hypotenuse = TL(0,1)→BR(1,0). // Diagonal BL(0,0) → TR(1,1) — line y = x.
// Dividing line: tx + ty = 1. // Triangles: {BL,BR,TR} below (tx > ty), {BL,TR,TL} above.
if (tx + ty <= 1f) if (tx > ty)
return hBL + (hBR - hBL) * tx + (hTL - hBL) * ty; // BL+BR+TL triangle return hBL + (hBR - hBL) * tx + (hTR - hBR) * ty; // BL+BR+TR triangle
else else
return hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); // TR+TL+BR triangle return hBL + (hTR - hTL) * tx + (hTL - hBL) * ty; // BL+TR+TL triangle
} }
else else
{ {
// Mesh: {BL,TR,BR} and {BL,TL,TR}. Shared hypotenuse = BL(0,0)→TR(1,1). // Diagonal BR(1,0) → TL(0,1) — line x + y = 1.
// Dividing line: ty = tx. // Triangles: {BL,BR,TL} below (tx+ty <= 1), {BR,TR,TL} above.
if (ty <= tx) if (tx + ty <= 1f)
return hBL + (hBR - hBL) * tx + (hTR - hBR) * ty; // BL+BR+TR triangle return hBL + (hBR - hBL) * tx + (hTL - hBL) * ty; // BL+BR+TL triangle
else else
return hBL + (hTR - hTL) * tx + (hTL - hBL) * ty; // BL+TR+TL triangle return hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); // BR+TR+TL triangle
} }
} }

View file

@ -127,29 +127,37 @@ public static class LandblockMesh
var nTR = normals[cx + 1, cy + 1]; var nTR = normals[cx + 1, cy + 1];
var nTL = normals[cx, cy + 1]; var nTL = normals[cx, cy + 1];
// Emit 6 vertices in the exact order WorldBuilder's Landscape.vert // Emit 6 vertices in an order matching ACE's
// expects. The vertex shader maps gl_VertexID % 6 → corner index // LandblockStruct.ConstructPolygons triangulation. The vertex
// for UV lookup, so the CPU order must match. // shader maps gl_VertexID % 6 → corner index for UV lookup,
// so the CPU order and shader table must stay in lockstep.
// //
// SWtoNE (splitDir=0): // SWtoNE (splitDir=0, SWtoNEcut=true): diagonal BL → TR.
// vIdx: 0 1 2 3 4 5 // Triangles: {BL,BR,TR} + {BL,TR,TL} (shared edge BL-TR)
// corner: 0 3 1 1 3 2 // vIdx: 0 1 2 3 4 5
// BL TL BR BR TL TR // corner: 0 1 2 0 2 3
// SEtoNW (splitDir=1): // BL BR TR BL TR TL
// vIdx: 0 1 2 3 4 5 // SEtoNW (splitDir=1, SWtoNEcut=false): diagonal BR → TL.
// corner: 0 2 1 0 3 2 // Triangles: {BL,BR,TL} + {BR,TR,TL} (shared edge BR-TL)
// BL TR BR BL TL TR // vIdx: 0 1 2 3 4 5
// corner: 0 1 3 1 2 3
// BL BR TL BR TR TL
//
// 2026-04-21 fix: previous mapping had the enum→geometry
// inversion — SWtoNE built NW-SE-diagonal triangles (ACE's
// SEtoNW geometry) and vice versa, causing remote players
// to hover/clip on sloped cells by up to ~1m.
if (split == CellSplitDirection.SWtoNE) if (split == CellSplitDirection.SWtoNE)
{ {
WriteCell(vertices, ref vi, d0, d1, d2, d3, WriteCell(vertices, ref vi, d0, d1, d2, d3,
posBL, nBL, posTL, nTL, posBR, nBR, posBL, nBL, posBR, nBR, posTR, nTR,
posBR, nBR, posTL, nTL, posTR, nTR); posBL, nBL, posTR, nTR, posTL, nTL);
} }
else else
{ {
WriteCell(vertices, ref vi, d0, d1, d2, d3, WriteCell(vertices, ref vi, d0, d1, d2, d3,
posBL, nBL, posTR, nTR, posBR, nBR, posBL, nBL, posBR, nBR, posTL, nTL,
posBL, nBL, posTL, nTL, posTR, nTR); posBR, nBR, posTR, nTR, posTL, nTL);
} }
} }
} }

View file

@ -79,7 +79,7 @@ public class ClientConformanceTests
public void SplitDirection_TerrainSurface_AgreesWith_TerrainBlending() public void SplitDirection_TerrainSurface_AgreesWith_TerrainBlending()
{ {
// Build an asymmetric heightmap where the two split directions // Build an asymmetric heightmap where the two split directions
// produce different Z at the cell center (0.5, 0.5). // produce different Z off-center.
// Heights: BL=0, BR=100, TL=0, TR=0 (steep slope along X only at Y=0) // Heights: BL=0, BR=100, TL=0, TR=0 (steep slope along X only at Y=0)
var heights = new byte[81]; var heights = new byte[81];
var heightTable = new float[256]; var heightTable = new float[256];
@ -91,32 +91,33 @@ public class ClientConformanceTests
heights[0 * 9 + 1] = 0; // TL heights[0 * 9 + 1] = 0; // TL
heights[1 * 9 + 1] = 0; // TR heights[1 * 9 + 1] = 0; // TR
// Sample at cell center (12, 12) = (0.5, 0.5) in cell coords. // Sample at (20, 2) = (0.833, 0.083) in cell-local coords.
// For SWtoNE split (tx+ty=1 boundary): both triangles give Z=50 at center. //
// For SEtoNW split (ty=tx boundary): both triangles give Z=50 at center. // 2026-04-21 fix: matched to ACE's ConstructPolygons geometry.
// So at exact center both agree. Sample at (18, 6) = (0.75, 0.25) instead. // Our enum values now mean:
// SWtoNE: tx+ty=1.0 → tx=0.75, ty=0.25, 0.75+0.25=1.0 → boundary // SWtoNE → diagonal BL→TR (y=x), triangles {BL,BR,TR} + {BL,TR,TL}
// Use (20, 4) = (0.833, 0.167): tx+ty=1.0 → still boundary // SEtoNW → diagonal BR→TL (x+y=1), triangles {BL,BR,TL} + {BR,TR,TL}
// Use (20, 2) = (0.833, 0.083): tx+ty=0.917 < 1 → BL+BR+TL triangle //
// Z = 0 + (100-0)*0.833 + (0-0)*0.083 = 83.3 // At (tx=0.833, ty=0.083):
// For SEtoNW: ty=0.083 < tx=0.833 → BL+BR+TR triangle // SWtoNE: tx > ty → {BL,BR,TR} triangle
// Z = 0 + (100-0)*0.833 + (0-100)*0.083 = 83.3 - 8.3 = 75.0 // Z = hBL + tx*(hBR-hBL) + ty*(hTR-hBR) = 0.833*100 + 0.083*(-100) ≈ 75.0
// These differ! So we can distinguish. // SEtoNW: tx+ty=0.917 ≤ 1 → {BL,BR,TL} triangle
// Z = hBL + tx*(hBR-hBL) + ty*(hTL-hBL) = 0.833*100 + 0 ≈ 83.3
// Expectations swapped vs pre-fix.
// Use landblock (0,0) and check what the client says the split is.
bool clientSplit = ClientReference.IsSWtoNECut(0, 0); bool clientSplit = ClientReference.IsSWtoNECut(0, 0);
var surface = new TerrainSurface(heights, heightTable, 0, 0); var surface = new TerrainSurface(heights, heightTable, 0, 0);
float z = surface.SampleZ(20f, 2f); float z = surface.SampleZ(20f, 2f);
if (clientSplit) if (clientSplit)
{ {
// SWtoNE → BL+BR+TL triangle at (0.833, 0.083) → Z ≈ 83.3 // SWtoNE → {BL,BR,TR} triangle at (0.833, 0.083) → Z ≈ 75.0
Assert.InRange(z, 82f, 85f); Assert.InRange(z, 74f, 77f);
} }
else else
{ {
// SEtoNW → BL+BR+TR triangle at (0.833, 0.083) → Z ≈ 75.0 // SEtoNW → {BL,BR,TL} triangle at (0.833, 0.083) → Z ≈ 83.3
Assert.InRange(z, 74f, 77f); Assert.InRange(z, 82f, 85f);
} }
} }