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 AcDream.Core.Terrain.TerrainBlendingContext? _blendCtx;
|
||||||
private Dictionary<uint, AcDream.Core.Terrain.SurfaceInfo>? _surfaceCache;
|
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>
|
/// <summary>
|
||||||
/// Phase 6.4: per-entity animation playback state for entities whose
|
/// Phase 6.4: per-entity animation playback state for entities whose
|
||||||
/// MotionTable resolved to a real cycle. The render loop ticks each
|
/// MotionTable resolved to a real cycle. The render loop ticks each
|
||||||
|
|
@ -870,10 +879,229 @@ public sealed class GameWindow : IDisposable
|
||||||
hydrated.Add(entity);
|
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(
|
return new AcDream.Core.World.LoadedLandblock(
|
||||||
baseLoaded.LandblockId,
|
baseLoaded.LandblockId,
|
||||||
baseLoaded.Heightmap,
|
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>
|
/// <summary>
|
||||||
|
|
@ -906,10 +1134,32 @@ public sealed class GameWindow : IDisposable
|
||||||
// EnsureUploaded is idempotent so duplicates across landblocks are free.
|
// EnsureUploaded is idempotent so duplicates across landblocks are free.
|
||||||
if (_staticMesh is not null)
|
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 entity in lb.Entities)
|
||||||
{
|
{
|
||||||
foreach (var meshRef in entity.MeshRefs)
|
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);
|
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(meshRef.GfxObjId);
|
||||||
if (gfx is null) continue;
|
if (gfx is null) continue;
|
||||||
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue