Merge phase-2c/scenery: procedural scenery from Region.SceneInfo
Phase 2c: adds SceneryGenerator that places trees, bushes, rocks, and fences across the 3x3 landblock grid using AC's deterministic LCG hash of global cell coordinates to drive ObjectDesc frequency rolls and pseudo-random placement/scale/rotation. Entity count: 239 → 658 (+419 scenery). Build clean, 48 tests green, runtime clean. Addresses the "missing trees" half of the user's post-Phase-2b visual feedback. The "foundry statue" half (hierarchical Setup parts) is deferred as a potential Phase 2d.
This commit is contained in:
commit
985cdc0044
2 changed files with 306 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
194
src/AcDream.Core/World/SceneryGenerator.cs
Normal file
194
src/AcDream.Core/World/SceneryGenerator.cs
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
using System.Numerics;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.World;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ScenerySpawn> Generate(
|
||||
DatCollection dats,
|
||||
Region region,
|
||||
LandBlock block,
|
||||
uint landblockId)
|
||||
{
|
||||
var result = new List<ScenerySpawn>();
|
||||
|
||||
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<Scene>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue