fix(scenery): #48 unify scenery Z with physics-path triangle picker
Closes #48. Trees on sloped cells visibly hovered above the visible
terrain because GameWindow.SampleTerrainZ (the bilinear fallback used
during scenery hydration before physics registers a landblock) had
its diagonal arms swapped — used the SEtoNW triangle test on SWtoNE
cells and vice versa. The ACDREAM_DUMP_SCENERY_Z=1 diagnostic showed
every scenery line ran through the bilinear path (streaming race),
so on hilly terrain scenery was placed at a Z up to ~1.5 m off from
the visible mesh.
Latent since ff325ab (2026-04-17 "feat(ui): debug overlay + refined
input controls" carrying along the upgrade). That commit reached for
WorldBuilder TerrainUtils.GetHeight as the secondary oracle and
re-derived the triangle-pair tests; the named-retail / ACE algorithm
in TerrainSurface.SampleZ (used by the physics path / player Z) was
always correct, so player feet stayed flush — the two paths just
disagreed and only scenery noticed.
Fix:
- TerrainSurface.InterpolateZInTriangle (private static) — single
source of truth for the triangle pick + barycentric Z, sourced
from FUN_00532a50 / ACE LandblockStruct.ConstructPolygons.
- TerrainSurface.SampleZFromHeightmap (public static) — heightmap-
byte-array variant for the scenery hydration fallback. Both this
and TerrainSurface.SampleZ (instance) now delegate to the same
InterpolateZInTriangle.
- GameWindow.SampleTerrainZ — thin wrapper over the new static.
- TerrainSurfaceTests.SampleZFromHeightmap_AgreesWithInstance_AcrossWholeLandblock
asserts both sampler paths agree at 1500 sample points across both
diagonals, so future drift gets caught.
The ACDREAM_DUMP_SCENERY_Z=1 diagnostic in BuildSceneryEntitiesForStreaming
is kept committed (env-var gated, zero cost when off) — useful for
the related #49 scenery (X, Y) placement investigation filed in the
same commit.
Visual verified at Holtburg landblock 0xA9B30001 2026-05-06: the
formerly floating 32 m pines (setups 0x020002D3 / 0x020002D9) now
sit flush on the visible terrain mesh.
Test baseline: dotnet test reports the same 8 pre-existing motion /
BSP step-up failures as the handoff doc warned about — no new
failures introduced.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c1bb43ab89
commit
a4693954d8
5 changed files with 352 additions and 60 deletions
|
|
@ -143,23 +143,89 @@ public sealed class TerrainSurface
|
|||
// and ACE's LandblockStruct.ConstructPolygons.
|
||||
bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy);
|
||||
|
||||
return InterpolateZInTriangle(hBL, hBR, hTR, hTL, tx, ty, splitSWtoNE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sample terrain Z directly from a landblock's raw heightmap. Same
|
||||
/// algorithm as <see cref="SampleZ"/> (instance), but reads the four
|
||||
/// corner heights through <c>heightTable[heights[x*9+y]]</c> on the fly
|
||||
/// instead of from the pre-resolved instance cache. Use this when a
|
||||
/// <see cref="TerrainSurface"/> hasn't been built yet for a landblock —
|
||||
/// e.g. scenery hydration during streaming, before physics has registered
|
||||
/// the landblock. Both paths produce the same Z, so scenery sits flush
|
||||
/// with the visible terrain mesh and with the player physics path.
|
||||
///
|
||||
/// <para>
|
||||
/// Issue #48 root cause: the previous bilinear fallback in
|
||||
/// <c>GameWindow.SampleTerrainZ</c> had its two diagonal arms swapped
|
||||
/// (used the SEtoNW triangle test for SWtoNE cells and vice versa),
|
||||
/// so on sloped cells scenery sat at a different Z than the visible
|
||||
/// terrain by up to ~1.5 m. Routing the fallback through this static
|
||||
/// helper guarantees both samplers stay in lock-step.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static float SampleZFromHeightmap(
|
||||
byte[] heights, float[] heightTable,
|
||||
uint landblockX, uint landblockY,
|
||||
float localX, float localY)
|
||||
{
|
||||
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));
|
||||
|
||||
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;
|
||||
|
||||
// x-major heightmap indexing matches TerrainSurface's pre-resolution
|
||||
// (heights[x * 9 + y]) and ACE LandblockStruct.
|
||||
float hBL = heightTable[heights[cx * HeightmapSide + cy ]];
|
||||
float hBR = heightTable[heights[(cx+1) * HeightmapSide + cy ]];
|
||||
float hTR = heightTable[heights[(cx+1) * HeightmapSide + (cy+1)]];
|
||||
float hTL = heightTable[heights[cx * HeightmapSide + (cy+1)]];
|
||||
|
||||
bool splitSWtoNE = IsSplitSWtoNE(landblockX, (uint)cx, landblockY, (uint)cy);
|
||||
return InterpolateZInTriangle(hBL, hBR, hTR, hTL, tx, ty, splitSWtoNE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pick the cell's triangle for the chosen diagonal and barycentric-
|
||||
/// interpolate Z. Single source of truth shared by both
|
||||
/// <see cref="SampleZ"/> (instance, pre-resolved cache) and
|
||||
/// <see cref="SampleZFromHeightmap"/> (static, raw heightmap).
|
||||
/// Triangle layout matches ACE <c>LandblockStruct.ConstructPolygons</c>:
|
||||
/// SWtoNE cells split BL→TR (line y=x), SEtoNW cells split BR→TL
|
||||
/// (line x+y=1).
|
||||
/// </summary>
|
||||
private static float InterpolateZInTriangle(
|
||||
float hBL, float hBR, float hTR, float hTL,
|
||||
float tx, float ty, bool splitSWtoNE)
|
||||
{
|
||||
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
|
||||
return hBL + (hBR - hBL) * tx + (hTR - hBR) * ty; // BL+BR+TR
|
||||
return hBL + (hTR - hTL) * tx + (hTL - hBL) * ty; // BL+TR+TL
|
||||
}
|
||||
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
|
||||
return hBL + (hBR - hBL) * tx + (hTL - hBL) * ty; // BL+BR+TL
|
||||
return hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); // BR+TR+TL
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue