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:
parent
efcf0c30d0
commit
fca299780c
1 changed files with 251 additions and 1 deletions
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue