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;
|
_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)
|
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