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); 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> /// <summary>
/// Pick the cell's triangle for the chosen diagonal and barycentric- /// Pick the cell's triangle for the chosen diagonal and barycentric-
/// interpolate Z. Single source of truth shared by both /// 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. /// 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. /// ACViewer's reference omits this cast and is subtly wrong for negative inputs.
/// We deliberately match the decompiled client, not ACViewer. /// 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> /// </summary>
public static class SceneryGenerator public static class SceneryGenerator
{ {
@ -72,14 +66,15 @@ public static class SceneryGenerator
uint blockX = (landblockId >> 24) * 8; // 8 cells per landblock uint blockX = (landblockId >> 24) * 8; // 8 cells per landblock
uint blockY = ((landblockId >> 16) & 0xFFu) * 8; uint blockY = ((landblockId >> 16) & 0xFFu) * 8;
// RETAIL iterates 8×8 = 64 CELLS, not 9×9 = 81 vertices. // RETAIL iterates 9×9 = 81 VERTICES, not 8×8 = 64 cells.
// Decompiled FUN_005311a0 at chunk_00530000.c:1123-1253 uses // Named retail: CLandBlock::get_land_scenes (0x00530460) uses
// `while (local_94 < 8)` and `while (local_8c < 8)` — bound by // `side_vertex_count` (offset 0x40, value 9) as the loop bound.
// `param_1+0x40` which is SideCellCount=8 for outdoor landblocks. // The do-while condition `(var+1) < side_vertex_count` runs var 0..8.
// The terrain word at each cell's SW corner drives that cell's scenery. // Edge vertices (x=8 or y=8) produce valid spawns when the per-object
for (int x = 0; x < CellsPerSide; x++) // 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; int i = x * VerticesPerSide + y;
ushort raw = block.Terrain[i]; ushort raw = block.Terrain[i];
@ -92,9 +87,6 @@ public static class SceneryGenerator
// polygonal OnRoad check (see below). Removing the // polygonal OnRoad check (see below). Removing the
// pre-displacement early-exit restores retail behavior. // 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; if (terrainType >= region.TerrainInfo.TerrainTypes.Count) continue;
var sceneTypeList = region.TerrainInfo.TerrainTypes[(int)terrainType].SceneTypes; var sceneTypeList = region.TerrainInfo.TerrainTypes[(int)terrainType].SceneTypes;
if (sceneType >= sceneTypeList.Count) continue; if (sceneType >= sceneTypeList.Count) continue;
@ -166,34 +158,27 @@ public static class SceneryGenerator
continue; continue;
} }
// L-fix2 (2026-04-28): the extra cell-origin road-vertex // Per-spawn building check on the DISPLACED position's cell.
// guard previously here is REMOVED. It wasn't in the // Retail: CSortCell::has_building(cell) per spawn, not per vertex.
// retail decomp — it was a heuristic added to widen // WorldBuilder: buildingsGrid[gx2, gy2] with 8×8 cell grid.
// road margins visually. The proper retail post- if (buildingCells is not null)
// displacement road check (FUN_00530d30 port via {
// IsOnRoad above) already handles road exclusion. int dcx = Math.Clamp((int)(lx / CellSize), 0, CellsPerSide - 1);
// The extra guard was over-suppressing — every cell int dcy = Math.Clamp((int)(ly / CellSize), 0, CellsPerSide - 1);
// whose SW corner happened to touch a road vertex if (buildingCells.Contains(dcx * VerticesPerSide + dcy))
// had ALL of its scenery dropped, even when the continue;
// displaced position was well clear of the ribbon. }
// User reported missing trees they could see in
// retail; this is the most likely cause.
// Slope filter (ACME conformance fix 4e): compute terrain normal // Slope filter: retail uses CLandCell::find_terrain_poly →
// Z-component at the displaced position and check against the // polygon->plane.N.z to get the triangle-specific normal.
// object's MinSlope/MaxSlope bounds. // 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)) if (heightTable is not null && (obj.MinSlope > 0f || obj.MaxSlope < 1f))
{ {
int sx = Math.Clamp((int)(lx / CellSize), 0, VerticesPerSide - 2); float nz = AcDream.Core.Physics.TerrainSurface.SampleNormalZFromHeightmap(
int sy = Math.Clamp((int)(ly / CellSize), 0, VerticesPerSide - 2); block.Height, heightTable,
int sxR = sx + 1; landblockId >> 24, (landblockId >> 16) & 0xFFu,
int syU = sy + 1; lx, ly);
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
if (nz < obj.MinSlope || nz > obj.MaxSlope) continue; 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 /// Decompiled normalises signed-int LCG results with "if (val &lt; 0) val += 2^32"; our
/// unchecked((uint)(...)) is exactly equivalent. /// unchecked((uint)(...)) is exactly equivalent.
/// </summary> /// </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; float x, y;
var baseLoc = obj.BaseLoc.Origin; var baseLoc = obj.BaseLoc.Origin;

View file

@ -139,6 +139,52 @@ public class TerrainSurfaceTests
Assert.Contains(sample.Vertices, v => v.X == 0f && v.Y == 0f); Assert.Contains(sample.Vertices, v => v.X == 0f && v.Y == 0f);
} }
[Fact]
public void SampleNormalZFromHeightmap_FlatTerrain_ReturnsOne()
{
var heights = FlatHeightmap(50);
var hTable = LinearHeightTable();
float nz = TerrainSurface.SampleNormalZFromHeightmap(heights, hTable, 0, 0, 96f, 96f);
Assert.Equal(1f, nz, precision: 5);
}
[Fact]
public void SampleNormalZFromHeightmap_SlopedTerrain_ReturnsLessThanOne()
{
var heights = new byte[81];
for (int x = 0; x < 9; x++)
for (int y = 0; y < 9; y++)
heights[x * 9 + y] = (byte)(x * 20);
var hTable = LinearHeightTable();
float nz = TerrainSurface.SampleNormalZFromHeightmap(heights, hTable, 0, 0, 12f, 12f);
Assert.True(nz < 1f, $"Sloped terrain should have nz < 1.0, got {nz}");
Assert.True(nz > 0f, $"nz should be positive, got {nz}");
}
[Fact]
public void SampleNormalZFromHeightmap_AgreesWithSampleSurface()
{
var heights = new byte[81];
for (int x = 0; x < 9; x++)
for (int y = 0; y < 9; y++)
heights[x * 9 + y] = (byte)((x * 17 + y * 13) % 256);
var hTable = LinearHeightTable();
const uint lbX = 0xA9, lbY = 0xB3;
var instance = new TerrainSurface(heights, hTable, lbX, lbY);
for (float lx = 0.5f; lx < 192f; lx += 8f)
for (float ly = 0.5f; ly < 192f; ly += 8f)
{
var (_, normal) = instance.SampleSurface(lx, ly);
float staticNz = TerrainSurface.SampleNormalZFromHeightmap(
heights, hTable, lbX, lbY, lx, ly);
Assert.True(
Math.Abs(normal.Z - staticNz) < 0.0001f,
$"NormalZ mismatch at ({lx:F1},{ly:F1}): instance={normal.Z:F4} static={staticNz:F4}");
}
}
[Fact] [Fact]
public void ComputeOutdoorCellId_Origin_ReturnsFirst() public void ComputeOutdoorCellId_Origin_ReturnsFirst()
{ {

View file

@ -1,13 +1,13 @@
using System.Numerics;
using AcDream.Core.World; using AcDream.Core.World;
using DatReaderWriter.Types; using DatReaderWriter.Types;
namespace AcDream.Core.Tests.World; namespace AcDream.Core.Tests.World;
/// <summary> /// <summary>
/// Tests for SceneryGenerator road-exclusion logic. /// Tests for SceneryGenerator: road-exclusion, loop bounds, building
/// The full Generate() pipeline requires real dat files (Region, Scene, etc.) /// suppression, and slope filter. The full Generate() pipeline requires
/// so road-check behavior is tested via the internal IsRoadVertex helper, /// real dat files so behavior is tested via internal helpers.
/// which is the single gate that guards against placing trees on roads.
/// </summary> /// </summary>
public class SceneryGeneratorTests public class SceneryGeneratorTests
{ {
@ -32,15 +32,12 @@ public class SceneryGeneratorTests
[Fact] [Fact]
public void IsRoadVertex_ZeroTerrain_IsNotRoad() public void IsRoadVertex_ZeroTerrain_IsNotRoad()
{ {
// A fully blank terrain entry (no type, no road, no scene) is not a road.
Assert.False(SceneryGenerator.IsRoadVertex(0)); Assert.False(SceneryGenerator.IsRoadVertex(0));
} }
[Fact] [Fact]
public void IsRoadVertex_MatchesTerrainInfoRoadProperty() public void IsRoadVertex_MatchesTerrainInfoRoadProperty()
{ {
// Verify that IsRoadVertex agrees with the typed TerrainInfo.Road property
// for a sample of raw values, ensuring the bit convention is consistent.
for (ushort raw = 0; raw < 4; raw++) for (ushort raw = 0; raw < 4; raw++)
{ {
TerrainInfo ti = raw; TerrainInfo ti = raw;
@ -50,4 +47,63 @@ public class SceneryGeneratorTests
$"raw=0x{raw:X4}: IsRoadVertex={actual} but TerrainInfo.Road={ti.Road}"); $"raw=0x{raw:X4}: IsRoadVertex={actual} but TerrainInfo.Road={ti.Road}");
} }
} }
// --- Edge vertex displacement tests ---
// Retail iterates 9×9 vertices (0..8 on each axis). Vertices at x=8 or y=8
// have base positions at 192 (= 8 * 24), which is AT the landblock boundary.
// These produce valid scenery when displacement shifts them back into [0, 192).
[Fact]
public void DisplaceObject_EdgeVertex_CanProduceValidPosition()
{
// Vertex (3, 8): base_y = 8 * 24 = 192.
// With DisplaceY > 0, some LCG seeds will produce negative displacement,
// shifting the Y back below 192 into the valid range.
var obj = new ObjectDesc
{
DisplaceX = 12f,
DisplaceY = 12f,
BaseLoc = new Frame { Origin = new Vector3(0, 0, 0) }
};
// Search across a range of global cell coords to find at least one
// case where vertex y=8 displaces into [0, 192).
bool foundValid = false;
for (uint gx = 0; gx < 64 && !foundValid; gx++)
{
for (uint gy = 0; gy < 64 && !foundValid; gy++)
{
var localPos = SceneryGenerator.DisplaceObject(obj, gx, gy, 0);
// Vertex (3, 8): cell corner at (3*24, 8*24) = (72, 192)
float lx = 3 * 24f + localPos.X;
float ly = 8 * 24f + localPos.Y;
if (ly >= 0 && ly < 192f && lx >= 0 && lx < 192f)
foundValid = true;
}
}
Assert.True(foundValid,
"Expected at least one (globalCellX, globalCellY) where vertex y=8 " +
"displaces back into [0, 192) — retail's 9×9 loop relies on this");
}
[Fact]
public void DisplaceObject_InteriorVertex_AlwaysNearOrigin()
{
var obj = new ObjectDesc
{
DisplaceX = 12f,
DisplaceY = 12f,
BaseLoc = new Frame { Origin = new Vector3(0, 0, 0) }
};
// For interior vertices (x < 8, y < 8), displacement is bounded by
// DisplaceX/Y (max 12 units each), so the result stays within one
// cell of the origin.
var localPos = SceneryGenerator.DisplaceObject(obj, 100, 100, 0);
Assert.True(Math.Abs(localPos.X) <= 12f,
$"Interior displacement X={localPos.X} exceeds DisplaceX=12");
Assert.True(Math.Abs(localPos.Y) <= 12f,
$"Interior displacement Y={localPos.Y} exceeds DisplaceY=12");
}
} }