feat(render): Phase A8 RR4 — wire BuildingRegistry into landblock load

LoadedCell.BuildingId (init + internal setter) — set exactly once at
    landblock load time by BuildingLoader; null when the cell isn't
    part of any building (outdoor surface cells; dungeon cells not
    enumerated in LandBlockInfo.Buildings).

  GameWindow landblock-load path: builds BuildingRegistry from
    LandBlockInfo.Buildings; stamps each cell's BuildingId; stores the
    registry on _buildingRegistries[landblockId] (GameWindow-level dict)
    for render-frame lookups. Note: LoadedLandblock is AcDream.Core.World
    (a sealed record) — adding an App-type field there would violate
    Code Structure Rule #2, so the registry is stored in a new
    GameWindow-level dictionary instead. Cleanup wired in both
    removeTerrain lambdas (OnLoad + OnResize paths).

  drainedCells dict: the existing _pendingCells drain loop is extended
    to also build a local CellId→LoadedCell dict; BuildingLoader.Build
    uses this dict for the stamping pass so no second iteration is needed.

  New BuildingLoaderTest verifies the stamping path. 5 BuildingLoader
  tests total (4 from RR3 + 1 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-27 11:13:48 +02:00
parent f125fdb220
commit f8d0499d8b
4 changed files with 93 additions and 4 deletions

View file

@ -150,6 +150,14 @@ public sealed class GameWindow : IDisposable
// to _cellVisibility on the render thread in ApplyLoadedTerrain.
private readonly System.Collections.Concurrent.ConcurrentBag<LoadedCell> _pendingCells = new();
// Phase A8 (2026-05-26): per-landblock BuildingRegistry keyed by full landblock
// id (e.g. 0xA9B40000). Built from LandBlockInfo.Buildings at ApplyLoadedTerrain
// time; each entry's BuildingRegistry.GetBuildingsContainingCell drives render-frame
// indoor-cell scoping. Entries are removed in the removeTerrain callbacks.
// Only touched on the render thread — no lock required.
private readonly System.Collections.Generic.Dictionary<uint, AcDream.App.Rendering.Wb.BuildingRegistry>
_buildingRegistries = new();
/// <summary>
/// Phase 6.4: per-entity animation playback state for entities whose
/// MotionTable resolved to a real cycle. The render loop ticks each
@ -1833,6 +1841,7 @@ public sealed class GameWindow : IDisposable
_terrain?.RemoveLandblock(id);
_physicsEngine.RemoveLandblock(id);
_cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu);
_buildingRegistries.Remove(id); // Phase A8
});
// A.5 T22.5: apply max-completions from resolved quality.
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
@ -5665,8 +5674,13 @@ public sealed class GameWindow : IDisposable
_terrain.AddLandblockWithMesh(lb.LandblockId, meshData, origin);
// Step 4: drain pending LoadedCells from the worker thread.
// Also collect into a local dict for the BuildingLoader stamping pass below.
var drainedCells = new System.Collections.Generic.Dictionary<uint, LoadedCell>();
while (_pendingCells.TryTake(out var cell))
{
_cellVisibility.AddCell(cell);
drainedCells[cell.CellId] = cell;
}
// Compute the per-landblock AABB for frustum culling. XY from the
// landblock's world origin + 192 footprint. Z from the terrain vertex
@ -5826,6 +5840,20 @@ public sealed class GameWindow : IDisposable
_physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces,
portalPlanes, origin.X, origin.Y);
// Phase A8 (2026-05-26): build per-landblock BuildingRegistry from
// LandBlockInfo.Buildings, stamping LoadedCell.BuildingId for each cell
// in a building's cell set. Uses the already-drained drainedCells dict
// (LoadedCells registered this frame) so stamping and registry build
// happen in the same render-thread pass — no extra dat reads required.
// Cells without a building stay at BuildingId == null (outdoor surface
// cells; dungeon cells not enumerated in LandBlockInfo.Buildings).
if (lbInfo is not null)
{
_buildingRegistries[lb.LandblockId] =
AcDream.App.Rendering.Wb.BuildingLoader.Build(
lbInfo, lb.LandblockId, drainedCells);
}
}
// N.5: WbMeshAdapter.Tick() handles GPU upload for all GfxObj meshes via
@ -8894,6 +8922,7 @@ public sealed class GameWindow : IDisposable
_terrain?.RemoveLandblock(id);
_physicsEngine.RemoveLandblock(id);
_cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu);
_buildingRegistries.Remove(id); // Phase A8
});
_streamingController.MaxCompletionsPerFrame = newResolved.MaxCompletionsPerFrame;