From d8d9897376a7f486af1cf2e1d4dbf0f4284565b0 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Jul 2026 00:44:58 +0200 Subject: [PATCH] feat(rendering): pack scene dispatcher inputs Capture immutable presentation payloads in the render scene, flatten unique entity and mesh-part records into the borrowed frame arena, and compare every routed dispatcher input tuple without changing the production draw source. --- ...-modern-runtime-slices-f-g-render-scene.md | 2 +- .../Rendering/RetailPViewRenderer.cs | 2 + .../Rendering/Scene/Arch/ArchRenderScene.cs | 6 +- .../Scene/CurrentRenderSceneOracle.cs | 66 +++- .../Rendering/Scene/RenderFrameProduct.cs | 87 ++++- .../Scene/RenderProjectionJournal.cs | 1 + .../Scene/RenderProjectionRecordFactory.cs | 6 +- .../Rendering/Scene/RenderSceneContracts.cs | 16 +- .../Scene/RenderScenePViewFrameProduct.cs | 318 +++++++++++++++++- .../Scene/StaticRenderProjectionJournal.cs | 12 + .../CurrentRenderSceneOracleTests.cs | 35 ++ .../Rendering/RenderFrameProductTests.cs | 17 +- .../RenderScenePViewFrameProductTests.cs | 49 ++- 13 files changed, 593 insertions(+), 24 deletions(-) diff --git a/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md b/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md index bc3fe942..3d01a300 100644 --- a/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md +++ b/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md @@ -25,7 +25,7 @@ retail-faithful gameplay | 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 — 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 | complete | Exact `e346f8bb` passed capped and uncapped nine-stop routes plus uncapped dense Arwic: 29,392 + 29,025 + 1,522 same-frame candidate comparisons, zero mismatches, equal counts/digests at every checkpoint, exact binary/source identity, and graceful shutdown. Evidence: `docs/research/2026-07-25-slice-g1-scene-query-candidate-gate.md`. | -| G2 — packed dispatcher input | active | Convert the accepted scene candidates into an acdream-owned packed dispatcher input and compare its complete instance payload against the current WorldEntity boundary without drawing twice. | +| G2 — packed dispatcher input | source-packing gate active | The scene now retains an immutable presentation payload and the frame product flattens each unique projection into entity and mesh-part spans, including palette and surface-override identity. The compare-only controller expands exact PView routes and checks every current dispatcher input tuple without drawing twice. The full Release gate is 3,746 App tests / 3 skips and 8,230 solution tests / 5 skips. Connected source-input parity and final instance-output parity remain before G2 closes. | | G3–G5 | pending | No production consumer has switched. | The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0 is diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 001103f3..b8d67ca4 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -257,6 +257,8 @@ public sealed class RetailPViewRenderer _lookInFrames, drawableCells, ctx.Cells, + ctx.AnimatedEntityIds, + ctx.PlayerLandblockId ?? 0, ctx.RootCell.IsOutdoorNode); return result; } diff --git a/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs b/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs index 3e9e6e31..48ef28d2 100644 --- a/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs +++ b/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs @@ -430,6 +430,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource _world.Set(entry.Entity, record.Material); _world.Set(entry.Entity, record.DegradeState); _world.Set(entry.Entity, record.Source); + _world.Set(entry.Entity, record.EntityPayload); OrDirty( entry.Entity, record.Id, @@ -528,6 +529,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource record.OwnerIncarnation, record.DirtyMask, record.Source, + record.EntityPayload, tag); private void WriteRecord( @@ -546,6 +548,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource _world.Set(entity, record.OwnerIncarnation); _world.Set(entity, record.DirtyMask); _world.Set(entity, record.Source); + _world.Set(entity, record.EntityPayload); } private RenderProjectionRecord ReadRecord(in SceneEntry entry) => @@ -563,7 +566,8 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource _world.Get(entry.Entity), _world.Get(entry.Entity), _world.Get(entry.Entity), - _world.Get(entry.Entity)); + _world.Get(entry.Entity), + _world.Get(entry.Entity)); private void Destroy(in SceneEntry entry) { diff --git a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs index a1538c54..5eb873b2 100644 --- a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs +++ b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs @@ -50,7 +50,10 @@ internal readonly record struct CurrentRenderDispatcherFingerprint( int MeshRefIndex, uint TupleLandblockId, uint CacheLandblockId, - CurrentRenderProjectionFingerprint Projection); + CurrentRenderProjectionFingerprint Projection, + uint GfxObjId, + Matrix4x4 PartTransform, + RenderSceneHash128 SurfaceOverrides); internal readonly record struct CurrentRenderDispatcherSubmission( int VisibleInstanceCount, @@ -535,7 +538,13 @@ internal sealed class CurrentRenderSceneOracle : MeshRefIndex: tuple.MeshRefIndex, TupleLandblockId: tuple.LandblockId, CacheLandblockId: cacheLandblockId, - Projection: projection); + Projection: projection, + GfxObjId: tuple.Entity.MeshRefs[tuple.MeshRefIndex].GfxObjId, + PartTransform: + tuple.Entity.MeshRefs[tuple.MeshRefIndex].PartTransform, + SurfaceOverrides: CreateSurfaceOverrideFingerprint( + tuple.Entity.MeshRefs[tuple.MeshRefIndex] + .SurfaceOverrides)); _dispatcherCandidates.Add(candidate); AddDispatcherCandidate(ref _dispatcherHash, in candidate); } @@ -730,6 +739,7 @@ internal sealed class CurrentRenderSceneOracle : MeshRef mesh = entity.MeshRefs[meshIndex]; geometry.Add(mesh.GfxObjId); geometry.Add(mesh.PartTransform); + AddSurfaceOverrides(ref geometry, mesh.SurfaceOverrides); } StableRenderHash128 appearance = StableRenderHash128.Create(); @@ -795,6 +805,54 @@ internal sealed class CurrentRenderSceneOracle : Appearance: appearance.Finish()); } + private static void AddSurfaceOverrides( + ref StableRenderHash128 geometry, + IReadOnlyDictionary? overrides) + { + RenderSceneHash128 fingerprint = + CreateSurfaceOverrideFingerprint(overrides); + geometry.Add(fingerprint.Low); + geometry.Add(fingerprint.High); + } + + internal static RenderSceneHash128 CreateSurfaceOverrideFingerprint( + IReadOnlyDictionary? overrides) + { + if (overrides is null) + { + StableRenderHash128 missing = StableRenderHash128.Create(); + missing.Add(-1); + return missing.Finish(); + } + + ulong xorLow = 0; + ulong xorHigh = 0; + ulong sumLow = 0; + ulong sumHigh = 0; + foreach ((uint surfaceId, uint replacementId) in overrides) + { + StableRenderHash128 pair = StableRenderHash128.Create(); + pair.Add(surfaceId); + pair.Add(replacementId); + RenderSceneHash128 value = pair.Finish(); + xorLow ^= value.Low; + xorHigh ^= value.High; + unchecked + { + sumLow += value.Low; + sumHigh += value.High; + } + } + + StableRenderHash128 geometry = StableRenderHash128.Create(); + geometry.Add(overrides.Count); + geometry.Add(xorLow); + geometry.Add(xorHigh); + geometry.Add(sumLow); + geometry.Add(sumHigh); + return geometry.Finish(); + } + private static RenderSceneHash128 FingerprintSelectionGeometry( RetailSelectionMesh mesh) { @@ -854,6 +912,10 @@ internal sealed class CurrentRenderSceneOracle : hash.Add(candidate.MeshRefIndex); hash.Add(candidate.TupleLandblockId); hash.Add(candidate.CacheLandblockId); + hash.Add(candidate.GfxObjId); + hash.Add(candidate.PartTransform); + hash.Add(candidate.SurfaceOverrides.Low); + hash.Add(candidate.SurfaceOverrides.High); CurrentRenderProjectionFingerprint projection = candidate.Projection; AddProjection(ref hash, in projection); } diff --git a/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs index 2a73f7e7..db928b46 100644 --- a/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs +++ b/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.Core.Selection; +using AcDream.Core.World; namespace AcDream.App.Rendering.Scene; @@ -36,6 +37,17 @@ internal readonly record struct RenderFrameTransformRecord( int PartIndex, Matrix4x4 LocalToWorld); +internal readonly record struct RenderFrameEntityCandidate( + RenderProjectionRecord Projection, + int MeshPartOffset, + int MeshPartCount, + bool Animated); + +internal readonly record struct RenderFrameMeshPart( + RenderProjectionId ProjectionId, + int PartIndex, + MeshRef MeshRef); + internal readonly record struct RenderFrameClassificationRecord( RenderProjectionId ProjectionId, int CandidateIndex, @@ -77,7 +89,9 @@ internal readonly record struct RenderFrameDiagnosticCounts( int AlphaClassificationCount, int LightSetCount, int SelectionPartCount, - int RouteCandidateCount); + int RouteCandidateCount, + int EntityCandidateCount, + int MeshPartCount); /// /// A validated borrowed view over one reusable frame arena. Copies are safe: @@ -133,6 +147,12 @@ internal readonly struct RenderFrameView public ReadOnlySpan Transforms => Arena.Transforms; + public ReadOnlySpan EntityCandidates => + Arena.EntityCandidates; + + public ReadOnlySpan MeshParts => + Arena.MeshParts; + public ReadOnlySpan Classifications => Arena.Classifications; @@ -192,6 +212,8 @@ internal sealed class RenderFrameArena private RenderFrameCellRange[] _cellRanges = []; private RenderProjectionRecord[] _dynamics = []; private RenderFrameTransformRecord[] _transforms = []; + private RenderFrameEntityCandidate[] _entityCandidates = []; + private RenderFrameMeshPart[] _meshParts = []; private RenderFrameClassificationRecord[] _classifications = []; private RenderFrameObjectLightSet[] _lightSets = []; private RenderFrameSelectionPart[] _selectionParts = []; @@ -202,6 +224,8 @@ internal sealed class RenderFrameArena private int _cellRangeCount; private int _dynamicCount; private int _transformCount; + private int _entityCandidateCount; + private int _meshPartCount; private int _classificationCount; private int _lightSetCount; private int _selectionPartCount; @@ -234,7 +258,9 @@ internal sealed class RenderFrameArena _alphaClassificationCount, _lightSetCount, _selectionPartCount, - _routeCandidateCount); + _routeCandidateCount, + _entityCandidateCount, + _meshPartCount); internal ReadOnlySpan OutdoorStaticCandidates => _outdoor.AsSpan(0, _outdoorCount); @@ -251,6 +277,12 @@ internal sealed class RenderFrameArena internal ReadOnlySpan Transforms => _transforms.AsSpan(0, _transformCount); + internal ReadOnlySpan EntityCandidates => + _entityCandidates.AsSpan(0, _entityCandidateCount); + + internal ReadOnlySpan MeshParts => + _meshParts.AsSpan(0, _meshPartCount); + internal ReadOnlySpan Classifications => _classifications.AsSpan(0, _classificationCount); @@ -284,6 +316,8 @@ internal sealed class RenderFrameArena _cellRangeCount = 0; _dynamicCount = 0; _transformCount = 0; + _entityCandidateCount = 0; + _meshPartCount = 0; _classificationCount = 0; _lightSetCount = 0; _selectionPartCount = 0; @@ -397,6 +431,46 @@ internal sealed class RenderFrameArena _transforms[_transformCount++] = transform; } + internal void AddEntityCandidate( + ulong epoch, + in RenderProjectionRecord projection, + bool animated) + { + EnsureBuilding(epoch); + IReadOnlyList? meshRefs = + projection.EntityPayload.MeshRefs; + int meshPartCount = meshRefs?.Count ?? 0; + if (meshPartCount != projection.MeshSet.MeshCount) + { + throw new InvalidOperationException( + $"Projection {projection.Id} declares " + + $"{projection.MeshSet.MeshCount} mesh parts but its " + + $"presentation payload contains {meshPartCount}."); + } + + int meshPartOffset = _meshPartCount; + EnsureCapacity( + ref _meshParts, + checked(meshPartOffset + meshPartCount)); + for (int partIndex = 0; partIndex < meshPartCount; partIndex++) + { + _meshParts[_meshPartCount++] = new RenderFrameMeshPart( + projection.Id, + partIndex, + meshRefs![partIndex]); + } + + EnsureCapacity( + ref _entityCandidates, + _entityCandidateCount + 1); + _entityCandidates[_entityCandidateCount++] = + new RenderFrameEntityCandidate( + projection, + meshPartOffset, + meshPartCount, + animated); + } + internal void AddClassification( ulong epoch, in RenderFrameClassificationRecord classification) @@ -529,6 +603,10 @@ internal sealed class RenderFrameArena { if (_selectionPartCount > 0) Array.Clear(_selectionParts, 0, _selectionPartCount); + if (_entityCandidateCount > 0) + Array.Clear(_entityCandidates, 0, _entityCandidateCount); + if (_meshPartCount > 0) + Array.Clear(_meshParts, 0, _meshPartCount); } private static void EnsureCapacity(ref T[] values, int required) @@ -603,6 +681,11 @@ internal readonly struct RenderFrameWriter public void AddTransform(in RenderFrameTransformRecord transform) => Arena.AddTransform(_epoch, in transform); + public void AddEntityCandidate( + in RenderProjectionRecord projection, + bool animated) => + Arena.AddEntityCandidate(_epoch, in projection, animated); + public void AddClassification( in RenderFrameClassificationRecord classification) => Arena.AddClassification(_epoch, in classification); diff --git a/src/AcDream.App/Rendering/Scene/RenderProjectionJournal.cs b/src/AcDream.App/Rendering/Scene/RenderProjectionJournal.cs index d8d8b72e..05bab25d 100644 --- a/src/AcDream.App/Rendering/Scene/RenderProjectionJournal.cs +++ b/src/AcDream.App/Rendering/Scene/RenderProjectionJournal.cs @@ -68,6 +68,7 @@ internal sealed class RenderProjectionJournal if (prior.MeshSet != current.MeshSet || prior.Material != current.Material || prior.DegradeState != current.DegradeState + || prior.EntityPayload != current.EntityPayload || prior.Source.GeometryFingerprint != current.Source.GeometryFingerprint || prior.Source.AppearanceFingerprint diff --git a/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs b/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs index 4442e17e..10f4c3a0 100644 --- a/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs +++ b/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs @@ -80,7 +80,11 @@ internal static class RenderProjectionRecordFactory fingerprint.Transform, fingerprint.Geometry, fingerprint.Appearance, - fingerprint.Flags)); + fingerprint.Flags), + new RenderEntityPayload( + entity.MeshRefs, + entity.PaletteOverride, + entity.IsBuildingShell)); } private static (Vector3 Minimum, Vector3 Maximum) CalculateBounds( diff --git a/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs b/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs index 8720bd5f..a0f96fa8 100644 --- a/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs +++ b/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs @@ -1,4 +1,5 @@ using System.Numerics; +using AcDream.Core.World; namespace AcDream.App.Rendering.Scene; @@ -194,6 +195,18 @@ internal readonly record struct RenderSourceMetadata( RenderSceneHash128 AppearanceFingerprint, uint CurrentProjectionFlags = 0); +/// +/// Borrowed immutable presentation payload captured at a scene publication +/// edge. WorldEntity replaces its MeshRefs collection when animated or +/// appearance state changes, so retaining this exact collection reference is +/// a snapshot rather than a callback into gameplay state. G2 copies it into +/// the reusable frame arena before any dispatcher consumer observes it. +/// +internal readonly record struct RenderEntityPayload( + IReadOnlyList MeshRefs, + PaletteOverride? PaletteOverride, + bool IsBuildingShell); + internal readonly record struct RenderProjectionRecord( RenderProjectionId Id, RenderProjectionClass ProjectionClass, @@ -208,7 +221,8 @@ internal readonly record struct RenderProjectionRecord( RenderDegradeState DegradeState, RenderSortKey SortKey, RenderDirtyMask DirtyMask, - RenderSourceMetadata Source = default); + RenderSourceMetadata Source = default, + RenderEntityPayload EntityPayload = default); internal enum RenderProjectionDeltaKind : byte { diff --git a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs index 2304beb2..7a5f673b 100644 --- a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs +++ b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs @@ -1,4 +1,5 @@ using System.Numerics; +using AcDream.App.Rendering.Wb; namespace AcDream.App.Rendering.Scene; @@ -11,6 +12,7 @@ internal readonly record struct RenderScenePViewBuildInput( IReadOnlyList LookInFrames, HashSet DrawableCells, IRetailPViewCellSource Cells, + HashSet? AnimatedEntityIds, bool RootIsOutdoor); internal readonly record struct RenderFrameProductComparisonSnapshot( @@ -25,6 +27,14 @@ internal readonly record struct RenderFrameProductComparisonSnapshot( string? FirstMismatch, RenderSceneHash128 ExpectedDigest, RenderSceneHash128 ActualDigest, + ulong PackedInputComparisonCount, + ulong PackedInputSuccessfulComparisonCount, + int PackedInputExpectedCount, + int PackedInputActualCount, + long PackedInputMismatchCount, + string? PackedInputFirstMismatch, + RenderSceneHash128 PackedInputExpectedDigest, + RenderSceneHash128 PackedInputActualDigest, RenderFrameDiagnosticCounts ProductCounts) { public static RenderFrameProductComparisonSnapshot Disabled { get; } = @@ -40,6 +50,14 @@ internal readonly record struct RenderFrameProductComparisonSnapshot( FirstMismatch: null, ExpectedDigest: RenderSceneHash128.Empty, ActualDigest: RenderSceneHash128.Empty, + PackedInputComparisonCount: 0, + PackedInputSuccessfulComparisonCount: 0, + PackedInputExpectedCount: 0, + PackedInputActualCount: 0, + PackedInputMismatchCount: 0, + PackedInputFirstMismatch: null, + PackedInputExpectedDigest: RenderSceneHash128.Empty, + PackedInputActualDigest: RenderSceneHash128.Empty, ProductCounts: default); } @@ -63,12 +81,18 @@ internal sealed class RenderScenePViewFrameProductController : private readonly RenderFrameExchange _exchange = new(); private readonly List _expected = []; private readonly List _actual = []; + private readonly List _packedExpected = []; + private readonly List _packedActual = []; private readonly Action? _log; private ulong _productFrameSequence; private ulong _comparisonCount; private ulong _successfulComparisonCount; private long _mismatchCount; private string? _firstMismatch; + private ulong _packedInputComparisonCount; + private ulong _packedInputSuccessfulComparisonCount; + private long _packedInputMismatchCount; + private string? _packedInputFirstMismatch; public RenderScenePViewFrameProductController( RenderSceneShadowRuntime shadow, @@ -93,6 +117,8 @@ internal sealed class RenderScenePViewFrameProductController : IReadOnlyList lookInFrames, HashSet drawableCells, IRetailPViewCellSource cells, + HashSet? animatedEntityIds, + uint tupleLandblockId, bool rootIsOutdoor) { ArgumentNullException.ThrowIfNull(portalFrame); @@ -113,6 +139,7 @@ internal sealed class RenderScenePViewFrameProductController : lookInFrames, drawableCells, cells, + animatedEntityIds, rootIsOutdoor); _builder.Build(_exchange, frameSequence, in input); RenderFrameView view = _exchange.BorrowLatest( @@ -120,7 +147,7 @@ internal sealed class RenderScenePViewFrameProductController : frameSequence); try { - Compare(in view); + Compare(in view, tupleLandblockId); } finally { @@ -128,7 +155,7 @@ internal sealed class RenderScenePViewFrameProductController : } } - private void Compare(in RenderFrameView view) + private void Compare(in RenderFrameView view, uint tupleLandblockId) { _comparisonCount = checked(_comparisonCount + 1); _expected.Clear(); @@ -196,6 +223,9 @@ internal sealed class RenderScenePViewFrameProductController : } } + PackedInputComparison packed = + ComparePackedInput(in view, tupleLandblockId); + Snapshot = new RenderFrameProductComparisonSnapshot( Enabled: true, ProductFrameSequence: view.FrameSequence, @@ -209,9 +239,184 @@ internal sealed class RenderScenePViewFrameProductController : FirstMismatch: _firstMismatch, ExpectedDigest: expectedDigest, ActualDigest: actualDigest, + PackedInputComparisonCount: _packedInputComparisonCount, + PackedInputSuccessfulComparisonCount: + _packedInputSuccessfulComparisonCount, + PackedInputExpectedCount: _packedExpected.Count, + PackedInputActualCount: _packedActual.Count, + PackedInputMismatchCount: _packedInputMismatchCount, + PackedInputFirstMismatch: _packedInputFirstMismatch, + PackedInputExpectedDigest: packed.ExpectedDigest, + PackedInputActualDigest: packed.ActualDigest, ProductCounts: view.DiagnosticCounts); } + private PackedInputComparison ComparePackedInput( + in RenderFrameView view, + uint tupleLandblockId) + { + _packedInputComparisonCount = + checked(_packedInputComparisonCount + 1); + _packedExpected.Clear(); + _packedActual.Clear(); + + IReadOnlyList expected = + _current.DispatcherCandidates; + if (_packedExpected.Capacity < expected.Count) + _packedExpected.Capacity = expected.Count; + for (int index = 0; index < expected.Count; index++) + { + CurrentRenderDispatcherFingerprint candidate = expected[index]; + CurrentRenderProjectionFingerprint projection = + candidate.Projection; + _packedExpected.Add(new PackedInputKey( + candidate.DrawSequence, + candidate.Set, + ExpectedId(in projection), + candidate.MeshRefIndex, + candidate.TupleLandblockId, + candidate.CacheLandblockId, + projection.EntityId, + projection.ServerGuid, + projection.SourceId, + projection.ParentCellId, + projection.EffectCellId, + projection.Flags, + projection.Transform, + projection.Geometry, + projection.Appearance, + candidate.GfxObjId, + candidate.PartTransform, + candidate.SurfaceOverrides)); + } + + ReadOnlySpan ranges = view.RouteRanges; + ReadOnlySpan routeCandidates = + view.RouteCandidates; + if (_packedActual.Capacity < view.MeshParts.Length) + _packedActual.Capacity = view.MeshParts.Length; + for (int drawSequence = 0; + drawSequence < ranges.Length; + drawSequence++) + { + RenderFrameCandidateRange range = ranges[drawSequence]; + int end = checked(range.Offset + range.Count); + for (int candidateIndex = range.Offset; + candidateIndex < end; + candidateIndex++) + { + RenderProjectionRecord projection = + routeCandidates[candidateIndex]; + if ((projection.Flags & RenderProjectionFlags.Draw) == 0) + continue; + + IReadOnlyList? meshRefs = + projection.EntityPayload.MeshRefs; + int meshCount = meshRefs?.Count ?? 0; + for (int partIndex = 0; + partIndex < meshCount; + partIndex++) + { + AcDream.Core.World.MeshRef meshRef = + meshRefs![partIndex]; + _packedActual.Add(new PackedInputKey( + drawSequence, + WbDrawDispatcher.EntitySet.All, + projection.Id, + partIndex, + tupleLandblockId, + DispatcherCacheLandblock( + in projection, + tupleLandblockId), + projection.Source.LocalEntityId, + projection.Source.ServerGuid, + projection.Source.SourceId, + projection.Source.ParentCellId, + projection.Source.EffectCellId, + projection.Source.CurrentProjectionFlags, + projection.Source.TransformFingerprint, + projection.Source.GeometryFingerprint, + projection.Source.AppearanceFingerprint, + meshRef.GfxObjId, + meshRef.PartTransform, + CurrentRenderSceneOracle + .CreateSurfaceOverrideFingerprint( + meshRef.SurfaceOverrides))); + } + } + } + + _packedExpected.Sort(PackedInputKeyComparer.Instance); + _packedActual.Sort(PackedInputKeyComparer.Instance); + RenderSceneHash128 expectedDigest = + BuildPackedInputDigest(_packedExpected); + RenderSceneHash128 actualDigest = + BuildPackedInputDigest(_packedActual); + string? mismatch = ComparePackedInputKeys(ranges.Length); + if (mismatch is null) + { + _packedInputSuccessfulComparisonCount = + checked(_packedInputSuccessfulComparisonCount + 1); + } + else + { + _packedInputMismatchCount = + checked(_packedInputMismatchCount + 1); + if (_packedInputFirstMismatch is null) + { + _packedInputFirstMismatch = mismatch; + _log?.Invoke( + "[render-packed-input] first mismatch: " + mismatch); + } + } + + return new PackedInputComparison(expectedDigest, actualDigest); + } + + private static uint DispatcherCacheLandblock( + in RenderProjectionRecord projection, + uint tupleLandblockId) => + projection.Source.ParentCellId != 0 + ? (projection.Source.ParentCellId & 0xFFFF0000u) | 0xFFFFu + : tupleLandblockId; + + private string? ComparePackedInputKeys(int actualDrawCount) + { + if (_current.Snapshot.DispatcherDrawCount != actualDrawCount) + { + return $"field=drawCount expected=" + + $"{_current.Snapshot.DispatcherDrawCount} " + + $"actual={actualDrawCount}"; + } + if (_packedExpected.Count != _packedActual.Count) + { + return $"field=count expected={_packedExpected.Count} " + + $"actual={_packedActual.Count}"; + } + + for (int index = 0; index < _packedExpected.Count; index++) + { + if (_packedExpected[index] != _packedActual[index]) + { + return $"field=meshPart index={index} " + + $"expected={_packedExpected[index]} " + + $"actual={_packedActual[index]}"; + } + } + + return null; + } + + private static RenderSceneHash128 BuildPackedInputDigest( + List candidates) + { + StableRenderHash128 hash = StableRenderHash128.Create(); + hash.Add(candidates.Count); + for (int index = 0; index < candidates.Count; index++) + candidates[index].AddTo(ref hash); + return hash.Finish(); + } + private string? CompareKeys() { if (_expected.Count != _actual.Count) @@ -283,6 +488,57 @@ internal sealed class RenderScenePViewFrameProductController : uint CellId, RenderProjectionId ProjectionId); + private readonly record struct PackedInputComparison( + RenderSceneHash128 ExpectedDigest, + RenderSceneHash128 ActualDigest); + + private readonly record struct PackedInputKey( + int DrawSequence, + WbDrawDispatcher.EntitySet Set, + RenderProjectionId ProjectionId, + int MeshRefIndex, + uint TupleLandblockId, + uint CacheLandblockId, + uint LocalEntityId, + uint ServerGuid, + uint SourceId, + uint ParentCellId, + uint EffectCellId, + uint SourceFlags, + RenderSceneHash128 Transform, + RenderSceneHash128 Geometry, + RenderSceneHash128 Appearance, + uint GfxObjId, + Matrix4x4 PartTransform, + RenderSceneHash128 SurfaceOverrides) + { + public void AddTo(ref StableRenderHash128 hash) + { + hash.Add(DrawSequence); + hash.Add((int)Set); + ProjectionId.AddTo(ref hash); + hash.Add(MeshRefIndex); + hash.Add(TupleLandblockId); + hash.Add(CacheLandblockId); + hash.Add(LocalEntityId); + hash.Add(ServerGuid); + hash.Add(SourceId); + hash.Add(ParentCellId); + hash.Add(EffectCellId); + hash.Add(SourceFlags); + hash.Add(Transform.Low); + hash.Add(Transform.High); + hash.Add(Geometry.Low); + hash.Add(Geometry.High); + hash.Add(Appearance.Low); + hash.Add(Appearance.High); + hash.Add(GfxObjId); + hash.Add(PartTransform); + hash.Add(SurfaceOverrides.Low); + hash.Add(SurfaceOverrides.High); + } + } + private sealed class CandidateKeyComparer : IComparer { public static CandidateKeyComparer Instance { get; } = new(); @@ -302,6 +558,25 @@ internal sealed class RenderScenePViewFrameProductController : return left.ProjectionId.CompareTo(right.ProjectionId); } } + + private sealed class PackedInputKeyComparer : + IComparer + { + public static PackedInputKeyComparer Instance { get; } = new(); + + private PackedInputKeyComparer() + { + } + + public int Compare(PackedInputKey left, PackedInputKey right) + { + int value = left.DrawSequence.CompareTo(right.DrawSequence); + if (value != 0) return value; + value = left.ProjectionId.CompareTo(right.ProjectionId); + if (value != 0) return value; + return left.MeshRefIndex.CompareTo(right.MeshRefIndex); + } + } } /// @@ -313,7 +588,7 @@ internal sealed class RenderScenePViewFrameBuilder { private const byte EnvCellProjectionDomain = 2; - private readonly HashSet _transformIds = []; + private readonly HashSet _projectionIds = []; private RenderProjectionRecord[] _outdoor = []; private RenderProjectionRecord[] _dynamics = []; private RenderProjectionRecord[] _cell = []; @@ -338,7 +613,7 @@ internal sealed class RenderScenePViewFrameBuilder input.ClipAssembly); RenderSceneDigest digest = input.SourceDigest; writer.SetSourceDigest(in digest); - _transformIds.Clear(); + _projectionIds.Clear(); LoadSceneIndices(input.Scene); BuildOutdoorRoutes(writer, in input); @@ -398,7 +673,10 @@ internal sealed class RenderScenePViewFrameBuilder _survivors[count++] = record; writer.AddOutdoor(in record); - AddTransform(writer, in record); + AddProjection( + writer, + in record, + input.AnimatedEntityIds); } writer.AddRouteRange( @@ -429,7 +707,10 @@ internal sealed class RenderScenePViewFrameBuilder continue; for (int index = 0; index < count; index++) - AddTransform(writer, in _cell[index]); + AddProjection( + writer, + in _cell[index], + input.AnimatedEntityIds); writer.AddRouteRange( RenderFrameCandidateRoute.LookInObject, i, @@ -471,7 +752,10 @@ internal sealed class RenderScenePViewFrameBuilder _survivors[count++] = record; writer.AddDynamic(in record); - AddTransform(writer, in record); + AddProjection( + writer, + in record, + input.AnimatedEntityIds); } writer.AddRouteRange( @@ -512,7 +796,10 @@ internal sealed class RenderScenePViewFrameBuilder } _cell[count++] = record; - AddTransform(writer, in record); + AddProjection( + writer, + in record, + input.AnimatedEntityIds); } writer.AddCellRange( @@ -563,7 +850,10 @@ internal sealed class RenderScenePViewFrameBuilder _survivors[count++] = record; writer.AddDynamic(in record); - AddTransform(writer, in record); + AddProjection( + writer, + in record, + input.AnimatedEntityIds); } writer.AddRouteRange( @@ -598,16 +888,20 @@ internal sealed class RenderScenePViewFrameBuilder return CompactAndSort(_cell, count); } - private void AddTransform( + private void AddProjection( RenderFrameWriter writer, - in RenderProjectionRecord record) + in RenderProjectionRecord record, + HashSet? animatedEntityIds) { - if (!_transformIds.Add(record.Id)) + if (!_projectionIds.Add(record.Id)) return; writer.AddTransform(new RenderFrameTransformRecord( record.Id, -1, record.Transform.LocalToWorld)); + writer.AddEntityCandidate( + in record, + animatedEntityIds?.Contains(record.Source.LocalEntityId) == true); } private static int CompactAndSort( diff --git a/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs b/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs index 5925c2e4..b1512e4c 100644 --- a/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs +++ b/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs @@ -105,6 +105,18 @@ internal sealed class StaticRenderProjectionJournal : PreviousTransform = new PreviousRenderTransform( retained.Record.Transform.LocalToWorld), }; + if (accepted.Source.GeometryFingerprint + == retained.Record.Source.GeometryFingerprint + && accepted.Source.AppearanceFingerprint + == retained.Record.Source.AppearanceFingerprint + && accepted.EntityPayload.IsBuildingShell + == retained.Record.EntityPayload.IsBuildingShell) + { + accepted = accepted with + { + EntityPayload = retained.Record.EntityPayload, + }; + } if (accepted.ProjectionClass != retained.Record.ProjectionClass) { diff --git a/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs b/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs index 2c4e47db..b9ec14c5 100644 --- a/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs +++ b/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs @@ -406,6 +406,41 @@ public sealed class CurrentRenderSceneOracleTests } } + [Fact] + public void SurfaceOverrideFingerprint_IsOrderIndependentAndContentExact() + { + var first = new Dictionary + { + [0x0800_0002] = 0x0500_0012, + [0x0800_0001] = 0x0500_0011, + }; + var reordered = new Dictionary + { + [0x0800_0001] = 0x0500_0011, + [0x0800_0002] = 0x0500_0012, + }; + var changed = new Dictionary + { + [0x0800_0001] = 0x0500_0011, + [0x0800_0002] = 0x0500_0013, + }; + + RenderSceneHash128 expected = + CurrentRenderSceneOracle.CreateSurfaceOverrideFingerprint(first); + + Assert.Equal( + expected, + CurrentRenderSceneOracle.CreateSurfaceOverrideFingerprint( + reordered)); + Assert.NotEqual( + expected, + CurrentRenderSceneOracle.CreateSurfaceOverrideFingerprint( + changed)); + Assert.NotEqual( + expected, + CurrentRenderSceneOracle.CreateSurfaceOverrideFingerprint(null)); + } + private static CurrentRenderProjectionFingerprint CaptureSingle( WorldEntity entity, uint landblockId, diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameProductTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameProductTests.cs index d0876c6e..2291d317 100644 --- a/tests/AcDream.App.Tests/Rendering/RenderFrameProductTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RenderFrameProductTests.cs @@ -2,6 +2,7 @@ using System.Numerics; using AcDream.App.Rendering; using AcDream.App.Rendering.Scene; using AcDream.Core.Selection; +using AcDream.Core.World; namespace AcDream.App.Tests.Rendering; @@ -26,6 +27,7 @@ public sealed class RenderFrameProductTests dynamic.Id, 0, Matrix4x4.CreateTranslation(1, 2, 3))); + writer.AddEntityCandidate(in dynamic, animated: true); writer.AddClassification(new RenderFrameClassificationRecord( outdoor.Id, 0, @@ -85,8 +87,16 @@ public sealed class RenderFrameProductTests 1), view.RouteRanges[0]); Assert.Equal( - new RenderFrameDiagnosticCounts(1, 2, 1, 1, 1, 1, 1, 1, 1), + new RenderFrameDiagnosticCounts( + 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1), view.DiagnosticCounts); + RenderFrameEntityCandidate packed = Assert.Single( + view.EntityCandidates.ToArray()); + Assert.Equal(dynamic.Id, packed.Projection.Id); + Assert.True(packed.Animated); + RenderFrameMeshPart part = Assert.Single(view.MeshParts.ToArray()); + Assert.Equal(dynamic.Id, part.ProjectionId); + Assert.Equal(0, part.PartIndex); Assert.Equal(digest, view.SourceDigest); exchange.Release(in view); @@ -231,6 +241,7 @@ public sealed class RenderFrameProductTests record.Id, 0, Matrix4x4.Identity)); + writer.AddEntityCandidate(in record, animated: false); writer.AddClassification(new RenderFrameClassificationRecord( record.Id, 0, @@ -277,6 +288,10 @@ public sealed class RenderFrameProductTests Transform = new RenderTransform(Matrix4x4.Identity), PreviousTransform = new PreviousRenderTransform(Matrix4x4.Identity), Flags = RenderProjectionFlags.Draw, + EntityPayload = new RenderEntityPayload( + [new MeshRef((uint)value, Matrix4x4.Identity)], + PaletteOverride: null, + IsBuildingShell: false), }; private static RetailSelectionMesh SelectionMesh() => diff --git a/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs b/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs index 9dc93d43..10b2b482 100644 --- a/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs @@ -2,6 +2,7 @@ using System.Numerics; using AcDream.App.Rendering; using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Scene.Arch; +using AcDream.App.Rendering.Wb; using AcDream.Core.World; namespace AcDream.App.Tests.Rendering; @@ -81,6 +82,7 @@ public sealed class RenderScenePViewFrameProductTests [], [Cell], EmptyCellSource.Instance, + [], RootIsOutdoor: true); builder.Build(exchange, frameSequence: 1, in input); @@ -115,8 +117,12 @@ public sealed class RenderScenePViewFrameProductTests AlphaClassificationCount: 0, LightSetCount: 0, SelectionPartCount: 0, - RouteCandidateCount: 5), + RouteCandidateCount: 5, + EntityCandidateCount: 5, + MeshPartCount: 5), view.DiagnosticCounts); + Assert.Equal(5, view.EntityCandidates.Length); + Assert.Equal(5, view.MeshParts.Length); Assert.Same(portal, view.PortalFrame); Assert.Same(clip, view.ClipAssembly); Assert.Equal(digest, view.SourceDigest); @@ -162,6 +168,22 @@ public sealed class RenderScenePViewFrameProductTests 0, [outdoorDynamic, cellDynamic]); oracle.CompletePViewFrame(); + oracle.BeginDispatcherFrame(); + oracle.ObserveDispatcherDraw( + WbDrawDispatcher.EntitySet.All, + entitiesWalked: 1, + [(outdoor, 0, Landblock)]); + oracle.ObserveDispatcherDraw( + WbDrawDispatcher.EntitySet.All, + entitiesWalked: 1, + [(cellStatic, 0, Landblock)]); + oracle.ObserveDispatcherDraw( + WbDrawDispatcher.EntitySet.All, + entitiesWalked: 2, + [ + (outdoorDynamic, 0, Landblock), + (cellDynamic, 0, Landblock), + ]); using var shadow = new RenderSceneShadowRuntime(Generation); foreach (WorldEntity entity in entities) @@ -187,6 +209,8 @@ public sealed class RenderScenePViewFrameProductTests [], [Cell], EmptyCellSource.Instance, + [], + Landblock, rootIsOutdoor: true); RenderFrameProductComparisonSnapshot matched = controller.Snapshot; @@ -198,6 +222,15 @@ public sealed class RenderScenePViewFrameProductTests Assert.Equal(0, matched.MismatchCount); Assert.Null(matched.FirstMismatch); Assert.Equal(matched.ExpectedDigest, matched.ActualDigest); + Assert.Equal(1uL, matched.PackedInputComparisonCount); + Assert.Equal(1uL, matched.PackedInputSuccessfulComparisonCount); + Assert.Equal(4, matched.PackedInputExpectedCount); + Assert.Equal(4, matched.PackedInputActualCount); + Assert.Equal(0, matched.PackedInputMismatchCount); + Assert.Null(matched.PackedInputFirstMismatch); + Assert.Equal( + matched.PackedInputExpectedDigest, + matched.PackedInputActualDigest); Assert.Empty(logs); RenderProjectionId withdrawnId = @@ -213,6 +246,8 @@ public sealed class RenderScenePViewFrameProductTests [], [Cell], EmptyCellSource.Instance, + [], + Landblock, rootIsOutdoor: true); RenderFrameProductComparisonSnapshot mismatch = controller.Snapshot; @@ -220,7 +255,11 @@ public sealed class RenderScenePViewFrameProductTests Assert.Equal(1uL, mismatch.SuccessfulComparisonCount); Assert.Equal(1, mismatch.MismatchCount); Assert.Contains("field=count expected=4 actual=3", mismatch.FirstMismatch); - Assert.Single(logs); + Assert.Equal(1, mismatch.PackedInputMismatchCount); + Assert.Contains( + "field=count expected=4 actual=3", + mismatch.PackedInputFirstMismatch); + Assert.Equal(2, logs.Count); } private static RenderProjectionRecord Project(WorldEntity entity) @@ -286,7 +325,11 @@ public sealed class RenderScenePViewFrameProductTests BuildingShellAnchorCellId: 0, TransformFingerprint: default, GeometryFingerprint: default, - AppearanceFingerprint: default)); + AppearanceFingerprint: default), + new RenderEntityPayload( + [new MeshRef((uint)id, Matrix4x4.Identity)], + PaletteOverride: null, + IsBuildingShell: false)); } private static WorldEntity Entity(