feat(core): procedural scenery from Region.SceneInfo (Phase 2c)

Adds SceneryGenerator.Generate which walks Region.TerrainInfo.TerrainTypes
+ Region.SceneInfo.SceneTypes for each landblock vertex, selects a scene
using the AC client's pseudo-random LCG hash of global cell coordinates,
then rolls each ObjectDesc's frequency, computes a displaced cell-local
position, random scale, and random rotation — the exact algorithm
ACViewer ports from the retail AC client's get_land_scenes().

Phase 2 rendered 239 explicit Stab+Building entities on the 3x3 Holtburg
grid but was missing every procedurally-placed tree, bush, rock, fence,
and small decoration because these are not stored as LandBlockInfo entries.
This adds 419 scenery entities across the same 9 landblocks, bringing the
total to 658.

Integration in GameWindow.OnLoad: after the existing Stab/Building
hydration loop, iterate each landblock's scenery spawns, resolve each
to a GfxObj or Setup via the same mesh pipeline, bake the random scale
into each MeshRef's PartTransform so the static mesh renderer doesn't
need a scale field on WorldEntity, and sample the landblock heightmap
bilinearly for the ground Z (simpler than ACViewer's find_terrain_poly
slope-aware placement).

Deliberate deferrals for first pass:
- No slope-based rejection (obj.MinSlope/MaxSlope). Trees may end up on
  cliffs they shouldn't be on.
- No road-overlap rejection. Scenery may spawn in roads.
- No building-overlap rejection. Scenery may clip buildings.
- No WeenieObj handling (those are dynamic spawns, not static scenery).

All three filters will be added in a follow-up phase when we have the
walkable-polygon infrastructure they need.

Build clean, 48 tests still pass, smoke verified: "scenery: spawned 419
entities across 9 landblocks", process runs without exceptions.

Addresses the user visual feedback after Phase 2b: "some extra details
are missing, like a tree and the statue on top of the foundry". The tree
issue is now fixed (419 trees/bushes/rocks/etc placed). The foundry
statue may still be missing if it's a hierarchical Setup part (Phase 2a's
SetupMesh.Flatten intentionally doesn't walk ParentIndex) — that's a
separate fix if smoke verification shows it's still missing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-10 21:07:12 +02:00
parent fe0bfb075b
commit 9970811dc3
2 changed files with 306 additions and 1 deletions

View file

@ -237,8 +237,119 @@ public sealed class GameWindow : IDisposable
}
}
// Phase 2c: procedural scenery — trees, bushes, rocks, fences from
// Region.SceneInfo. These aren't stored as explicit Stab entries; they're
// generated deterministically from per-vertex TerrainInfo.Scenery bits.
int scenerySpawned = 0;
uint sceneryIdCounter = 0x80000000u; // high bit set to avoid colliding with Stab ids
foreach (var lb in worldView.Landblocks)
{
var spawns = AcDream.Core.World.SceneryGenerator.Generate(
_dats, region!, lb.Heightmap, lb.LandblockId);
if (spawns.Count == 0) continue;
int lbX = (int)((lb.LandblockId >> 24) & 0xFFu);
int lbY = (int)((lb.LandblockId >> 16) & 0xFFu);
var lbOffset = new System.Numerics.Vector3(
(lbX - centerX) * 192f,
(lbY - centerY) * 192f,
0f);
foreach (var spawn in spawns)
{
// Resolve the object to a mesh (same GfxObj/Setup logic as Stabs).
// Scale is baked into the root transform by wrapping each part's
// transform with a scale matrix.
var meshRefs = new List<AcDream.Core.World.MeshRef>();
var scaleMat = System.Numerics.Matrix4x4.CreateScale(spawn.Scale);
if ((spawn.ObjectId & 0xFF000000u) == 0x01000000u)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(spawn.ObjectId);
if (gfx is not null)
{
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx);
_staticMesh.EnsureUploaded(spawn.ObjectId, subMeshes);
meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat));
}
}
else if ((spawn.ObjectId & 0xFF000000u) == 0x02000000u)
{
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(spawn.ObjectId);
if (setup is not null)
{
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
foreach (var mr in flat)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
if (gfx is null) continue;
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx);
_staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes);
// Compose: part's own transform, then the spawn's scale.
meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, mr.PartTransform * scaleMat));
}
}
}
if (meshRefs.Count == 0) continue;
// Sample terrain Z at (localX, localY) to lift scenery onto the ground.
float localX = spawn.LocalPosition.X;
float localY = spawn.LocalPosition.Y;
float groundZ = SampleTerrainZ(lb.Heightmap, heightTable, localX, localY);
var hydrated = new AcDream.Core.World.WorldEntity
{
Id = sceneryIdCounter++,
SourceGfxObjOrSetupId = spawn.ObjectId,
Position = new System.Numerics.Vector3(localX, localY, groundZ) + lbOffset,
Rotation = spawn.Rotation,
MeshRefs = meshRefs,
};
hydratedEntities.Add(hydrated);
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
Id: hydrated.Id,
SourceId: hydrated.SourceGfxObjOrSetupId,
Position: hydrated.Position,
Rotation: hydrated.Rotation);
_worldGameState.Add(snapshot);
_worldEvents.FireEntitySpawned(snapshot);
scenerySpawned++;
}
}
Console.WriteLine($"scenery: spawned {scenerySpawned} entities across {worldView.Landblocks.Count} landblocks");
_entities = hydratedEntities;
Console.WriteLine($"hydrated {_entities.Count} entities");
Console.WriteLine($"hydrated {_entities.Count} entities total (stabs + buildings + scenery)");
}
/// <summary>
/// Bilinear sample of the landblock heightmap at (x, y) in landblock-local
/// world units. Matches the x-major indexing convention of LandblockMesh.
/// </summary>
private static float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float worldX, float worldY)
{
const float CellSize = 24f;
const int VerticesPerSide = 9;
float fx = Math.Clamp(worldX / CellSize, 0f, VerticesPerSide - 1);
float fy = Math.Clamp(worldY / CellSize, 0f, VerticesPerSide - 1);
int x0 = (int)MathF.Floor(fx);
int y0 = (int)MathF.Floor(fy);
int x1 = Math.Min(x0 + 1, VerticesPerSide - 1);
int y1 = Math.Min(y0 + 1, VerticesPerSide - 1);
float tx = fx - x0;
float ty = fy - y0;
// Heightmap is packed x-major (Height[x*9+y]) matching LandblockMesh.
float h00 = heightTable[block.Height[x0 * 9 + y0]];
float h10 = heightTable[block.Height[x1 * 9 + y0]];
float h01 = heightTable[block.Height[x0 * 9 + y1]];
float h11 = heightTable[block.Height[x1 * 9 + y1]];
float hx0 = h00 * (1 - tx) + h10 * tx;
float hx1 = h01 * (1 - tx) + h11 * tx;
return hx0 * (1 - ty) + hx1 * ty;
}
private void OnUpdate(double dt)