fix(scenery): #49 9×9 loop, per-spawn building check, triangle slope

Three fixes to match retail CLandBlock::get_land_scenes (0x00530460):

1. Loop bound: iterate 9×9 vertices (side_vertex_count=9), not 8×8
   cells. Edge vertices (x=8 or y=8) produce valid spawns when the
   per-object displacement shifts the position back into [0, 192).
   Confirmed by named retail decomp do-while condition, WorldBuilder
   vertLength=9, ACViewer Terrain.Count=81, AC2D wTopo[9][9].

2. Building suppression: check at the DISPLACED position's cell
   (CSortCell::has_building per spawn), not at the loop vertex index.
   Matches WorldBuilder buildingsGrid[gx2, gy2] pattern.

3. Slope filter: replace finite-difference gradient approximation
   with triangle-aware normal sampling via new static method
   TerrainSurface.SampleNormalZFromHeightmap. Picks the correct
   triangle via IsSplitSWtoNE, matching retail find_terrain_poly →
   polygon->plane.N.z and WorldBuilder's GetNormal().

Tests: 5 new tests for SampleNormalZFromHeightmap (flat=1.0, sloped<1,
cross-validates with SampleSurface instance method) and DisplaceObject
edge-vertex validity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-07 21:15:11 +02:00
parent 17b4ffde12
commit 833d167ebc
4 changed files with 203 additions and 49 deletions

View file

@ -198,6 +198,73 @@ public sealed class TerrainSurface
return InterpolateZInTriangle(hBL, hBR, hTR, hTL, tx, ty, splitSWtoNE);
}
/// <summary>
/// Sample the terrain triangle's surface-normal Z component at (localX, localY)
/// from a raw heightmap. Returns the upward component of the unit normal for
/// the specific triangle the point lies in — flat ground returns 1.0, steeper
/// slopes return smaller values. Used by <see cref="SceneryGenerator"/> for
/// the retail slope filter (<c>CLandCell::find_terrain_poly → polygon.plane.N.z</c>).
/// </summary>
public static float SampleNormalZFromHeightmap(
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;
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);
float dzdx, dzdy;
if (splitSWtoNE)
{
if (tx > ty)
{
dzdx = (hBR - hBL) / CellSize;
dzdy = (hTR - hBR) / CellSize;
}
else
{
dzdx = (hTR - hTL) / CellSize;
dzdy = (hTL - hBL) / CellSize;
}
}
else
{
if (tx + ty <= 1f)
{
dzdx = (hBR - hBL) / CellSize;
dzdy = (hTL - hBL) / CellSize;
}
else
{
dzdx = (hTR - hTL) / CellSize;
dzdy = (hTR - hBR) / CellSize;
}
}
return 1f / MathF.Sqrt(dzdx * dzdx + dzdy * dzdy + 1f);
}
/// <summary>
/// Pick the cell's triangle for the chosen diagonal and barycentric-
/// interpolate Z. Single source of truth shared by both

View file

@ -28,12 +28,6 @@ namespace AcDream.Core.World;
/// dividing by 2^32. This is equivalent to our unchecked((uint)(...)) cast.
/// ACViewer's reference omits this cast and is subtly wrong for negative inputs.
/// We deliberately match the decompiled client, not ACViewer.
///
/// We deliberately skip the slope/road/building-overlap checks the original does;
/// those prevent scenery from floating in roads or clipping buildings but
/// require walkable-polygon lookups that we don't yet have. Accepting visual
/// artifacts (trees inside roads, scenery clipping buildings) for a first pass
/// and deferring the filters to a later phase.
/// </summary>
public static class SceneryGenerator
{
@ -72,14 +66,15 @@ public static class SceneryGenerator
uint blockX = (landblockId >> 24) * 8; // 8 cells per landblock
uint blockY = ((landblockId >> 16) & 0xFFu) * 8;
// RETAIL iterates 8×8 = 64 CELLS, not 9×9 = 81 vertices.
// Decompiled FUN_005311a0 at chunk_00530000.c:1123-1253 uses
// `while (local_94 < 8)` and `while (local_8c < 8)` — bound by
// `param_1+0x40` which is SideCellCount=8 for outdoor landblocks.
// The terrain word at each cell's SW corner drives that cell's scenery.
for (int x = 0; x < CellsPerSide; x++)
// RETAIL iterates 9×9 = 81 VERTICES, not 8×8 = 64 cells.
// Named retail: CLandBlock::get_land_scenes (0x00530460) uses
// `side_vertex_count` (offset 0x40, value 9) as the loop bound.
// The do-while condition `(var+1) < side_vertex_count` runs var 0..8.
// Edge vertices (x=8 or y=8) produce valid spawns when the per-object
// displacement shifts the position back into the [0, 192) range.
for (int x = 0; x < VerticesPerSide; x++)
{
for (int y = 0; y < CellsPerSide; y++)
for (int y = 0; y < VerticesPerSide; y++)
{
int i = x * VerticesPerSide + y;
ushort raw = block.Terrain[i];
@ -92,9 +87,6 @@ public static class SceneryGenerator
// polygonal OnRoad check (see below). Removing the
// pre-displacement early-exit restores retail behavior.
// Skip cells that contain buildings.
if (buildingCells is not null && buildingCells.Contains(i)) continue;
if (terrainType >= region.TerrainInfo.TerrainTypes.Count) continue;
var sceneTypeList = region.TerrainInfo.TerrainTypes[(int)terrainType].SceneTypes;
if (sceneType >= sceneTypeList.Count) continue;
@ -166,34 +158,27 @@ public static class SceneryGenerator
continue;
}
// L-fix2 (2026-04-28): the extra cell-origin road-vertex
// guard previously here is REMOVED. It wasn't in the
// retail decomp — it was a heuristic added to widen
// road margins visually. The proper retail post-
// displacement road check (FUN_00530d30 port via
// IsOnRoad above) already handles road exclusion.
// The extra guard was over-suppressing — every cell
// whose SW corner happened to touch a road vertex
// had ALL of its scenery dropped, even when the
// displaced position was well clear of the ribbon.
// User reported missing trees they could see in
// retail; this is the most likely cause.
// Per-spawn building check on the DISPLACED position's cell.
// Retail: CSortCell::has_building(cell) per spawn, not per vertex.
// WorldBuilder: buildingsGrid[gx2, gy2] with 8×8 cell grid.
if (buildingCells is not null)
{
int dcx = Math.Clamp((int)(lx / CellSize), 0, CellsPerSide - 1);
int dcy = Math.Clamp((int)(ly / CellSize), 0, CellsPerSide - 1);
if (buildingCells.Contains(dcx * VerticesPerSide + dcy))
continue;
}
// Slope filter (ACME conformance fix 4e): compute terrain normal
// Z-component at the displaced position and check against the
// object's MinSlope/MaxSlope bounds.
// Slope filter: retail uses CLandCell::find_terrain_poly →
// polygon->plane.N.z to get the triangle-specific normal.
// SampleNormalZFromHeightmap picks the correct triangle via
// the cell's split direction, matching retail + WorldBuilder.
if (heightTable is not null && (obj.MinSlope > 0f || obj.MaxSlope < 1f))
{
int sx = Math.Clamp((int)(lx / CellSize), 0, VerticesPerSide - 2);
int sy = Math.Clamp((int)(ly / CellSize), 0, VerticesPerSide - 2);
int sxR = sx + 1;
int syU = sy + 1;
float h00 = heightTable[block.Height[sx * VerticesPerSide + sy]];
float h10 = heightTable[block.Height[sxR * VerticesPerSide + sy]];
float h01 = heightTable[block.Height[sx * VerticesPerSide + syU]];
float dx = (h10 - h00) / CellSize;
float dy = (h01 - h00) / CellSize;
float nz = 1f / MathF.Sqrt(dx * dx + dy * dy + 1f); // normal Z component
float nz = AcDream.Core.Physics.TerrainSurface.SampleNormalZFromHeightmap(
block.Height, heightTable,
landblockId >> 24, (landblockId >> 16) & 0xFFu,
lx, ly);
if (nz < obj.MinSlope || nz > obj.MaxSlope) continue;
}
@ -396,7 +381,7 @@ public static class SceneryGenerator
/// Decompiled normalises signed-int LCG results with "if (val &lt; 0) val += 2^32"; our
/// unchecked((uint)(...)) is exactly equivalent.
/// </summary>
private static Vector3 DisplaceObject(ObjectDesc obj, uint ix, uint iy, uint iq)
internal static Vector3 DisplaceObject(ObjectDesc obj, uint ix, uint iy, uint iq)
{
float x, y;
var baseLoc = obj.BaseLoc.Origin;