perf(rendering): retain packed classification

Retain appearance classification per projection while refreshing transforms, clip slots, lights, selection lighting, and group instance payloads each frame. Incomplete resources and active animation remain on the live classification path, and only an exact full packed comparison acknowledges scene dirtiness.
This commit is contained in:
Erik 2026-07-25 02:46:40 +02:00
parent f10dec58ba
commit 54d17eb446
7 changed files with 731 additions and 30 deletions

View file

@ -386,7 +386,8 @@ internal sealed class FrameRootCompositionPhase
? new RenderSceneShadowComparisonController(
live.RenderSceneShadow,
currentRenderSceneOracle,
message => d.Log("[UI-PROBE] " + message))
message => d.Log("[UI-PROBE] " + message),
acknowledgeDirty: false)
: null;
RenderScenePViewFrameProductController? renderFrameProduct =
currentRenderSceneOracle is not null

View file

@ -51,6 +51,13 @@ internal readonly record struct RenderFrameProductComparisonSnapshot(
string? SelectionOutputFirstMismatch,
RenderSceneHash128 SelectionOutputExpectedDigest,
RenderSceneHash128 SelectionOutputActualDigest,
int PackedClassificationProjectionCount,
int PackedClassificationStaticRebuildCount,
int PackedClassificationSameFrameReuseCount,
int PackedClassificationCrossFrameReuseCount,
int PackedClassificationAnimatedCount,
int PackedClassificationRetiredCount,
long PackedClassificationRetainedPayloadBytes,
RenderFrameDiagnosticCounts ProductCounts)
{
public static RenderFrameProductComparisonSnapshot Disabled { get; } =
@ -90,6 +97,13 @@ internal readonly record struct RenderFrameProductComparisonSnapshot(
SelectionOutputFirstMismatch: null,
SelectionOutputExpectedDigest: RenderSceneHash128.Empty,
SelectionOutputActualDigest: RenderSceneHash128.Empty,
PackedClassificationProjectionCount: 0,
PackedClassificationStaticRebuildCount: 0,
PackedClassificationSameFrameReuseCount: 0,
PackedClassificationCrossFrameReuseCount: 0,
PackedClassificationAnimatedCount: 0,
PackedClassificationRetiredCount: 0,
PackedClassificationRetainedPayloadBytes: 0,
ProductCounts: default);
}
@ -136,6 +150,8 @@ internal sealed class RenderScenePViewFrameProductController :
private ulong _selectionOutputSuccessfulComparisonCount;
private long _selectionOutputMismatchCount;
private string? _selectionOutputFirstMismatch;
private PackedClassificationCacheSnapshot
_packedClassification;
public RenderScenePViewFrameProductController(
RenderSceneShadowRuntime shadow,
@ -280,6 +296,17 @@ internal sealed class RenderScenePViewFrameProductController :
in view,
tupleLandblockId,
cameraWorldPosition);
if (_dispatcher is not null
&& mismatch is null
&& packed.Successful
&& classified.Successful)
{
// The frame product and retained classifier have consumed every
// dirty fact and matched the production oracle. A later update
// starts the next dirty epoch; a mismatch or exception above
// deliberately leaves dirtiness intact for the next rebuild.
_shadow.ClearDirty();
}
Snapshot = new RenderFrameProductComparisonSnapshot(
Enabled: true,
@ -335,6 +362,20 @@ internal sealed class RenderScenePViewFrameProductController :
classified.ExpectedSelectionDigest,
SelectionOutputActualDigest:
classified.ActualSelectionDigest,
PackedClassificationProjectionCount:
_packedClassification.ProjectionCount,
PackedClassificationStaticRebuildCount:
_packedClassification.StaticRebuildCount,
PackedClassificationSameFrameReuseCount:
_packedClassification.SameFrameReuseCount,
PackedClassificationCrossFrameReuseCount:
_packedClassification.CrossFrameReuseCount,
PackedClassificationAnimatedCount:
_packedClassification.AnimatedClassificationCount,
PackedClassificationRetiredCount:
_packedClassification.RetiredProjectionCount,
PackedClassificationRetainedPayloadBytes:
_packedClassification.RetainedPayloadBytes,
ProductCounts: view.DiagnosticCounts);
}
@ -351,6 +392,8 @@ internal sealed class RenderScenePViewFrameProductController :
in view,
tupleLandblockId,
cameraWorldPosition);
_packedClassification =
dispatcher.PackedClassificationSnapshot;
IReadOnlyList<CurrentRenderDispatcherSubmission> expected =
_current.DispatcherSubmissions;
IReadOnlyList<CurrentRenderDispatcherSubmission> actual =
@ -420,6 +463,7 @@ internal sealed class RenderScenePViewFrameProductController :
}
return new ClassifiedOutputComparison(
Successful: mismatch is null && selectionMismatch is null,
expected.Count,
actual.Count,
expectedDigest,
@ -661,7 +705,10 @@ internal sealed class RenderScenePViewFrameProductController :
}
}
return new PackedInputComparison(expectedDigest, actualDigest);
return new PackedInputComparison(
Successful: mismatch is null,
expectedDigest,
actualDigest);
}
private static uint DispatcherCacheLandblock(
@ -780,10 +827,12 @@ internal sealed class RenderScenePViewFrameProductController :
RenderProjectionId ProjectionId);
private readonly record struct PackedInputComparison(
bool Successful,
RenderSceneHash128 ExpectedDigest,
RenderSceneHash128 ActualDigest);
private readonly record struct ClassifiedOutputComparison(
bool Successful,
int ExpectedDrawCount,
int ActualDrawCount,
RenderSceneHash128 ExpectedDigest,

View file

@ -223,6 +223,7 @@ internal sealed class RenderSceneShadowComparisonController :
private readonly RenderSceneShadowRuntime _shadow;
private readonly CurrentRenderSceneOracle _oracle;
private readonly Action<string>? _log;
private readonly bool _acknowledgeDirty;
private readonly List<RenderProjectionRecord> _sceneRecords = [];
private readonly HashSet<RenderProjectionId> _expectedIds = [];
private ulong _renderFrameSequence;
@ -236,11 +237,13 @@ internal sealed class RenderSceneShadowComparisonController :
public RenderSceneShadowComparisonController(
RenderSceneShadowRuntime shadow,
CurrentRenderSceneOracle oracle,
Action<string>? log = null)
Action<string>? log = null,
bool acknowledgeDirty = true)
{
_shadow = shadow ?? throw new ArgumentNullException(nameof(shadow));
_oracle = oracle ?? throw new ArgumentNullException(nameof(oracle));
_log = log;
_acknowledgeDirty = acknowledgeDirty;
Snapshot = BuildSnapshot();
}
@ -300,7 +303,8 @@ internal sealed class RenderSceneShadowComparisonController :
}
}
_shadow.ClearDirty();
if (_acknowledgeDirty && mismatch is null)
_shadow.ClearDirty();
Snapshot = BuildSnapshot();
}

