feat(rendering): add borrowed render frame product
Introduce two reusable generation-stamped arenas whose views are token-validated across build, publish, borrow, release, and abort. This is a non-drawing G0 seam for the incremental scene and changes no production consumer.
This commit is contained in:
parent
9b5a530e7b
commit
8c638654be
3 changed files with 960 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
670
src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs
Normal file
670
src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs
Normal file
|
|
@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>Release</c>.
|
||||
/// </summary>
|
||||
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<RenderProjectionRecord> OutdoorStaticCandidates =>
|
||||
Arena.OutdoorStaticCandidates;
|
||||
|
||||
public ReadOnlySpan<RenderProjectionRecord> CellStaticCandidates =>
|
||||
Arena.CellStaticCandidates;
|
||||
|
||||
public ReadOnlySpan<RenderFrameCellRange> CellRanges =>
|
||||
Arena.CellRanges;
|
||||
|
||||
public ReadOnlySpan<RenderProjectionRecord> DynamicCandidates =>
|
||||
Arena.DynamicCandidates;
|
||||
|
||||
public ReadOnlySpan<RenderFrameTransformRecord> Transforms =>
|
||||
Arena.Transforms;
|
||||
|
||||
public ReadOnlySpan<RenderFrameClassificationRecord> Classifications =>
|
||||
Arena.Classifications;
|
||||
|
||||
public ReadOnlySpan<RenderFrameObjectLightSet> LightSets =>
|
||||
Arena.LightSets;
|
||||
|
||||
public ReadOnlySpan<RenderFrameSelectionPart> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<RenderProjectionRecord> OutdoorStaticCandidates =>
|
||||
_outdoor.AsSpan(0, _outdoorCount);
|
||||
|
||||
internal ReadOnlySpan<RenderProjectionRecord> CellStaticCandidates =>
|
||||
_cellStatics.AsSpan(0, _cellStaticCount);
|
||||
|
||||
internal ReadOnlySpan<RenderFrameCellRange> CellRanges =>
|
||||
_cellRanges.AsSpan(0, _cellRangeCount);
|
||||
|
||||
internal ReadOnlySpan<RenderProjectionRecord> DynamicCandidates =>
|
||||
_dynamics.AsSpan(0, _dynamicCount);
|
||||
|
||||
internal ReadOnlySpan<RenderFrameTransformRecord> Transforms =>
|
||||
_transforms.AsSpan(0, _transformCount);
|
||||
|
||||
internal ReadOnlySpan<RenderFrameClassificationRecord> Classifications =>
|
||||
_classifications.AsSpan(0, _classificationCount);
|
||||
|
||||
internal ReadOnlySpan<RenderFrameObjectLightSet> LightSets =>
|
||||
_lightSets.AsSpan(0, _lightSetCount);
|
||||
|
||||
internal ReadOnlySpan<RenderFrameSelectionPart> 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<RenderProjectionRecord> 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<T>(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,
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allocation-free write facade for the exact arena selected by
|
||||
/// <see cref="RenderFrameExchange.BeginBuild"/>.
|
||||
/// </summary>
|
||||
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<RenderProjectionRecord> 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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
275
tests/AcDream.App.Tests/Rendering/RenderFrameProductTests.cs
Normal file
275
tests/AcDream.App.Tests/Rendering/RenderFrameProductTests.cs
Normal file
|
|
@ -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<InvalidOperationException>(
|
||||
() => 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<InvalidOperationException>(() => _ = copy.FrameSequence);
|
||||
|
||||
PublishEmpty(exchange, Generation, 2);
|
||||
RenderFrameView second = exchange.BorrowLatest(Generation, 2);
|
||||
exchange.Release(in second);
|
||||
PublishEmpty(exchange, Generation, 3);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => _ = 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<InvalidOperationException>(
|
||||
() => exchange.BorrowLatest(
|
||||
RenderSceneGeneration.FromRaw(8),
|
||||
9));
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => 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<InvalidOperationException>(
|
||||
() => writer.SetSourceDigest(in digest));
|
||||
writer.Abort();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishRequiresSourceDigest()
|
||||
{
|
||||
var exchange = new RenderFrameExchange();
|
||||
RenderFrameWriter writer = exchange.BeginBuild(Generation, 1);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(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)]);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue