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 c8fdc5df..547f7545 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 @@ -23,7 +23,9 @@ retail-faithful gameplay | F3 — live/equipped projection | complete | Exact EntityReady/resource teardown, visibility, attachment pose/removal, and active-only final-frame seams now drive generation/incarnation-gated live records. Duplicate CreateObject, pending/loaded rebucket, hidden/appearance, reentrancy, session clear, attachment, and GUID-generation replacement pass 11 focused tests. Production still constructs no render scene. | | 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–G5 | active | F's non-drawing referee is accepted. No production consumer has switched yet; G0 begins the borrowed double-buffered frame product. | +| 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. | +| G2–G5 | pending | No production consumer has switched. | The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0 is non-drawing diagnostic infrastructure and therefore is not a visual-cutover @@ -525,6 +527,18 @@ Rules: - abort recycles the incomplete arena without publishing it; - world generation and frame sequence must match at draw. +**Completed 2026-07-24.** `RenderFrameExchange` owns exactly two retained +arenas. A published view is accessible only through a validated borrow token; +release invalidates every struct copy, and arena reuse advances its epoch. +Publication requires a source digest from the same scene generation, while +borrowing requires the exact expected generation and frame sequence. The +non-published arena alone may be reused, so an aborted build cannot destroy the +last completed product. PView/clip products remain borrowed references and all +variable-size payloads use retained arrays. The 1,000-frame warm regression +allocates zero bytes. The product is deliberately uncomposed until G1 supplies +scene-query data; G0 therefore changes no production draw behavior and is not a +visual rollback unit. + ### G1 — Replace whole-world partition with scene queries 1. Query outdoor statics directly. diff --git a/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs new file mode 100644 index 00000000..436d13e6 --- /dev/null +++ b/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs @@ -0,0 +1,670 @@ +using System.Numerics; +using AcDream.Core.Selection; + +namespace AcDream.App.Rendering.Scene; + +internal enum RenderFrameBlendClass : byte +{ + Opaque, + Alpha, +} + +internal readonly record struct RenderFrameCellRange( + uint CellId, + int PViewOrder, + int Offset, + int Count); + +internal readonly record struct RenderFrameTransformRecord( + RenderProjectionId ProjectionId, + int PartIndex, + Matrix4x4 LocalToWorld); + +internal readonly record struct RenderFrameClassificationRecord( + RenderProjectionId ProjectionId, + int CandidateIndex, + int MeshRefIndex, + RenderFrameBlendClass BlendClass, + float Opacity); + +/// +/// The eight fixed-function-era light indices selected for one projected +/// object. This App-owned value keeps the frame product independent of the +/// current WorldBuilder dispatcher representation. +/// +internal readonly record struct RenderFrameObjectLightSet( + RenderProjectionId ProjectionId, + int L0, + int L1, + int L2, + int L3, + int L4, + int L5, + int L6, + int L7); + +internal readonly record struct RenderFrameSelectionPart( + RenderProjectionId ProjectionId, + uint ServerGuid, + uint LocalEntityId, + int PartIndex, + uint GfxObjId, + Matrix4x4 LocalToWorld, + RetailSelectionMesh Mesh); + +internal readonly record struct RenderFrameDiagnosticCounts( + int OutdoorStaticCandidates, + int CellStaticCandidates, + int DynamicCandidates, + int TransformCount, + int OpaqueClassificationCount, + int AlphaClassificationCount, + int LightSetCount, + int SelectionPartCount); + +/// +/// A validated borrowed view over one reusable frame arena. Copies are safe: +/// releasing any copy invalidates every copy through the arena's borrow token. +/// Consumers must not retain this value past Release. +/// +internal readonly struct RenderFrameView +{ + private readonly RenderFrameArena? _arena; + private readonly ulong _arenaEpoch; + private readonly ulong _borrowToken; + + internal RenderFrameView( + RenderFrameArena arena, + ulong arenaEpoch, + ulong borrowToken) + { + _arena = arena; + _arenaEpoch = arenaEpoch; + _borrowToken = borrowToken; + } + + public RenderSceneGeneration Generation + { + get + { + RenderFrameArena arena = Arena; + return arena.Generation; + } + } + + public ulong FrameSequence + { + get + { + RenderFrameArena arena = Arena; + return arena.FrameSequence; + } + } + + public ReadOnlySpan OutdoorStaticCandidates => + Arena.OutdoorStaticCandidates; + + public ReadOnlySpan CellStaticCandidates => + Arena.CellStaticCandidates; + + public ReadOnlySpan CellRanges => + Arena.CellRanges; + + public ReadOnlySpan DynamicCandidates => + Arena.DynamicCandidates; + + public ReadOnlySpan Transforms => + Arena.Transforms; + + public ReadOnlySpan Classifications => + Arena.Classifications; + + public ReadOnlySpan LightSets => + Arena.LightSets; + + public ReadOnlySpan SelectionParts => + Arena.SelectionParts; + + public PortalVisibilityFrame? PortalFrame => Arena.PortalFrame; + + public ClipFrameAssembly? ClipAssembly => Arena.ClipAssembly; + + public RenderFrameDiagnosticCounts DiagnosticCounts => + Arena.DiagnosticCounts; + + public RenderSceneDigest SourceDigest => Arena.SourceDigest; + + internal RenderFrameArena Owner => + Arena; + + internal ulong ArenaEpoch => _arenaEpoch; + + internal ulong BorrowToken => _borrowToken; + + private RenderFrameArena Arena + { + get + { + RenderFrameArena? arena = _arena; + if (arena is null + || !arena.IsBorrowValid(_arenaEpoch, _borrowToken)) + { + throw new InvalidOperationException( + "The render-frame view is no longer borrowed from its arena."); + } + + return arena; + } + } +} + +/// +/// One side of the double-buffered render-frame product. Arrays grow only +/// when a new high-water mark is reached and are reused on every later frame. +/// +internal sealed class RenderFrameArena +{ + private RenderProjectionRecord[] _outdoor = []; + private RenderProjectionRecord[] _cellStatics = []; + private RenderFrameCellRange[] _cellRanges = []; + private RenderProjectionRecord[] _dynamics = []; + private RenderFrameTransformRecord[] _transforms = []; + private RenderFrameClassificationRecord[] _classifications = []; + private RenderFrameObjectLightSet[] _lightSets = []; + private RenderFrameSelectionPart[] _selectionParts = []; + private int _outdoorCount; + private int _cellStaticCount; + private int _cellRangeCount; + private int _dynamicCount; + private int _transformCount; + private int _classificationCount; + private int _lightSetCount; + private int _selectionPartCount; + private int _opaqueClassificationCount; + private int _alphaClassificationCount; + private ulong _epoch; + private ulong _activeBorrowToken; + private bool _sourceDigestSet; + private ArenaState _state; + + public RenderSceneGeneration Generation { get; private set; } + + public ulong FrameSequence { get; private set; } + + public PortalVisibilityFrame? PortalFrame { get; private set; } + + public ClipFrameAssembly? ClipAssembly { get; private set; } + + public RenderSceneDigest SourceDigest { get; private set; } + + public RenderFrameDiagnosticCounts DiagnosticCounts => + new( + _outdoorCount, + _cellStaticCount, + _dynamicCount, + _transformCount, + _opaqueClassificationCount, + _alphaClassificationCount, + _lightSetCount, + _selectionPartCount); + + internal ReadOnlySpan OutdoorStaticCandidates => + _outdoor.AsSpan(0, _outdoorCount); + + internal ReadOnlySpan CellStaticCandidates => + _cellStatics.AsSpan(0, _cellStaticCount); + + internal ReadOnlySpan CellRanges => + _cellRanges.AsSpan(0, _cellRangeCount); + + internal ReadOnlySpan DynamicCandidates => + _dynamics.AsSpan(0, _dynamicCount); + + internal ReadOnlySpan Transforms => + _transforms.AsSpan(0, _transformCount); + + internal ReadOnlySpan Classifications => + _classifications.AsSpan(0, _classificationCount); + + internal ReadOnlySpan LightSets => + _lightSets.AsSpan(0, _lightSetCount); + + internal ReadOnlySpan SelectionParts => + _selectionParts.AsSpan(0, _selectionPartCount); + + internal ulong BeginWrite( + RenderSceneGeneration generation, + ulong frameSequence) + { + if (_state is ArenaState.Building or ArenaState.Borrowed) + { + throw new InvalidOperationException( + "A render-frame arena cannot be reused while building or borrowed."); + } + if (frameSequence == 0) + throw new ArgumentOutOfRangeException(nameof(frameSequence)); + + ClearReferenceStorage(); + _outdoorCount = 0; + _cellStaticCount = 0; + _cellRangeCount = 0; + _dynamicCount = 0; + _transformCount = 0; + _classificationCount = 0; + _lightSetCount = 0; + _selectionPartCount = 0; + _opaqueClassificationCount = 0; + _alphaClassificationCount = 0; + PortalFrame = null; + ClipAssembly = null; + SourceDigest = default; + _sourceDigestSet = false; + Generation = generation; + FrameSequence = frameSequence; + _activeBorrowToken = 0; + _epoch = checked(_epoch + 1); + _state = ArenaState.Building; + return _epoch; + } + + internal void SetBorrowedProducts( + ulong epoch, + PortalVisibilityFrame? portalFrame, + ClipFrameAssembly? clipAssembly) + { + EnsureBuilding(epoch); + PortalFrame = portalFrame; + ClipAssembly = clipAssembly; + } + + internal void SetSourceDigest(ulong epoch, in RenderSceneDigest digest) + { + EnsureBuilding(epoch); + if (digest.Generation != Generation) + { + throw new InvalidOperationException( + $"The source digest belongs to {digest.Generation}, " + + $"but the frame arena is building {Generation}."); + } + + SourceDigest = digest; + _sourceDigestSet = true; + } + + internal void AddOutdoor( + ulong epoch, + in RenderProjectionRecord record) + { + EnsureBuilding(epoch); + EnsureCapacity(ref _outdoor, _outdoorCount + 1); + _outdoor[_outdoorCount++] = record; + } + + internal void AddCellRange( + ulong epoch, + uint cellId, + int pviewOrder, + ReadOnlySpan records) + { + EnsureBuilding(epoch); + if (pviewOrder < 0) + throw new ArgumentOutOfRangeException(nameof(pviewOrder)); + + int offset = _cellStaticCount; + EnsureCapacity(ref _cellStatics, checked(offset + records.Length)); + records.CopyTo(_cellStatics.AsSpan(offset)); + _cellStaticCount += records.Length; + EnsureCapacity(ref _cellRanges, _cellRangeCount + 1); + _cellRanges[_cellRangeCount++] = new RenderFrameCellRange( + cellId, + pviewOrder, + offset, + records.Length); + } + + internal void AddCellRange( + ulong epoch, + uint cellId, + int pviewOrder, + in RenderProjectionRecord record) + { + EnsureBuilding(epoch); + if (pviewOrder < 0) + throw new ArgumentOutOfRangeException(nameof(pviewOrder)); + + int offset = _cellStaticCount; + EnsureCapacity(ref _cellStatics, offset + 1); + _cellStatics[_cellStaticCount++] = record; + EnsureCapacity(ref _cellRanges, _cellRangeCount + 1); + _cellRanges[_cellRangeCount++] = new RenderFrameCellRange( + cellId, + pviewOrder, + offset, + 1); + } + + internal void AddDynamic( + ulong epoch, + in RenderProjectionRecord record) + { + EnsureBuilding(epoch); + EnsureCapacity(ref _dynamics, _dynamicCount + 1); + _dynamics[_dynamicCount++] = record; + } + + internal void AddTransform( + ulong epoch, + in RenderFrameTransformRecord transform) + { + EnsureBuilding(epoch); + EnsureCapacity(ref _transforms, _transformCount + 1); + _transforms[_transformCount++] = transform; + } + + internal void AddClassification( + ulong epoch, + in RenderFrameClassificationRecord classification) + { + EnsureBuilding(epoch); + EnsureCapacity(ref _classifications, _classificationCount + 1); + _classifications[_classificationCount++] = classification; + if (classification.BlendClass is RenderFrameBlendClass.Alpha) + _alphaClassificationCount++; + else + _opaqueClassificationCount++; + } + + internal void AddLightSet( + ulong epoch, + in RenderFrameObjectLightSet lightSet) + { + EnsureBuilding(epoch); + EnsureCapacity(ref _lightSets, _lightSetCount + 1); + _lightSets[_lightSetCount++] = lightSet; + } + + internal void AddSelectionPart( + ulong epoch, + in RenderFrameSelectionPart part) + { + EnsureBuilding(epoch); + ArgumentNullException.ThrowIfNull(part.Mesh); + EnsureCapacity(ref _selectionParts, _selectionPartCount + 1); + _selectionParts[_selectionPartCount++] = part; + } + + internal void Publish(ulong epoch) + { + EnsureBuilding(epoch); + if (!_sourceDigestSet) + { + throw new InvalidOperationException( + "A render-frame arena cannot publish without its source digest."); + } + _state = ArenaState.Published; + } + + internal void Abort(ulong epoch) + { + EnsureBuilding(epoch); + ClearReferenceStorage(); + PortalFrame = null; + ClipAssembly = null; + SourceDigest = default; + _sourceDigestSet = false; + _state = ArenaState.Available; + } + + internal ulong Borrow(ulong epoch, ulong borrowToken) + { + if (_state is not ArenaState.Published || _epoch != epoch) + { + throw new InvalidOperationException( + "Only the exact published render-frame arena may be borrowed."); + } + if (borrowToken == 0) + throw new ArgumentOutOfRangeException(nameof(borrowToken)); + + _activeBorrowToken = borrowToken; + _state = ArenaState.Borrowed; + return _activeBorrowToken; + } + + internal void Release(ulong epoch, ulong borrowToken) + { + if (!IsBorrowValid(epoch, borrowToken)) + { + throw new InvalidOperationException( + "The render-frame borrow is stale or has already been released."); + } + + _activeBorrowToken = 0; + _state = ArenaState.Published; + } + + internal bool IsBorrowValid(ulong epoch, ulong borrowToken) => + _state is ArenaState.Borrowed + && _epoch == epoch + && borrowToken != 0 + && _activeBorrowToken == borrowToken; + + internal bool CanBuild => _state is ArenaState.Available or ArenaState.Published; + + internal ulong Epoch => _epoch; + + private void EnsureBuilding(ulong epoch) + { + if (_state is not ArenaState.Building || _epoch != epoch) + { + throw new InvalidOperationException( + "The render-frame writer no longer owns this arena."); + } + } + + private void ClearReferenceStorage() + { + if (_selectionPartCount > 0) + Array.Clear(_selectionParts, 0, _selectionPartCount); + } + + 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 enum ArenaState : byte + { + Available, + Building, + Published, + Borrowed, + } +} + +/// +/// Allocation-free write facade for the exact arena selected by +/// . +/// +internal readonly struct RenderFrameWriter +{ + private readonly RenderFrameExchange? _owner; + private readonly RenderFrameArena? _arena; + private readonly int _arenaIndex; + private readonly ulong _epoch; + + internal RenderFrameWriter( + RenderFrameExchange owner, + RenderFrameArena arena, + int arenaIndex, + ulong epoch) + { + _owner = owner; + _arena = arena; + _arenaIndex = arenaIndex; + _epoch = epoch; + } + + public void SetBorrowedProducts( + PortalVisibilityFrame? portalFrame, + ClipFrameAssembly? clipAssembly) => + Arena.SetBorrowedProducts(_epoch, portalFrame, clipAssembly); + + public void SetSourceDigest(in RenderSceneDigest digest) => + Arena.SetSourceDigest(_epoch, in digest); + + public void AddOutdoor(in RenderProjectionRecord record) => + Arena.AddOutdoor(_epoch, in record); + + public void AddCellRange( + uint cellId, + int pviewOrder, + ReadOnlySpan records) => + Arena.AddCellRange(_epoch, cellId, pviewOrder, records); + + public void AddCellRange( + uint cellId, + int pviewOrder, + in RenderProjectionRecord record) => + Arena.AddCellRange(_epoch, cellId, pviewOrder, in record); + + public void AddDynamic(in RenderProjectionRecord record) => + Arena.AddDynamic(_epoch, in record); + + public void AddTransform(in RenderFrameTransformRecord transform) => + Arena.AddTransform(_epoch, in transform); + + public void AddClassification( + in RenderFrameClassificationRecord classification) => + Arena.AddClassification(_epoch, in classification); + + public void AddLightSet(in RenderFrameObjectLightSet lightSet) => + Arena.AddLightSet(_epoch, in lightSet); + + public void AddSelectionPart(in RenderFrameSelectionPart part) => + Arena.AddSelectionPart(_epoch, in part); + + public void Publish() => + Owner.Publish(_arenaIndex, _epoch); + + public void Abort() => + Owner.Abort(_arenaIndex, _epoch); + + private RenderFrameExchange Owner => + _owner + ?? throw new InvalidOperationException( + "The render-frame writer is uninitialized."); + + private RenderFrameArena Arena => + _arena + ?? throw new InvalidOperationException( + "The render-frame writer is uninitialized."); +} + +/// +/// Owns exactly two reusable arenas. It permits frame N to build in one arena +/// while frame N-1 remains borrowed from the other, but never permits mutation +/// of a borrowed arena or publication of an incomplete frame. +/// +internal sealed class RenderFrameExchange +{ + private readonly RenderFrameArena[] _arenas = + [new RenderFrameArena(), new RenderFrameArena()]; + private int _buildingIndex = -1; + private int _publishedIndex = -1; + private ulong _nextBorrowToken; + + public RenderFrameWriter BeginBuild( + RenderSceneGeneration generation, + ulong frameSequence) + { + if (_buildingIndex >= 0) + { + throw new InvalidOperationException( + "A render-frame build is already in progress."); + } + + int index = _publishedIndex < 0 + ? 0 + : 1 - _publishedIndex; + if (!_arenas[index].CanBuild) + index = -1; + if (index < 0) + { + throw new InvalidOperationException( + "The non-published render-frame arena is still borrowed."); + } + + RenderFrameArena arena = _arenas[index]; + ulong epoch = arena.BeginWrite(generation, frameSequence); + _buildingIndex = index; + return new RenderFrameWriter(this, arena, index, epoch); + } + + public RenderFrameView BorrowLatest( + RenderSceneGeneration expectedGeneration, + ulong expectedFrameSequence) + { + if (_publishedIndex < 0) + { + throw new InvalidOperationException( + "No completed render frame has been published."); + } + + RenderFrameArena arena = _arenas[_publishedIndex]; + if (arena.Generation != expectedGeneration + || arena.FrameSequence != expectedFrameSequence) + { + throw new InvalidOperationException( + $"Published frame {arena.Generation}/{arena.FrameSequence} " + + $"does not match requested {expectedGeneration}/{expectedFrameSequence}."); + } + + ulong token = checked(++_nextBorrowToken); + arena.Borrow(arena.Epoch, token); + return new RenderFrameView(arena, arena.Epoch, token); + } + + public void Release(in RenderFrameView view) + { + RenderFrameArena arena = view.Owner; + if (!ReferenceEquals(arena, _arenas[0]) + && !ReferenceEquals(arena, _arenas[1])) + { + throw new InvalidOperationException( + "The render-frame view belongs to another exchange."); + } + + arena.Release(view.ArenaEpoch, view.BorrowToken); + } + + internal void Publish(int index, ulong epoch) + { + EnsureWriter(index); + RenderFrameArena arena = _arenas[index]; + arena.Publish(epoch); + _publishedIndex = index; + _buildingIndex = -1; + } + + internal void Abort(int index, ulong epoch) + { + EnsureWriter(index); + _arenas[index].Abort(epoch); + _buildingIndex = -1; + } + + private void EnsureWriter(int index) + { + if (_buildingIndex != index) + { + throw new InvalidOperationException( + "The render-frame writer is stale or has already completed."); + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameProductTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameProductTests.cs new file mode 100644 index 00000000..1ac539b0 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RenderFrameProductTests.cs @@ -0,0 +1,275 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Scene; +using AcDream.Core.Selection; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RenderFrameProductTests +{ + private static readonly RenderSceneGeneration Generation = + RenderSceneGeneration.FromRaw(7); + + [Fact] + public void PublishesAndBorrowsTheFrameBuiltForTheSameSequence() + { + var exchange = new RenderFrameExchange(); + RenderFrameWriter writer = exchange.BeginBuild(Generation, 41); + RenderProjectionRecord outdoor = Projection(1); + RenderProjectionRecord cellA = Projection(2); + RenderProjectionRecord cellB = Projection(3); + RenderProjectionRecord dynamic = Projection(4); + writer.AddOutdoor(in outdoor); + writer.AddCellRange(0x12340102, 5, [cellA, cellB]); + writer.AddDynamic(in dynamic); + writer.AddTransform(new RenderFrameTransformRecord( + dynamic.Id, + 0, + Matrix4x4.CreateTranslation(1, 2, 3))); + writer.AddClassification(new RenderFrameClassificationRecord( + outdoor.Id, + 0, + 0, + RenderFrameBlendClass.Opaque, + 1f)); + writer.AddClassification(new RenderFrameClassificationRecord( + dynamic.Id, + 1, + 0, + RenderFrameBlendClass.Alpha, + 0.5f)); + writer.AddLightSet(new RenderFrameObjectLightSet( + dynamic.Id, + 0, 1, 2, 3, 4, 5, 6, 7)); + writer.AddSelectionPart(new RenderFrameSelectionPart( + dynamic.Id, + 0x50000001, + 4, + 0, + 0x01000001, + Matrix4x4.Identity, + SelectionMesh())); + var digest = new RenderSceneDigest( + Generation, + new RenderProjectionCounts(Total: 4, 1, 2, 1, 0, 0), + new RenderSceneHash128(11, 12)); + writer.SetSourceDigest(in digest); + writer.Publish(); + + RenderFrameView view = exchange.BorrowLatest(Generation, 41); + + Assert.Equal((ulong)41, view.FrameSequence); + Assert.Equal(Generation, view.Generation); + Assert.Equal(outdoor.Id, view.OutdoorStaticCandidates[0].Id); + Assert.Equal([cellA.Id, cellB.Id], + view.CellStaticCandidates.ToArray().Select(static item => item.Id)); + Assert.Equal(new RenderFrameCellRange(0x12340102, 5, 0, 2), + view.CellRanges[0]); + Assert.Equal(dynamic.Id, view.DynamicCandidates[0].Id); + Assert.Equal(1, view.Transforms.Length); + Assert.Equal(2, view.Classifications.Length); + Assert.Equal(1, view.LightSets.Length); + Assert.Equal(1, view.SelectionParts.Length); + Assert.Equal( + new RenderFrameDiagnosticCounts(1, 2, 1, 1, 1, 1, 1, 1), + view.DiagnosticCounts); + Assert.Equal(digest, view.SourceDigest); + + exchange.Release(in view); + } + + [Fact] + public void BuildsInOtherArenaWhilePriorFrameRemainsBorrowed() + { + var exchange = new RenderFrameExchange(); + PublishEmpty(exchange, Generation, 1); + RenderFrameView first = exchange.BorrowLatest(Generation, 1); + + PublishEmpty(exchange, Generation, 2); + + Assert.Equal((ulong)1, first.FrameSequence); + RenderFrameView second = exchange.BorrowLatest(Generation, 2); + Assert.Equal((ulong)2, second.FrameSequence); + Assert.Throws( + () => exchange.BeginBuild(Generation, 3)); + + exchange.Release(in first); + exchange.Release(in second); + } + + [Fact] + public void ReuseInvalidatesEveryCopyOfReleasedView() + { + var exchange = new RenderFrameExchange(); + PublishEmpty(exchange, Generation, 1); + RenderFrameView first = exchange.BorrowLatest(Generation, 1); + RenderFrameView copy = first; + exchange.Release(in first); + + Assert.Throws(() => _ = copy.FrameSequence); + + PublishEmpty(exchange, Generation, 2); + RenderFrameView second = exchange.BorrowLatest(Generation, 2); + exchange.Release(in second); + PublishEmpty(exchange, Generation, 3); + + Assert.Throws(() => _ = copy.Generation); + } + + [Fact] + public void AbortDoesNotPublishIncompleteFrame() + { + var exchange = new RenderFrameExchange(); + PublishEmpty(exchange, Generation, 1); + RenderFrameWriter writer = exchange.BeginBuild(Generation, 2); + RenderProjectionRecord record = Projection(8); + writer.AddOutdoor(in record); + writer.Abort(); + + RenderFrameView prior = exchange.BorrowLatest(Generation, 1); + Assert.Equal(0, prior.OutdoorStaticCandidates.Length); + exchange.Release(in prior); + + PublishEmpty(exchange, Generation, 2); + RenderFrameView replacement = exchange.BorrowLatest(Generation, 2); + Assert.Equal(0, replacement.OutdoorStaticCandidates.Length); + exchange.Release(in replacement); + } + + [Fact] + public void BorrowRequiresExactGenerationAndFrameSequence() + { + var exchange = new RenderFrameExchange(); + PublishEmpty(exchange, Generation, 9); + + Assert.Throws( + () => exchange.BorrowLatest( + RenderSceneGeneration.FromRaw(8), + 9)); + Assert.Throws( + () => exchange.BorrowLatest(Generation, 10)); + + RenderFrameView view = exchange.BorrowLatest(Generation, 9); + exchange.Release(in view); + } + + [Fact] + public void SourceDigestMustMatchArenaGeneration() + { + var exchange = new RenderFrameExchange(); + RenderFrameWriter writer = exchange.BeginBuild(Generation, 1); + var digest = new RenderSceneDigest( + RenderSceneGeneration.FromRaw(8), + default, + default); + + Assert.Throws( + () => writer.SetSourceDigest(in digest)); + writer.Abort(); + } + + [Fact] + public void PublishRequiresSourceDigest() + { + var exchange = new RenderFrameExchange(); + RenderFrameWriter writer = exchange.BeginBuild(Generation, 1); + + Assert.Throws(writer.Publish); + writer.Abort(); + } + + [Fact] + public void WarmProductBuildAndBorrowAllocateNothing() + { + var exchange = new RenderFrameExchange(); + RenderProjectionRecord record = Projection(1); + RenderFrameSelectionPart selection = new( + record.Id, + 1, + 1, + 0, + 1, + Matrix4x4.Identity, + SelectionMesh()); + + for (ulong sequence = 1; sequence <= 8; sequence++) + BuildPopulated(exchange, sequence, in record, in selection); + + long before = GC.GetAllocatedBytesForCurrentThread(); + for (ulong sequence = 9; sequence <= 1_008; sequence++) + BuildPopulated(exchange, sequence, in record, in selection); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(0, allocated); + } + + private static void BuildPopulated( + RenderFrameExchange exchange, + ulong sequence, + in RenderProjectionRecord record, + in RenderFrameSelectionPart selection) + { + RenderFrameWriter writer = exchange.BeginBuild(Generation, sequence); + writer.AddOutdoor(in record); + writer.AddCellRange(0x01010001, 0, in record); + writer.AddDynamic(in record); + writer.AddTransform(new RenderFrameTransformRecord( + record.Id, + 0, + Matrix4x4.Identity)); + writer.AddClassification(new RenderFrameClassificationRecord( + record.Id, + 0, + 0, + RenderFrameBlendClass.Opaque, + 1f)); + writer.AddLightSet(new RenderFrameObjectLightSet( + record.Id, + 0, 1, 2, 3, 4, 5, 6, 7)); + writer.AddSelectionPart(in selection); + writer.SetSourceDigest(new RenderSceneDigest( + Generation, + new RenderProjectionCounts(3, 1, 1, 1, 0, 0), + default)); + writer.Publish(); + RenderFrameView view = exchange.BorrowLatest(Generation, sequence); + _ = view.DiagnosticCounts; + exchange.Release(in view); + } + + private static void PublishEmpty( + RenderFrameExchange exchange, + RenderSceneGeneration generation, + ulong sequence) + { + RenderFrameWriter writer = exchange.BeginBuild(generation, sequence); + writer.SetSourceDigest(new RenderSceneDigest( + generation, + default, + default)); + writer.Publish(); + } + + private static RenderProjectionRecord Projection(ulong value) => + new RenderProjectionRecord() with + { + Id = RenderProjectionId.FromRaw(value), + OwnerIncarnation = RenderOwnerIncarnation.FromRaw(1), + MeshSet = new RenderMeshSet( + RenderAssetHandle.FromRaw(value), + 1, + 1), + Material = new RenderMaterialVariant(0, 0, 1), + Transform = new RenderTransform(Matrix4x4.Identity), + PreviousTransform = new PreviousRenderTransform(Matrix4x4.Identity), + Flags = RenderProjectionFlags.Draw, + }; + + private static RetailSelectionMesh SelectionMesh() => + new( + Vector3.Zero, + 1f, + [new RetailSelectionPolygon( + [Vector3.Zero, Vector3.UnitX, Vector3.UnitY], + SingleSided: false)]); +}