From 29195fb255932f50d97542770187a5456f941788 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Jul 2026 01:31:56 +0200 Subject: [PATCH] feat(rendering): prove packed dispatcher output parity Build a compare-only dispatcher classifier from RenderFrameView and compare complete opaque, alpha, selection, clip, light, texture, transform, and draw payloads against the accepted path. Preserve retail's stable equal-CYpt ordering with explicit draw-local submission ordinals so material-group history cannot affect alpha ties. --- .../Composition/FrameRootComposition.cs | 3 +- .../Rendering/RetailPViewRenderer.cs | 3 +- .../Scene/CurrentRenderSceneOracle.cs | 12 +- .../Scene/RenderInstanceCandidate.cs | 25 + .../Scene/RenderScenePViewFrameProduct.cs | 342 ++++++++++++- .../Selection/IRetailSelectionRenderSink.cs | 17 + .../Selection/RetailSelectionScene.cs | 58 ++- .../Wb/WbDrawDispatcher.PackedOracle.cs | 459 ++++++++++++++++++ .../Rendering/Wb/WbDrawDispatcher.cs | 324 +++++++++++-- .../CurrentRenderSceneOracleTests.cs | 3 + .../Rendering/Wb/InstanceGroupClearTests.cs | 83 +++- 11 files changed, 1264 insertions(+), 65 deletions(-) create mode 100644 src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 95b7412c..6cc6ca88 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -394,7 +394,8 @@ internal sealed class FrameRootCompositionPhase ? new RenderScenePViewFrameProductController( live.RenderSceneShadow, currentRenderSceneOracle, - message => d.Log("[UI-PROBE] " + message)) + message => d.Log("[UI-PROBE] " + message), + live.DrawDispatcher) : null; live.DrawDispatcher.SetCurrentRenderSceneObserver( currentRenderSceneOracle); diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index b8d67ca4..17bca20e 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -259,7 +259,8 @@ public sealed class RetailPViewRenderer ctx.Cells, ctx.AnimatedEntityIds, ctx.PlayerLandblockId ?? 0, - ctx.RootCell.IsOutdoorNode); + ctx.RootCell.IsOutdoorNode, + ctx.CameraWorldPosition); return result; } catch diff --git a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs index 1499b446..aee9a1f9 100644 --- a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs +++ b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs @@ -61,6 +61,9 @@ internal readonly record struct CurrentRenderDispatcherSubmission( int OpaqueGroupCount, int TransparentGroupCount, bool TransparentDeferred, + RenderSceneHash128 OpaqueDigest, + RenderSceneHash128 TransparentDigest, + RenderSceneHash128 TransparentSetDigest, RenderSceneHash128 Digest); internal readonly record struct CurrentRenderSelectionFingerprint( @@ -209,6 +212,8 @@ internal sealed class CurrentRenderSceneOracle : _pviewCandidates = []; private readonly List _dispatcherCandidates = []; + private readonly List + _dispatcherSubmissions = []; private readonly Dictionary _dispatcherProjectionByEntity = new(ReferenceEqualityComparer.Instance); @@ -249,6 +254,8 @@ internal sealed class CurrentRenderSceneOracle : PViewCandidates => _pviewCandidates; internal IReadOnlyList DispatcherCandidates => _dispatcherCandidates; + internal IReadOnlyList + DispatcherSubmissions => _dispatcherSubmissions; internal IReadOnlyList SelectionParts => _selectionParts; @@ -470,6 +477,7 @@ internal sealed class CurrentRenderSceneOracle : { _dispatcherFrameOpen = true; _dispatcherCandidates.Clear(); + _dispatcherSubmissions.Clear(); _dispatcherProjectionByEntity.Clear(); _dispatcherDrawCount = 0; _dispatcherEntityCount = 0; @@ -589,6 +597,7 @@ internal sealed class CurrentRenderSceneOracle : _dispatcherInstanceCount = checked( _dispatcherInstanceCount + submission.VisibleInstanceCount); + _dispatcherSubmissions.Add(submission); _dispatcherOpaqueGroupCount = checked( _dispatcherOpaqueGroupCount + submission.OpaqueGroupCount); _dispatcherTransparentGroupCount = checked( @@ -621,6 +630,7 @@ internal sealed class CurrentRenderSceneOracle : _dispatcherFrameOpen = false; _dispatcherCandidates.Clear(); + _dispatcherSubmissions.Clear(); _dispatcherProjectionByEntity.Clear(); _abortedDispatcherFrames = checked(_abortedDispatcherFrames + 1); Snapshot = Snapshot with @@ -862,7 +872,7 @@ internal sealed class CurrentRenderSceneOracle : return geometry.Finish(); } - private static RenderSceneHash128 FingerprintSelectionGeometry( + internal static RenderSceneHash128 FingerprintSelectionGeometry( RetailSelectionMesh mesh) { StableRenderHash128 geometry = StableRenderHash128.Create(); diff --git a/src/AcDream.App/Rendering/Scene/RenderInstanceCandidate.cs b/src/AcDream.App/Rendering/Scene/RenderInstanceCandidate.cs index 844bcc43..5b30afd6 100644 --- a/src/AcDream.App/Rendering/Scene/RenderInstanceCandidate.cs +++ b/src/AcDream.App/Rendering/Scene/RenderInstanceCandidate.cs @@ -69,6 +69,31 @@ internal readonly record struct RenderInstanceCandidate( MeshPartCount: meshPartCount, TupleLandblockId: tupleLandblockId); } + + internal static RenderInstanceCandidate FromFrame( + in RenderFrameEntityCandidate source, + uint tupleLandblockId) + { + RenderProjectionRecord projection = source.Projection; + return new RenderInstanceCandidate( + ProjectionId: projection.Id, + LocalEntityId: projection.Source.LocalEntityId, + ServerGuid: projection.Source.ServerGuid, + SourceId: projection.Source.SourceId, + ParentCellId: projection.Source.ParentCellId, + RootWorld: projection.Transform.LocalToWorld, + Position: projection.Transform.Position, + Rotation: projection.Transform.Rotation, + Scale: projection.Transform.UniformScale, + Bounds: projection.Bounds, + PaletteOverride: + projection.EntityPayload.PaletteOverride, + IsBuildingShell: + projection.EntityPayload.IsBuildingShell, + Animated: source.Animated, + MeshPartCount: source.MeshPartCount, + TupleLandblockId: tupleLandblockId); + } } /// diff --git a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs index 7a5f673b..c91af846 100644 --- a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs +++ b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs @@ -35,6 +35,22 @@ internal readonly record struct RenderFrameProductComparisonSnapshot( string? PackedInputFirstMismatch, RenderSceneHash128 PackedInputExpectedDigest, RenderSceneHash128 PackedInputActualDigest, + ulong ClassifiedOutputComparisonCount, + ulong ClassifiedOutputSuccessfulComparisonCount, + int ClassifiedOutputExpectedDrawCount, + int ClassifiedOutputActualDrawCount, + long ClassifiedOutputMismatchCount, + string? ClassifiedOutputFirstMismatch, + RenderSceneHash128 ClassifiedOutputExpectedDigest, + RenderSceneHash128 ClassifiedOutputActualDigest, + ulong SelectionOutputComparisonCount, + ulong SelectionOutputSuccessfulComparisonCount, + int SelectionOutputExpectedPartCount, + int SelectionOutputActualPartCount, + long SelectionOutputMismatchCount, + string? SelectionOutputFirstMismatch, + RenderSceneHash128 SelectionOutputExpectedDigest, + RenderSceneHash128 SelectionOutputActualDigest, RenderFrameDiagnosticCounts ProductCounts) { public static RenderFrameProductComparisonSnapshot Disabled { get; } = @@ -58,6 +74,22 @@ internal readonly record struct RenderFrameProductComparisonSnapshot( PackedInputFirstMismatch: null, PackedInputExpectedDigest: RenderSceneHash128.Empty, PackedInputActualDigest: RenderSceneHash128.Empty, + ClassifiedOutputComparisonCount: 0, + ClassifiedOutputSuccessfulComparisonCount: 0, + ClassifiedOutputExpectedDrawCount: 0, + ClassifiedOutputActualDrawCount: 0, + ClassifiedOutputMismatchCount: 0, + ClassifiedOutputFirstMismatch: null, + ClassifiedOutputExpectedDigest: RenderSceneHash128.Empty, + ClassifiedOutputActualDigest: RenderSceneHash128.Empty, + SelectionOutputComparisonCount: 0, + SelectionOutputSuccessfulComparisonCount: 0, + SelectionOutputExpectedPartCount: 0, + SelectionOutputActualPartCount: 0, + SelectionOutputMismatchCount: 0, + SelectionOutputFirstMismatch: null, + SelectionOutputExpectedDigest: RenderSceneHash128.Empty, + SelectionOutputActualDigest: RenderSceneHash128.Empty, ProductCounts: default); } @@ -77,12 +109,15 @@ internal sealed class RenderScenePViewFrameProductController : { private readonly RenderSceneShadowRuntime _shadow; private readonly CurrentRenderSceneOracle _current; + private readonly WbDrawDispatcher? _dispatcher; private readonly RenderScenePViewFrameBuilder _builder = new(); private readonly RenderFrameExchange _exchange = new(); private readonly List _expected = []; private readonly List _actual = []; private readonly List _packedExpected = []; private readonly List _packedActual = []; + private readonly List _selectionExpected = []; + private readonly List _selectionActual = []; private readonly Action? _log; private ulong _productFrameSequence; private ulong _comparisonCount; @@ -93,15 +128,25 @@ internal sealed class RenderScenePViewFrameProductController : private ulong _packedInputSuccessfulComparisonCount; private long _packedInputMismatchCount; private string? _packedInputFirstMismatch; + private ulong _classifiedOutputComparisonCount; + private ulong _classifiedOutputSuccessfulComparisonCount; + private long _classifiedOutputMismatchCount; + private string? _classifiedOutputFirstMismatch; + private ulong _selectionOutputComparisonCount; + private ulong _selectionOutputSuccessfulComparisonCount; + private long _selectionOutputMismatchCount; + private string? _selectionOutputFirstMismatch; public RenderScenePViewFrameProductController( RenderSceneShadowRuntime shadow, CurrentRenderSceneOracle current, - Action? log = null) + Action? log = null, + WbDrawDispatcher? dispatcher = null) { _shadow = shadow ?? throw new ArgumentNullException(nameof(shadow)); _current = current ?? throw new ArgumentNullException(nameof(current)); _log = log; + _dispatcher = dispatcher; Snapshot = RenderFrameProductComparisonSnapshot.Disabled with { Enabled = true, @@ -119,7 +164,8 @@ internal sealed class RenderScenePViewFrameProductController : IRetailPViewCellSource cells, HashSet? animatedEntityIds, uint tupleLandblockId, - bool rootIsOutdoor) + bool rootIsOutdoor, + Vector3 cameraWorldPosition = default) { ArgumentNullException.ThrowIfNull(portalFrame); ArgumentNullException.ThrowIfNull(clipAssembly); @@ -147,7 +193,10 @@ internal sealed class RenderScenePViewFrameProductController : frameSequence); try { - Compare(in view, tupleLandblockId); + Compare( + in view, + tupleLandblockId, + cameraWorldPosition); } finally { @@ -155,7 +204,10 @@ internal sealed class RenderScenePViewFrameProductController : } } - private void Compare(in RenderFrameView view, uint tupleLandblockId) + private void Compare( + in RenderFrameView view, + uint tupleLandblockId, + Vector3 cameraWorldPosition) { _comparisonCount = checked(_comparisonCount + 1); _expected.Clear(); @@ -225,6 +277,11 @@ internal sealed class RenderScenePViewFrameProductController : PackedInputComparison packed = ComparePackedInput(in view, tupleLandblockId); + ClassifiedOutputComparison classified = + CompareClassifiedOutput( + in view, + tupleLandblockId, + cameraWorldPosition); Snapshot = new RenderFrameProductComparisonSnapshot( Enabled: true, @@ -248,9 +305,247 @@ internal sealed class RenderScenePViewFrameProductController : PackedInputFirstMismatch: _packedInputFirstMismatch, PackedInputExpectedDigest: packed.ExpectedDigest, PackedInputActualDigest: packed.ActualDigest, + ClassifiedOutputComparisonCount: + _classifiedOutputComparisonCount, + ClassifiedOutputSuccessfulComparisonCount: + _classifiedOutputSuccessfulComparisonCount, + ClassifiedOutputExpectedDrawCount: + classified.ExpectedDrawCount, + ClassifiedOutputActualDrawCount: + classified.ActualDrawCount, + ClassifiedOutputMismatchCount: + _classifiedOutputMismatchCount, + ClassifiedOutputFirstMismatch: + _classifiedOutputFirstMismatch, + ClassifiedOutputExpectedDigest: + classified.ExpectedDigest, + ClassifiedOutputActualDigest: + classified.ActualDigest, + SelectionOutputComparisonCount: + _selectionOutputComparisonCount, + SelectionOutputSuccessfulComparisonCount: + _selectionOutputSuccessfulComparisonCount, + SelectionOutputExpectedPartCount: + classified.ExpectedSelectionCount, + SelectionOutputActualPartCount: + classified.ActualSelectionCount, + SelectionOutputMismatchCount: + _selectionOutputMismatchCount, + SelectionOutputFirstMismatch: + _selectionOutputFirstMismatch, + SelectionOutputExpectedDigest: + classified.ExpectedSelectionDigest, + SelectionOutputActualDigest: + classified.ActualSelectionDigest, ProductCounts: view.DiagnosticCounts); } + private ClassifiedOutputComparison CompareClassifiedOutput( + in RenderFrameView view, + uint tupleLandblockId, + Vector3 cameraWorldPosition) + { + WbDrawDispatcher? dispatcher = _dispatcher; + if (dispatcher is null) + return default; + + dispatcher.BuildPackedDispatcherOracle( + in view, + tupleLandblockId, + cameraWorldPosition); + IReadOnlyList expected = + _current.DispatcherSubmissions; + IReadOnlyList actual = + dispatcher.PackedDispatcherSubmissions; + RenderSceneHash128 expectedDigest = + BuildSubmissionDigest(expected); + RenderSceneHash128 actualDigest = + BuildSubmissionDigest(actual); + _classifiedOutputComparisonCount = + checked(_classifiedOutputComparisonCount + 1); + string? mismatch = CompareSubmissions(expected, actual); + if (mismatch is null) + { + _classifiedOutputSuccessfulComparisonCount = + checked( + _classifiedOutputSuccessfulComparisonCount + 1); + } + else + { + _classifiedOutputMismatchCount = + checked(_classifiedOutputMismatchCount + 1); + if (_classifiedOutputFirstMismatch is null) + { + _classifiedOutputFirstMismatch = mismatch; + _log?.Invoke( + "[render-classified-output] first mismatch: " + + mismatch); + } + } + + IReadOnlyList + expectedSelection = _current.SelectionParts; + IReadOnlyList + actualSelection = dispatcher.PackedSelectionParts; + BuildSelectionKeys( + expectedSelection, + _selectionExpected); + BuildSelectionKeys( + actualSelection, + _selectionActual); + RenderSceneHash128 expectedSelectionDigest = + BuildSelectionDigest(_selectionExpected); + RenderSceneHash128 actualSelectionDigest = + BuildSelectionDigest(_selectionActual); + _selectionOutputComparisonCount = + checked(_selectionOutputComparisonCount + 1); + string? selectionMismatch = CompareSelection( + _selectionExpected, + _selectionActual); + if (selectionMismatch is null) + { + _selectionOutputSuccessfulComparisonCount = + checked( + _selectionOutputSuccessfulComparisonCount + 1); + } + else + { + _selectionOutputMismatchCount = + checked(_selectionOutputMismatchCount + 1); + if (_selectionOutputFirstMismatch is null) + { + _selectionOutputFirstMismatch = selectionMismatch; + _log?.Invoke( + "[render-selection-output] first mismatch: " + + selectionMismatch); + } + } + + return new ClassifiedOutputComparison( + expected.Count, + actual.Count, + expectedDigest, + actualDigest, + expectedSelection.Count, + actualSelection.Count, + expectedSelectionDigest, + actualSelectionDigest); + } + + private static string? CompareSubmissions( + IReadOnlyList expected, + IReadOnlyList actual) + { + if (expected.Count != actual.Count) + { + return $"field=drawCount expected={expected.Count} " + + $"actual={actual.Count}"; + } + + for (int index = 0; index < expected.Count; index++) + { + if (expected[index] != actual[index]) + { + return $"field=draw index={index} " + + $"expected={expected[index]} " + + $"actual={actual[index]}"; + } + } + + return null; + } + + private static string? CompareSelection( + IReadOnlyList expected, + IReadOnlyList actual) + { + if (expected.Count != actual.Count) + { + return $"field=partCount expected={expected.Count} " + + $"actual={actual.Count}"; + } + + for (int index = 0; index < expected.Count; index++) + { + if (expected[index] != actual[index]) + { + return $"field=part index={index} " + + $"expected={expected[index]} " + + $"actual={actual[index]}"; + } + } + + return null; + } + + private static RenderSceneHash128 BuildSubmissionDigest( + IReadOnlyList submissions) + { + StableRenderHash128 hash = StableRenderHash128.Create(); + hash.Add(submissions.Count); + for (int index = 0; index < submissions.Count; index++) + { + CurrentRenderDispatcherSubmission submission = + submissions[index]; + hash.Add(submission.VisibleInstanceCount); + hash.Add(submission.ImmediateInstanceCount); + hash.Add(submission.OpaqueGroupCount); + hash.Add(submission.TransparentGroupCount); + hash.Add(submission.TransparentDeferred); + hash.Add(submission.OpaqueDigest.Low); + hash.Add(submission.OpaqueDigest.High); + hash.Add(submission.TransparentDigest.Low); + hash.Add(submission.TransparentDigest.High); + hash.Add(submission.TransparentSetDigest.Low); + hash.Add(submission.TransparentSetDigest.High); + hash.Add(submission.Digest.Low); + hash.Add(submission.Digest.High); + } + + return hash.Finish(); + } + + private static void BuildSelectionKeys( + IReadOnlyList source, + List destination) + { + destination.Clear(); + if (destination.Capacity < source.Count) + destination.Capacity = source.Count; + for (int index = 0; index < source.Count; index++) + { + CurrentRenderSelectionFingerprint part = source[index]; + destination.Add(new SelectionKey( + part.ServerGuid, + part.LocalEntityId, + part.PartIndex, + part.GfxObjId, + part.LocalToWorld, + part.Geometry)); + } + destination.Sort(SelectionKeyComparer.Instance); + } + + private static RenderSceneHash128 BuildSelectionDigest( + IReadOnlyList parts) + { + StableRenderHash128 hash = StableRenderHash128.Create(); + hash.Add(parts.Count); + for (int index = 0; index < parts.Count; index++) + { + SelectionKey part = parts[index]; + hash.Add(part.ServerGuid); + hash.Add(part.LocalEntityId); + hash.Add(part.PartIndex); + hash.Add(part.GfxObjId); + hash.Add(part.LocalToWorld); + hash.Add(part.Geometry.Low); + hash.Add(part.Geometry.High); + } + + return hash.Finish(); + } + private PackedInputComparison ComparePackedInput( in RenderFrameView view, uint tupleLandblockId) @@ -492,6 +787,45 @@ internal sealed class RenderScenePViewFrameProductController : RenderSceneHash128 ExpectedDigest, RenderSceneHash128 ActualDigest); + private readonly record struct ClassifiedOutputComparison( + int ExpectedDrawCount, + int ActualDrawCount, + RenderSceneHash128 ExpectedDigest, + RenderSceneHash128 ActualDigest, + int ExpectedSelectionCount, + int ActualSelectionCount, + RenderSceneHash128 ExpectedSelectionDigest, + RenderSceneHash128 ActualSelectionDigest); + + private readonly record struct SelectionKey( + uint ServerGuid, + uint LocalEntityId, + int PartIndex, + uint GfxObjId, + Matrix4x4 LocalToWorld, + RenderSceneHash128 Geometry); + + private sealed class SelectionKeyComparer : IComparer + { + public static SelectionKeyComparer Instance { get; } = new(); + + private SelectionKeyComparer() + { + } + + public int Compare(SelectionKey left, SelectionKey right) + { + int value = left.LocalEntityId.CompareTo( + right.LocalEntityId); + if (value != 0) return value; + value = left.PartIndex.CompareTo(right.PartIndex); + if (value != 0) return value; + value = left.GfxObjId.CompareTo(right.GfxObjId); + if (value != 0) return value; + return left.ServerGuid.CompareTo(right.ServerGuid); + } + } + private readonly record struct PackedInputKey( int DrawSequence, WbDrawDispatcher.EntitySet Set, diff --git a/src/AcDream.App/Rendering/Selection/IRetailSelectionRenderSink.cs b/src/AcDream.App/Rendering/Selection/IRetailSelectionRenderSink.cs index 9a9aebfd..45d15725 100644 --- a/src/AcDream.App/Rendering/Selection/IRetailSelectionRenderSink.cs +++ b/src/AcDream.App/Rendering/Selection/IRetailSelectionRenderSink.cs @@ -1,4 +1,5 @@ using System.Numerics; +using AcDream.Core.Selection; namespace AcDream.App.Rendering.Selection; /// @@ -14,3 +15,19 @@ internal interface IRetailSelectionRenderSink uint gfxObjId, Matrix4x4 partWorld); } + +/// +/// Read-only mirror of the exact geometry and drawing-sphere acceptance used +/// by the retained selection scene. G2's packed dispatcher referee uses it +/// without publishing a second picking frame. +/// +internal interface IRetailSelectionRenderOracle +{ + bool TryCreateVisiblePart( + uint serverGuid, + uint localEntityId, + int partIndex, + uint gfxObjId, + Matrix4x4 partWorld, + out RetailSelectionPart part); +} diff --git a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs index ea9b7072..636cc3e6 100644 --- a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs +++ b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs @@ -21,6 +21,7 @@ internal interface IWorldSceneSelectionFrame internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, + IRetailSelectionRenderOracle, IRetailSelectionLightingSource, IWorldSceneSelectionFrame { @@ -128,32 +129,65 @@ internal sealed class RetailSelectionScene : return; if (serverGuid == 0u) return; - if (!_buildingKeys.Add(new PartKey( localEntityId, partIndex, gfxObjId))) return; - RetailSelectionMesh? mesh = _geometry.Resolve(gfxObjId); - if (mesh is null) - return; - if (_viewFrustum is not { } frustum - || !DrawingSphereIntersectsFrustum(mesh, partWorld, frustum)) + if (!TryCreateVisiblePart( + serverGuid, + localEntityId, + partIndex, + gfxObjId, + partWorld, + out RetailSelectionPart part)) + { return; + } - _building.Add(new RetailSelectionPart( - serverGuid, - localEntityId, - partIndex, - partWorld, - mesh)); + _building.Add(part); _currentRenderSceneObserver?.ObserveSelectionPart( serverGuid, localEntityId, partIndex, gfxObjId, partWorld, + part.Mesh); + } + + public bool TryCreateVisiblePart( + uint serverGuid, + uint localEntityId, + int partIndex, + uint gfxObjId, + Matrix4x4 partWorld, + out RetailSelectionPart part) + { + if (serverGuid == 0u) + { + part = default; + return false; + } + + RetailSelectionMesh? mesh = _geometry.Resolve(gfxObjId); + if (mesh is null + || _viewFrustum is not { } frustum + || !DrawingSphereIntersectsFrustum( + mesh, + partWorld, + frustum)) + { + part = default; + return false; + } + + part = new RetailSelectionPart( + serverGuid, + localEntityId, + partIndex, + partWorld, mesh); + return true; } public void CompleteFrame() diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs new file mode 100644 index 00000000..93cded25 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs @@ -0,0 +1,459 @@ +using System.Numerics; +using AcDream.App.Rendering.Scene; +using AcDream.App.Rendering.Selection; +using AcDream.Core.Lighting; +using AcDream.Core.Meshing; +using AcDream.Core.Selection; +using AcDream.Core.World; +using DatReaderWriter.Enums; + +namespace AcDream.App.Rendering.Wb; + +/// +/// G2 compare-only classification of the scene-built frame product. This file +/// deliberately shares the production dispatcher's exact mesh, texture, +/// light, translucency, selection, grouping, and ordering owners while keeping +/// its group storage separate. It never uploads or draws. +/// +public sealed unsafe partial class WbDrawDispatcher +{ + private readonly Dictionary + _packedEntityById = []; + private readonly Dictionary _packedGroups = []; + private readonly List _packedRetiredGroupKeys = []; + private readonly List _packedOpaque = []; + private readonly List _packedTransparent = []; + private readonly List + _packedAlphaFingerprintScratch = []; + private readonly List + _packedSubmissions = []; + private readonly List + _packedSelectionParts = []; + private readonly HashSet _packedSelectionKeys = []; + private long _packedGroupFrame; + private long _nextPackedGroupRegistration = 1; + private int _nextPackedInstanceSubmissionOrder; + + internal IReadOnlyList + PackedDispatcherSubmissions => _packedSubmissions; + + internal IReadOnlyList + PackedSelectionParts => _packedSelectionParts; + + internal void BuildPackedDispatcherOracle( + in RenderFrameView view, + uint tupleLandblockId, + Vector3 cameraWorldPosition) + { + if (_packedGroupFrame == long.MaxValue) + { + throw new InvalidOperationException( + "Packed dispatcher frame identity was exhausted."); + } + + _packedGroupFrame++; + PruneInstanceGroupsUnusedBeforeFrame( + _packedGroups, + _packedRetiredGroupKeys, + _packedGroupFrame - 1); + _packedEntityById.Clear(); + _packedSubmissions.Clear(); + _packedSelectionParts.Clear(); + _packedSelectionKeys.Clear(); + + ReadOnlySpan entities = + view.EntityCandidates; + for (int i = 0; i < entities.Length; i++) + { + if (!_packedEntityById.TryAdd( + entities[i].Projection.Id, + entities[i])) + { + throw new InvalidOperationException( + $"Packed dispatcher received duplicate projection " + + $"{entities[i].Projection.Id}."); + } + } + + ReadOnlySpan meshParts = view.MeshParts; + ReadOnlySpan routeCandidates = + view.RouteCandidates; + ReadOnlySpan ranges = + view.RouteRanges; + for (int rangeIndex = 0; + rangeIndex < ranges.Length; + rangeIndex++) + { + _nextPackedInstanceSubmissionOrder = 0; + foreach (InstanceGroup group in _packedGroups.Values) + group.ClearPerInstanceData(); + + RenderFrameCandidateRange range = ranges[rangeIndex]; + int end = checked(range.Offset + range.Count); + if ((uint)range.Offset > (uint)routeCandidates.Length + || (uint)end > (uint)routeCandidates.Length) + { + throw new InvalidOperationException( + $"Packed dispatcher route {rangeIndex} exceeds " + + "candidate storage."); + } + + for (int routeIndex = range.Offset; + routeIndex < end; + routeIndex++) + { + RenderProjectionRecord projection = + routeCandidates[routeIndex]; + if ((projection.Flags & RenderProjectionFlags.Draw) == 0) + continue; + if (!_packedEntityById.TryGetValue( + projection.Id, + out RenderFrameEntityCandidate source)) + { + throw new InvalidOperationException( + $"Packed route references absent entity " + + $"{projection.Id}."); + } + + int meshEnd = checked( + source.MeshPartOffset + source.MeshPartCount); + if ((uint)source.MeshPartOffset + > (uint)meshParts.Length + || (uint)meshEnd > (uint)meshParts.Length) + { + throw new InvalidOperationException( + $"Packed entity {projection.Id} exceeds mesh " + + "part storage."); + } + + RenderInstanceCandidate candidate = + RenderInstanceCandidate.FromFrame( + in source, + tupleLandblockId); + ClassifyPackedEntity( + in candidate, + meshParts.Slice( + source.MeshPartOffset, + source.MeshPartCount)); + } + + bool deferTransparent = + _alphaQueue?.IsCollecting == true; + InstanceLayoutCounts counts = PartitionInstanceGroups( + _packedGroups.Values, + deferTransparent, + cameraWorldPosition, + _packedOpaque, + _packedTransparent); + _packedOpaque.Sort(CompareOpaqueSubmissionOrder); + if (!deferTransparent) + { + _packedTransparent.Sort( + CompareTransparentSubmissionOrder); + } + + _packedSubmissions.Add(CreateDispatcherSubmission( + counts.VisibleInstances, + counts.ImmediateInstances, + deferTransparent, + _packedOpaque, + _packedTransparent, + cameraWorldPosition, + _packedAlphaFingerprintScratch)); + } + } + + private void ClassifyPackedEntity( + in RenderInstanceCandidate entity, + ReadOnlySpan meshParts) + { + (uint slot, bool culled) = ResolveSlotForFrame( + _clipRoutingActive, + entity.ServerGuid, + entity.ParentCell, + _cellIdToSlot, + _outdoorSlot, + _outdoorVisible); + if (culled) + return; + + ResolvePackedLightSet( + in entity, + out InstanceLightSet lights, + out bool indoor); + Vector2 selectionLighting = + _selectionLighting?.TryGetLighting( + entity.ServerGuid, + entity.LocalEntityId, + out RetailSelectionLighting lighting) == true + ? new Vector2( + lighting.Luminosity, + lighting.Diffuse) + : new Vector2(0f, 1f); + PaletteCompositeIdentity paletteIdentity = default; + if (entity.PaletteOverride is not null) + { + paletteIdentity = TextureCache.GetPaletteIdentity( + entity.PaletteOverride); + } + + for (int meshIndex = 0; + meshIndex < meshParts.Length; + meshIndex++) + { + RenderFrameMeshPart packedPart = meshParts[meshIndex]; + if (packedPart.ProjectionId != entity.ProjectionId) + { + throw new InvalidOperationException( + $"Packed mesh part {packedPart.PartIndex} belongs " + + $"to {packedPart.ProjectionId}, expected " + + $"{entity.ProjectionId}."); + } + + int partIndex = packedPart.PartIndex; + MeshRef meshRef = packedPart.MeshRef; + ObjectRenderData? renderData = + _meshAdapter.TryGetRenderData(meshRef.GfxObjId); + if (renderData is null) + continue; + + if (renderData.IsSetup + && renderData.SetupParts.Count > 0) + { + for (int setupPartIndex = 0; + setupPartIndex < renderData.SetupParts.Count; + setupPartIndex++) + { + (ulong gfxObjId, Matrix4x4 partTransform) = + renderData.SetupParts[setupPartIndex]; + ObjectRenderData? partData = + _meshAdapter.TryGetRenderData(gfxObjId); + if (partData is null) + continue; + + float opacity = PackedPartOpacity( + entity.LocalEntityId, + (uint)setupPartIndex); + if (opacity <= 0f) + continue; + + Matrix4x4 model = ComposePartWorldMatrix( + entity.RootWorld, + meshRef.PartTransform, + partTransform); + ClassifyPackedBatches( + partData, + model, + in entity, + meshRef, + paletteIdentity, + slot, + lights, + indoor, + selectionLighting, + opacity); + AddPackedSelectionPart( + in entity, + unchecked( + (partIndex << 16) + | (setupPartIndex & 0xFFFF)), + (uint)gfxObjId, + model); + } + } + else + { + float opacity = PackedPartOpacity( + entity.LocalEntityId, + 0u); + if (opacity <= 0f) + continue; + + Matrix4x4 model = + meshRef.PartTransform * entity.RootWorld; + ClassifyPackedBatches( + renderData, + model, + in entity, + meshRef, + paletteIdentity, + slot, + lights, + indoor, + selectionLighting, + opacity); + AddPackedSelectionPart( + in entity, + partIndex, + meshRef.GfxObjId, + model); + } + } + } + + private float PackedPartOpacity( + uint localEntityId, + uint setupPartIndex) + { + if (!_translucencyFades.TryGetCurrentValue( + localEntityId, + setupPartIndex, + out float translucency)) + { + return 1f; + } + + return translucency >= 1f + ? 0f + : 1f - translucency; + } + + private void ClassifyPackedBatches( + ObjectRenderData renderData, + Matrix4x4 model, + in RenderInstanceCandidate entity, + MeshRef meshRef, + PaletteCompositeIdentity paletteIdentity, + uint slot, + InstanceLightSet lights, + bool indoor, + Vector2 selectionLighting, + float opacity) + { + for (int batchIndex = 0; + batchIndex < renderData.Batches.Count; + batchIndex++) + { + ObjectRenderBatch batch = + renderData.Batches[batchIndex]; + TranslucencyKind translucency = batch.Translucency; + if (opacity < 1f && IsOpaque(translucency)) + translucency = TranslucencyKind.AlphaBlend; + + ResolvedTexture texture = ResolveTexture( + in entity, + meshRef, + batch, + paletteIdentity, + out _); + if (texture.Handle == 0) + continue; + + var key = new GroupKey( + batch.FirstIndex, + (int)batch.BaseVertex, + batch.IndexCount, + texture.Handle, + 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); + } + } + + private InstanceGroup GetOrCreatePackedGroup(GroupKey key) + { + if (_packedGroups.TryGetValue( + key, + out InstanceGroup? group)) + { + group.LastUsedFrame = _packedGroupFrame; + return group; + } + if (_nextPackedGroupRegistration == long.MaxValue) + { + throw new InvalidOperationException( + "Packed instance-group registration space was exhausted."); + } + + group = new InstanceGroup + { + FirstIndex = key.FirstIndex, + BaseVertex = key.BaseVertex, + IndexCount = key.IndexCount, + BindlessTextureHandle = key.BindlessTextureHandle, + TextureLayer = key.TextureLayer, + Translucency = key.Translucency, + CullMode = key.CullMode, + Registration = _nextPackedGroupRegistration++, + LastUsedFrame = _packedGroupFrame, + }; + _packedGroups.Add(key, group); + return group; + } + + private void ResolvePackedLightSet( + in RenderInstanceCandidate entity, + out InstanceLightSet lights, + out bool indoor) + { + indoor = IndoorObjectReceivesTorches(entity.ParentCell); + lights = InstanceLightSet.Disabled; + IReadOnlyList? snapshot = _pointSnapshot; + if (!indoor || snapshot is null || snapshot.Count == 0) + return; + + Vector3 center = + (entity.Bounds.Minimum + entity.Bounds.Maximum) * 0.5f; + float radius = + (entity.Bounds.Maximum - entity.Bounds.Minimum) + .Length() * 0.5f; + Span selected = + stackalloc int[LightManager.MaxLightsPerObject]; + selected.Fill(-1); + LightManager.SelectForObject( + snapshot, + center, + radius, + selected); + lights = InstanceLightSet.From(selected); + } + + private void AddPackedSelectionPart( + in RenderInstanceCandidate entity, + int partIndex, + uint gfxObjId, + Matrix4x4 localToWorld) + { + if (_selectionSink is not IRetailSelectionRenderOracle oracle + || !_packedSelectionKeys.Add(new PackedSelectionKey( + entity.LocalEntityId, + partIndex, + gfxObjId)) + || !oracle.TryCreateVisiblePart( + entity.ServerGuid, + entity.LocalEntityId, + partIndex, + gfxObjId, + localToWorld, + out RetailSelectionPart part)) + { + return; + } + + _packedSelectionParts.Add( + new CurrentRenderSelectionFingerprint( + Sequence: _packedSelectionParts.Count, + ServerGuid: part.ServerGuid, + LocalEntityId: part.LocalEntityId, + PartIndex: part.PartIndex, + GfxObjId: gfxObjId, + LocalToWorld: part.LocalToWorld, + Geometry: CurrentRenderSceneOracle + .FingerprintSelectionGeometry(part.Mesh))); + } + + private readonly record struct PackedSelectionKey( + uint LocalEntityId, + int PartIndex, + uint GfxObjId); +} diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 7cda7523..56f8e724 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -65,7 +65,7 @@ namespace AcDream.App.Rendering.Wb; /// glMultiDrawElementsIndirect. /// /// -public sealed unsafe class WbDrawDispatcher : IDisposable +public sealed unsafe partial class WbDrawDispatcher : IDisposable { /// /// Which subset of entities to walk in a single Draw call. @@ -646,9 +646,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private readonly Dictionary _groups = new(); private readonly List _opaqueDraws = new(); private readonly List _translucentDraws = new(); + private readonly List _alphaFingerprintScratch = []; private readonly List _deferredAlpha = new(128); private TranslucencyKind[] _deferredAlphaKinds = new TranslucencyKind[128]; private Matrix4x4 _deferredAlphaViewProjection; + private int _nextInstanceSubmissionOrder; internal long AlphaScratchBudgetBytes => _alphaScratchPolicy.BudgetBytes; internal long RetainedAlphaScratchBytes => checked( @@ -1504,6 +1506,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // Draw is invoked several times per frame (landscape slices, late // dynamics, paperdoll). Per-dispatch payloads reset here, while group // retirement happens once in BeginFrame from whole-frame liveness. + _nextInstanceSubmissionOrder = 0; foreach (InstanceGroup group in _groups.Values) group.ClearPerInstanceData(); @@ -2045,7 +2048,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable ObserveCurrentDispatcherSubmission( visibleInstanceCount: 0, immediateInstanceCount: 0, - deferTransparent: false); + deferTransparent: false, + camPos); _cpuStopwatch.Stop(); if (diag) MaybeFlushDiag(); return; @@ -2067,7 +2071,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable ObserveCurrentDispatcherSubmission( visibleInstanceCount: 0, immediateInstanceCount: 0, - deferTransparent); + deferTransparent, + camPos); _cpuStopwatch.Stop(); if (diag) MaybeFlushDiag(); return; @@ -2191,7 +2196,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable ObserveCurrentDispatcherSubmission( totalInstances, immediateInstances, - deferTransparent); + deferTransparent, + camPos); // ── Phase 5: upload four buffers ──────────────────────────────────── ActivateNextDynamicBufferSet(); @@ -2533,42 +2539,191 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private void ObserveCurrentDispatcherSubmission( int visibleInstanceCount, int immediateInstanceCount, - bool deferTransparent) + bool deferTransparent, + Vector3 cameraWorldPosition) { ICurrentRenderDispatcherObserver? observer = _currentRenderSceneObserver; if (observer is null) return; + CurrentRenderDispatcherSubmission submission = + CreateDispatcherSubmission( + visibleInstanceCount, + immediateInstanceCount, + deferTransparent, + _opaqueDraws, + _translucentDraws, + cameraWorldPosition, + _alphaFingerprintScratch); + observer.ObserveDispatcherSubmission(in submission); + } + + internal static CurrentRenderDispatcherSubmission + CreateDispatcherSubmission( + int visibleInstanceCount, + int immediateInstanceCount, + bool deferTransparent, + IReadOnlyList opaque, + IReadOnlyList transparent, + Vector3 cameraWorldPosition, + List alphaScratch) + { int opaqueGroupCount = - visibleInstanceCount == 0 ? 0 : _opaqueDraws.Count; + visibleInstanceCount == 0 ? 0 : opaque.Count; int transparentGroupCount = - visibleInstanceCount == 0 ? 0 : _translucentDraws.Count; + visibleInstanceCount == 0 ? 0 : transparent.Count; StableRenderHash128 hash = StableRenderHash128.Create(); hash.Add(visibleInstanceCount); hash.Add(immediateInstanceCount); hash.Add(opaqueGroupCount); hash.Add(transparentGroupCount); hash.Add(deferTransparent); - if (visibleInstanceCount != 0) - { - foreach (InstanceGroup group in _opaqueDraws) - AddSubmissionGroup(ref hash, group); - foreach (InstanceGroup group in _translucentDraws) - AddSubmissionGroup(ref hash, group); - } + RenderSceneHash128 opaqueDigest = + BuildOpaqueSubmissionDigest(opaque); + RenderSceneHash128 transparentDigest = + BuildTransparentSubmissionDigest( + transparent, + cameraWorldPosition, + alphaScratch); + RenderSceneHash128 transparentSetDigest = + BuildOpaqueSubmissionDigest(transparent); + hash.Add(opaqueDigest.Low); + hash.Add(opaqueDigest.High); + hash.Add(transparentDigest.Low); + hash.Add(transparentDigest.High); + hash.Add(transparentSetDigest.Low); + hash.Add(transparentSetDigest.High); - var submission = new CurrentRenderDispatcherSubmission( + return new CurrentRenderDispatcherSubmission( VisibleInstanceCount: visibleInstanceCount, ImmediateInstanceCount: immediateInstanceCount, OpaqueGroupCount: opaqueGroupCount, TransparentGroupCount: transparentGroupCount, TransparentDeferred: deferTransparent, + OpaqueDigest: opaqueDigest, + TransparentDigest: transparentDigest, + TransparentSetDigest: transparentSetDigest, Digest: hash.Finish()); - observer.ObserveDispatcherSubmission(in submission); } - private static void AddSubmissionGroup( + private static RenderSceneHash128 BuildOpaqueSubmissionDigest( + IReadOnlyList groups) + { + // Opaque groups are a mathematical set: depth testing makes submission + // order irrelevant, and equal-distance List.Sort ties can reflect the + // persistent dictionary's historical insertion order. Preserve exact + // group and per-instance contents while combining group fingerprints + // commutatively. Transparent groups remain strictly order-sensitive. + ulong xorLow = 0; + ulong xorHigh = 0; + ulong sumLow = 0; + ulong sumHigh = 0; + for (int index = 0; index < groups.Count; index++) + { + StableRenderHash128 groupHash = StableRenderHash128.Create(); + AddOpaqueSubmissionGroup( + ref groupHash, + groups[index]); + RenderSceneHash128 digest = groupHash.Finish(); + xorLow ^= digest.Low; + xorHigh ^= digest.High; + sumLow = unchecked(sumLow + digest.Low); + sumHigh = unchecked(sumHigh + digest.High); + } + + StableRenderHash128 hash = StableRenderHash128.Create(); + hash.Add(groups.Count); + hash.Add(xorLow); + hash.Add(xorHigh); + hash.Add(sumLow); + hash.Add(sumHigh); + return hash.Finish(); + } + + private static RenderSceneHash128 BuildTransparentSubmissionDigest( + IReadOnlyList groups, + Vector3 cameraWorldPosition, + List scratch) + { + scratch.Clear(); + for (int groupIndex = 0; + groupIndex < groups.Count; + groupIndex++) + { + InstanceGroup group = groups[groupIndex]; + for (int instanceIndex = 0; + instanceIndex < group.Matrices.Count; + instanceIndex++) + { + float distance = + RetailAlphaOrdering.ComputeViewerDistance( + group.LocalSortCenters[instanceIndex], + group.Matrices[instanceIndex], + cameraWorldPosition); + if (!float.IsFinite(distance) || distance <= 0f) + distance = 0f; + scratch.Add(new AlphaFingerprint( + group, + instanceIndex, + distance, + group.SubmissionOrders[instanceIndex])); + } + } + scratch.Sort(AlphaFingerprintComparer.Instance); + + StableRenderHash128 hash = StableRenderHash128.Create(); + hash.Add(scratch.Count); + for (int index = 0; index < scratch.Count; index++) + { + AlphaFingerprint entry = scratch[index]; + GroupKey key = ToKey(entry.Group); + hash.Add(key.FirstIndex); + hash.Add(key.BaseVertex); + hash.Add(key.IndexCount); + hash.Add(key.BindlessTextureHandle); + hash.Add(key.TextureLayer); + hash.Add((int)key.Translucency); + hash.Add((int)key.CullMode); + hash.Add(entry.ViewerDistance); + AddSubmissionInstance( + ref hash, + entry.Group, + entry.InstanceIndex); + } + return hash.Finish(); + } + + internal readonly record struct AlphaFingerprint( + InstanceGroup Group, + int InstanceIndex, + float ViewerDistance, + int SubmissionOrder); + + private sealed class AlphaFingerprintComparer : + IComparer + { + public static AlphaFingerprintComparer Instance { get; } = + new(); + + private AlphaFingerprintComparer() + { + } + + public int Compare( + AlphaFingerprint left, + AlphaFingerprint right) + { + int value = right.ViewerDistance.CompareTo( + left.ViewerDistance); + return value != 0 + ? value + : left.SubmissionOrder.CompareTo( + right.SubmissionOrder); + } + } + + private static void AddOpaqueSubmissionGroup( ref StableRenderHash128 hash, InstanceGroup group) { @@ -2581,22 +2736,52 @@ public sealed unsafe class WbDrawDispatcher : IDisposable hash.Add((int)key.Translucency); hash.Add((int)key.CullMode); hash.Add(group.Matrices.Count); - for (int index = 0; index < group.Matrices.Count; index++) + + ulong xorLow = 0; + ulong xorHigh = 0; + ulong sumLow = 0; + ulong sumHigh = 0; + for (int index = 0; + index < group.Matrices.Count; + index++) { - hash.Add(group.Matrices[index]); - hash.Add(group.LocalSortCenters[index]); - hash.Add(group.Slots[index]); - InstanceLightSet lights = group.LightSets[index]; - for (int lightIndex = 0; - lightIndex < LightManager.MaxLightsPerObject; - lightIndex++) - { - hash.Add(lights[lightIndex]); - } - hash.Add(group.IndoorFlags[index]); - hash.Add(group.Opacities[index]); - hash.Add(group.SelectionLighting[index]); + StableRenderHash128 instanceHash = + StableRenderHash128.Create(); + AddSubmissionInstance( + ref instanceHash, + group, + index); + RenderSceneHash128 digest = instanceHash.Finish(); + xorLow ^= digest.Low; + xorHigh ^= digest.High; + sumLow = unchecked(sumLow + digest.Low); + sumHigh = unchecked(sumHigh + digest.High); } + + hash.Add(xorLow); + hash.Add(xorHigh); + hash.Add(sumLow); + hash.Add(sumHigh); + } + + private static void AddSubmissionInstance( + ref StableRenderHash128 hash, + InstanceGroup group, + int index) + { + hash.Add(group.Matrices[index]); + hash.Add(group.LocalSortCenters[index]); + hash.Add(group.Slots[index]); + InstanceLightSet lights = group.LightSets[index]; + for (int lightIndex = 0; + lightIndex < LightManager.MaxLightsPerObject; + lightIndex++) + { + hash.Add(lights[lightIndex]); + } + hash.Add(group.IndoorFlags[index]); + hash.Add(group.Opacities[index]); + hash.Add(group.SelectionLighting[index]); } private void DeferTransparentGroups(Vector3 cameraWorldPosition, Matrix4x4 viewProjection) @@ -2608,9 +2793,15 @@ public sealed unsafe class WbDrawDispatcher : IDisposable throw new InvalidOperationException( "One retail alpha scope cannot combine different view-projection matrices."); + // Retail CShadowPart::insertion_sort (0x006B5130) is stable: + // equal-CYpt parts keep the order in which the cell submitted them. + // Material grouping is an acdream batching detail and must not become + // that tiebreak. Reconstruct the original draw-local instance order + // before handing entries to the queue; its stable CYpt radix then + // preserves this sequence for exact-distance ties. + _alphaFingerprintScratch.Clear(); foreach (InstanceGroup group in _translucentDraws) { - GroupKey key = ToKey(group); for (int i = 0; i < group.Matrices.Count; i++) { Matrix4x4 model = group.Matrices[i]; @@ -2619,20 +2810,55 @@ public sealed unsafe class WbDrawDispatcher : IDisposable localSortCenter, model, cameraWorldPosition); - InstanceLightSet lights = group.LightSets[i]; - - int token = _deferredAlpha.Count; - _deferredAlpha.Add(new DeferredAlphaInstance( - key, - model, - group.Slots[i], - lights, - group.IndoorFlags[i], - group.Opacities[i], - group.SelectionLighting[i])); - queue.Submit(_alphaSource, token, viewerDistance); + if (!float.IsFinite(viewerDistance) + || viewerDistance <= 0f) + { + viewerDistance = 0f; + } + _alphaFingerprintScratch.Add(new AlphaFingerprint( + group, + i, + viewerDistance, + group.SubmissionOrders[i])); } } + _alphaFingerprintScratch.Sort( + AlphaSubmissionOrderComparer.Instance); + + foreach (AlphaFingerprint entry in _alphaFingerprintScratch) + { + InstanceGroup group = entry.Group; + int i = entry.InstanceIndex; + int token = _deferredAlpha.Count; + _deferredAlpha.Add(new DeferredAlphaInstance( + ToKey(group), + group.Matrices[i], + group.Slots[i], + group.LightSets[i], + group.IndoorFlags[i], + group.Opacities[i], + group.SelectionLighting[i])); + queue.Submit( + _alphaSource, + token, + entry.ViewerDistance); + } + } + + private sealed class AlphaSubmissionOrderComparer : + IComparer + { + public static AlphaSubmissionOrderComparer Instance { get; } = + new(); + + private AlphaSubmissionOrderComparer() + { + } + + public int Compare( + AlphaFingerprint left, + AlphaFingerprint right) => + left.SubmissionOrder.CompareTo(right.SubmissionOrder); } private void PrepareDeferredAlphaDraws(ReadOnlySpan tokens) @@ -3415,6 +3641,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable grp.LastUsedFrame = _groupFrame; grp.Matrices.Add(model); grp.LocalSortCenters.Add(localSortCenter); + grp.SubmissionOrders.Add(_nextInstanceSubmissionOrder++); grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices // #188: cache-hit entities are always non-animated (the Tier-1 cache @@ -3586,6 +3813,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable InstanceGroup grp = GetOrCreateInstanceGroup(key); grp.Matrices.Add(model); grp.LocalSortCenters.Add(renderData.SortCenter); + grp.SubmissionOrders.Add(_nextInstanceSubmissionOrder++); grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices grp.Opacities.Add(opacityMultiplier); // #188 — parallel to Matrices @@ -4071,6 +4299,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // submissions retain the exact per-part key after material grouping. public readonly List LocalSortCenters = new(); + // Retail CShadowPart::insertion_sort (0x006B5130) is stable for equal + // CYpt. Material groups erase authored entity/part/batch traversal + // unless that order is retained explicitly. SubmissionOrders[i] is + // the draw-local append ordinal for Matrices[i]. + public readonly List SubmissionOrders = new(); + // Phase U.4: per-instance clip-slot index, parallel to Matrices (Slots[i] // is the binding=2 CellClip slot for the instance whose matrix is // Matrices[i]). At layout time the dispatcher writes Slots[i] into @@ -4116,6 +4350,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable { Matrices.Clear(); LocalSortCenters.Clear(); + SubmissionOrders.Clear(); Slots.Clear(); LightSets.Clear(); IndoorFlags.Clear(); @@ -4128,6 +4363,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable ClearPerInstanceData(); Matrices.TrimExcess(); LocalSortCenters.TrimExcess(); + SubmissionOrders.TrimExcess(); Slots.TrimExcess(); LightSets.TrimExcess(); IndoorFlags.TrimExcess(); diff --git a/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs b/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs index 47b9f946..e52c7b0b 100644 --- a/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs +++ b/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs @@ -318,6 +318,9 @@ public sealed class CurrentRenderSceneOracleTests OpaqueGroupCount: 1, TransparentGroupCount: 1, TransparentDeferred: true, + OpaqueDigest: new RenderSceneHash128(11, 12), + TransparentDigest: new RenderSceneHash128(21, 22), + TransparentSetDigest: new RenderSceneHash128(31, 32), Digest: new RenderSceneHash128(101, 202)); oracle.ObserveDispatcherSubmission(in submission); CurrentRenderSceneOracleSnapshot first = oracle.Snapshot; diff --git a/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs index 440a8837..fb2f002d 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs @@ -86,6 +86,58 @@ public class InstanceGroupClearTests Assert.Equal(6, immediate.ImmediateInstances); } + [Fact] + public void DispatcherFingerprint_EqualDistanceAlphaUsesOriginalSubmissionOrder() + { + WbDrawDispatcher.InstanceGroup first = MakeCompleteGroup( + textureHandle: 0xAA, + submissionOrder: 0); + WbDrawDispatcher.InstanceGroup second = MakeCompleteGroup( + textureHandle: 0xBB, + submissionOrder: 1); + var scratch = new List(); + + var forward = WbDrawDispatcher.CreateDispatcherSubmission( + visibleInstanceCount: 2, + immediateInstanceCount: 0, + deferTransparent: true, + opaque: [], + transparent: [first, second], + cameraWorldPosition: Vector3.Zero, + alphaScratch: scratch); + var reversedMaterialGroups = + WbDrawDispatcher.CreateDispatcherSubmission( + visibleInstanceCount: 2, + immediateInstanceCount: 0, + deferTransparent: true, + opaque: [], + transparent: [second, first], + cameraWorldPosition: Vector3.Zero, + alphaScratch: scratch); + + Assert.Equal( + forward.TransparentDigest, + reversedMaterialGroups.TransparentDigest); + + first.SubmissionOrders[0] = 1; + second.SubmissionOrders[0] = 0; + var reversedSubmission = WbDrawDispatcher.CreateDispatcherSubmission( + visibleInstanceCount: 2, + immediateInstanceCount: 0, + deferTransparent: true, + opaque: [], + transparent: [first, second], + cameraWorldPosition: Vector3.Zero, + alphaScratch: scratch); + + Assert.NotEqual( + forward.TransparentDigest, + reversedSubmission.TransparentDigest); + Assert.Equal( + forward.TransparentSetDigest, + reversedSubmission.TransparentSetDigest); + } + [Fact] public void CachedGroupHandle_RequiresLiveMatchingRegistration() { @@ -210,9 +262,34 @@ public class InstanceGroupClearTests return group; } + private static WbDrawDispatcher.InstanceGroup MakeCompleteGroup( + ulong textureHandle, + int submissionOrder) + { + var group = new WbDrawDispatcher.InstanceGroup + { + FirstIndex = 0, + BaseVertex = 0, + IndexCount = 6, + BindlessTextureHandle = textureHandle, + TextureLayer = 0, + Translucency = TranslucencyKind.AlphaBlend, + }; + group.Matrices.Add(Matrix4x4.CreateTranslation(10f, 0f, 0f)); + group.LocalSortCenters.Add(Vector3.Zero); + group.SubmissionOrders.Add(submissionOrder); + group.Slots.Add(0u); + group.LightSets.Add(WbDrawDispatcher.InstanceLightSet.Disabled); + group.IndoorFlags.Add(0u); + group.Opacities.Add(1f); + group.SelectionLighting.Add(new Vector2(0f, 1f)); + return group; + } + // #193 (regression from #188, 2026-07-09): WbDrawDispatcher's InstanceGroup holds - // seven per-instance parallel lists — Matrices, LocalSortCenters, Slots, - // LightSets, IndoorFlags, Opacities, SelectionLighting — appended in lockstep + // eight per-instance parallel lists — Matrices, LocalSortCenters, + // SubmissionOrders, Slots, LightSets, IndoorFlags, Opacities, and + // SelectionLighting — appended in lockstep // (one entry per drawn instance) every frame. The // per-frame reset must clear ALL of them. #188 added Opacities but left it out of // the inline clear loop, so it grew one float per instance per frame forever; as @@ -225,6 +302,7 @@ public class InstanceGroupClearTests var grp = new WbDrawDispatcher.InstanceGroup(); grp.Matrices.Add(Matrix4x4.Identity); grp.LocalSortCenters.Add(Vector3.Zero); + grp.SubmissionOrders.Add(0); grp.Slots.Add(1u); grp.LightSets.Add(WbDrawDispatcher.InstanceLightSet.Disabled); grp.IndoorFlags.Add(0u); @@ -235,6 +313,7 @@ public class InstanceGroupClearTests Assert.Empty(grp.Matrices); Assert.Empty(grp.LocalSortCenters); + Assert.Empty(grp.SubmissionOrders); Assert.Empty(grp.Slots); Assert.Empty(grp.LightSets); Assert.Empty(grp.IndoorFlags);