View file

@ -0,0 +1,251 @@
using System.Numerics;
using System.Runtime.CompilerServices;
using AcDream.App.Rendering.Scene;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// The appearance-only identity of one retained scene projection. Root
/// transform, clip slot, light selection, and selection lighting deliberately
/// stay outside this key: G3 updates those instance fields without rebuilding
/// mesh/material classification.
/// </summary>
internal readonly record struct PackedClassificationIdentity(
RenderOwnerIncarnation Incarnation,
RenderMeshSet MeshSet,
RenderMaterialVariant Material,
RenderDegradeState DegradeState,
RenderSceneHash128 Geometry,
RenderSceneHash128 Appearance)
{
public static PackedClassificationIdentity From(
in RenderProjectionRecord projection) =>
new(
projection.OwnerIncarnation,
projection.MeshSet,
projection.Material,
projection.DegradeState,
projection.Source.GeometryFingerprint,
projection.Source.AppearanceFingerprint);
}
internal readonly record struct PackedClassifiedBatch(
GroupKey Key,
Matrix4x4 RestPose,
Vector3 LocalSortCenter);
internal readonly record struct PackedClassifiedSelectionPart(
int PartIndex,
uint GfxObjId,
Matrix4x4 RestPose);
/// <summary>
/// Reusable variable-size payload for one projection. Lists grow only when an
/// appearance first exposes a higher batch/part count and are cleared in
/// place on the next appearance rebuild.
/// </summary>
internal sealed class PackedProjectionClassificationEntry
{
public PackedClassificationIdentity Identity;
public readonly List<PackedClassifiedBatch> Batches = [];
public readonly List<PackedClassifiedSelectionPart> SelectionParts = [];
public long LastBuiltFrame;
public long LastSeenFrame;
public bool ReusableAcrossFrames;
public long AccountedPayloadBytes;
public void BeginRebuild(
in PackedClassificationIdentity identity,
long frame)
{
Identity = identity;
Batches.Clear();
SelectionParts.Clear();
LastBuiltFrame = frame;
LastSeenFrame = frame;
ReusableAcrossFrames = false;
}
public void ReleaseStorage()
{
Batches.Clear();
SelectionParts.Clear();
Batches.TrimExcess();
SelectionParts.TrimExcess();
}
}
internal readonly record struct PackedClassificationCacheSnapshot(
int ProjectionCount,
int StaticRebuildCount,
int SameFrameReuseCount,
int CrossFrameReuseCount,
int AnimatedClassificationCount,
int RetiredProjectionCount,
long RetainedPayloadBytes);
/// <summary>
/// G3 retained classification owner. Structural classification is rebuilt
/// only for first sight, incarnation/appearance changes, incomplete resource
/// readiness, or active animation. Warm unchanged projections replay their
/// retained batch templates while current transforms, lights, clip slots, and
/// selection lighting are refreshed.
/// </summary>
internal sealed class PackedProjectionClassificationCache
{
private const RenderDirtyMask ClassificationDirtyMask =
RenderDirtyMask.Appearance;
private readonly Dictionary<
RenderProjectionId,
PackedProjectionClassificationEntry> _entries = [];
private readonly List<RenderProjectionId> _retired = [];
private RenderSceneGeneration _generation;
private bool _generationSet;
private long _frame;
private int _staticRebuildCount;
private int _sameFrameReuseCount;
private int _crossFrameReuseCount;
private int _animatedClassificationCount;
private int _retiredProjectionCount;
private long _retainedPayloadBytes;
public long Frame => _frame;
public PackedClassificationCacheSnapshot Snapshot =>
new(
_entries.Count,
_staticRebuildCount,
_sameFrameReuseCount,
_crossFrameReuseCount,
_animatedClassificationCount,
_retiredProjectionCount,
_retainedPayloadBytes);
public void BeginFrame(RenderSceneGeneration generation)
{
if (_frame == long.MaxValue)
{
throw new InvalidOperationException(
"Packed classification frame identity was exhausted.");
}
if (!_generationSet || generation != _generation)
{
Clear();
_generation = generation;
_generationSet = true;
}
_frame++;
_staticRebuildCount = 0;
_sameFrameReuseCount = 0;
_crossFrameReuseCount = 0;
_animatedClassificationCount = 0;
_retiredProjectionCount = 0;
}
public bool TryGetReusable(
RenderProjectionId id,
in PackedClassificationIdentity identity,
RenderDirtyMask dirtyMask,
out PackedProjectionClassificationEntry? entry)
{
if (!_entries.TryGetValue(id, out entry)
|| entry.Identity != identity)
{
return false;
}
entry.LastSeenFrame = _frame;
if (entry.LastBuiltFrame == _frame)
{
_sameFrameReuseCount++;
return true;
}
if (!entry.ReusableAcrossFrames
|| (dirtyMask & ClassificationDirtyMask) != 0)
{
return false;
}
_crossFrameReuseCount++;
return true;
}
public PackedProjectionClassificationEntry BeginRebuild(
RenderProjectionId id,
in PackedClassificationIdentity identity)
{
if (!_entries.TryGetValue(id, out var entry))
{
entry = new PackedProjectionClassificationEntry();
_entries.Add(id, entry);
}
entry.BeginRebuild(in identity, _frame);
_staticRebuildCount++;
return entry;
}
public void CompleteRebuild(
PackedProjectionClassificationEntry entry,
bool reusableAcrossFrames)
{
ArgumentNullException.ThrowIfNull(entry);
if (entry.LastBuiltFrame != _frame)
{
throw new InvalidOperationException(
"Only an entry rebuilt in the active frame can be completed.");
}
entry.ReusableAcrossFrames = reusableAcrossFrames;
long payloadBytes = checked(
(long)entry.Batches.Capacity
* Unsafe.SizeOf<PackedClassifiedBatch>()
+ (long)entry.SelectionParts.Capacity
* Unsafe.SizeOf<PackedClassifiedSelectionPart>());
_retainedPayloadBytes = checked(
_retainedPayloadBytes
- entry.AccountedPayloadBytes
+ payloadBytes);
entry.AccountedPayloadBytes = payloadBytes;
}
public void RecordAnimatedClassification() =>
_animatedClassificationCount++;
public void EndFrame()
{
_retired.Clear();
foreach ((RenderProjectionId id, var entry) in _entries)
{
if (entry.LastSeenFrame != _frame)
{
_retainedPayloadBytes -= entry.AccountedPayloadBytes;
entry.AccountedPayloadBytes = 0;
entry.ReleaseStorage();
_retired.Add(id);
}
}
for (int i = 0; i < _retired.Count; i++)
_entries.Remove(_retired[i]);
_retiredProjectionCount = _retired.Count;
_retired.Clear();
}
public void Clear()
{
foreach (PackedProjectionClassificationEntry entry
in _entries.Values)
{
entry.ReleaseStorage();
}
_entries.Clear();
_retired.Clear();
_frame = 0;
_retainedPayloadBytes = 0;
}
}

