feat(app): Phase A.1 Task 8 — restore scenery + interior in streamed loads

Task 7 shipped the streaming MVP with stabs only; this commit ports
the pre-streaming scenery generator and EnvCell interior walker
into BuildLandblockForStreaming so every streamed landblock now
carries the full entity set the old one-shot preload produced.

Scenery (trees/rocks/bushes) from SceneryGenerator.Generate runs
per-landblock on the worker thread with a landblock-derived id
namespace (0x80000000 | (lbId & 0x00FFFF00) | local_index) so
scenery ids don't collide across landblocks.

Interior (EnvCell walls/floors/ceilings via Phase 7.1 CellMesh
plus static interior objects) runs on the worker thread with a
0x40000000-based id namespace. Cell sub-meshes are pre-built on
the worker and handed to the render thread via a
ConcurrentDictionary<uint, IReadOnlyList<GfxObjSubMesh>> which
ApplyLoadedTerrain drains before its per-MeshRef upload loop.

The per-MeshRef upload loop in ApplyLoadedTerrain now skips
non-0x01xxxxxx ids (EnvCell synthetic ids, Setup ids) so it no
longer attempts GfxObj.Get on ids that aren't GfxObj dat records.

The cross-thread cell-mesh dictionary is the only shared mutable
state between the worker and render threads — everything else
flows through the Channel<LandblockStreamResult> outbox.

212 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 22:39:33 +02:00
parent efcf0c30d0
commit fca299780c

View file

@ -41,6 +41,15 @@ public sealed class GameWindow : IDisposable
private AcDream.Core.Terrain.TerrainBlendingContext? _blendCtx;
private Dictionary<uint, AcDream.Core.Terrain.SurfaceInfo>? _surfaceCache;
// Phase A.1 Task 8: worker thread pre-builds EnvCell room-mesh sub-meshes
// (CPU only) and stores them here. ApplyLoadedTerrain (render thread) drains
// this dict and uploads them via EnsureUploaded before the per-MeshRef loop.
// ConcurrentDictionary is required because the worker and render threads
// access this simultaneously without a broader lock.
private readonly System.Collections.Concurrent.ConcurrentDictionary<
uint, System.Collections.Generic.IReadOnlyList<AcDream.Core.Meshing.GfxObjSubMesh>>
_pendingCellMeshes = new();
/// <summary>
/// Phase 6.4: per-entity animation playback state for entities whose
/// MotionTable resolved to a real cycle. The render loop ticks each
@ -870,10 +879,229 @@ public sealed class GameWindow : IDisposable
hydrated.Add(entity);
}
// Task 8: merge stabs + scenery + interior into one entity list.
var merged = new List<AcDream.Core.World.WorldEntity>(hydrated);
merged.AddRange(BuildSceneryEntitiesForStreaming(baseLoaded, lbX, lbY));
merged.AddRange(BuildInteriorEntitiesForStreaming(landblockId, lbX, lbY));
return new AcDream.Core.World.LoadedLandblock(
baseLoaded.LandblockId,
baseLoaded.Heightmap,
hydrated);
merged);
}
/// <summary>
/// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single
/// landblock on the worker thread. Pure CPU — no GL calls.
///
/// Ported from the pre-streaming preload loop in GameWindow.OnLoad
/// (pre-Task-7 version, lines 329-405). Adapted to operate on a single
/// LoadedLandblock instead of iterating worldView.Landblocks.
/// </summary>
private List<AcDream.Core.World.WorldEntity> BuildSceneryEntitiesForStreaming(
AcDream.Core.World.LoadedLandblock lb, int lbX, int lbY)
{
var result = new List<AcDream.Core.World.WorldEntity>();
if (_dats is null || _heightTable is null) return result;
var region = _dats.Get<DatReaderWriter.DBObjs.Region>(0x13000000u);
if (region is null) return result;
var spawns = AcDream.Core.World.SceneryGenerator.Generate(
_dats, region, lb.Heightmap, lb.LandblockId);
if (spawns.Count == 0) return result;
var lbOffset = new System.Numerics.Vector3(
(lbX - _liveCenterX) * 192f,
(lbY - _liveCenterY) * 192f,
0f);
// Per-landblock id namespace: 0x80000000 | (lbId & 0x00FFFF00) | local_index.
// The landblock coord occupies bits 16-23 (X) and 8-15 (Y) — both fit in the
// 0x00FFFF00 mask. Local index uses bits 0-7 (256 slots per landblock), which
// is enough because SceneryGenerator caps at ~200 spawns per block in practice.
uint sceneryIdBase = 0x80000000u | (lb.LandblockId & 0x00FFFF00u);
uint localIndex = 0;
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)
{
// Sub-meshes pre-built CPU-side; upload deferred to ApplyLoadedTerrain.
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
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;
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
// 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 = sceneryIdBase + localIndex++,
SourceGfxObjOrSetupId = spawn.ObjectId,
Position = new System.Numerics.Vector3(localX, localY, groundZ) + lbOffset,
Rotation = spawn.Rotation,
MeshRefs = meshRefs,
};
result.Add(hydrated);
}
return result;
}
/// <summary>
/// Phase A.1 Task 8: walk a landblock's EnvCells and produce (a) the cell
/// room-mesh entity (Phase 7.1) for each EnvCell with an EnvironmentId, and
/// (b) a WorldEntity per StaticObject in each cell. Pure CPU — no GL calls.
///
/// Cell sub-meshes are stored in _pendingCellMeshes (ConcurrentDictionary)
/// so ApplyLoadedTerrain can drain + upload them on the render thread.
///
/// Ported from pre-streaming preload lines 407-565.
/// </summary>
private List<AcDream.Core.World.WorldEntity> BuildInteriorEntitiesForStreaming(
uint landblockId, int lbX, int lbY)
{
var result = new List<AcDream.Core.World.WorldEntity>();
if (_dats is null) return result;
var lbInfo = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>((landblockId & 0xFFFF0000u) | 0xFFFEu);
if (lbInfo is null || lbInfo.NumCells == 0) return result;
var lbOffset = new System.Numerics.Vector3(
(lbX - _liveCenterX) * 192f,
(lbY - _liveCenterY) * 192f,
0f);
// Per-landblock id namespace: 0x40000000 | (lbId & 0x00FFFF00) | local_counter.
// Distinct from scenery (0x80000000+) and stabs (ids from LandblockLoader).
uint interiorIdBase = 0x40000000u | (landblockId & 0x00FFFF00u);
uint localCounter = 0;
uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u;
for (uint offset = 0; offset < lbInfo.NumCells; offset++)
{
uint envCellId = firstCellId + offset;
var envCell = _dats.Get<DatReaderWriter.DBObjs.EnvCell>(envCellId);
if (envCell is null) continue;
// Phase 7.1: build and register room geometry for this EnvCell.
if (envCell.EnvironmentId != 0)
{
var environment = _dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
if (environment is not null
&& environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct))
{
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
if (cellSubMeshes.Count > 0)
{
// Store in the pending dict so ApplyLoadedTerrain can upload on
// the render thread. The key is the EnvCell dat id — same key
// used in the MeshRef below so EnsureUploaded can find it.
_pendingCellMeshes[envCellId] = cellSubMeshes;
// Z lift: 2 cm to avoid depth-fighting with terrain polygon.
var cellOrigin = envCell.Position.Origin + lbOffset
+ new System.Numerics.Vector3(0f, 0f, 0.02f);
var cellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
var cellMeshRef = new AcDream.Core.World.MeshRef(envCellId, cellTransform);
var cellEntity = new AcDream.Core.World.WorldEntity
{
Id = interiorIdBase + localCounter++,
SourceGfxObjOrSetupId = envCellId,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = new[] { cellMeshRef },
};
result.Add(cellEntity);
}
}
}
// Phase 2d: static objects inside the EnvCell.
foreach (var stab in envCell.StaticObjects)
{
var meshRefs = new List<AcDream.Core.World.MeshRef>();
if ((stab.Id & 0xFF000000u) == 0x01000000u)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(stab.Id);
if (gfx is not null)
{
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity));
}
}
else if ((stab.Id & 0xFF000000u) == 0x02000000u)
{
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(stab.Id);
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;
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
meshRefs.Add(mr);
}
}
}
if (meshRefs.Count == 0) continue;
// Stabs inside EnvCells are already in landblock-local coordinates
// (same space as LandBlockInfo.Objects stabs). Adding cellOrigin would
// be wrong — see Phase 2d comment in the pre-streaming preload.
var worldPos = stab.Frame.Origin + lbOffset;
var worldRot = stab.Frame.Orientation;
var hydrated = new AcDream.Core.World.WorldEntity
{
Id = interiorIdBase + localCounter++,
SourceGfxObjOrSetupId = stab.Id,
Position = worldPos,
Rotation = worldRot,
MeshRefs = meshRefs,
};
result.Add(hydrated);
}
}
return result;
}
/// <summary>
@ -906,10 +1134,32 @@ public sealed class GameWindow : IDisposable
// EnsureUploaded is idempotent so duplicates across landblocks are free.
if (_staticMesh is not null)
{
// Task 8: drain any pending EnvCell room-mesh sub-meshes first.
// The worker thread pre-built these CPU-side and stored them in
// _pendingCellMeshes. We must upload them here (render thread) before
// the per-MeshRef loop below tries to look them up via GfxObjMesh.Build,
// which would fail because EnvCell ids (0xAAAA01xx) aren't real GfxObj
// dat ids. EnsureUploaded is idempotent so calling it here then seeing
// the same id again in the loop below is safe.
foreach (var entity in lb.Entities)
{
foreach (var meshRef in entity.MeshRefs)
{
if (_pendingCellMeshes.TryRemove(meshRef.GfxObjId, out var cellSubMeshes))
_staticMesh.EnsureUploaded(meshRef.GfxObjId, cellSubMeshes);
}
}
// Now upload regular GfxObj sub-meshes (stabs, scenery, interior stabs).
// Skip any ids already uploaded (includes the cell meshes just drained).
foreach (var entity in lb.Entities)
{
foreach (var meshRef in entity.MeshRefs)
{
// Skip EnvCell synthetic ids — already handled above (or already
// uploaded on a prior tick). GfxObj ids are 0x01xxxxxx; Setup ids
// are 0x02xxxxxx; anything else is not a GfxObj dat record.
if ((meshRef.GfxObjId & 0xFF000000u) != 0x01000000u) continue;
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(meshRef.GfxObjId);
if (gfx is null) continue;
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);