From 9970811dc325ef6095fab5bf82c47d0709c5050a Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 10 Apr 2026 21:07:12 +0200 Subject: [PATCH] feat(core): procedural scenery from Region.SceneInfo (Phase 2c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.App/Rendering/GameWindow.cs | 113 +++++++++++- src/AcDream.Core/World/SceneryGenerator.cs | 194 +++++++++++++++++++++ 2 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 src/AcDream.Core/World/SceneryGenerator.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 868c42e..b4d4c21 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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(); + var scaleMat = System.Numerics.Matrix4x4.CreateScale(spawn.Scale); + + if ((spawn.ObjectId & 0xFF000000u) == 0x01000000u) + { + var gfx = _dats.Get(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(spawn.ObjectId); + if (setup is not null) + { + var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); + foreach (var mr in flat) + { + var gfx = _dats.Get(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)"); + } + + /// + /// Bilinear sample of the landblock heightmap at (x, y) in landblock-local + /// world units. Matches the x-major indexing convention of LandblockMesh. + /// + 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) diff --git a/src/AcDream.Core/World/SceneryGenerator.cs b/src/AcDream.Core/World/SceneryGenerator.cs new file mode 100644 index 0000000..791c905 --- /dev/null +++ b/src/AcDream.Core/World/SceneryGenerator.cs @@ -0,0 +1,194 @@ +using System.Numerics; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.Core.World; + +/// +/// Procedural scenery placement for a landblock. AC encodes "sparse decoration" +/// (trees, bushes, rocks, fences, small props) NOT as explicit Stab entries on +/// the LandBlockInfo but as per-terrain-vertex Scene/ObjectDesc references in +/// the Region dat, placed pseudo-randomly via deterministic LCG math keyed on +/// the vertex's global cell coordinates. Without this generator, any landblock +/// rendered from dats is missing all of its natural scenery. +/// +/// Algorithm ported from ACViewer's Physics/Common/Landblock.get_land_scenes() +/// which is itself a port of the original AC client's scenery walker. 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. +/// +public static class SceneryGenerator +{ + // AC landblock geometry — matches LandblockMesh. + private const int VerticesPerSide = 9; + private const float CellSize = 24.0f; + private const float LandblockSize = 192.0f; // 8 cells * 24 units + + public readonly record struct ScenerySpawn( + uint ObjectId, // GfxObj or Setup id + Vector3 LocalPosition, // landblock-local world units + Quaternion Rotation, + float Scale); + + /// + /// Generate all scenery entries for one landblock. Uses the bit-packed + /// TerrainInfo Type (bits 2-6) and Scenery (bits 11-15) fields to index into + /// Region.TerrainInfo.TerrainTypes[type].SceneTypes[scenery] → a SceneInfo + /// index into Region.SceneInfo.SceneTypes[sceneInfo].Scenes. Each cell picks + /// one scene via a pseudo-random hash of the cell's global coordinates, then + /// iterates the scene's ObjectDesc entries with per-object frequency rolls. + /// + public static IReadOnlyList Generate( + DatCollection dats, + Region region, + LandBlock block, + uint landblockId) + { + var result = new List(); + + if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null) + return result; + + uint blockX = (landblockId >> 24) * 8; // 8 cells per landblock + uint blockY = ((landblockId >> 16) & 0xFFu) * 8; + + // The original iterates Terrain[0..80] — 81 vertices of a 9x9 grid. + // The heightmap is packed x-major (Height[x*9+y]), so we match that here. + for (int x = 0; x < VerticesPerSide; x++) + { + for (int y = 0; y < VerticesPerSide; y++) + { + int i = x * VerticesPerSide + y; + ushort raw = block.Terrain[i]; + + uint terrainType = (uint)((raw >> 2) & 0x1F); // bits 2-6 + uint sceneType = (uint)((raw >> 11) & 0x1F); // bits 11-15 + + if (terrainType >= region.TerrainInfo.TerrainTypes.Count) continue; + var sceneTypeList = region.TerrainInfo.TerrainTypes[(int)terrainType].SceneTypes; + if (sceneType >= sceneTypeList.Count) continue; + + uint sceneInfo = sceneTypeList[(int)sceneType]; + if (sceneInfo >= region.SceneInfo.SceneTypes.Count) continue; + + var scenes = region.SceneInfo.SceneTypes[(int)sceneInfo].Scenes; + if (scenes.Count == 0) continue; + + uint cellX = (uint)x; + uint cellY = (uint)y; + uint globalCellX = cellX + blockX; + uint globalCellY = cellY + blockY; + + // Scene-selection hash: picks one scene from the terrain's scene list. + uint cellMat = globalCellY * (712977289u * globalCellX + 1813693831u) + - 1109124029u * globalCellX + 2139937281u; + double offset = cellMat * 2.3283064e-10; + int sceneIdx = (int)(scenes.Count * offset); + if (sceneIdx >= scenes.Count || sceneIdx < 0) sceneIdx = 0; + + uint sceneId = (uint)scenes[sceneIdx]; + var scene = dats.Get(sceneId); + if (scene is null) continue; + + // Per-object hashes: roll frequency, compute displacement, scale, rotation. + uint cellXMat = unchecked(0u - 1109124029u * globalCellX); + uint cellYMat = 1813693831u * globalCellY; + uint cellMat2 = 1360117743u * globalCellX * globalCellY + 1888038839u; + + for (uint j = 0; j < scene.Objects.Count; j++) + { + var obj = scene.Objects[(int)j]; + if (obj.WeenieObj != 0) continue; // Weenie entries are dynamic spawns, not static scenery + + double noise = unchecked((uint)(cellXMat + cellYMat - cellMat2 * (23399u + j))) * 2.3283064e-10; + if (noise >= obj.Frequency) continue; + + // Displacement: pseudo-random offset within the cell. + var localPos = DisplaceObject(obj, globalCellX, globalCellY, j); + + float lx = cellX * CellSize + localPos.X; + float ly = cellY * CellSize + localPos.Y; + + if (lx < 0 || ly < 0 || lx >= LandblockSize || ly >= LandblockSize) + continue; + + // Z at the cell corner from the heightmap. Skipping slope-based + // Z placement (ACViewer uses find_terrain_poly which we don't have) + // — accept that some scenery will float or clip. + float lz = 0f; // will be lifted to ground at render time via landblock heightmap + + // Rotation + Quaternion rotation = Quaternion.Identity; + if (obj.MaxRotation > 0) + { + double rotNoise = unchecked((uint)(1813693831u * globalCellY + - (j + 63127u) * (1360117743u * globalCellY * globalCellX + 1888038839u) + - 1109124029u * globalCellX)) * 2.3283064e-10; + float degrees = (float)(rotNoise * obj.MaxRotation); + float radians = degrees * MathF.PI / 180f; + rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, radians); + } + + // Scale + float scale; + if (obj.MinScale == obj.MaxScale) + { + scale = obj.MaxScale; + } + else + { + double scaleNoise = unchecked((uint)(1813693831u * globalCellY + - (j + 32593u) * (1360117743u * globalCellY * globalCellX + 1888038839u) + - 1109124029u * globalCellX)) * 2.3283064e-10; + scale = (float)(Math.Pow(obj.MaxScale / obj.MinScale, scaleNoise) * obj.MinScale); + } + if (scale <= 0) scale = 1f; + + result.Add(new ScenerySpawn( + ObjectId: obj.ObjectId, + LocalPosition: new Vector3(lx, ly, lz), + Rotation: rotation, + Scale: scale)); + } + } + } + + return result; + } + + /// + /// Pseudo-random displacement within a cell for a scenery object. Returns a + /// Vector3 in local cell-offset space (the caller adds it to the cell corner + /// to get landblock-local position). + /// + private static Vector3 DisplaceObject(ObjectDesc obj, uint ix, uint iy, uint iq) + { + float x, y; + var baseLoc = obj.BaseLoc.Origin; + + if (obj.DisplaceX <= 0) + x = baseLoc.X; + else + x = (float)(unchecked((uint)(1813693831u * iy - (iq + 45773u) * (1360117743u * iy * ix + 1888038839u) - 1109124029u * ix)) + * 2.3283064e-10 * obj.DisplaceX + baseLoc.X); + + if (obj.DisplaceY <= 0) + y = baseLoc.Y; + else + y = (float)(unchecked((uint)(1813693831u * iy - (iq + 72719u) * (1360117743u * iy * ix + 1888038839u) - 1109124029u * ix)) + * 2.3283064e-10 * obj.DisplaceY + baseLoc.Y); + + float z = baseLoc.Z; + + double quadrant = unchecked((uint)(1813693831u * iy - ix * (1870387557u * iy + 1109124029u) - 402451965u)) * 2.3283064e-10; + + if (quadrant >= 0.75) return new Vector3(y, -x, z); + if (quadrant >= 0.5) return new Vector3(-x, -y, z); + if (quadrant >= 0.25) return new Vector3(-y, x, z); + return new Vector3(x, y, z); + } +}