View file

@ -30,6 +30,8 @@ public sealed unsafe partial class WbDrawDispatcher
private readonly List<CurrentRenderSelectionFingerprint>
_packedSelectionParts = [];
private readonly HashSet<PackedSelectionKey> _packedSelectionKeys = [];
private readonly PackedProjectionClassificationCache
_packedClassificationCache = new();
private long _packedGroupFrame;
private long _nextPackedGroupRegistration = 1;
private int _nextPackedInstanceSubmissionOrder;
@ -40,6 +42,10 @@ public sealed unsafe partial class WbDrawDispatcher
internal IReadOnlyList<CurrentRenderSelectionFingerprint>
PackedSelectionParts => _packedSelectionParts;
internal PackedClassificationCacheSnapshot
PackedClassificationSnapshot =>
_packedClassificationCache.Snapshot;
internal void BuildPackedDispatcherOracle(
in RenderFrameView view,
uint tupleLandblockId,
@ -60,6 +66,7 @@ public sealed unsafe partial class WbDrawDispatcher
_packedSubmissions.Clear();
_packedSelectionParts.Clear();
_packedSelectionKeys.Clear();
_packedClassificationCache.BeginFrame(view.Generation);
ReadOnlySpan<RenderFrameEntityCandidate> entities =
view.EntityCandidates;
@ -132,6 +139,7 @@ public sealed unsafe partial class WbDrawDispatcher
in source,
tupleLandblockId);
ClassifyPackedEntity(
in projection,
in candidate,
meshParts.Slice(
source.MeshPartOffset,
@ -164,9 +172,12 @@ public sealed unsafe partial class WbDrawDispatcher
cameraWorldPosition,
_packedAlphaFingerprintScratch));
}
_packedClassificationCache.EndFrame();
}
private void ClassifyPackedEntity(
in RenderProjectionRecord projection,
in RenderInstanceCandidate entity,
ReadOnlySpan<RenderFrameMeshPart> meshParts,
ref uint anyVao)
@ -194,6 +205,45 @@ public sealed unsafe partial class WbDrawDispatcher
lighting.Luminosity,
lighting.Diffuse)
: new Vector2(0f, 1f);
PackedProjectionClassificationEntry? cacheEntry = null;
if (!entity.Animated)
{
PackedClassificationIdentity identity =
PackedClassificationIdentity.From(in projection);
if (_packedClassificationCache.TryGetReusable(
entity.ProjectionId,
in identity,
projection.DirtyMask,
out cacheEntry))
{
if (anyVao == 0 && !meshParts.IsEmpty)
{
ObjectRenderData? firstRenderData =
_meshAdapter.TryGetRenderData(
meshParts[0].MeshRef.GfxObjId);
if (firstRenderData is not null)
anyVao = firstRenderData.VAO;
}
ReplayPackedClassification(
cacheEntry!,
in entity,
slot,
lights,
indoor,
selectionLighting);
return;
}
cacheEntry = _packedClassificationCache.BeginRebuild(
entity.ProjectionId,
in identity);
}
else
{
_packedClassificationCache.RecordAnimatedClassification();
}
PaletteCompositeIdentity paletteIdentity = default;
if (entity.PaletteOverride is not null)
{
@ -201,6 +251,7 @@ public sealed unsafe partial class WbDrawDispatcher
entity.PaletteOverride);
}
bool reusableAcrossFrames = !entity.Animated;
for (int meshIndex = 0;
meshIndex < meshParts.Length;
meshIndex++)
@ -219,7 +270,10 @@ public sealed unsafe partial class WbDrawDispatcher
ObjectRenderData? renderData =
_meshAdapter.TryGetRenderData(meshRef.GfxObjId);
if (renderData is null)
{
reusableAcrossFrames = false;
continue;
}
if (anyVao == 0)
anyVao = renderData.VAO;
@ -235,20 +289,26 @@ public sealed unsafe partial class WbDrawDispatcher
ObjectRenderData? partData =
_meshAdapter.TryGetRenderData(gfxObjId);
if (partData is null)
{
reusableAcrossFrames = false;
continue;
}
float opacity = PackedPartOpacity(
entity.LocalEntityId,
(uint)setupPartIndex);
if (opacity < 1f)
reusableAcrossFrames = false;
if (opacity <= 0f)
continue;
Matrix4x4 model = ComposePartWorldMatrix(
entity.RootWorld,
meshRef.PartTransform,
partTransform);
ClassifyPackedBatches(
Matrix4x4 restPose =
partTransform * meshRef.PartTransform;
Matrix4x4 model =
restPose * entity.RootWorld;
if (!ClassifyPackedBatches(
partData,
restPose,
model,
in entity,
meshRef,
@ -257,12 +317,22 @@ public sealed unsafe partial class WbDrawDispatcher
lights,
indoor,
selectionLighting,
opacity);
opacity,
cacheEntry))
{
reusableAcrossFrames = false;
}
int selectionPartIndex = unchecked(
(partIndex << 16)
| (setupPartIndex & 0xFFFF));
cacheEntry?.SelectionParts.Add(
new PackedClassifiedSelectionPart(
selectionPartIndex,
(uint)gfxObjId,
restPose));
AddPackedSelectionPart(
in entity,
unchecked(
(partIndex << 16)
| (setupPartIndex & 0xFFFF)),
selectionPartIndex,
(uint)gfxObjId,
model);
}
@ -272,13 +342,16 @@ public sealed unsafe partial class WbDrawDispatcher
float opacity = PackedPartOpacity(
entity.LocalEntityId,
0u);
if (opacity < 1f)
reusableAcrossFrames = false;
if (opacity <= 0f)
continue;
Matrix4x4 model =
meshRef.PartTransform * entity.RootWorld;
ClassifyPackedBatches(
Matrix4x4 restPose = meshRef.PartTransform;
Matrix4x4 model = restPose * entity.RootWorld;
if (!ClassifyPackedBatches(
renderData,
restPose,
model,
in entity,
meshRef,
@ -287,7 +360,16 @@ public sealed unsafe partial class WbDrawDispatcher
lights,
indoor,
selectionLighting,
opacity);
opacity,
cacheEntry))
{
reusableAcrossFrames = false;
}
cacheEntry?.SelectionParts.Add(
new PackedClassifiedSelectionPart(
partIndex,
meshRef.GfxObjId,
restPose));
AddPackedSelectionPart(
in entity,
partIndex,
@ -295,6 +377,13 @@ public sealed unsafe partial class WbDrawDispatcher
model);
}
}
if (cacheEntry is not null)
{
_packedClassificationCache.CompleteRebuild(
cacheEntry,
reusableAcrossFrames);
}
}
/// <summary>
@ -324,8 +413,9 @@ public sealed unsafe partial class WbDrawDispatcher
: 1f - translucency;
}
private void ClassifyPackedBatches(
private bool ClassifyPackedBatches(
ObjectRenderData renderData,
Matrix4x4 restPose,
Matrix4x4 model,
in RenderInstanceCandidate entity,
MeshRef meshRef,
@ -334,8 +424,10 @@ public sealed unsafe partial class WbDrawDispatcher
InstanceLightSet lights,
bool indoor,
Vector2 selectionLighting,
float opacity)
float opacity,
PackedProjectionClassificationEntry? cacheEntry)
{
bool reusableAcrossFrames = true;
for (int batchIndex = 0;
batchIndex < renderData.Batches.Count;
batchIndex++)
@ -351,7 +443,9 @@ public sealed unsafe partial class WbDrawDispatcher
meshRef,
batch,
paletteIdentity,
out _);
out bool compositePending);
if (compositePending)
reusableAcrossFrames = false;
if (texture.Handle == 0)
continue;
@ -363,17 +457,78 @@ public sealed unsafe partial class WbDrawDispatcher
texture.Layer,
translucency,
batch.CullMode);
InstanceGroup group = GetOrCreatePackedGroup(key);
group.Matrices.Add(model);
group.LocalSortCenters.Add(renderData.SortCenter);
group.SubmissionOrders.Add(
_nextPackedInstanceSubmissionOrder++);
group.Slots.Add(slot);
group.LightSets.Add(lights);
group.IndoorFlags.Add(indoor ? 1u : 0u);
group.Opacities.Add(opacity);
group.SelectionLighting.Add(selectionLighting);
var classified = new PackedClassifiedBatch(
key,
restPose,
renderData.SortCenter);
cacheEntry?.Batches.Add(classified);
AppendPackedClassification(
in classified,
model,
slot,
lights,
indoor,
opacity,
selectionLighting);
}
return reusableAcrossFrames;
}
private void ReplayPackedClassification(
PackedProjectionClassificationEntry entry,
in RenderInstanceCandidate entity,
uint slot,
InstanceLightSet lights,
bool indoor,
Vector2 selectionLighting)
{
for (int i = 0; i < entry.Batches.Count; i++)
{
PackedClassifiedBatch classified = entry.Batches[i];
AppendPackedClassification(
in classified,
classified.RestPose * entity.RootWorld,
slot,
lights,
indoor,
opacity: 1f,
selectionLighting);
}
for (int i = 0; i < entry.SelectionParts.Count; i++)
{
PackedClassifiedSelectionPart part =
entry.SelectionParts[i];
AddPackedSelectionPart(
in entity,
part.PartIndex,
part.GfxObjId,
part.RestPose * entity.RootWorld);
}
}
private void AppendPackedClassification(
in PackedClassifiedBatch classified,
Matrix4x4 model,
uint slot,
InstanceLightSet lights,
bool indoor,
float opacity,
Vector2 selectionLighting)
{
InstanceGroup group =
GetOrCreatePackedGroup(classified.Key);
group.Matrices.Add(model);
group.LocalSortCenters.Add(
classified.LocalSortCenter);
group.SubmissionOrders.Add(
_nextPackedInstanceSubmissionOrder++);
group.Slots.Add(slot);
group.LightSets.Add(lights);
group.IndoorFlags.Add(indoor ? 1u : 0u);
group.Opacities.Add(opacity);
group.SelectionLighting.Add(selectionLighting);
}
private InstanceGroup GetOrCreatePackedGroup(GroupKey key)

