refactor(streaming): extract landblock render publisher
This commit is contained in:
parent
0f9c03f422
commit
1029f8372e
4 changed files with 708 additions and 128 deletions
|
|
@ -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())
|
||||
{
|
||||
|
|
|
|||
290
src/AcDream.App/Streaming/LandblockRenderPublisher.cs
Normal file
290
src/AcDream.App/Streaming/LandblockRenderPublisher.cs
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.Terrain;
|
||||
|
||||
namespace AcDream.App.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable receipt for the render-owned prefix of one accepted landblock
|
||||
/// publication. Physics and static presentation consume the same captured
|
||||
/// origin between <see cref="LandblockRenderPublisher.BeginPublication"/> and
|
||||
/// <see cref="LandblockRenderPublisher.CompletePublication"/>.
|
||||
/// </summary>
|
||||
public sealed class LandblockRenderPublication
|
||||
{
|
||||
private readonly IReadOnlyDictionary<uint, LoadedCell> _visibilityCells;
|
||||
|
||||
internal LandblockRenderPublication(
|
||||
object owner,
|
||||
LandblockBuild build,
|
||||
Vector3 origin,
|
||||
Dictionary<uint, LoadedCell> visibilityCells)
|
||||
{
|
||||
Owner = owner;
|
||||
Build = build;
|
||||
Origin = origin;
|
||||
_visibilityCells = new ReadOnlyDictionary<uint, LoadedCell>(visibilityCells);
|
||||
}
|
||||
|
||||
internal object Owner { get; }
|
||||
internal LandblockBuild Build { get; }
|
||||
internal bool CompletionCommitted { get; set; }
|
||||
|
||||
public uint LandblockId => Build.Landblock.LandblockId;
|
||||
public Vector3 Origin { get; }
|
||||
public IReadOnlyDictionary<uint, LoadedCell> VisibilityCells => _visibilityCells;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Typed cumulative diagnostics owned by the render publication boundary.
|
||||
/// Times use <see cref="Stopwatch"/> ticks so callers can aggregate without
|
||||
/// losing sub-millisecond precision.
|
||||
/// </summary>
|
||||
public readonly record struct LandblockRenderPublisherDiagnostics(
|
||||
long BeginCount,
|
||||
long CompleteCount,
|
||||
long TerrainPublishTicks,
|
||||
long BeginPublishTicks,
|
||||
long CompletePublishTicks,
|
||||
long EnvCellPreparationCount,
|
||||
long TerrainRemovalCount,
|
||||
long CellVisibilityRemovalCount,
|
||||
long BuildingRegistryRemovalCount,
|
||||
long EnvCellRemovalCount);
|
||||
|
||||
/// <summary>
|
||||
/// Update-thread owner of landblock render publication. It publishes terrain,
|
||||
/// the complete visibility-cell transaction, frustum bounds, building
|
||||
/// registries, and EnvCell renderer state without reading DATs or deciding
|
||||
/// residency. Render-ID acquisition remains in <see cref="GpuWorldState"/>;
|
||||
/// schema-aware EnvCell preparation is replayed only after that acquisition.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The ordering preserves retail <c>LScape::grab_visible_cells</c>
|
||||
/// (0x00504EC0), <c>CLandBlock::init_buildings</c> (0x0052FD80),
|
||||
/// <c>CLandBlock::grab_visible_cells</c> (0x0052F460), and the extracted
|
||||
/// WorldBuilder one-job/one-EnvCell-transaction seam documented in
|
||||
/// <c>docs/architecture/worldbuilder-inventory.md</c>.
|
||||
/// </remarks>
|
||||
public sealed class LandblockRenderPublisher
|
||||
{
|
||||
private readonly object _receiptOwner = new();
|
||||
private readonly Action<uint, LandblockMeshData, Vector3> _publishTerrain;
|
||||
private readonly Action<uint> _removeTerrain;
|
||||
private readonly CellVisibility _cellVisibility;
|
||||
private readonly GpuWorldState _worldState;
|
||||
private readonly Action<EnvCellLandblockBuild>? _commitEnvCells;
|
||||
private readonly Action<EnvCellLandblockBuild>? _prepareEnvCells;
|
||||
private readonly Action<uint>? _removeEnvCells;
|
||||
private readonly Dictionary<uint, BuildingRegistry> _buildingRegistries = new();
|
||||
|
||||
private long _beginCount;
|
||||
private long _completeCount;
|
||||
private long _terrainPublishTicks;
|
||||
private long _beginPublishTicks;
|
||||
private long _completePublishTicks;
|
||||
private long _envCellPreparationCount;
|
||||
private long _terrainRemovalCount;
|
||||
private long _cellVisibilityRemovalCount;
|
||||
private long _buildingRegistryRemovalCount;
|
||||
private long _envCellRemovalCount;
|
||||
|
||||
public LandblockRenderPublisher(
|
||||
Action<uint, LandblockMeshData, Vector3> publishTerrain,
|
||||
Action<uint> removeTerrain,
|
||||
CellVisibility cellVisibility,
|
||||
GpuWorldState worldState,
|
||||
Action<EnvCellLandblockBuild>? commitEnvCells = null,
|
||||
Action<EnvCellLandblockBuild>? prepareEnvCells = null,
|
||||
Action<uint>? removeEnvCells = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(publishTerrain);
|
||||
ArgumentNullException.ThrowIfNull(removeTerrain);
|
||||
ArgumentNullException.ThrowIfNull(cellVisibility);
|
||||
ArgumentNullException.ThrowIfNull(worldState);
|
||||
|
||||
_publishTerrain = publishTerrain;
|
||||
_removeTerrain = removeTerrain;
|
||||
_cellVisibility = cellVisibility;
|
||||
_worldState = worldState;
|
||||
_commitEnvCells = commitEnvCells;
|
||||
_prepareEnvCells = prepareEnvCells;
|
||||
_removeEnvCells = removeEnvCells;
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<BuildingRegistry> BuildingRegistries =>
|
||||
_buildingRegistries.Values;
|
||||
|
||||
public LandblockRenderPublisherDiagnostics Diagnostics => new(
|
||||
_beginCount,
|
||||
_completeCount,
|
||||
_terrainPublishTicks,
|
||||
_beginPublishTicks,
|
||||
_completePublishTicks,
|
||||
_envCellPreparationCount,
|
||||
_terrainRemovalCount,
|
||||
_cellVisibilityRemovalCount,
|
||||
_buildingRegistryRemovalCount,
|
||||
_envCellRemovalCount);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes the prefix that retail establishes before physics/static
|
||||
/// activation: terrain, complete visibility cells, and spatial bounds.
|
||||
/// </summary>
|
||||
public LandblockRenderPublication BeginPublication(
|
||||
LandblockBuild build,
|
||||
LandblockMeshData meshData)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(build);
|
||||
ArgumentNullException.ThrowIfNull(meshData);
|
||||
if (!build.Origin.IsSpecified)
|
||||
throw new ArgumentException(
|
||||
"Render publication requires the build's captured origin.",
|
||||
nameof(build));
|
||||
|
||||
long beginStarted = Stopwatch.GetTimestamp();
|
||||
uint landblockId = build.Landblock.LandblockId;
|
||||
if (build.EnvCells is { } candidateEnvCells &&
|
||||
candidateEnvCells.LandblockId != landblockId)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"EnvCell transaction 0x{candidateEnvCells.LandblockId:X8} was attached to " +
|
||||
$"landblock 0x{landblockId:X8}.");
|
||||
}
|
||||
|
||||
Vector3 origin = ComputeOrigin(landblockId, build.Origin);
|
||||
|
||||
long terrainStarted = Stopwatch.GetTimestamp();
|
||||
_publishTerrain(landblockId, meshData, origin);
|
||||
_terrainPublishTicks += Stopwatch.GetTimestamp() - terrainStarted;
|
||||
|
||||
var committedCells = new Dictionary<uint, LoadedCell>();
|
||||
if (build.EnvCells is { } envCells)
|
||||
{
|
||||
_cellVisibility.CommitLandblock(landblockId, envCells.VisibilityCells);
|
||||
foreach (LoadedCell cell in envCells.VisibilityCells)
|
||||
committedCells[cell.CellId] = cell;
|
||||
}
|
||||
|
||||
(Vector3 aabbMin, Vector3 aabbMax) = ComputeAabb(meshData, origin);
|
||||
_worldState.SetLandblockAabb(landblockId, aabbMin, aabbMax);
|
||||
|
||||
_beginCount++;
|
||||
_beginPublishTicks += Stopwatch.GetTimestamp() - beginStarted;
|
||||
return new LandblockRenderPublication(
|
||||
_receiptOwner,
|
||||
build,
|
||||
origin,
|
||||
committedCells);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes the render-owned suffix after physics has consumed the same
|
||||
/// receipt: building membership is stamped before the EnvCell renderer
|
||||
/// receives the complete immutable transaction.
|
||||
/// </summary>
|
||||
public void CompletePublication(LandblockRenderPublication publication)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(publication);
|
||||
if (!ReferenceEquals(publication.Owner, _receiptOwner))
|
||||
throw new ArgumentException(
|
||||
"The render publication receipt belongs to another publisher.",
|
||||
nameof(publication));
|
||||
if (publication.CompletionCommitted)
|
||||
return;
|
||||
|
||||
long started = Stopwatch.GetTimestamp();
|
||||
LandblockBuild build = publication.Build;
|
||||
uint landblockId = publication.LandblockId;
|
||||
if (build.Landblock.PhysicsDats?.Info is { } info)
|
||||
{
|
||||
uint registryKey = landblockId & 0xFFFF0000u;
|
||||
_buildingRegistries[registryKey] = BuildingLoader.Build(
|
||||
info,
|
||||
landblockId,
|
||||
publication.VisibilityCells);
|
||||
}
|
||||
|
||||
if (build.EnvCells is { } envCells)
|
||||
_commitEnvCells?.Invoke(envCells);
|
||||
|
||||
publication.CompletionCommitted = true;
|
||||
_completeCount++;
|
||||
_completePublishTicks += Stopwatch.GetTimestamp() - started;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replays EnvCell schema preparation after <see cref="GpuWorldState"/>
|
||||
/// has acquired every synthetic render identifier.
|
||||
/// </summary>
|
||||
public void PrepareAfterRenderPins(EnvCellLandblockBuild build)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(build);
|
||||
_prepareEnvCells?.Invoke(build);
|
||||
_envCellPreparationCount++;
|
||||
}
|
||||
|
||||
public void RemoveTerrain(uint landblockId)
|
||||
{
|
||||
_removeTerrain(landblockId);
|
||||
_terrainRemovalCount++;
|
||||
}
|
||||
|
||||
public void RemoveCellVisibility(uint landblockId)
|
||||
{
|
||||
_cellVisibility.RemoveLandblock((landblockId >> 16) & 0xFFFFu);
|
||||
_cellVisibilityRemovalCount++;
|
||||
}
|
||||
|
||||
public void RemoveBuildingRegistry(uint landblockId)
|
||||
{
|
||||
_buildingRegistries.Remove(landblockId & 0xFFFF0000u);
|
||||
_buildingRegistryRemovalCount++;
|
||||
}
|
||||
|
||||
public void RemoveEnvironmentCells(uint landblockId)
|
||||
{
|
||||
_removeEnvCells?.Invoke(landblockId);
|
||||
_envCellRemovalCount++;
|
||||
}
|
||||
|
||||
private static Vector3 ComputeOrigin(
|
||||
uint landblockId,
|
||||
LandblockBuildOrigin capturedOrigin)
|
||||
{
|
||||
int landblockX = (int)((landblockId >> 24) & 0xFFu);
|
||||
int landblockY = (int)((landblockId >> 16) & 0xFFu);
|
||||
return new Vector3(
|
||||
(landblockX - capturedOrigin.CenterX) * 192f,
|
||||
(landblockY - capturedOrigin.CenterY) * 192f,
|
||||
0f);
|
||||
}
|
||||
|
||||
private static (Vector3 Min, Vector3 Max) ComputeAabb(
|
||||
LandblockMeshData meshData,
|
||||
Vector3 origin)
|
||||
{
|
||||
float zMin = float.MaxValue;
|
||||
float zMax = float.MinValue;
|
||||
foreach (TerrainVertex vertex in meshData.Vertices)
|
||||
{
|
||||
float z = vertex.Position.Z;
|
||||
if (z < zMin) zMin = z;
|
||||
if (z > zMax) zMax = z;
|
||||
}
|
||||
if (zMin == float.MaxValue)
|
||||
{
|
||||
zMin = 0f;
|
||||
zMax = 0f;
|
||||
}
|
||||
|
||||
zMax += 50f;
|
||||
zMin -= 10f;
|
||||
return (
|
||||
new Vector3(origin.X, origin.Y, zMin),
|
||||
new Vector3(origin.X + 192f, origin.Y + 192f, zMax));
|
||||
}
|
||||
}
|
||||
|
|
@ -247,6 +247,12 @@ public sealed class LandblockBuildOriginTests
|
|||
"AcDream.App",
|
||||
"Streaming",
|
||||
"LandblockBuildFactory.cs"));
|
||||
string renderPublisherSource = File.ReadAllText(Path.Combine(
|
||||
root,
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Streaming",
|
||||
"LandblockRenderPublisher.cs"));
|
||||
string publicationSection = Slice(
|
||||
gameWindowSource,
|
||||
"private void ApplyLoadedTerrainLocked(",
|
||||
|
|
@ -271,8 +277,10 @@ public sealed class LandblockBuildOriginTests
|
|||
"BuildPhysicsDatBundle",
|
||||
gameWindowSource,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("build.Origin.CenterX", publicationSection, StringComparison.Ordinal);
|
||||
Assert.Contains("build.Origin.CenterY", publicationSection, StringComparison.Ordinal);
|
||||
Assert.Contains("ComputeOrigin(landblockId, build.Origin)", renderPublisherSource, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_liveCenterX", renderPublisherSource, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_liveCenterY", renderPublisherSource, StringComparison.Ordinal);
|
||||
Assert.Contains("renderPublication.Origin", publicationSection, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_liveCenterX", publicationSection, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_liveCenterY", publicationSection, StringComparison.Ordinal);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,365 @@
|
|||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.Core.Terrain;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.Tests.Streaming;
|
||||
|
||||
public sealed class LandblockRenderPublisherTests
|
||||
{
|
||||
private const uint LandblockId = 0xA9B4FFFFu;
|
||||
private static readonly LandblockBuildOrigin CapturedOrigin = new(0xA8, 0xB3);
|
||||
|
||||
[Fact]
|
||||
public void BeginPublication_PublishesTerrainVisibilityAndAabbFromCapturedOrigin()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var visibility = new CellVisibility();
|
||||
var state = new GpuWorldState();
|
||||
LoadedCell cell = VisibilityCell(0xA9B40100u);
|
||||
LandblockBuild build = Build(
|
||||
new EnvCellLandblockBuild(
|
||||
LandblockId,
|
||||
[cell],
|
||||
Array.Empty<EnvCellShellPlacement>()));
|
||||
LandblockMeshData mesh = MeshAtHeights(7f, 19f);
|
||||
var publisher = Publisher(
|
||||
calls,
|
||||
visibility,
|
||||
state,
|
||||
out _,
|
||||
out _,
|
||||
out _);
|
||||
|
||||
LandblockRenderPublication receipt = publisher.BeginPublication(build, mesh);
|
||||
|
||||
Assert.Equal(new Vector3(192f, 192f, 0f), receipt.Origin);
|
||||
Assert.Equal(["terrain:A9B4FFFF:192:192"], calls);
|
||||
Assert.True(visibility.TryGetCell(cell.CellId, out LoadedCell? committed));
|
||||
Assert.Same(cell, committed);
|
||||
Assert.Single(receipt.VisibilityCells);
|
||||
|
||||
state.AddLandblock(build.Landblock);
|
||||
var bounds = Assert.Single(state.LandblockBounds);
|
||||
Assert.Equal(new Vector3(192f, 192f, -3f), bounds.AabbMin);
|
||||
Assert.Equal(new Vector3(384f, 384f, 69f), bounds.AabbMax);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginPublication_VisibilityOnlyCellDoesNotRequireDrawableShell()
|
||||
{
|
||||
var visibility = new CellVisibility();
|
||||
LoadedCell cell = VisibilityCell(0xA9B40100u);
|
||||
LandblockBuild build = Build(new EnvCellLandblockBuild(
|
||||
LandblockId,
|
||||
[cell],
|
||||
Array.Empty<EnvCellShellPlacement>()));
|
||||
var publisher = Publisher(
|
||||
new List<string>(),
|
||||
visibility,
|
||||
new GpuWorldState(),
|
||||
out _,
|
||||
out _,
|
||||
out _);
|
||||
|
||||
LandblockRenderPublication receipt = publisher.BeginPublication(
|
||||
build,
|
||||
EmptyMesh());
|
||||
publisher.CompletePublication(receipt);
|
||||
|
||||
Assert.True(visibility.TryGetCell(cell.CellId, out _));
|
||||
Assert.Single(publisher.BuildingRegistries);
|
||||
Assert.Empty(Assert.Single(publisher.BuildingRegistries).All());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginPublication_MismatchedEnvCellTransactionHasNoSideEffects()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var visibility = new CellVisibility();
|
||||
var state = new GpuWorldState();
|
||||
LoadedCell cell = VisibilityCell(0xAAB40100u);
|
||||
LandblockBuild build = Build(new EnvCellLandblockBuild(
|
||||
0xAAB4FFFFu,
|
||||
[cell],
|
||||
Array.Empty<EnvCellShellPlacement>()));
|
||||
var publisher = Publisher(
|
||||
calls,
|
||||
visibility,
|
||||
state,
|
||||
out _,
|
||||
out _,
|
||||
out _);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
publisher.BeginPublication(build, EmptyMesh()));
|
||||
|
||||
Assert.Empty(calls);
|
||||
Assert.False(visibility.TryGetCell(cell.CellId, out _));
|
||||
Assert.Null(state.DetachLandblock(LandblockId));
|
||||
Assert.Equal(0, publisher.Diagnostics.BeginCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompletePublication_CommitsBuildingThenEnvCellsAndIsIdempotent()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
LandblockBuild build = Build(EmptyEnvCells());
|
||||
int envCommits = 0;
|
||||
LandblockRenderPublisher? publisher = null;
|
||||
publisher = new LandblockRenderPublisher(
|
||||
publishTerrain: (_, _, _) => calls.Add("terrain"),
|
||||
removeTerrain: _ => { },
|
||||
cellVisibility: new CellVisibility(),
|
||||
worldState: new GpuWorldState(),
|
||||
commitEnvCells: _ =>
|
||||
{
|
||||
Assert.Single(publisher!.BuildingRegistries);
|
||||
calls.Add("env-cells");
|
||||
envCommits++;
|
||||
});
|
||||
LandblockRenderPublication receipt = publisher.BeginPublication(build, EmptyMesh());
|
||||
|
||||
publisher.CompletePublication(receipt);
|
||||
publisher.CompletePublication(receipt);
|
||||
|
||||
Assert.Equal(["terrain", "env-cells"], calls);
|
||||
Assert.Equal(1, envCommits);
|
||||
Assert.Single(publisher.BuildingRegistries);
|
||||
Assert.Equal(1, publisher.Diagnostics.CompleteCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompletePublication_FailedEnvCommitRetriesWithoutDuplicatingRegistry()
|
||||
{
|
||||
int attempts = 0;
|
||||
LandblockBuild build = Build(EmptyEnvCells());
|
||||
var publisher = new LandblockRenderPublisher(
|
||||
publishTerrain: (_, _, _) => { },
|
||||
removeTerrain: _ => { },
|
||||
cellVisibility: new CellVisibility(),
|
||||
worldState: new GpuWorldState(),
|
||||
commitEnvCells: _ =>
|
||||
{
|
||||
attempts++;
|
||||
if (attempts == 1)
|
||||
throw new InvalidOperationException("injected EnvCell failure");
|
||||
});
|
||||
LandblockRenderPublication receipt = publisher.BeginPublication(build, EmptyMesh());
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
publisher.CompletePublication(receipt));
|
||||
publisher.CompletePublication(receipt);
|
||||
|
||||
Assert.Equal(2, attempts);
|
||||
Assert.Single(publisher.BuildingRegistries);
|
||||
Assert.Equal(1, publisher.Diagnostics.CompleteCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompletePublication_ForeignReceiptIsRejectedWithoutConsumingIt()
|
||||
{
|
||||
var first = Publisher(
|
||||
new List<string>(),
|
||||
new CellVisibility(),
|
||||
new GpuWorldState(),
|
||||
out int[] firstCommits,
|
||||
out _,
|
||||
out _);
|
||||
var second = Publisher(
|
||||
new List<string>(),
|
||||
new CellVisibility(),
|
||||
new GpuWorldState(),
|
||||
out int[] secondCommits,
|
||||
out _,
|
||||
out _);
|
||||
LandblockRenderPublication receipt = first.BeginPublication(
|
||||
Build(EmptyEnvCells()),
|
||||
EmptyMesh());
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
second.CompletePublication(receipt));
|
||||
|
||||
Assert.Empty(second.BuildingRegistries);
|
||||
Assert.Equal(0, secondCommits[0]);
|
||||
Assert.Equal(0, second.Diagnostics.CompleteCount);
|
||||
|
||||
first.CompletePublication(receipt);
|
||||
Assert.Single(first.BuildingRegistries);
|
||||
Assert.Equal(1, firstCommits[0]);
|
||||
Assert.Equal(1, first.Diagnostics.CompleteCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrepareAndRetirementOperationsStayBalancedAtTheirOwnerBoundary()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var visibility = new CellVisibility();
|
||||
var publisher = Publisher(
|
||||
calls,
|
||||
visibility,
|
||||
new GpuWorldState(),
|
||||
out _,
|
||||
out int[] preparations,
|
||||
out int[] envRemovals);
|
||||
LoadedCell cell = VisibilityCell(0xA9B40100u);
|
||||
EnvCellLandblockBuild envCells = new(
|
||||
LandblockId,
|
||||
[cell],
|
||||
Array.Empty<EnvCellShellPlacement>());
|
||||
LandblockRenderPublication receipt = publisher.BeginPublication(
|
||||
Build(envCells),
|
||||
EmptyMesh());
|
||||
publisher.CompletePublication(receipt);
|
||||
|
||||
publisher.PrepareAfterRenderPins(envCells);
|
||||
publisher.RemoveTerrain(LandblockId);
|
||||
publisher.RemoveCellVisibility(LandblockId);
|
||||
publisher.RemoveBuildingRegistry(LandblockId);
|
||||
publisher.RemoveEnvironmentCells(LandblockId);
|
||||
|
||||
Assert.Equal(1, preparations[0]);
|
||||
Assert.Equal(1, envRemovals[0]);
|
||||
Assert.Equal(
|
||||
[
|
||||
"terrain:A9B4FFFF:192:192",
|
||||
"prepare",
|
||||
"remove-terrain:A9B4FFFF",
|
||||
"remove-env:A9B4FFFF",
|
||||
],
|
||||
calls);
|
||||
Assert.False(visibility.TryGetCell(cell.CellId, out _));
|
||||
Assert.Empty(publisher.BuildingRegistries);
|
||||
LandblockRenderPublisherDiagnostics diagnostics = publisher.Diagnostics;
|
||||
Assert.Equal(1, diagnostics.EnvCellPreparationCount);
|
||||
Assert.Equal(1, diagnostics.TerrainRemovalCount);
|
||||
Assert.Equal(1, diagnostics.CellVisibilityRemovalCount);
|
||||
Assert.Equal(1, diagnostics.BuildingRegistryRemovalCount);
|
||||
Assert.Equal(1, diagnostics.EnvCellRemovalCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublisherSurface_HasNoDatResidencyPolicyOrLiveOriginDependency()
|
||||
{
|
||||
Type type = typeof(LandblockRenderPublisher);
|
||||
Type[] dependencies = type
|
||||
.GetFields(BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.Select(field => field.FieldType)
|
||||
.Concat(type.GetConstructors().SelectMany(ctor =>
|
||||
ctor.GetParameters().Select(parameter => parameter.ParameterType)))
|
||||
.ToArray();
|
||||
|
||||
Assert.DoesNotContain(dependencies, dependency =>
|
||||
dependency.FullName?.Contains("DatReader", StringComparison.Ordinal) == true);
|
||||
Assert.DoesNotContain(dependencies, dependency =>
|
||||
dependency.FullName?.Contains("StreamingRegion", StringComparison.Ordinal) == true);
|
||||
Assert.DoesNotContain(dependencies, dependency =>
|
||||
dependency.FullName?.Contains("LiveWorldOrigin", StringComparison.Ordinal) == true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameWindow_HasNoDirectRenderPublicationBodies()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"GameWindow.cs"));
|
||||
|
||||
Assert.DoesNotContain("_terrain.AddLandblockWithMesh", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_cellVisibility.CommitLandblock", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_buildingRegistries", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_envCellRenderer?.CommitLandblock", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("EnsureEnvCellMeshesAfterPin", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static LandblockRenderPublisher Publisher(
|
||||
List<string> calls,
|
||||
CellVisibility visibility,
|
||||
GpuWorldState state,
|
||||
out int[] envCommits,
|
||||
out int[] preparations,
|
||||
out int[] envRemovals)
|
||||
{
|
||||
envCommits = [0];
|
||||
preparations = [0];
|
||||
envRemovals = [0];
|
||||
int[] commitCounter = envCommits;
|
||||
int[] preparationCounter = preparations;
|
||||
int[] removalCounter = envRemovals;
|
||||
return new LandblockRenderPublisher(
|
||||
publishTerrain: (id, _, origin) => calls.Add(
|
||||
$"terrain:{id:X8}:{origin.X:0}:{origin.Y:0}"),
|
||||
removeTerrain: id => calls.Add($"remove-terrain:{id:X8}"),
|
||||
cellVisibility: visibility,
|
||||
worldState: state,
|
||||
commitEnvCells: _ => commitCounter[0]++,
|
||||
prepareEnvCells: _ =>
|
||||
{
|
||||
preparationCounter[0]++;
|
||||
calls.Add("prepare");
|
||||
},
|
||||
removeEnvCells: id =>
|
||||
{
|
||||
removalCounter[0]++;
|
||||
calls.Add($"remove-env:{id:X8}");
|
||||
});
|
||||
}
|
||||
|
||||
private static LandblockBuild Build(EnvCellLandblockBuild envCells)
|
||||
{
|
||||
var physics = new PhysicsDatBundle(
|
||||
new LandBlockInfo(),
|
||||
new Dictionary<uint, EnvCell>(),
|
||||
new Dictionary<uint, DatReaderWriter.DBObjs.Environment>(),
|
||||
new Dictionary<uint, Setup>(),
|
||||
new Dictionary<uint, GfxObj>());
|
||||
return new LandblockBuild(
|
||||
new LoadedLandblock(
|
||||
LandblockId,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>(),
|
||||
physics),
|
||||
envCells,
|
||||
CapturedOrigin);
|
||||
}
|
||||
|
||||
private static EnvCellLandblockBuild EmptyEnvCells() => new(
|
||||
LandblockId,
|
||||
Array.Empty<LoadedCell>(),
|
||||
Array.Empty<EnvCellShellPlacement>());
|
||||
|
||||
private static LoadedCell VisibilityCell(uint cellId) => new()
|
||||
{
|
||||
CellId = cellId,
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
};
|
||||
|
||||
private static LandblockMeshData MeshAtHeights(float first, float second) => new(
|
||||
[
|
||||
new TerrainVertex(new Vector3(0f, 0f, first), Vector3.UnitZ, 0, 0, 0, 0),
|
||||
new TerrainVertex(new Vector3(1f, 1f, second), Vector3.UnitZ, 0, 0, 0, 0),
|
||||
],
|
||||
Array.Empty<uint>());
|
||||
|
||||
private static LandblockMeshData EmptyMesh() =>
|
||||
new(Array.Empty<TerrainVertex>(), Array.Empty<uint>());
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
string? directory = AppContext.BaseDirectory;
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory, "AcDream.slnx")))
|
||||
return directory;
|
||||
directory = Directory.GetParent(directory)?.FullName;
|
||||
}
|
||||
throw new DirectoryNotFoundException("Could not locate repository root.");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue