diff --git a/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md b/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md index 547f7545..183e2841 100644 --- a/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md +++ b/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md @@ -24,7 +24,7 @@ retail-faithful gameplay | F4 — dynamic indices | complete | The contained scene now maintains outdoor/static, per-cell static/dynamic, special dynamic-route, translucent, selectable, light-candidate, and dirty indices incrementally. Final-frame live/equipped synchronization remains active-only, and active animated statics are synchronized from the scheduler's active workset rather than a resident-world scan. The production scene remains unconstructed and non-drawing. | | F5 | complete | Exact `81e2f1a5` passed capped and uncapped nine-stop routes plus uncapped dense Arwic. Across the final checkpoints the referee completed 1,663 + 1,677 + 81 comparisons with zero mismatches, zero pending deltas, zero rejected operations, exact binary/source identity, and graceful shutdown. Evidence: `docs/research/2026-07-24-slice-f5-render-scene-shadow-gate.md`. | | G0 — borrowed frame product | complete | Two reusable arenas publish exact generation/frame-stamped borrowed views for scene candidates, cell ranges, transforms, classification, light sets, selection, PView/clip references, counts, and source digest. Release/reuse invalidates every copied view; abort never publishes; frame N may build while N-1 is borrowed; the warm path allocates zero bytes. Eight focused tests pass inside the 3,743-App / 8,227-solution Release gate. | -| G1 — scene-query replacement | active | Build the candidate product from the incremental scene and compare it with the current PView partition without changing the draw source. | +| G1 — scene-query replacement | code gate complete; connected gate pending | The incremental scene now builds the same-frame candidate product from outdoor, per-cell, look-in, outside-stage, and dynamic indices, preserving PView route multiplicity and hidden-versus-withdrawn ownership. A compare-only controller checks canonical route/cell/entity membership against the accepted current PView path; lifecycle JSON publishes counts, digests, and the first mismatch. Twenty-four focused render-scene/frame-product tests pass inside the 3,745-App / 8,229-solution Release gate. No production draw consumer has switched. | | G2–G5 | pending | No production consumer has switched. | The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0 is diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index c3e16388..95b7412c 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -388,6 +388,14 @@ internal sealed class FrameRootCompositionPhase currentRenderSceneOracle, message => d.Log("[UI-PROBE] " + message)) : null; + RenderScenePViewFrameProductController? renderFrameProduct = + currentRenderSceneOracle is not null + && live.RenderSceneShadow is not null + ? new RenderScenePViewFrameProductController( + live.RenderSceneShadow, + currentRenderSceneOracle, + message => d.Log("[UI-PROBE] " + message)) + : null; live.DrawDispatcher.SetCurrentRenderSceneObserver( currentRenderSceneOracle); live.SelectionScene.SetCurrentRenderSceneObserver( @@ -402,7 +410,9 @@ internal sealed class FrameRootCompositionPhase d.RetailAlphaQueue, d.ParticleVisibility, new WorldScenePViewRenderer( - new RetailPViewRenderer(currentRenderSceneOracle), + new RetailPViewRenderer( + currentRenderSceneOracle, + renderFrameProduct), retailPViewPassExecutor, retailPViewPassExecutor), retailPViewCells, @@ -436,7 +446,8 @@ internal sealed class FrameRootCompositionPhase content.Dats, foundation.Residency, currentRenderSceneOracle, - renderSceneShadowComparison); + renderSceneShadowComparison, + renderFrameProduct); lifecycleAutomation = new WorldLifecycleAutomationController( () => session.WorldReveal.Snapshot, diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs index 69306001..3d8e8ba0 100644 --- a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs +++ b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs @@ -41,6 +41,7 @@ internal sealed record WorldLifecycleResourceSnapshot( int MaterializedLiveEntities, CurrentRenderSceneOracleSnapshot RenderSceneOracle, RenderSceneShadowComparisonSnapshot RenderSceneShadow, + RenderFrameProductComparisonSnapshot RenderFrameProduct, int PendingLiveTeardowns, int PendingLandblockRetirements, int ParticleEmitters, diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs b/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs index 5937e125..a5740511 100644 --- a/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs +++ b/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs @@ -43,6 +43,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource _renderSceneOracle; private readonly IRenderSceneShadowSnapshotSource? _renderSceneShadow; + private readonly IRenderFrameProductSnapshotSource? + _renderFrameProduct; public WorldLifecycleResourceSnapshotSource( GpuWorldState world, @@ -62,7 +64,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource IDatReaderWriter dats, ResidencyManager residency, ICurrentRenderSceneOracleSnapshotSource? renderSceneOracle = null, - IRenderSceneShadowSnapshotSource? renderSceneShadow = null) + IRenderSceneShadowSnapshotSource? renderSceneShadow = null, + IRenderFrameProductSnapshotSource? renderFrameProduct = null) { _world = world ?? throw new ArgumentNullException(nameof(world)); _animations = animations @@ -91,6 +94,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource ?? throw new ArgumentNullException(nameof(residency)); _renderSceneOracle = renderSceneOracle; _renderSceneShadow = renderSceneShadow; + _renderFrameProduct = renderFrameProduct; } public WorldLifecycleResourceSnapshot Capture(RenderFrameOutcome outcome) @@ -136,6 +140,9 @@ internal sealed class WorldLifecycleResourceSnapshotSource RenderSceneShadow: _renderSceneShadow?.CaptureCheckpointSnapshot() ?? RenderSceneShadowComparisonSnapshot.Disabled, + RenderFrameProduct: + _renderFrameProduct?.Snapshot + ?? RenderFrameProductComparisonSnapshot.Disabled, PendingLiveTeardowns: _liveEntities.PendingTeardownCount, PendingLandblockRetirements: _streaming.PendingRetirementCount, ParticleEmitters: _particles.ActiveEmitterCount, diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 747b90de..001103f3 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -15,6 +15,8 @@ public sealed class RetailPViewRenderer { private readonly InteriorEntityPartition.IObserver? _partitionObserver; private readonly ICurrentRenderPViewObserver? _candidateObserver; + private readonly RenderScenePViewFrameProductController? + _sceneFrameProduct; private readonly PortalVisibilityFrame _mainPortalFrameScratch = new(); private readonly ClipFrameAssembly _clipAssemblyScratch = new(); private readonly ViewconeCuller _viewconeScratch = new(); @@ -71,10 +73,19 @@ public sealed class RetailPViewRenderer } internal RetailPViewRenderer( - InteriorEntityPartition.IObserver? partitionObserver) + InteriorEntityPartition.IObserver? partitionObserver, + RenderScenePViewFrameProductController? sceneFrameProduct = null) { + if (sceneFrameProduct is not null + && partitionObserver is not ICurrentRenderPViewObserver) + { + throw new ArgumentException( + "The scene frame-product referee requires the current PView observer.", + nameof(partitionObserver)); + } _partitionObserver = partitionObserver; _candidateObserver = partitionObserver as ICurrentRenderPViewObserver; + _sceneFrameProduct = sceneFrameProduct; } // T2 (BR-4): retail has NO distance constant on the flood-admission chain @@ -239,6 +250,14 @@ public sealed class RetailPViewRenderer DrawDynamicsLast(ctx, passes, partition, viewcone, ctx.RootCell.IsOutdoorNode); _candidateObserver?.CompletePViewFrame(); + _sceneFrameProduct?.BuildAndCompare( + pvFrame, + clipAssembly, + viewcone, + _lookInFrames, + drawableCells, + ctx.Cells, + ctx.RootCell.IsOutdoorNode); return result; } catch diff --git a/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs index 436d13e6..2a73f7e7 100644 --- a/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs +++ b/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs @@ -9,6 +9,22 @@ internal enum RenderFrameBlendClass : byte Alpha, } +internal enum RenderFrameCandidateRoute : byte +{ + LandscapeOutdoorStatic, + LandscapeOutsideDynamic, + LookInObject, + CellStatic, + DynamicLast, +} + +internal readonly record struct RenderFrameCandidateRange( + RenderFrameCandidateRoute Route, + int RouteIndex, + uint CellId, + int Offset, + int Count); + internal readonly record struct RenderFrameCellRange( uint CellId, int PViewOrder, @@ -60,7 +76,8 @@ internal readonly record struct RenderFrameDiagnosticCounts( int OpaqueClassificationCount, int AlphaClassificationCount, int LightSetCount, - int SelectionPartCount); + int SelectionPartCount, + int RouteCandidateCount); /// /// A validated borrowed view over one reusable frame arena. Copies are safe: @@ -125,6 +142,12 @@ internal readonly struct RenderFrameView public ReadOnlySpan SelectionParts => Arena.SelectionParts; + public ReadOnlySpan RouteCandidates => + Arena.RouteCandidates; + + public ReadOnlySpan RouteRanges => + Arena.RouteRanges; + public PortalVisibilityFrame? PortalFrame => Arena.PortalFrame; public ClipFrameAssembly? ClipAssembly => Arena.ClipAssembly; @@ -172,6 +195,8 @@ internal sealed class RenderFrameArena private RenderFrameClassificationRecord[] _classifications = []; private RenderFrameObjectLightSet[] _lightSets = []; private RenderFrameSelectionPart[] _selectionParts = []; + private RenderProjectionRecord[] _routeCandidates = []; + private RenderFrameCandidateRange[] _routeRanges = []; private int _outdoorCount; private int _cellStaticCount; private int _cellRangeCount; @@ -180,6 +205,8 @@ internal sealed class RenderFrameArena private int _classificationCount; private int _lightSetCount; private int _selectionPartCount; + private int _routeCandidateCount; + private int _routeRangeCount; private int _opaqueClassificationCount; private int _alphaClassificationCount; private ulong _epoch; @@ -206,7 +233,8 @@ internal sealed class RenderFrameArena _opaqueClassificationCount, _alphaClassificationCount, _lightSetCount, - _selectionPartCount); + _selectionPartCount, + _routeCandidateCount); internal ReadOnlySpan OutdoorStaticCandidates => _outdoor.AsSpan(0, _outdoorCount); @@ -232,6 +260,12 @@ internal sealed class RenderFrameArena internal ReadOnlySpan SelectionParts => _selectionParts.AsSpan(0, _selectionPartCount); + internal ReadOnlySpan RouteCandidates => + _routeCandidates.AsSpan(0, _routeCandidateCount); + + internal ReadOnlySpan RouteRanges => + _routeRanges.AsSpan(0, _routeRangeCount); + internal ulong BeginWrite( RenderSceneGeneration generation, ulong frameSequence) @@ -253,6 +287,8 @@ internal sealed class RenderFrameArena _classificationCount = 0; _lightSetCount = 0; _selectionPartCount = 0; + _routeCandidateCount = 0; + _routeRangeCount = 0; _opaqueClassificationCount = 0; _alphaClassificationCount = 0; PortalFrame = null; @@ -393,6 +429,34 @@ internal sealed class RenderFrameArena _selectionParts[_selectionPartCount++] = part; } + internal void AddRouteRange( + ulong epoch, + RenderFrameCandidateRoute route, + int routeIndex, + uint cellId, + ReadOnlySpan records) + { + EnsureBuilding(epoch); + if (routeIndex < 0) + throw new ArgumentOutOfRangeException(nameof(routeIndex)); + if (records.Length == 0) + return; + + int offset = _routeCandidateCount; + EnsureCapacity( + ref _routeCandidates, + checked(offset + records.Length)); + records.CopyTo(_routeCandidates.AsSpan(offset)); + _routeCandidateCount += records.Length; + EnsureCapacity(ref _routeRanges, _routeRangeCount + 1); + _routeRanges[_routeRangeCount++] = new RenderFrameCandidateRange( + route, + routeIndex, + cellId, + offset, + records.Length); + } + internal void Publish(ulong epoch) { EnsureBuilding(epoch); @@ -549,6 +613,18 @@ internal readonly struct RenderFrameWriter public void AddSelectionPart(in RenderFrameSelectionPart part) => Arena.AddSelectionPart(_epoch, in part); + public void AddRouteRange( + RenderFrameCandidateRoute route, + int routeIndex, + uint cellId, + ReadOnlySpan records) => + Arena.AddRouteRange( + _epoch, + route, + routeIndex, + cellId, + records); + public void Publish() => Owner.Publish(_arenaIndex, _epoch); diff --git a/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs b/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs index 7f12457a..4442e17e 100644 --- a/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs +++ b/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs @@ -27,6 +27,8 @@ internal static class RenderProjectionRecordFactory ownerLandblockId, entity); RenderProjectionFlags flags = RenderProjectionFlags.Selectable; + if (spatiallyVisible) + flags |= RenderProjectionFlags.SpatiallyResident; if (entity.MeshRefs.Count > 0 && spatiallyVisible && entity.IsDrawVisible diff --git a/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs b/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs index 2047c0eb..8720bd5f 100644 --- a/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs +++ b/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs @@ -13,6 +13,7 @@ internal readonly record struct RenderProjectionId internal ulong RawValue => _value; internal byte Domain => (byte)(_value >> 56); internal RenderSortKey ToSortKey() => new(_value); + internal void AddTo(ref StableRenderHash128 hash) => hash.Add(_value); public int CompareTo(RenderProjectionId other) => _value.CompareTo(other._value); @@ -104,6 +105,13 @@ internal enum RenderProjectionFlags : uint Selectable = 1 << 4, LightCandidate = 1 << 5, PortalStraddling = 1 << 6, + /// + /// The projection currently participates in the accepted spatial + /// presentation workset. Hidden objects retain this bit; a + /// portal-withdrawn attachment does not. This is derived render + /// ownership, not gameplay PhysicsState. + /// + SpatiallyResident = 1 << 7, } [Flags] diff --git a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs new file mode 100644 index 00000000..2304beb2 --- /dev/null +++ b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs @@ -0,0 +1,685 @@ +using System.Numerics; + +namespace AcDream.App.Rendering.Scene; + +internal readonly record struct RenderScenePViewBuildInput( + RenderSceneQuery Scene, + RenderSceneDigest SourceDigest, + PortalVisibilityFrame PortalFrame, + ClipFrameAssembly ClipAssembly, + ViewconeCuller Viewcone, + IReadOnlyList LookInFrames, + HashSet DrawableCells, + IRetailPViewCellSource Cells, + bool RootIsOutdoor); + +internal readonly record struct RenderFrameProductComparisonSnapshot( + bool Enabled, + ulong ProductFrameSequence, + ulong CurrentPViewFrameSequence, + ulong ComparisonCount, + ulong SuccessfulComparisonCount, + int ExpectedCandidateCount, + int ActualCandidateCount, + long MismatchCount, + string? FirstMismatch, + RenderSceneHash128 ExpectedDigest, + RenderSceneHash128 ActualDigest, + RenderFrameDiagnosticCounts ProductCounts) +{ + public static RenderFrameProductComparisonSnapshot Disabled { get; } = + new( + Enabled: false, + ProductFrameSequence: 0, + CurrentPViewFrameSequence: 0, + ComparisonCount: 0, + SuccessfulComparisonCount: 0, + ExpectedCandidateCount: 0, + ActualCandidateCount: 0, + MismatchCount: 0, + FirstMismatch: null, + ExpectedDigest: RenderSceneHash128.Empty, + ActualDigest: RenderSceneHash128.Empty, + ProductCounts: default); +} + +internal interface IRenderFrameProductSnapshotSource +{ + RenderFrameProductComparisonSnapshot Snapshot { get; } +} + +/// +/// G1 compare-only owner. It builds the candidate frame product directly from +/// the incremental scene after the accepted PView traversal, borrows it in the +/// same render frame, and compares route membership against the current-path +/// observer. It never supplies a production draw input. +/// +internal sealed class RenderScenePViewFrameProductController : + IRenderFrameProductSnapshotSource +{ + private readonly RenderSceneShadowRuntime _shadow; + private readonly CurrentRenderSceneOracle _current; + private readonly RenderScenePViewFrameBuilder _builder = new(); + private readonly RenderFrameExchange _exchange = new(); + private readonly List _expected = []; + private readonly List _actual = []; + private readonly Action? _log; + private ulong _productFrameSequence; + private ulong _comparisonCount; + private ulong _successfulComparisonCount; + private long _mismatchCount; + private string? _firstMismatch; + + public RenderScenePViewFrameProductController( + RenderSceneShadowRuntime shadow, + CurrentRenderSceneOracle current, + Action? log = null) + { + _shadow = shadow ?? throw new ArgumentNullException(nameof(shadow)); + _current = current ?? throw new ArgumentNullException(nameof(current)); + _log = log; + Snapshot = RenderFrameProductComparisonSnapshot.Disabled with + { + Enabled = true, + }; + } + + public RenderFrameProductComparisonSnapshot Snapshot { get; private set; } + + public void BuildAndCompare( + PortalVisibilityFrame portalFrame, + ClipFrameAssembly clipAssembly, + ViewconeCuller viewcone, + IReadOnlyList lookInFrames, + HashSet drawableCells, + IRetailPViewCellSource cells, + bool rootIsOutdoor) + { + ArgumentNullException.ThrowIfNull(portalFrame); + ArgumentNullException.ThrowIfNull(clipAssembly); + ArgumentNullException.ThrowIfNull(viewcone); + ArgumentNullException.ThrowIfNull(lookInFrames); + ArgumentNullException.ThrowIfNull(drawableCells); + ArgumentNullException.ThrowIfNull(cells); + + ulong frameSequence = checked(++_productFrameSequence); + RenderSceneQuery scene = _shadow.Query; + var input = new RenderScenePViewBuildInput( + scene, + _shadow.BuildDigest(), + portalFrame, + clipAssembly, + viewcone, + lookInFrames, + drawableCells, + cells, + rootIsOutdoor); + _builder.Build(_exchange, frameSequence, in input); + RenderFrameView view = _exchange.BorrowLatest( + scene.Generation, + frameSequence); + try + { + Compare(in view); + } + finally + { + _exchange.Release(in view); + } + } + + private void Compare(in RenderFrameView view) + { + _comparisonCount = checked(_comparisonCount + 1); + _expected.Clear(); + _actual.Clear(); + + IReadOnlyList expected = + _current.PViewCandidates; + if (_expected.Capacity < expected.Count) + _expected.Capacity = expected.Count; + for (int i = 0; i < expected.Count; i++) + { + CurrentRenderPViewCandidateFingerprint candidate = expected[i]; + CurrentRenderProjectionFingerprint projection = + candidate.Projection; + _expected.Add(new CandidateKey( + Map(candidate.Route), + candidate.RouteIndex, + candidate.CellId, + ExpectedId(in projection))); + } + + ReadOnlySpan ranges = view.RouteRanges; + ReadOnlySpan records = view.RouteCandidates; + if (_actual.Capacity < records.Length) + _actual.Capacity = records.Length; + for (int rangeIndex = 0; rangeIndex < ranges.Length; rangeIndex++) + { + RenderFrameCandidateRange range = ranges[rangeIndex]; + int end = checked(range.Offset + range.Count); + if ((uint)range.Offset > (uint)records.Length + || (uint)end > (uint)records.Length) + { + throw new InvalidOperationException( + $"Render-frame route range {rangeIndex} exceeds its candidate storage."); + } + + for (int index = range.Offset; index < end; index++) + { + _actual.Add(new CandidateKey( + range.Route, + range.RouteIndex, + range.CellId, + records[index].Id)); + } + } + + _expected.Sort(CandidateKeyComparer.Instance); + _actual.Sort(CandidateKeyComparer.Instance); + RenderSceneHash128 expectedDigest = BuildDigest(_expected); + RenderSceneHash128 actualDigest = BuildDigest(_actual); + string? mismatch = CompareKeys(); + if (mismatch is null) + { + _successfulComparisonCount = + checked(_successfulComparisonCount + 1); + } + else + { + _mismatchCount = checked(_mismatchCount + 1); + if (_firstMismatch is null) + { + _firstMismatch = mismatch; + _log?.Invoke( + "[render-frame-product] first mismatch: " + mismatch); + } + } + + Snapshot = new RenderFrameProductComparisonSnapshot( + Enabled: true, + ProductFrameSequence: view.FrameSequence, + CurrentPViewFrameSequence: + _current.Snapshot.CompletedPViewFrameSequence, + ComparisonCount: _comparisonCount, + SuccessfulComparisonCount: _successfulComparisonCount, + ExpectedCandidateCount: _expected.Count, + ActualCandidateCount: _actual.Count, + MismatchCount: _mismatchCount, + FirstMismatch: _firstMismatch, + ExpectedDigest: expectedDigest, + ActualDigest: actualDigest, + ProductCounts: view.DiagnosticCounts); + } + + private string? CompareKeys() + { + if (_expected.Count != _actual.Count) + { + return $"field=count expected={_expected.Count} " + + $"actual={_actual.Count}"; + } + + for (int i = 0; i < _expected.Count; i++) + { + if (_expected[i] != _actual[i]) + { + return $"field=candidate index={i} " + + $"expected={_expected[i]} actual={_actual[i]}"; + } + } + + return null; + } + + private static RenderSceneHash128 BuildDigest( + List candidates) + { + StableRenderHash128 hash = StableRenderHash128.Create(); + hash.Add(candidates.Count); + for (int i = 0; i < candidates.Count; i++) + { + CandidateKey candidate = candidates[i]; + hash.Add((byte)candidate.Route); + hash.Add(candidate.RouteIndex); + hash.Add(candidate.CellId); + candidate.ProjectionId.AddTo(ref hash); + } + return hash.Finish(); + } + + private static RenderProjectionId ExpectedId( + in CurrentRenderProjectionFingerprint projection) => + projection.ServerGuid == 0 + ? StaticRenderProjectionJournal.StaticEntityId( + projection.LandblockId, + projection.EntityId) + : LiveRenderProjectionJournal.ProjectionId( + projection.EntityId); + + private static RenderFrameCandidateRoute Map( + CurrentRenderPViewRoute route) => + route switch + { + CurrentRenderPViewRoute.LandscapeOutdoorStatic => + RenderFrameCandidateRoute.LandscapeOutdoorStatic, + CurrentRenderPViewRoute.LandscapeOutsideDynamic => + RenderFrameCandidateRoute.LandscapeOutsideDynamic, + CurrentRenderPViewRoute.LookInObject => + RenderFrameCandidateRoute.LookInObject, + CurrentRenderPViewRoute.CellStatic => + RenderFrameCandidateRoute.CellStatic, + CurrentRenderPViewRoute.DynamicLast => + RenderFrameCandidateRoute.DynamicLast, + _ => throw new ArgumentOutOfRangeException( + nameof(route), + route, + null), + }; + + private readonly record struct CandidateKey( + RenderFrameCandidateRoute Route, + int RouteIndex, + uint CellId, + RenderProjectionId ProjectionId); + + private sealed class CandidateKeyComparer : IComparer + { + public static CandidateKeyComparer Instance { get; } = new(); + + private CandidateKeyComparer() + { + } + + public int Compare(CandidateKey left, CandidateKey right) + { + int value = left.Route.CompareTo(right.Route); + if (value != 0) return value; + value = left.RouteIndex.CompareTo(right.RouteIndex); + if (value != 0) return value; + value = left.CellId.CompareTo(right.CellId); + if (value != 0) return value; + return left.ProjectionId.CompareTo(right.ProjectionId); + } + } +} + +/// +/// Allocation-free scene-query projection for one accepted PView frame. +/// It applies the existing viewcone and outside-stage predicates verbatim, +/// changing only the source of candidate records. +/// +internal sealed class RenderScenePViewFrameBuilder +{ + private const byte EnvCellProjectionDomain = 2; + + private readonly HashSet _transformIds = []; + private RenderProjectionRecord[] _outdoor = []; + private RenderProjectionRecord[] _dynamics = []; + private RenderProjectionRecord[] _cell = []; + private RenderProjectionRecord[] _survivors = []; + private RenderProjectionRecord[] _cellRoute = []; + private int _outdoorCount; + private int _dynamicCount; + private int _cellRouteCount; + + public void Build( + RenderFrameExchange exchange, + ulong frameSequence, + in RenderScenePViewBuildInput input) + { + ArgumentNullException.ThrowIfNull(exchange); + RenderFrameWriter writer = + exchange.BeginBuild(input.Scene.Generation, frameSequence); + try + { + writer.SetBorrowedProducts( + input.PortalFrame, + input.ClipAssembly); + RenderSceneDigest digest = input.SourceDigest; + writer.SetSourceDigest(in digest); + _transformIds.Clear(); + LoadSceneIndices(input.Scene); + + BuildOutdoorRoutes(writer, in input); + BuildLookInRoutes(writer, in input); + BuildOutsideDynamicRoutes(writer, in input); + BuildCellStaticRoute(writer, in input); + BuildDynamicLastRoute(writer, in input); + writer.Publish(); + } + catch + { + writer.Abort(); + throw; + } + } + + private void LoadSceneIndices(RenderSceneQuery scene) + { + RenderSceneIndexCounts counts = scene.IndexCounts; + EnsureCapacity(ref _outdoor, counts.OutdoorStatic); + _outdoorCount = scene.CopyIndexTo( + RenderSceneIndex.OutdoorStatic, + _outdoor); + _outdoorCount = CompactAndSort( + _outdoor, + _outdoorCount); + + EnsureCapacity(ref _dynamics, counts.Dynamic); + _dynamicCount = scene.CopyIndexTo( + RenderSceneIndex.Dynamic, + _dynamics); + _dynamicCount = CompactAndSort( + _dynamics, + _dynamicCount); + } + + private void BuildOutdoorRoutes( + RenderFrameWriter writer, + in RenderScenePViewBuildInput input) + { + int sliceCount = input.ClipAssembly.OutsideViewSlices.Length; + for (int sliceIndex = 0; sliceIndex < sliceCount; sliceIndex++) + { + int count = 0; + EnsureCapacity(ref _survivors, _outdoorCount); + for (int i = 0; i < _outdoorCount; i++) + { + RenderProjectionRecord record = _outdoor[i]; + Sphere(in record, out Vector3 center, out float radius); + if (!input.Viewcone.SphereVisibleInOutsideSlice( + sliceIndex, + in center, + radius)) + { + continue; + } + + _survivors[count++] = record; + writer.AddOutdoor(in record); + AddTransform(writer, in record); + } + + writer.AddRouteRange( + RenderFrameCandidateRoute.LandscapeOutdoorStatic, + sliceIndex, + 0, + _survivors.AsSpan(0, count)); + } + } + + private void BuildLookInRoutes( + RenderFrameWriter writer, + in RenderScenePViewBuildInput input) + { + for (int frameIndex = 0; + frameIndex < input.LookInFrames.Count; + frameIndex++) + { + PortalVisibilityFrame frame = input.LookInFrames[frameIndex]; + for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--) + { + uint cellId = frame.OrderedVisibleCells[i]; + int count = LoadCell( + input.Scene, + cellId, + includeDynamics: true); + if (count == 0) + continue; + + for (int index = 0; index < count; index++) + AddTransform(writer, in _cell[index]); + writer.AddRouteRange( + RenderFrameCandidateRoute.LookInObject, + i, + cellId, + _cell.AsSpan(0, count)); + } + } + } + + private void BuildOutsideDynamicRoutes( + RenderFrameWriter writer, + in RenderScenePViewBuildInput input) + { + if (input.RootIsOutdoor) + return; + + int sliceCount = input.ClipAssembly.OutsideViewSlices.Length; + for (int sliceIndex = 0; sliceIndex < sliceCount; sliceIndex++) + { + int count = 0; + EnsureCapacity(ref _survivors, _dynamicCount); + for (int i = 0; i < _dynamicCount; i++) + { + RenderProjectionRecord record = _dynamics[i]; + Sphere(in record, out Vector3 center, out float radius); + if (!RetailPViewRenderer.DynamicDrawsInOutsideStage( + ParentCell(in record), + center, + radius, + input.DrawableCells, + input.Cells) + || !input.Viewcone.SphereVisibleInOutsideSlice( + sliceIndex, + in center, + radius)) + { + continue; + } + + _survivors[count++] = record; + writer.AddDynamic(in record); + AddTransform(writer, in record); + } + + writer.AddRouteRange( + RenderFrameCandidateRoute.LandscapeOutsideDynamic, + sliceIndex, + 0, + _survivors.AsSpan(0, count)); + } + } + + private void BuildCellStaticRoute( + RenderFrameWriter writer, + in RenderScenePViewBuildInput input) + { + _cellRouteCount = 0; + IReadOnlyList ordered = input.PortalFrame.OrderedVisibleCells; + for (int i = ordered.Count - 1; i >= 0; i--) + { + uint cellId = ordered[i]; + if (!input.DrawableCells.Contains(cellId)) + continue; + + int loaded = LoadCell( + input.Scene, + cellId, + includeDynamics: false); + int count = 0; + for (int index = 0; index < loaded; index++) + { + RenderProjectionRecord record = _cell[index]; + Sphere(in record, out Vector3 center, out float radius); + if (!input.Viewcone.SphereVisibleInCell( + cellId, + in center, + radius)) + { + continue; + } + + _cell[count++] = record; + AddTransform(writer, in record); + } + + writer.AddCellRange( + cellId, + i, + _cell.AsSpan(0, count)); + EnsureCapacity( + ref _cellRoute, + checked(_cellRouteCount + count)); + _cell.AsSpan(0, count).CopyTo( + _cellRoute.AsSpan(_cellRouteCount)); + _cellRouteCount += count; + } + + writer.AddRouteRange( + RenderFrameCandidateRoute.CellStatic, + 0, + 0, + _cellRoute.AsSpan(0, _cellRouteCount)); + } + + private void BuildDynamicLastRoute( + RenderFrameWriter writer, + in RenderScenePViewBuildInput input) + { + int count = 0; + EnsureCapacity(ref _survivors, _dynamicCount); + for (int i = 0; i < _dynamicCount; i++) + { + RenderProjectionRecord record = _dynamics[i]; + uint? parentCellId = ParentCell(in record); + bool indoor = InteriorEntityPartition.IsIndoorCellId( + parentCellId); + if (!input.RootIsOutdoor && !indoor) + continue; + + Sphere(in record, out Vector3 center, out float radius); + bool visible = indoor + ? input.Viewcone.SphereVisibleInCell( + parentCellId!.Value, + in center, + radius) + : input.Viewcone.SphereVisibleOutside( + in center, + radius); + if (!visible) + continue; + + _survivors[count++] = record; + writer.AddDynamic(in record); + AddTransform(writer, in record); + } + + writer.AddRouteRange( + RenderFrameCandidateRoute.DynamicLast, + 0, + 0, + _survivors.AsSpan(0, count)); + } + + private int LoadCell( + RenderSceneQuery scene, + uint cellId, + bool includeDynamics) + { + int staticCount = scene.GetCellStaticCount(cellId); + int dynamicCount = includeDynamics + ? scene.GetCellDynamicCount(cellId) + : 0; + EnsureCapacity( + ref _cell, + checked(staticCount + dynamicCount)); + int count = scene.CopyCellStaticsTo( + cellId, + _cell.AsSpan(0, staticCount)); + if (dynamicCount > 0) + { + count += scene.CopyCellDynamicsTo( + cellId, + _cell.AsSpan(count, dynamicCount)); + } + + return CompactAndSort(_cell, count); + } + + private void AddTransform( + RenderFrameWriter writer, + in RenderProjectionRecord record) + { + if (!_transformIds.Add(record.Id)) + return; + writer.AddTransform(new RenderFrameTransformRecord( + record.Id, + -1, + record.Transform.LocalToWorld)); + } + + private static int CompactAndSort( + RenderProjectionRecord[] records, + int count) + { + int write = 0; + for (int read = 0; read < count; read++) + { + RenderProjectionRecord record = records[read]; + if (record.Id.Domain == EnvCellProjectionDomain + || record.MeshSet.MeshCount <= 0 + || (record.Flags + & RenderProjectionFlags.SpatiallyResident) == 0) + { + continue; + } + + records[write++] = record; + } + + Array.Sort( + records, + 0, + write, + RenderProjectionRecordSortComparer.Instance); + return write; + } + + private static uint? ParentCell(in RenderProjectionRecord record) => + record.Source.ParentCellId == 0 + ? null + : record.Source.ParentCellId; + + private static void Sphere( + in RenderProjectionRecord record, + out Vector3 center, + out float radius) + { + center = (record.Bounds.Minimum + record.Bounds.Maximum) * 0.5f; + radius = + (record.Bounds.Maximum - record.Bounds.Minimum).Length() * 0.5f; + } + + private static void EnsureCapacity(ref T[] values, int required) + { + if (values.Length >= required) + return; + int capacity = values.Length == 0 ? 4 : values.Length; + while (capacity < required) + capacity = checked(capacity * 2); + Array.Resize(ref values, capacity); + } + + private sealed class RenderProjectionRecordSortComparer : + IComparer + { + public static RenderProjectionRecordSortComparer Instance { get; } = + new(); + + private RenderProjectionRecordSortComparer() + { + } + + public int Compare( + RenderProjectionRecord left, + RenderProjectionRecord right) + { + int value = left.SortKey.Value.CompareTo(right.SortKey.Value); + return value != 0 + ? value + : left.Id.CompareTo(right.Id); + } + } +} diff --git a/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs b/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs index 96f51d37..5925c2e4 100644 --- a/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs +++ b/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs @@ -336,7 +336,8 @@ internal sealed class StaticRenderProjectionJournal : new RenderWorldBounds( shell.WorldBounds.Min, shell.WorldBounds.Max), - RenderProjectionFlags.Draw, + RenderProjectionFlags.Draw + | RenderProjectionFlags.SpatiallyResident, default, id.ToSortKey(), RenderDirtyMask.All, diff --git a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs index 40205b88..253d5c25 100644 --- a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs @@ -377,6 +377,7 @@ public sealed class WorldLifecycleAutomationControllerTests private static WorldLifecycleResourceSnapshot EmptyResources() => new( 0, 0, 0, 0, 0, 0, 0, CurrentRenderSceneOracleSnapshot.Disabled, RenderSceneShadowComparisonSnapshot.Disabled, + RenderFrameProductComparisonSnapshot.Disabled, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0L, 0, 0L, 0L, 0L, 0L, 0, 0, 0, 0, 0, 0, 0, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameProductTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameProductTests.cs index 1ac539b0..d0876c6e 100644 --- a/tests/AcDream.App.Tests/Rendering/RenderFrameProductTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RenderFrameProductTests.cs @@ -49,6 +49,11 @@ public sealed class RenderFrameProductTests 0x01000001, Matrix4x4.Identity, SelectionMesh())); + writer.AddRouteRange( + RenderFrameCandidateRoute.LandscapeOutdoorStatic, + 0, + 0, + [outdoor]); var digest = new RenderSceneDigest( Generation, new RenderProjectionCounts(Total: 4, 1, 2, 1, 0, 0), @@ -70,8 +75,17 @@ public sealed class RenderFrameProductTests Assert.Equal(2, view.Classifications.Length); Assert.Equal(1, view.LightSets.Length); Assert.Equal(1, view.SelectionParts.Length); + Assert.Equal(outdoor.Id, view.RouteCandidates[0].Id); Assert.Equal( - new RenderFrameDiagnosticCounts(1, 2, 1, 1, 1, 1, 1, 1), + new RenderFrameCandidateRange( + RenderFrameCandidateRoute.LandscapeOutdoorStatic, + 0, + 0, + 0, + 1), + view.RouteRanges[0]); + Assert.Equal( + new RenderFrameDiagnosticCounts(1, 2, 1, 1, 1, 1, 1, 1, 1), view.DiagnosticCounts); Assert.Equal(digest, view.SourceDigest); diff --git a/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs b/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs new file mode 100644 index 00000000..9dc93d43 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs @@ -0,0 +1,353 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Scene; +using AcDream.App.Rendering.Scene.Arch; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RenderScenePViewFrameProductTests +{ + private const uint Landblock = 0xA9B4FFFF; + private const uint Cell = 0xA9B40170; + private static readonly RenderSceneGeneration Generation = + RenderSceneGeneration.FromRaw(1); + + [Fact] + public void Builder_PreservesPViewRoutesAndExcludesShellsAndWithdrawnOwners() + { + using var scene = new ArchRenderScene(Generation); + RenderProjectionRecord outdoor = Record( + 0x0100_0000_0000_0001, + RenderProjectionClass.OutdoorStatic); + RenderProjectionRecord hidden = Record( + 0x0100_0000_0000_0002, + RenderProjectionClass.OutdoorStatic, + flags: RenderProjectionFlags.Hidden + | RenderProjectionFlags.SpatiallyResident); + RenderProjectionRecord withdrawn = Record( + 0x0100_0000_0000_0003, + RenderProjectionClass.OutdoorStatic, + flags: RenderProjectionFlags.Draw); + RenderProjectionRecord shell = Record( + 0x0200_0000_0000_0004, + RenderProjectionClass.IndoorCellStatic, + parentCell: Cell); + RenderProjectionRecord cellStatic = Record( + 0x0100_0000_0000_0005, + RenderProjectionClass.IndoorCellStatic, + parentCell: Cell); + RenderProjectionRecord outdoorDynamic = Record( + 0x0300_0000_0000_0006, + RenderProjectionClass.LiveDynamicRoot); + RenderProjectionRecord cellDynamic = Record( + 0x0300_0000_0000_0007, + RenderProjectionClass.LiveDynamicRoot, + parentCell: Cell); + RenderProjectionRecord[] records = + [ + outdoor, + hidden, + withdrawn, + shell, + cellStatic, + outdoorDynamic, + cellDynamic, + ]; + var deltas = new RenderProjectionDelta[records.Length]; + for (int index = 0; index < records.Length; index++) + { + deltas[index] = RenderProjectionDelta.Register( + Generation, + (ulong)index + 1, + records[index]); + } + scene.Apply(deltas); + + PortalVisibilityFrame portal = Portal(Cell); + ClipFrameAssembly clip = FullScreenClip(Cell); + ViewconeCuller viewcone = + ViewconeCuller.Build(clip, Matrix4x4.Identity); + var exchange = new RenderFrameExchange(); + var builder = new RenderScenePViewFrameBuilder(); + RenderSceneDigest digest = + scene.BuildDigest(new RenderSceneDigestBuffer()); + var input = new RenderScenePViewBuildInput( + scene.OpenQuery(), + digest, + portal, + clip, + viewcone, + [], + [Cell], + EmptyCellSource.Instance, + RootIsOutdoor: true); + + builder.Build(exchange, frameSequence: 1, in input); + RenderFrameView view = exchange.BorrowLatest(Generation, 1); + try + { + Assert.Equal( + [outdoor.Id, hidden.Id], + view.OutdoorStaticCandidates.ToArray() + .Select(static item => item.Id)); + Assert.Equal( + [cellStatic.Id], + view.CellStaticCandidates.ToArray() + .Select(static item => item.Id)); + Assert.Equal( + [outdoorDynamic.Id, cellDynamic.Id], + view.DynamicCandidates.ToArray() + .Select(static item => item.Id)); + Assert.Equal(5, view.Transforms.Length); + Assert.Equal(5, view.RouteCandidates.Length); + Assert.Equal(3, view.RouteRanges.Length); + Assert.DoesNotContain( + view.RouteCandidates.ToArray(), + item => item.Id == withdrawn.Id || item.Id == shell.Id); + Assert.Equal( + new RenderFrameDiagnosticCounts( + OutdoorStaticCandidates: 2, + CellStaticCandidates: 1, + DynamicCandidates: 2, + TransformCount: 5, + OpaqueClassificationCount: 0, + AlphaClassificationCount: 0, + LightSetCount: 0, + SelectionPartCount: 0, + RouteCandidateCount: 5), + view.DiagnosticCounts); + Assert.Same(portal, view.PortalFrame); + Assert.Same(clip, view.ClipAssembly); + Assert.Equal(digest, view.SourceDigest); + } + finally + { + exchange.Release(in view); + } + } + + [Fact] + public void Controller_MatchesCurrentPViewThenNamesAWithdrawnCandidate() + { + WorldEntity outdoor = Entity(10, serverGuid: 0, parentCell: null); + WorldEntity cellStatic = Entity(11, serverGuid: 0, parentCell: Cell); + WorldEntity outdoorDynamic = + Entity(1_000_012, 0x8000_0012, parentCell: null); + WorldEntity cellDynamic = + Entity(1_000_013, 0x8000_0013, parentCell: Cell); + WorldEntity[] entities = + [outdoor, cellStatic, outdoorDynamic, cellDynamic]; + var oracle = new CurrentRenderSceneOracle(); + var partition = new InteriorEntityPartition.Result(); + InteriorEntityPartition.Partition( + partition, + [Cell], + [Entry(entities)], + oracle); + oracle.BeginPViewFrame(); + oracle.ObservePViewBucket( + CurrentRenderPViewRoute.LandscapeOutdoorStatic, + 0, + 0, + [outdoor]); + oracle.ObservePViewBucket( + CurrentRenderPViewRoute.CellStatic, + 0, + 0, + [cellStatic]); + oracle.ObservePViewBucket( + CurrentRenderPViewRoute.DynamicLast, + 0, + 0, + [outdoorDynamic, cellDynamic]); + oracle.CompletePViewFrame(); + + using var shadow = new RenderSceneShadowRuntime(Generation); + foreach (WorldEntity entity in entities) + { + RenderProjectionRecord record = Project(entity); + shadow.Journal.Register(in record); + } + shadow.DrainUpdateBoundary(); + var logs = new List(); + var controller = new RenderScenePViewFrameProductController( + shadow, + oracle, + logs.Add); + PortalVisibilityFrame portal = Portal(Cell); + ClipFrameAssembly clip = FullScreenClip(Cell); + ViewconeCuller viewcone = + ViewconeCuller.Build(clip, Matrix4x4.Identity); + + controller.BuildAndCompare( + portal, + clip, + viewcone, + [], + [Cell], + EmptyCellSource.Instance, + rootIsOutdoor: true); + + RenderFrameProductComparisonSnapshot matched = controller.Snapshot; + Assert.True(matched.Enabled); + Assert.Equal(1uL, matched.ComparisonCount); + Assert.Equal(1uL, matched.SuccessfulComparisonCount); + Assert.Equal(4, matched.ExpectedCandidateCount); + Assert.Equal(4, matched.ActualCandidateCount); + Assert.Equal(0, matched.MismatchCount); + Assert.Null(matched.FirstMismatch); + Assert.Equal(matched.ExpectedDigest, matched.ActualDigest); + Assert.Empty(logs); + + RenderProjectionId withdrawnId = + LiveRenderProjectionJournal.ProjectionId(cellDynamic.Id); + shadow.Journal.Unregister( + withdrawnId, + RenderOwnerIncarnation.FromRaw(cellDynamic.Id)); + shadow.DrainUpdateBoundary(); + controller.BuildAndCompare( + portal, + clip, + viewcone, + [], + [Cell], + EmptyCellSource.Instance, + rootIsOutdoor: true); + + RenderFrameProductComparisonSnapshot mismatch = controller.Snapshot; + Assert.Equal(2uL, mismatch.ComparisonCount); + Assert.Equal(1uL, mismatch.SuccessfulComparisonCount); + Assert.Equal(1, mismatch.MismatchCount); + Assert.Contains("field=count expected=4 actual=3", mismatch.FirstMismatch); + Assert.Single(logs); + } + + private static RenderProjectionRecord Project(WorldEntity entity) + { + bool dynamic = entity.ServerGuid != 0; + RenderProjectionId id = dynamic + ? LiveRenderProjectionJournal.ProjectionId(entity.Id) + : StaticRenderProjectionJournal.StaticEntityId( + Landblock, + entity.Id); + RenderProjectionClass projectionClass = dynamic + ? RenderProjectionClass.LiveDynamicRoot + : entity.ParentCellId is uint parentCell + && InteriorEntityPartition.IsIndoorCellId(parentCell) + ? RenderProjectionClass.IndoorCellStatic + : RenderProjectionClass.OutdoorStatic; + return RenderProjectionRecordFactory.ProjectEntity( + id, + projectionClass, + RenderOwnerIncarnation.FromRaw(entity.Id), + Landblock, + entity.ParentCellId ?? Landblock, + entity, + spatiallyVisible: true); + } + + private static RenderProjectionRecord Record( + ulong id, + RenderProjectionClass projectionClass, + uint? parentCell = null, + RenderProjectionFlags flags = + RenderProjectionFlags.Draw + | RenderProjectionFlags.SpatiallyResident) + { + uint fullCellId = parentCell ?? Landblock; + Matrix4x4 transform = Matrix4x4.Identity; + return new RenderProjectionRecord( + RenderProjectionId.FromRaw(id), + projectionClass, + RenderOwnerIncarnation.FromRaw(id), + new RenderTransform(transform), + new PreviousRenderTransform(transform), + new RenderMeshSet(RenderAssetHandle.FromRaw(id), 1, 1), + new RenderMaterialVariant(0, 0, 1), + new RenderSpatialResidency( + RenderSpatialBucket.FromRaw(fullCellId), + Landblock, + fullCellId), + new RenderWorldBounds( + new Vector3(-1), + new Vector3(1)), + flags, + default, + new RenderSortKey(id), + RenderDirtyMask.All, + new RenderSourceMetadata( + LocalEntityId: (uint)id, + ServerGuid: projectionClass + is RenderProjectionClass.LiveDynamicRoot ? (uint)id : 0, + SourceId: 1, + ParentCellId: parentCell ?? 0, + EffectCellId: 0, + BuildingShellAnchorCellId: 0, + TransformFingerprint: default, + GeometryFingerprint: default, + AppearanceFingerprint: default)); + } + + private static WorldEntity Entity( + uint id, + uint serverGuid, + uint? parentCell) => + new() + { + Id = id, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = 0x0200_0001, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = + [ + new MeshRef(0x0100_0001, Matrix4x4.Identity), + ], + ParentCellId = parentCell, + }; + + private static PortalVisibilityFrame Portal(uint cellId) + { + var frame = new PortalVisibilityFrame(); + frame.OrderedVisibleCells.Add(cellId); + return frame; + } + + private static ClipFrameAssembly FullScreenClip(uint cellId) + { + var slice = new ClipViewSlice( + Slot: 0, + NdcAabb: new Vector4(-1, -1, 1, 1), + Planes: []); + var assembly = new ClipFrameAssembly(); + assembly.SetOutsideViewSlices([slice]); + assembly.CellIdToViewSlices[cellId] = [slice]; + return assembly; + } + + private static ( + uint LandblockId, + Vector3 AabbMin, + Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById) + Entry(IReadOnlyList entities) => + ( + Landblock, + Vector3.Zero, + Vector3.One, + entities, + null); + + private sealed class EmptyCellSource : IRetailPViewCellSource + { + public static EmptyCellSource Instance { get; } = new(); + + private EmptyCellSource() + { + } + + public LoadedCell? Find(uint cellId) => null; + } +}