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:
parent
17b4ffde12
commit
833d167ebc
4 changed files with 203 additions and 49 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 < 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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue