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
|
|
@ -2532,62 +2532,28 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bilinear sample of the landblock heightmap at (x, y) in landblock-local
|
||||
/// world units. Matches the x-major indexing convention of LandblockMesh.
|
||||
/// Triangle-aware terrain Z sample directly from a landblock's raw
|
||||
/// heightmap. Used as the bilinear fallback in scenery hydration when
|
||||
/// physics hasn't built a <c>TerrainSurface</c> for the landblock yet
|
||||
/// (streaming race). Delegates to
|
||||
/// <see cref="AcDream.Core.Physics.TerrainSurface.SampleZFromHeightmap"/>
|
||||
/// so this fallback and the player-physics path stay in lock-step on
|
||||
/// sloped cells.
|
||||
///
|
||||
/// <para>
|
||||
/// Issue #48: the previous in-place implementation here had its two
|
||||
/// diagonal arms swapped (SWtoNE cells used the SEtoNW triangle test
|
||||
/// and vice versa), so scenery on hilly terrain sat at a different Z
|
||||
/// than the visible terrain mesh — a multi-meter offset in some
|
||||
/// cells, the user-reported "floating trees" symptom.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float worldX, float worldY)
|
||||
private static float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float localX, float localY)
|
||||
{
|
||||
// Exact port of WorldBuilder TerrainUtils.GetHeight (line 59-108).
|
||||
// Barycentric interpolation over the cell's triangle pair, respecting
|
||||
// the cell's split direction (SWtoNE vs SEtoNW).
|
||||
const float CellSize = 24f;
|
||||
|
||||
uint cellX = (uint)(worldX / CellSize);
|
||||
uint cellY = (uint)(worldY / CellSize);
|
||||
if (cellX >= 8) cellX = 7;
|
||||
if (cellY >= 8) cellY = 7;
|
||||
|
||||
uint landblockX = (block.Id >> 24) & 0xFFu;
|
||||
uint landblockY = (block.Id >> 16) & 0xFFu;
|
||||
var splitDirection = AcDream.Core.Terrain.TerrainBlending.CalculateSplitDirection(
|
||||
landblockX, cellX, landblockY, cellY);
|
||||
|
||||
// 4 cell corners (heightmap x-major: Height[x*9 + y])
|
||||
float h0 = heightTable[block.Height[cellX * 9 + cellY]]; // BL
|
||||
float h1 = heightTable[block.Height[(cellX + 1) * 9 + cellY]]; // BR
|
||||
float h2 = heightTable[block.Height[(cellX + 1) * 9 + (cellY + 1)]]; // TR
|
||||
float h3 = heightTable[block.Height[cellX * 9 + (cellY + 1)]]; // TL
|
||||
|
||||
float lx = worldX - cellX * CellSize;
|
||||
float ly = worldY - cellY * CellSize;
|
||||
float s = lx / CellSize;
|
||||
float t = ly / CellSize;
|
||||
|
||||
if (splitDirection == AcDream.Core.Terrain.CellSplitDirection.SWtoNE)
|
||||
{
|
||||
if (s + t <= 1f)
|
||||
{
|
||||
return h0 * (1f - s - t) + h1 * s + h3 * t;
|
||||
}
|
||||
else
|
||||
{
|
||||
float u = s + t - 1f;
|
||||
float v = 1f - s;
|
||||
float w = 1f - u - v;
|
||||
return h1 * w + h2 * u + h3 * v;
|
||||
}
|
||||
}
|
||||
else // SEtoNW
|
||||
{
|
||||
if (s >= t)
|
||||
{
|
||||
return h0 * (1f - s) + h1 * (s - t) + h2 * t;
|
||||
}
|
||||
else
|
||||
{
|
||||
return h0 * (1f - t) + h2 * s + h3 * (t - s);
|
||||
}
|
||||
}
|
||||
return AcDream.Core.Physics.TerrainSurface.SampleZFromHeightmap(
|
||||
block.Height, heightTable, landblockX, landblockY, localX, localY);
|
||||
}
|
||||
|
||||
private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete)
|
||||
|
|
@ -4674,6 +4640,11 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
if (float.IsPositiveInfinity(zMin)) { zMin = 0f; zMax = 0f; }
|
||||
|
||||
// Per-part transform offset inside the setup (post-spawn-scale).
|
||||
// For setup spawns this is Setup.PlacementFrames[Default].Frames[i] *
|
||||
// spawn.Scale. For single-GfxObj spawns it's identity * spawn.Scale.
|
||||
var partT = mr.PartTransform.Translation;
|
||||
|
||||
bool hasDD = dgfx.Flags.HasFlag(DatReaderWriter.Enums.GfxObjFlags.HasDIDDegrade);
|
||||
string ddInfo = string.Empty;
|
||||
if (hasDD && dgfx.DIDDegrade != 0)
|
||||
|
|
@ -4695,12 +4666,20 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
// partWorldZMin = the lowest vertex of this part in world space.
|
||||
// = finalZ (setup origin in world Z) + partT.Z (part offset) + zMin (mesh-local lowest vertex)
|
||||
// If everything is right and the lowest part of the tree should
|
||||
// touch the ground, we expect partWorldZMin <= groundZ for at
|
||||
// least one part of a multi-part setup.
|
||||
float partWorldZMin = finalZ + partT.Z + zMin;
|
||||
|
||||
Console.WriteLine(
|
||||
$"[scenery-z] lb=0x{lb.LandblockId:X8} root=0x{spawn.ObjectId:X8} gfx=0x{mr.GfxObjId:X8}" +
|
||||
$" source={source}" +
|
||||
$" world=({worldPx:F2},{worldPy:F2}) localXY=({localX:F2},{localY:F2})" +
|
||||
$" groundZ={groundZ:F3} BaseLoc.Z={spawn.LocalPosition.Z:F3} finalZ={finalZ:F3}" +
|
||||
$" zRange=[{zMin:F3}..{zMax:F3}] zSpan={zMax - zMin:F3}" +
|
||||
$" partT=({partT.X:F2},{partT.Y:F2},{partT.Z:F3}) spawnScale={spawn.Scale:F3}" +
|
||||
$" zRange=[{zMin:F3}..{zMax:F3}] partWorldZMin={partWorldZMin:F3} delta={partWorldZMin - groundZ:F3}" +
|
||||
$" hasDIDDegrade={hasDD}{ddInfo}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue