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)); +}