View file

@ -73,6 +73,26 @@ public sealed class RenderSceneShadowRuntimeTests
Assert.Contains("sourceChannel=static", snapshot.FirstMismatch);
Assert.Contains("field=transform", snapshot.FirstMismatch);
Assert.Single(logs);
Assert.Equal(1, snapshot.SceneIndexCounts.Dirty);
}
[Fact]
public void PackedProductOwnership_LeavesDirtyAcknowledgementToProduct()
{
WorldEntity entity = Entity(23, serverGuid: 0, parentCell: null);
CurrentRenderSceneOracle oracle = Capture(entity, []);
using var shadow = new RenderSceneShadowRuntime(Generation(1));
Register(shadow, entity, RenderProjectionClass.OutdoorStatic);
shadow.DrainUpdateBoundary();
var controller = new RenderSceneShadowComparisonController(
shadow,
oracle,
acknowledgeDirty: false);
controller.CompareNow();
Assert.Equal(1uL, controller.Snapshot.SuccessfulComparisonCount);
Assert.Equal(1, controller.Snapshot.SceneIndexCounts.Dirty);
}
[Fact]

View file

@ -0,0 +1,221 @@
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Wb;
using Xunit;
namespace AcDream.App.Tests.Rendering.Wb;
public sealed class PackedProjectionClassificationCacheTests
{
[Fact]
public void UnchangedProjection_ReusesClassificationAcrossFrames()
{
var cache = new PackedProjectionClassificationCache();
RenderSceneGeneration generation =
RenderSceneGeneration.FromRaw(1);
RenderProjectionId id = RenderProjectionId.FromRaw(0x1234);
PackedClassificationIdentity identity = Identity(7);
cache.BeginFrame(generation);
PackedProjectionClassificationEntry entry =
cache.BeginRebuild(id, in identity);
cache.CompleteRebuild(entry, reusableAcrossFrames: true);
cache.EndFrame();
cache.BeginFrame(generation);
Assert.True(cache.TryGetReusable(
id,
in identity,
RenderDirtyMask.Transform,
out PackedProjectionClassificationEntry? reused));
cache.EndFrame();
Assert.Same(entry, reused);
Assert.Equal(0, cache.Snapshot.StaticRebuildCount);
Assert.Equal(1, cache.Snapshot.CrossFrameReuseCount);
Assert.Equal(1, cache.Snapshot.ProjectionCount);
}
[Fact]
public void AppearanceDirty_RequiresRebuildButTransformDirtyDoesNot()
{
var cache = new PackedProjectionClassificationCache();
RenderSceneGeneration generation =
RenderSceneGeneration.FromRaw(1);
RenderProjectionId id = RenderProjectionId.FromRaw(0x1234);
PackedClassificationIdentity identity = Identity(7);
cache.BeginFrame(generation);
PackedProjectionClassificationEntry entry =
cache.BeginRebuild(id, in identity);
cache.CompleteRebuild(entry, reusableAcrossFrames: true);
cache.EndFrame();
cache.BeginFrame(generation);
Assert.False(cache.TryGetReusable(
id,
in identity,
RenderDirtyMask.Appearance,
out _));
PackedProjectionClassificationEntry rebuilt =
cache.BeginRebuild(id, in identity);
cache.CompleteRebuild(rebuilt, reusableAcrossFrames: true);
Assert.Same(entry, rebuilt);
Assert.Equal(1, cache.Snapshot.StaticRebuildCount);
}
[Fact]
public void IncompleteBuild_CanReplayDuplicateRouteOnlyInSameFrame()
{
var cache = new PackedProjectionClassificationCache();
RenderSceneGeneration generation =
RenderSceneGeneration.FromRaw(1);
RenderProjectionId id = RenderProjectionId.FromRaw(0x1234);
PackedClassificationIdentity identity = Identity(7);
cache.BeginFrame(generation);
PackedProjectionClassificationEntry entry =
cache.BeginRebuild(id, in identity);
cache.CompleteRebuild(entry, reusableAcrossFrames: false);
Assert.True(cache.TryGetReusable(
id,
in identity,
RenderDirtyMask.All,
out _));
Assert.Equal(1, cache.Snapshot.SameFrameReuseCount);
cache.EndFrame();
cache.BeginFrame(generation);
Assert.False(cache.TryGetReusable(
id,
in identity,
RenderDirtyMask.None,
out _));
}
[Fact]
public void IncarnationOrAppearanceChange_CannotReuseRetainedPayload()
{
var cache = new PackedProjectionClassificationCache();
RenderSceneGeneration generation =
RenderSceneGeneration.FromRaw(1);
RenderProjectionId id = RenderProjectionId.FromRaw(0x1234);
PackedClassificationIdentity original = Identity(7);
cache.BeginFrame(generation);
PackedProjectionClassificationEntry entry =
cache.BeginRebuild(id, in original);
cache.CompleteRebuild(entry, reusableAcrossFrames: true);
cache.EndFrame();
cache.BeginFrame(generation);
PackedClassificationIdentity replacement = Identity(17);
Assert.False(cache.TryGetReusable(
id,
in replacement,
RenderDirtyMask.None,
out _));
PackedProjectionClassificationEntry rebuilt =
cache.BeginRebuild(id, in replacement);
cache.CompleteRebuild(rebuilt, reusableAcrossFrames: true);
cache.EndFrame();
Assert.Same(entry, rebuilt);
Assert.Equal(replacement, rebuilt.Identity);
Assert.Equal(1, cache.Snapshot.StaticRebuildCount);
Assert.Equal(0, cache.Snapshot.CrossFrameReuseCount);
}
[Fact]
public void GenerationReplacementAndUnseenProjectionReleaseRetainedEntries()
{
var cache = new PackedProjectionClassificationCache();
RenderProjectionId id = RenderProjectionId.FromRaw(0x1234);
PackedClassificationIdentity identity = Identity(7);
cache.BeginFrame(RenderSceneGeneration.FromRaw(1));
PackedProjectionClassificationEntry entry =
cache.BeginRebuild(id, in identity);
entry.Batches.Capacity = 32;
entry.SelectionParts.Capacity = 16;
cache.CompleteRebuild(entry, reusableAcrossFrames: true);
cache.EndFrame();
Assert.True(cache.Snapshot.RetainedPayloadBytes > 0);
cache.BeginFrame(RenderSceneGeneration.FromRaw(1));
cache.EndFrame();
Assert.Equal(0, cache.Snapshot.ProjectionCount);
Assert.Equal(1, cache.Snapshot.RetiredProjectionCount);
Assert.Equal(0, entry.Batches.Capacity);
Assert.Equal(0, entry.SelectionParts.Capacity);
Assert.Equal(0, cache.Snapshot.RetainedPayloadBytes);
cache.BeginFrame(RenderSceneGeneration.FromRaw(2));
Assert.Equal(0, cache.Snapshot.ProjectionCount);
}
[Fact]
public void WarmUnchangedProjectionCache_AllocatesZeroBytes()
{
var cache = new PackedProjectionClassificationCache();
RenderSceneGeneration generation =
RenderSceneGeneration.FromRaw(1);
RenderProjectionId id = RenderProjectionId.FromRaw(0x1234);
PackedClassificationIdentity identity = Identity(7);
cache.BeginFrame(generation);
PackedProjectionClassificationEntry entry =
cache.BeginRebuild(id, in identity);
cache.CompleteRebuild(entry, reusableAcrossFrames: true);
cache.EndFrame();
for (int i = 0; i < 100; i++)
Hit(cache, generation, id, identity);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1_000; i++)
Hit(cache, generation, id, identity);
long allocated =
GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
}
private static void Hit(
PackedProjectionClassificationCache cache,
RenderSceneGeneration generation,
RenderProjectionId id,
PackedClassificationIdentity identity)
{
cache.BeginFrame(generation);
if (!cache.TryGetReusable(
id,
in identity,
RenderDirtyMask.None,
out _))
{
throw new InvalidOperationException(
"The warmed projection classification was not reusable.");
}
cache.EndFrame();
}
private static PackedClassificationIdentity Identity(ulong seed) =>
new(
RenderOwnerIncarnation.FromRaw(seed),
new RenderMeshSet(
RenderAssetHandle.FromRaw(seed + 1),
MeshCount: 2,
Revision: seed + 2),
new RenderMaterialVariant(
PaletteKey: seed + 3,
TextureReplacementKey: seed + 4,
Opacity: 1f),
new RenderDegradeState(
Level: 0,
Revision: (uint)(seed + 5)),
new RenderSceneHash128(seed + 6, seed + 7),
new RenderSceneHash128(seed + 8, seed + 9));
}