From 54d17eb446dec6a0d8ff5b7bb938728c569b0782 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Jul 2026 02:46:40 +0200 Subject: [PATCH] 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. --- .../Composition/FrameRootComposition.cs | 3 +- .../Scene/RenderScenePViewFrameProduct.cs | 51 +++- .../Scene/RenderSceneShadowRuntime.cs | 8 +- .../Wb/PackedProjectionClassificationCache.cs | 251 ++++++++++++++++++ .../Wb/WbDrawDispatcher.PackedOracle.cs | 207 +++++++++++++-- .../RenderSceneShadowRuntimeTests.cs | 20 ++ ...ackedProjectionClassificationCacheTests.cs | 221 +++++++++++++++ 7 files changed, 731 insertions(+), 30 deletions(-) create mode 100644 src/AcDream.App/Rendering/Wb/PackedProjectionClassificationCache.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/PackedProjectionClassificationCacheTests.cs diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 6cc6ca88..8ecad939 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -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 diff --git a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs index ee06e47f..d210fab9 100644 --- a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs +++ b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs @@ -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 expected = _current.DispatcherSubmissions; IReadOnlyList 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, diff --git a/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs b/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs index 23cef3c7..25ab889f 100644 --- a/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs +++ b/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs @@ -223,6 +223,7 @@ internal sealed class RenderSceneShadowComparisonController : private readonly RenderSceneShadowRuntime _shadow; private readonly CurrentRenderSceneOracle _oracle; private readonly Action? _log; + private readonly bool _acknowledgeDirty; private readonly List _sceneRecords = []; private readonly HashSet _expectedIds = []; private ulong _renderFrameSequence; @@ -236,11 +237,13 @@ internal sealed class RenderSceneShadowComparisonController : public RenderSceneShadowComparisonController( RenderSceneShadowRuntime shadow, CurrentRenderSceneOracle oracle, - Action? log = null) + Action? 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(); } diff --git a/src/AcDream.App/Rendering/Wb/PackedProjectionClassificationCache.cs b/src/AcDream.App/Rendering/Wb/PackedProjectionClassificationCache.cs new file mode 100644 index 00000000..84142e63 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/PackedProjectionClassificationCache.cs @@ -0,0 +1,251 @@ +using System.Numerics; +using System.Runtime.CompilerServices; +using AcDream.App.Rendering.Scene; + +namespace AcDream.App.Rendering.Wb; + +/// +/// 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. +/// +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); + +/// +/// 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. +/// +internal sealed class PackedProjectionClassificationEntry +{ + public PackedClassificationIdentity Identity; + public readonly List Batches = []; + public readonly List 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); + +/// +/// 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. +/// +internal sealed class PackedProjectionClassificationCache +{ + private const RenderDirtyMask ClassificationDirtyMask = + RenderDirtyMask.Appearance; + + private readonly Dictionary< + RenderProjectionId, + PackedProjectionClassificationEntry> _entries = []; + private readonly List _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() + + (long)entry.SelectionParts.Capacity + * Unsafe.SizeOf()); + _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; + } +} diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs index c5634063..00b19924 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs @@ -30,6 +30,8 @@ public sealed unsafe partial class WbDrawDispatcher private readonly List _packedSelectionParts = []; private readonly HashSet _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 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 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 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); + } } /// @@ -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) diff --git a/tests/AcDream.App.Tests/Rendering/RenderSceneShadowRuntimeTests.cs b/tests/AcDream.App.Tests/Rendering/RenderSceneShadowRuntimeTests.cs index c8f352c1..7c91a890 100644 --- a/tests/AcDream.App.Tests/Rendering/RenderSceneShadowRuntimeTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RenderSceneShadowRuntimeTests.cs @@ -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] diff --git a/tests/AcDream.App.Tests/Rendering/Wb/PackedProjectionClassificationCacheTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/PackedProjectionClassificationCacheTests.cs new file mode 100644 index 00000000..b0176135 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/PackedProjectionClassificationCacheTests.cs @@ -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)); +}