refactor(streaming): extract landblock render publisher

This commit is contained in:
Erik 2026-07-21 20:47:01 +02:00
parent 0f9c03f422
commit 1029f8372e
4 changed files with 708 additions and 128 deletions

View file

@ -151,6 +151,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private AcDream.App.Streaming.LandblockStreamer? _streamer;
private AcDream.App.Streaming.GpuWorldState _worldState = new();
private AcDream.App.Streaming.LandblockRetirementCoordinator? _landblockRetirements;
private AcDream.App.Streaming.LandblockRenderPublisher? _landblockRenderPublisher;
private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer;
private AcDream.App.Streaming.StreamingController? _streamingController;
private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal;
@ -228,14 +229,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// mesh builder (A.5 T11+) can call LandblockMesh.Build without a lock.
private System.Collections.Concurrent.ConcurrentDictionary<uint, AcDream.Core.Terrain.SurfaceInfo>? _surfaceCache;
// 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();
// Phase A8 (2026-05-28): WB EnvCellRenderManager port. Cells render
// through this dedicated pipeline now, NOT through WbDrawDispatcher.
// The dispatcher's IndoorPass still walks cell-static stabs (WorldEntity
@ -2419,6 +2412,18 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_envCellRenderer = new AcDream.App.Rendering.Wb.EnvCellRenderer(
_gl, _wbMeshAdapter!.MeshManager!, _envCellFrustum);
_envCellRenderer.Initialize(_meshShader!);
_landblockRenderPublisher = new AcDream.App.Streaming.LandblockRenderPublisher(
publishTerrain: (landblockId, meshData, origin) =>
_terrain!.AddLandblockWithMesh(landblockId, meshData, origin),
removeTerrain: landblockId => _terrain!.RemoveLandblock(landblockId),
cellVisibility: _cellVisibility,
worldState: _worldState,
commitEnvCells: _envCellRenderer.CommitLandblock,
prepareEnvCells: build =>
AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule(
build,
_wbMeshAdapter.MeshManager!),
removeEnvCells: _envCellRenderer.RemoveLandblock);
_clipFrame ??= ClipFrame.NoClip();
_retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer(
@ -2531,7 +2536,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// objects it thinks we still know, so we re-project them ourselves.
onLandblockLoaded: loadedLandblockId =>
_liveEntityHydration!.OnLandblockLoaded(loadedLandblockId),
ensureEnvCellMeshes: EnsureEnvCellMeshesAfterPin,
ensureEnvCellMeshes: _landblockRenderPublisher!.PrepareAfterRenderPins,
retirementCoordinator: _landblockRetirements);
// A.5 T22.5: apply max-completions from resolved quality.
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
@ -3774,12 +3779,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
$"live: AdminEnvirons sound cue = {name} " +
$"(0x{environChangeType:X2}) — audio binding pending");
}
private void EnsureEnvCellMeshesAfterPin(
AcDream.App.Rendering.Wb.EnvCellLandblockBuild build)
{
if (_wbMeshAdapter?.MeshManager is { } meshManager)
AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule(build, meshManager);
}
private void AdvanceLandblockPresentationRetirement(
AcDream.App.Streaming.LandblockRetirementTicket ticket)
{
@ -3807,7 +3806,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
{
ticket.RunOnce(
AcDream.App.Streaming.LandblockRetirementStage.Terrain,
() => _terrain?.RemoveLandblock(ticket.LandblockId));
() => _landblockRenderPublisher?.RemoveTerrain(ticket.LandblockId));
}
ticket.RunOnce(
@ -3821,15 +3820,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
});
ticket.RunOnce(
AcDream.App.Streaming.LandblockRetirementStage.CellVisibility,
() => _cellVisibility.RemoveLandblock(
(ticket.LandblockId >> 16) & 0xFFFFu));
() => _landblockRenderPublisher?.RemoveCellVisibility(ticket.LandblockId));
ticket.RunOnce(
AcDream.App.Streaming.LandblockRetirementStage.BuildingRegistry,
() => _buildingRegistries.Remove(
ticket.LandblockId & 0xFFFF0000u));
() => _landblockRenderPublisher?.RemoveBuildingRegistry(ticket.LandblockId));
ticket.RunOnce(
AcDream.App.Streaming.LandblockRetirementStage.EnvironmentCells,
() => _envCellRenderer?.RemoveLandblock(ticket.LandblockId));
() => _landblockRenderPublisher?.RemoveEnvironmentCells(ticket.LandblockId));
}
/// <summary>
@ -3893,76 +3890,35 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
AcDream.Core.Terrain.LandblockMeshData meshData)
{
var lb = build.Landblock;
// _blendCtx / _surfaceCache no longer needed here (mesh pre-built by worker).
// _heightTable still needed for physics TerrainSurface below.
if (_terrain is null || _dats is null || _heightTable is null) return;
if (_landblockRenderPublisher is null || _dats is null || _heightTable is null)
return;
// datLock fix: every dat the apply needs was pre-read by the worker into
// lb.PhysicsDats. Read from the bundle — NO _dats.Get here, so this method
// (and its caller) need no _datLock.
// Every DAT object publication consumes was captured by the worker.
// The update-thread publishers consume only this immutable bundle.
var datBundle = lb.PhysicsDats ?? AcDream.Core.World.PhysicsDatBundle.Empty;
uint lbXu = (lb.LandblockId >> 24) & 0xFFu;
uint lbYu = (lb.LandblockId >> 16) & 0xFFu;
int lbX = (int)lbXu;
int lbY = (int)lbYu;
var origin = new System.Numerics.Vector3(
(lbX - build.Origin.CenterX) * 192f,
(lbY - build.Origin.CenterY) * 192f,
0f);
// Phase A.5 T15/T16: route through AddLandblockWithMesh — the named
// two-tier entry point. Delegates to AddLandblock internally; both
// paths share one GPU upload path.
// [FRAME-DIAG]: bracket the terrain glBufferSubData sub-span so we can tell
// the (tiny ~17KB) GPU upload apart from the dat-read/registration tail.
long fdU0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
_terrain.AddLandblockWithMesh(lb.LandblockId, meshData, origin);
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderDiagBefore =
_landblockRenderPublisher.Diagnostics;
AcDream.App.Streaming.LandblockRenderPublication renderPublication =
_landblockRenderPublisher.BeginPublication(build, meshData);
var origin = renderPublication.Origin;
if (_frameDiag)
_applyUploadAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdU0;
{
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderDiagAfter =
_landblockRenderPublisher.Diagnostics;
long terrainTicks =
renderDiagAfter.TerrainPublishTicks - renderDiagBefore.TerrainPublishTicks;
_applyUploadAccumTicks += terrainTicks;
_applyCellAccumTicks +=
renderDiagAfter.BeginPublishTicks
- renderDiagBefore.BeginPublishTicks
- terrainTicks;
}
// [FRAME-DIAG]: split the post-upload apply CPU into three sub-spans via
// method-scope checkpoints — cell-build → gfxobj-BSP → ShadowObjects+lights.
// [FRAME-DIAG]: the publisher reports terrain and render-prefix time;
// this checkpoint begins the physics/cache portion of the cell span.
long fdCheck = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
// Commit the exact visibility-cell snapshot produced by THIS streaming
// completion. The former global ConcurrentBag let one landblock drain
// another landblock's cells and exposed partial builds to the flood.
var committedCells = new System.Collections.Generic.Dictionary<uint, LoadedCell>();
if (build.EnvCells is { } envCellBuild)
{
if (envCellBuild.LandblockId != lb.LandblockId)
throw new InvalidOperationException(
$"EnvCell transaction 0x{envCellBuild.LandblockId:X8} was attached to " +
$"landblock 0x{lb.LandblockId:X8}.");
_cellVisibility.CommitLandblock(lb.LandblockId, envCellBuild.VisibilityCells);
foreach (var cell in envCellBuild.VisibilityCells)
committedCells[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
// range padded +50 above (for trees/buildings) and -10 below (for
// basements). TerrainRenderer already scans vertices internally; we
// replicate here so GpuWorldState has the same bounds for the static
// mesh renderer's culling pass.
{
float zMin = float.MaxValue, zMax = float.MinValue;
foreach (var v in meshData.Vertices)
{
float z = v.Position.Z;
if (z < zMin) zMin = z;
if (z > zMax) zMax = z;
}
if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; }
zMax += 50f; // generous pad for trees and buildings
zMin -= 10f; // below-ground buffer for basements/cellars
var aabbMin = new System.Numerics.Vector3(origin.X, origin.Y, zMin);
var aabbMax = new System.Numerics.Vector3(origin.X + 192f, origin.Y + 192f, zMax);
_worldState.SetLandblockAabb(lb.LandblockId, aabbMin, aabbMax);
}
// Phase B.3: populate the physics engine with terrain + indoor cell
// surfaces for this landblock. Runs under _datLock (same lock as the
// rest of ApplyLoadedTerrainLocked) so dat reads are safe.
@ -4155,47 +4111,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces,
portalPlanes, origin.X, origin.Y);
// Phase A8 (2026-05-26 / fix 2026-05-28): build per-landblock
// BuildingRegistry from LandBlockInfo.Buildings, stamping
// LoadedCell.BuildingId for each cell in a building's cell set.
//
// FIX 2026-05-28: previously passed `drainedCells` only — that's the
// dict of cells drained THIS frame. Cells loaded on prior frames
// were missed, leaving their BuildingId null and the
// `cameraInsideBuilding` gate FALSE even when the player was inside
// a tagged cottage. (First visual-gate launch showed 8737 [vis]
// lines with inside=True really=True but ZERO [buildings] probe
// emissions — same root cause as the RR7.1 / RR7.2 saga.) The fix:
// merge `drainedCells` with every cell currently registered with
// _cellVisibility for this landblock prefix. BuildingLoader's BFS
// + stamping pass then sees the complete cell set.
//
// Cells without a building stay at BuildingId == null (outdoor
// surface cells; dungeon cells not enumerated in LandBlockInfo.Buildings).
if (lbInfo is not null)
{
// The streaming result now carries the complete landblock cell
// set; no cross-frame merge or global-bag recovery is required.
var lbStampCells = committedCells;
// FIX 2026-05-28: normalize storage key to the cell-prefix
// convention (`landblockId & 0xFFFF0000u`). The lb.LandblockId
// field encodes 0xXXYY_FFFF (low 16 bits = 0xFFFF for the
// landblock's own LandBlockInfo dat id), but the runtime
// lookup at line ~7110 derives the key from a cell id via
// `cellId & 0xFFFF0000u` which yields 0xXXYY_0000. Storage
// and lookup must agree. Mask both sides to the upper-16
// form so the registry resolves correctly. This is the same
// bug the RR7.2 commit (`efe3520`, reverted by `9aaae02`)
// tried to fix; landing it here in the data-flow layer.
uint regKey = lb.LandblockId & 0xFFFF0000u;
_buildingRegistries[regKey] =
AcDream.App.Rendering.Wb.BuildingLoader.Build(
lbInfo, lb.LandblockId, lbStampCells);
}
if (build.EnvCells is { } renderBuild)
_envCellRenderer?.CommitLandblock(renderBuild);
_landblockRenderPublisher.CompletePublication(renderPublication);
}
// N.5: WbMeshAdapter.Tick() handles GPU upload for all GfxObj meshes via
@ -5623,7 +5539,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// routes interior-root look-ins to its landscape-stage sub-pass
// (DrawBuildingLookIns); the root's own building self-excludes
// via the seed eye-side test.
foreach (var registry in _buildingRegistries.Values)
foreach (var registry in _landblockRenderPublisher?.BuildingRegistries
?? Array.Empty<AcDream.App.Rendering.Wb.BuildingRegistry>())
{
foreach (var b in registry.All())
{