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.
This commit is contained in:
Erik 2026-07-25 01:31:56 +02:00
parent 58b712c6ec
commit 29195fb255
11 changed files with 1264 additions and 65 deletions

View file

@ -394,7 +394,8 @@ internal sealed class FrameRootCompositionPhase
? new RenderScenePViewFrameProductController( ? new RenderScenePViewFrameProductController(
live.RenderSceneShadow, live.RenderSceneShadow,
currentRenderSceneOracle, currentRenderSceneOracle,
message => d.Log("[UI-PROBE] " + message)) message => d.Log("[UI-PROBE] " + message),
live.DrawDispatcher)
: null; : null;
live.DrawDispatcher.SetCurrentRenderSceneObserver( live.DrawDispatcher.SetCurrentRenderSceneObserver(
currentRenderSceneOracle); currentRenderSceneOracle);

View file

@ -259,7 +259,8 @@ public sealed class RetailPViewRenderer
ctx.Cells, ctx.Cells,
ctx.AnimatedEntityIds, ctx.AnimatedEntityIds,
ctx.PlayerLandblockId ?? 0, ctx.PlayerLandblockId ?? 0,
ctx.RootCell.IsOutdoorNode); ctx.RootCell.IsOutdoorNode,
ctx.CameraWorldPosition);
return result; return result;
} }
catch catch

View file

@ -61,6 +61,9 @@ internal readonly record struct CurrentRenderDispatcherSubmission(
int OpaqueGroupCount, int OpaqueGroupCount,
int TransparentGroupCount, int TransparentGroupCount,
bool TransparentDeferred, bool TransparentDeferred,
RenderSceneHash128 OpaqueDigest,
RenderSceneHash128 TransparentDigest,
RenderSceneHash128 TransparentSetDigest,
RenderSceneHash128 Digest); RenderSceneHash128 Digest);
internal readonly record struct CurrentRenderSelectionFingerprint( internal readonly record struct CurrentRenderSelectionFingerprint(
@ -209,6 +212,8 @@ internal sealed class CurrentRenderSceneOracle :
_pviewCandidates = []; _pviewCandidates = [];
private readonly List<CurrentRenderDispatcherFingerprint> private readonly List<CurrentRenderDispatcherFingerprint>
_dispatcherCandidates = []; _dispatcherCandidates = [];
private readonly List<CurrentRenderDispatcherSubmission>
_dispatcherSubmissions = [];
private readonly Dictionary<WorldEntity, CurrentRenderProjectionFingerprint> private readonly Dictionary<WorldEntity, CurrentRenderProjectionFingerprint>
_dispatcherProjectionByEntity = _dispatcherProjectionByEntity =
new(ReferenceEqualityComparer.Instance); new(ReferenceEqualityComparer.Instance);
@ -249,6 +254,8 @@ internal sealed class CurrentRenderSceneOracle :
PViewCandidates => _pviewCandidates; PViewCandidates => _pviewCandidates;
internal IReadOnlyList<CurrentRenderDispatcherFingerprint> internal IReadOnlyList<CurrentRenderDispatcherFingerprint>
DispatcherCandidates => _dispatcherCandidates; DispatcherCandidates => _dispatcherCandidates;
internal IReadOnlyList<CurrentRenderDispatcherSubmission>
DispatcherSubmissions => _dispatcherSubmissions;
internal IReadOnlyList<CurrentRenderSelectionFingerprint> internal IReadOnlyList<CurrentRenderSelectionFingerprint>
SelectionParts => _selectionParts; SelectionParts => _selectionParts;
@ -470,6 +477,7 @@ internal sealed class CurrentRenderSceneOracle :
{ {
_dispatcherFrameOpen = true; _dispatcherFrameOpen = true;
_dispatcherCandidates.Clear(); _dispatcherCandidates.Clear();
_dispatcherSubmissions.Clear();
_dispatcherProjectionByEntity.Clear(); _dispatcherProjectionByEntity.Clear();
_dispatcherDrawCount = 0; _dispatcherDrawCount = 0;
_dispatcherEntityCount = 0; _dispatcherEntityCount = 0;
@ -589,6 +597,7 @@ internal sealed class CurrentRenderSceneOracle :
_dispatcherInstanceCount = checked( _dispatcherInstanceCount = checked(
_dispatcherInstanceCount + submission.VisibleInstanceCount); _dispatcherInstanceCount + submission.VisibleInstanceCount);
_dispatcherSubmissions.Add(submission);
_dispatcherOpaqueGroupCount = checked( _dispatcherOpaqueGroupCount = checked(
_dispatcherOpaqueGroupCount + submission.OpaqueGroupCount); _dispatcherOpaqueGroupCount + submission.OpaqueGroupCount);
_dispatcherTransparentGroupCount = checked( _dispatcherTransparentGroupCount = checked(
@ -621,6 +630,7 @@ internal sealed class CurrentRenderSceneOracle :
_dispatcherFrameOpen = false; _dispatcherFrameOpen = false;
_dispatcherCandidates.Clear(); _dispatcherCandidates.Clear();
_dispatcherSubmissions.Clear();
_dispatcherProjectionByEntity.Clear(); _dispatcherProjectionByEntity.Clear();
_abortedDispatcherFrames = checked(_abortedDispatcherFrames + 1); _abortedDispatcherFrames = checked(_abortedDispatcherFrames + 1);
Snapshot = Snapshot with Snapshot = Snapshot with
@ -862,7 +872,7 @@ internal sealed class CurrentRenderSceneOracle :
return geometry.Finish(); return geometry.Finish();
} }
private static RenderSceneHash128 FingerprintSelectionGeometry( internal static RenderSceneHash128 FingerprintSelectionGeometry(
RetailSelectionMesh mesh) RetailSelectionMesh mesh)
{ {
StableRenderHash128 geometry = StableRenderHash128.Create(); StableRenderHash128 geometry = StableRenderHash128.Create();

View file

@ -69,6 +69,31 @@ internal readonly record struct RenderInstanceCandidate(
MeshPartCount: meshPartCount, MeshPartCount: meshPartCount,
TupleLandblockId: tupleLandblockId); 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);
}
} }
/// <summary> /// <summary>

View file

@ -35,6 +35,22 @@ internal readonly record struct RenderFrameProductComparisonSnapshot(
string? PackedInputFirstMismatch, string? PackedInputFirstMismatch,
RenderSceneHash128 PackedInputExpectedDigest, RenderSceneHash128 PackedInputExpectedDigest,
RenderSceneHash128 PackedInputActualDigest, 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) RenderFrameDiagnosticCounts ProductCounts)
{ {
public static RenderFrameProductComparisonSnapshot Disabled { get; } = public static RenderFrameProductComparisonSnapshot Disabled { get; } =
@ -58,6 +74,22 @@ internal readonly record struct RenderFrameProductComparisonSnapshot(
PackedInputFirstMismatch: null, PackedInputFirstMismatch: null,
PackedInputExpectedDigest: RenderSceneHash128.Empty, PackedInputExpectedDigest: RenderSceneHash128.Empty,
PackedInputActualDigest: 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); ProductCounts: default);
} }
@ -77,12 +109,15 @@ internal sealed class RenderScenePViewFrameProductController :
{ {
private readonly RenderSceneShadowRuntime _shadow; private readonly RenderSceneShadowRuntime _shadow;
private readonly CurrentRenderSceneOracle _current; private readonly CurrentRenderSceneOracle _current;
private readonly WbDrawDispatcher? _dispatcher;
private readonly RenderScenePViewFrameBuilder _builder = new(); private readonly RenderScenePViewFrameBuilder _builder = new();
private readonly RenderFrameExchange _exchange = new(); private readonly RenderFrameExchange _exchange = new();
private readonly List<CandidateKey> _expected = []; private readonly List<CandidateKey> _expected = [];
private readonly List<CandidateKey> _actual = []; private readonly List<CandidateKey> _actual = [];
private readonly List<PackedInputKey> _packedExpected = []; private readonly List<PackedInputKey> _packedExpected = [];
private readonly List<PackedInputKey> _packedActual = []; private readonly List<PackedInputKey> _packedActual = [];
private readonly List<SelectionKey> _selectionExpected = [];
private readonly List<SelectionKey> _selectionActual = [];
private readonly Action<string>? _log; private readonly Action<string>? _log;
private ulong _productFrameSequence; private ulong _productFrameSequence;
private ulong _comparisonCount; private ulong _comparisonCount;
@ -93,15 +128,25 @@ internal sealed class RenderScenePViewFrameProductController :
private ulong _packedInputSuccessfulComparisonCount; private ulong _packedInputSuccessfulComparisonCount;
private long _packedInputMismatchCount; private long _packedInputMismatchCount;
private string? _packedInputFirstMismatch; 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( public RenderScenePViewFrameProductController(
RenderSceneShadowRuntime shadow, RenderSceneShadowRuntime shadow,
CurrentRenderSceneOracle current, CurrentRenderSceneOracle current,
Action<string>? log = null) Action<string>? log = null,
WbDrawDispatcher? dispatcher = null)
{ {
_shadow = shadow ?? throw new ArgumentNullException(nameof(shadow)); _shadow = shadow ?? throw new ArgumentNullException(nameof(shadow));
_current = current ?? throw new ArgumentNullException(nameof(current)); _current = current ?? throw new ArgumentNullException(nameof(current));
_log = log; _log = log;
_dispatcher = dispatcher;
Snapshot = RenderFrameProductComparisonSnapshot.Disabled with Snapshot = RenderFrameProductComparisonSnapshot.Disabled with
{ {
Enabled = true, Enabled = true,
@ -119,7 +164,8 @@ internal sealed class RenderScenePViewFrameProductController :
IRetailPViewCellSource cells, IRetailPViewCellSource cells,
HashSet<uint>? animatedEntityIds, HashSet<uint>? animatedEntityIds,
uint tupleLandblockId, uint tupleLandblockId,
bool rootIsOutdoor) bool rootIsOutdoor,
Vector3 cameraWorldPosition = default)
{ {
ArgumentNullException.ThrowIfNull(portalFrame); ArgumentNullException.ThrowIfNull(portalFrame);
ArgumentNullException.ThrowIfNull(clipAssembly); ArgumentNullException.ThrowIfNull(clipAssembly);
@ -147,7 +193,10 @@ internal sealed class RenderScenePViewFrameProductController :
frameSequence); frameSequence);
try try
{ {
Compare(in view, tupleLandblockId); Compare(
in view,
tupleLandblockId,
cameraWorldPosition);
} }
finally 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); _comparisonCount = checked(_comparisonCount + 1);
_expected.Clear(); _expected.Clear();
@ -225,6 +277,11 @@ internal sealed class RenderScenePViewFrameProductController :
PackedInputComparison packed = PackedInputComparison packed =
ComparePackedInput(in view, tupleLandblockId); ComparePackedInput(in view, tupleLandblockId);
ClassifiedOutputComparison classified =
CompareClassifiedOutput(
in view,
tupleLandblockId,
cameraWorldPosition);
Snapshot = new RenderFrameProductComparisonSnapshot( Snapshot = new RenderFrameProductComparisonSnapshot(
Enabled: true, Enabled: true,
@ -248,9 +305,247 @@ internal sealed class RenderScenePViewFrameProductController :
PackedInputFirstMismatch: _packedInputFirstMismatch, PackedInputFirstMismatch: _packedInputFirstMismatch,
PackedInputExpectedDigest: packed.ExpectedDigest, PackedInputExpectedDigest: packed.ExpectedDigest,
PackedInputActualDigest: packed.ActualDigest, 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); 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<CurrentRenderDispatcherSubmission> expected =
_current.DispatcherSubmissions;
IReadOnlyList<CurrentRenderDispatcherSubmission> 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<CurrentRenderSelectionFingerprint>
expectedSelection = _current.SelectionParts;
IReadOnlyList<CurrentRenderSelectionFingerprint>
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<CurrentRenderDispatcherSubmission> expected,
IReadOnlyList<CurrentRenderDispatcherSubmission> 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<SelectionKey> expected,
IReadOnlyList<SelectionKey> 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<CurrentRenderDispatcherSubmission> 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<CurrentRenderSelectionFingerprint> source,
List<SelectionKey> 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<SelectionKey> 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( private PackedInputComparison ComparePackedInput(
in RenderFrameView view, in RenderFrameView view,
uint tupleLandblockId) uint tupleLandblockId)
@ -492,6 +787,45 @@ internal sealed class RenderScenePViewFrameProductController :
RenderSceneHash128 ExpectedDigest, RenderSceneHash128 ExpectedDigest,
RenderSceneHash128 ActualDigest); 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<SelectionKey>
{
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( private readonly record struct PackedInputKey(
int DrawSequence, int DrawSequence,
WbDrawDispatcher.EntitySet Set, WbDrawDispatcher.EntitySet Set,

View file

@ -1,4 +1,5 @@
using System.Numerics; using System.Numerics;
using AcDream.Core.Selection;
namespace AcDream.App.Rendering.Selection; namespace AcDream.App.Rendering.Selection;
/// <summary> /// <summary>
@ -14,3 +15,19 @@ internal interface IRetailSelectionRenderSink
uint gfxObjId, uint gfxObjId,
Matrix4x4 partWorld); Matrix4x4 partWorld);
} }
/// <summary>
/// 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.
/// </summary>
internal interface IRetailSelectionRenderOracle
{
bool TryCreateVisiblePart(
uint serverGuid,
uint localEntityId,
int partIndex,
uint gfxObjId,
Matrix4x4 partWorld,
out RetailSelectionPart part);
}

View file

@ -21,6 +21,7 @@ internal interface IWorldSceneSelectionFrame
internal sealed class RetailSelectionScene : internal sealed class RetailSelectionScene :
IRetailSelectionRenderSink, IRetailSelectionRenderSink,
IRetailSelectionRenderOracle,
IRetailSelectionLightingSource, IRetailSelectionLightingSource,
IWorldSceneSelectionFrame IWorldSceneSelectionFrame
{ {
@ -128,32 +129,65 @@ internal sealed class RetailSelectionScene :
return; return;
if (serverGuid == 0u) if (serverGuid == 0u)
return; return;
if (!_buildingKeys.Add(new PartKey( if (!_buildingKeys.Add(new PartKey(
localEntityId, localEntityId,
partIndex, partIndex,
gfxObjId))) gfxObjId)))
return; return;
RetailSelectionMesh? mesh = _geometry.Resolve(gfxObjId); if (!TryCreateVisiblePart(
if (mesh is null) serverGuid,
return; localEntityId,
if (_viewFrustum is not { } frustum partIndex,
|| !DrawingSphereIntersectsFrustum(mesh, partWorld, frustum)) gfxObjId,
partWorld,
out RetailSelectionPart part))
{
return; return;
}
_building.Add(new RetailSelectionPart( _building.Add(part);
serverGuid,
localEntityId,
partIndex,
partWorld,
mesh));
_currentRenderSceneObserver?.ObserveSelectionPart( _currentRenderSceneObserver?.ObserveSelectionPart(
serverGuid, serverGuid,
localEntityId, localEntityId,
partIndex, partIndex,
gfxObjId, gfxObjId,
partWorld, 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); mesh);
return true;
} }
public void CompleteFrame() public void CompleteFrame()

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed unsafe partial class WbDrawDispatcher
{
private readonly Dictionary<RenderProjectionId, RenderFrameEntityCandidate>
_packedEntityById = [];
private readonly Dictionary<GroupKey, InstanceGroup> _packedGroups = [];
private readonly List<GroupKey> _packedRetiredGroupKeys = [];
private readonly List<InstanceGroup> _packedOpaque = [];
private readonly List<InstanceGroup> _packedTransparent = [];
private readonly List<AlphaFingerprint>
_packedAlphaFingerprintScratch = [];
private readonly List<CurrentRenderDispatcherSubmission>
_packedSubmissions = [];
private readonly List<CurrentRenderSelectionFingerprint>
_packedSelectionParts = [];
private readonly HashSet<PackedSelectionKey> _packedSelectionKeys = [];
private long _packedGroupFrame;
private long _nextPackedGroupRegistration = 1;
private int _nextPackedInstanceSubmissionOrder;
internal IReadOnlyList<CurrentRenderDispatcherSubmission>
PackedDispatcherSubmissions => _packedSubmissions;
internal IReadOnlyList<CurrentRenderSelectionFingerprint>
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<RenderFrameEntityCandidate> 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<RenderFrameMeshPart> meshParts = view.MeshParts;
ReadOnlySpan<RenderProjectionRecord> routeCandidates =
view.RouteCandidates;
ReadOnlySpan<RenderFrameCandidateRange> 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<RenderFrameMeshPart> 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<LightSource>? 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<int> 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);
}

View file

@ -65,7 +65,7 @@ namespace AcDream.App.Rendering.Wb;
/// <c>glMultiDrawElementsIndirect</c>. /// <c>glMultiDrawElementsIndirect</c>.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed unsafe class WbDrawDispatcher : IDisposable public sealed unsafe partial class WbDrawDispatcher : IDisposable
{ {
/// <summary> /// <summary>
/// Which subset of entities to walk in a single Draw call. /// Which subset of entities to walk in a single Draw call.
@ -646,9 +646,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private readonly Dictionary<GroupKey, InstanceGroup> _groups = new(); private readonly Dictionary<GroupKey, InstanceGroup> _groups = new();
private readonly List<InstanceGroup> _opaqueDraws = new(); private readonly List<InstanceGroup> _opaqueDraws = new();
private readonly List<InstanceGroup> _translucentDraws = new(); private readonly List<InstanceGroup> _translucentDraws = new();
private readonly List<AlphaFingerprint> _alphaFingerprintScratch = [];
private readonly List<DeferredAlphaInstance> _deferredAlpha = new(128); private readonly List<DeferredAlphaInstance> _deferredAlpha = new(128);
private TranslucencyKind[] _deferredAlphaKinds = new TranslucencyKind[128]; private TranslucencyKind[] _deferredAlphaKinds = new TranslucencyKind[128];
private Matrix4x4 _deferredAlphaViewProjection; private Matrix4x4 _deferredAlphaViewProjection;
private int _nextInstanceSubmissionOrder;
internal long AlphaScratchBudgetBytes => _alphaScratchPolicy.BudgetBytes; internal long AlphaScratchBudgetBytes => _alphaScratchPolicy.BudgetBytes;
internal long RetainedAlphaScratchBytes => checked( internal long RetainedAlphaScratchBytes => checked(
@ -1504,6 +1506,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// Draw is invoked several times per frame (landscape slices, late // Draw is invoked several times per frame (landscape slices, late
// dynamics, paperdoll). Per-dispatch payloads reset here, while group // dynamics, paperdoll). Per-dispatch payloads reset here, while group
// retirement happens once in BeginFrame from whole-frame liveness. // retirement happens once in BeginFrame from whole-frame liveness.
_nextInstanceSubmissionOrder = 0;
foreach (InstanceGroup group in _groups.Values) foreach (InstanceGroup group in _groups.Values)
group.ClearPerInstanceData(); group.ClearPerInstanceData();
@ -2045,7 +2048,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
ObserveCurrentDispatcherSubmission( ObserveCurrentDispatcherSubmission(
visibleInstanceCount: 0, visibleInstanceCount: 0,
immediateInstanceCount: 0, immediateInstanceCount: 0,
deferTransparent: false); deferTransparent: false,
camPos);
_cpuStopwatch.Stop(); _cpuStopwatch.Stop();
if (diag) MaybeFlushDiag(); if (diag) MaybeFlushDiag();
return; return;
@ -2067,7 +2071,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
ObserveCurrentDispatcherSubmission( ObserveCurrentDispatcherSubmission(
visibleInstanceCount: 0, visibleInstanceCount: 0,
immediateInstanceCount: 0, immediateInstanceCount: 0,
deferTransparent); deferTransparent,
camPos);
_cpuStopwatch.Stop(); _cpuStopwatch.Stop();
if (diag) MaybeFlushDiag(); if (diag) MaybeFlushDiag();
return; return;
@ -2191,7 +2196,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
ObserveCurrentDispatcherSubmission( ObserveCurrentDispatcherSubmission(
totalInstances, totalInstances,
immediateInstances, immediateInstances,
deferTransparent); deferTransparent,
camPos);
// ── Phase 5: upload four buffers ──────────────────────────────────── // ── Phase 5: upload four buffers ────────────────────────────────────
ActivateNextDynamicBufferSet(); ActivateNextDynamicBufferSet();
@ -2533,42 +2539,191 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private void ObserveCurrentDispatcherSubmission( private void ObserveCurrentDispatcherSubmission(
int visibleInstanceCount, int visibleInstanceCount,
int immediateInstanceCount, int immediateInstanceCount,
bool deferTransparent) bool deferTransparent,
Vector3 cameraWorldPosition)
{ {
ICurrentRenderDispatcherObserver? observer = ICurrentRenderDispatcherObserver? observer =
_currentRenderSceneObserver; _currentRenderSceneObserver;
if (observer is null) if (observer is null)
return; 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<InstanceGroup> opaque,
IReadOnlyList<InstanceGroup> transparent,
Vector3 cameraWorldPosition,
List<AlphaFingerprint> alphaScratch)
{
int opaqueGroupCount = int opaqueGroupCount =
visibleInstanceCount == 0 ? 0 : _opaqueDraws.Count; visibleInstanceCount == 0 ? 0 : opaque.Count;
int transparentGroupCount = int transparentGroupCount =
visibleInstanceCount == 0 ? 0 : _translucentDraws.Count; visibleInstanceCount == 0 ? 0 : transparent.Count;
StableRenderHash128 hash = StableRenderHash128.Create(); StableRenderHash128 hash = StableRenderHash128.Create();
hash.Add(visibleInstanceCount); hash.Add(visibleInstanceCount);
hash.Add(immediateInstanceCount); hash.Add(immediateInstanceCount);
hash.Add(opaqueGroupCount); hash.Add(opaqueGroupCount);
hash.Add(transparentGroupCount); hash.Add(transparentGroupCount);
hash.Add(deferTransparent); hash.Add(deferTransparent);
if (visibleInstanceCount != 0) RenderSceneHash128 opaqueDigest =
{ BuildOpaqueSubmissionDigest(opaque);
foreach (InstanceGroup group in _opaqueDraws) RenderSceneHash128 transparentDigest =
AddSubmissionGroup(ref hash, group); BuildTransparentSubmissionDigest(
foreach (InstanceGroup group in _translucentDraws) transparent,
AddSubmissionGroup(ref hash, group); 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, VisibleInstanceCount: visibleInstanceCount,
ImmediateInstanceCount: immediateInstanceCount, ImmediateInstanceCount: immediateInstanceCount,
OpaqueGroupCount: opaqueGroupCount, OpaqueGroupCount: opaqueGroupCount,
TransparentGroupCount: transparentGroupCount, TransparentGroupCount: transparentGroupCount,
TransparentDeferred: deferTransparent, TransparentDeferred: deferTransparent,
OpaqueDigest: opaqueDigest,
TransparentDigest: transparentDigest,
TransparentSetDigest: transparentSetDigest,
Digest: hash.Finish()); Digest: hash.Finish());
observer.ObserveDispatcherSubmission(in submission);
} }
private static void AddSubmissionGroup( private static RenderSceneHash128 BuildOpaqueSubmissionDigest(
IReadOnlyList<InstanceGroup> 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<InstanceGroup> groups,
Vector3 cameraWorldPosition,
List<AlphaFingerprint> 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<AlphaFingerprint>
{
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, ref StableRenderHash128 hash,
InstanceGroup group) InstanceGroup group)
{ {
@ -2581,22 +2736,52 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
hash.Add((int)key.Translucency); hash.Add((int)key.Translucency);
hash.Add((int)key.CullMode); hash.Add((int)key.CullMode);
hash.Add(group.Matrices.Count); 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]); StableRenderHash128 instanceHash =
hash.Add(group.LocalSortCenters[index]); StableRenderHash128.Create();
hash.Add(group.Slots[index]); AddSubmissionInstance(
InstanceLightSet lights = group.LightSets[index]; ref instanceHash,
for (int lightIndex = 0; group,
lightIndex < LightManager.MaxLightsPerObject; index);
lightIndex++) RenderSceneHash128 digest = instanceHash.Finish();
{ xorLow ^= digest.Low;
hash.Add(lights[lightIndex]); xorHigh ^= digest.High;
} sumLow = unchecked(sumLow + digest.Low);
hash.Add(group.IndoorFlags[index]); sumHigh = unchecked(sumHigh + digest.High);
hash.Add(group.Opacities[index]);
hash.Add(group.SelectionLighting[index]);
} }
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) private void DeferTransparentGroups(Vector3 cameraWorldPosition, Matrix4x4 viewProjection)
@ -2608,9 +2793,15 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
throw new InvalidOperationException( throw new InvalidOperationException(
"One retail alpha scope cannot combine different view-projection matrices."); "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) foreach (InstanceGroup group in _translucentDraws)
{ {
GroupKey key = ToKey(group);
for (int i = 0; i < group.Matrices.Count; i++) for (int i = 0; i < group.Matrices.Count; i++)
{ {
Matrix4x4 model = group.Matrices[i]; Matrix4x4 model = group.Matrices[i];
@ -2619,20 +2810,55 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
localSortCenter, localSortCenter,
model, model,
cameraWorldPosition); cameraWorldPosition);
InstanceLightSet lights = group.LightSets[i]; if (!float.IsFinite(viewerDistance)
|| viewerDistance <= 0f)
int token = _deferredAlpha.Count; {
_deferredAlpha.Add(new DeferredAlphaInstance( viewerDistance = 0f;
key, }
model, _alphaFingerprintScratch.Add(new AlphaFingerprint(
group.Slots[i], group,
lights, i,
group.IndoorFlags[i], viewerDistance,
group.Opacities[i], group.SubmissionOrders[i]));
group.SelectionLighting[i]));
queue.Submit(_alphaSource, token, viewerDistance);
} }
} }
_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<AlphaFingerprint>
{
public static AlphaSubmissionOrderComparer Instance { get; } =
new();
private AlphaSubmissionOrderComparer()
{
}
public int Compare(
AlphaFingerprint left,
AlphaFingerprint right) =>
left.SubmissionOrder.CompareTo(right.SubmissionOrder);
} }
private void PrepareDeferredAlphaDraws(ReadOnlySpan<int> tokens) private void PrepareDeferredAlphaDraws(ReadOnlySpan<int> tokens)
@ -3415,6 +3641,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
grp.LastUsedFrame = _groupFrame; grp.LastUsedFrame = _groupFrame;
grp.Matrices.Add(model); grp.Matrices.Add(model);
grp.LocalSortCenters.Add(localSortCenter); grp.LocalSortCenters.Add(localSortCenter);
grp.SubmissionOrders.Add(_nextInstanceSubmissionOrder++);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, 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 // #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); InstanceGroup grp = GetOrCreateInstanceGroup(key);
grp.Matrices.Add(model); grp.Matrices.Add(model);
grp.LocalSortCenters.Add(renderData.SortCenter); grp.LocalSortCenters.Add(renderData.SortCenter);
grp.SubmissionOrders.Add(_nextInstanceSubmissionOrder++);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
grp.Opacities.Add(opacityMultiplier); // #188 — 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. // submissions retain the exact per-part key after material grouping.
public readonly List<Vector3> LocalSortCenters = new(); public readonly List<Vector3> 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<int> SubmissionOrders = new();
// Phase U.4: per-instance clip-slot index, parallel to Matrices (Slots[i] // 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 // is the binding=2 CellClip slot for the instance whose matrix is
// Matrices[i]). At layout time the dispatcher writes Slots[i] into // Matrices[i]). At layout time the dispatcher writes Slots[i] into
@ -4116,6 +4350,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
{ {
Matrices.Clear(); Matrices.Clear();
LocalSortCenters.Clear(); LocalSortCenters.Clear();
SubmissionOrders.Clear();
Slots.Clear(); Slots.Clear();
LightSets.Clear(); LightSets.Clear();
IndoorFlags.Clear(); IndoorFlags.Clear();
@ -4128,6 +4363,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
ClearPerInstanceData(); ClearPerInstanceData();
Matrices.TrimExcess(); Matrices.TrimExcess();
LocalSortCenters.TrimExcess(); LocalSortCenters.TrimExcess();
SubmissionOrders.TrimExcess();
Slots.TrimExcess(); Slots.TrimExcess();
LightSets.TrimExcess(); LightSets.TrimExcess();
IndoorFlags.TrimExcess(); IndoorFlags.TrimExcess();

View file

@ -318,6 +318,9 @@ public sealed class CurrentRenderSceneOracleTests
OpaqueGroupCount: 1, OpaqueGroupCount: 1,
TransparentGroupCount: 1, TransparentGroupCount: 1,
TransparentDeferred: true, TransparentDeferred: true,
OpaqueDigest: new RenderSceneHash128(11, 12),
TransparentDigest: new RenderSceneHash128(21, 22),
TransparentSetDigest: new RenderSceneHash128(31, 32),
Digest: new RenderSceneHash128(101, 202)); Digest: new RenderSceneHash128(101, 202));
oracle.ObserveDispatcherSubmission(in submission); oracle.ObserveDispatcherSubmission(in submission);
CurrentRenderSceneOracleSnapshot first = oracle.Snapshot; CurrentRenderSceneOracleSnapshot first = oracle.Snapshot;

View file

@ -86,6 +86,58 @@ public class InstanceGroupClearTests
Assert.Equal(6, immediate.ImmediateInstances); 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<WbDrawDispatcher.AlphaFingerprint>();
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] [Fact]
public void CachedGroupHandle_RequiresLiveMatchingRegistration() public void CachedGroupHandle_RequiresLiveMatchingRegistration()
{ {
@ -210,9 +262,34 @@ public class InstanceGroupClearTests
return group; 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 // #193 (regression from #188, 2026-07-09): WbDrawDispatcher's InstanceGroup holds
// seven per-instance parallel lists — Matrices, LocalSortCenters, Slots, // eight per-instance parallel lists — Matrices, LocalSortCenters,
// LightSets, IndoorFlags, Opacities, SelectionLighting — appended in lockstep // SubmissionOrders, Slots, LightSets, IndoorFlags, Opacities, and
// SelectionLighting — appended in lockstep
// (one entry per drawn instance) every frame. The // (one entry per drawn instance) every frame. The
// per-frame reset must clear ALL of them. #188 added Opacities but left it out of // 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 // 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(); var grp = new WbDrawDispatcher.InstanceGroup();
grp.Matrices.Add(Matrix4x4.Identity); grp.Matrices.Add(Matrix4x4.Identity);
grp.LocalSortCenters.Add(Vector3.Zero); grp.LocalSortCenters.Add(Vector3.Zero);
grp.SubmissionOrders.Add(0);
grp.Slots.Add(1u); grp.Slots.Add(1u);
grp.LightSets.Add(WbDrawDispatcher.InstanceLightSet.Disabled); grp.LightSets.Add(WbDrawDispatcher.InstanceLightSet.Disabled);
grp.IndoorFlags.Add(0u); grp.IndoorFlags.Add(0u);
@ -235,6 +313,7 @@ public class InstanceGroupClearTests
Assert.Empty(grp.Matrices); Assert.Empty(grp.Matrices);
Assert.Empty(grp.LocalSortCenters); Assert.Empty(grp.LocalSortCenters);
Assert.Empty(grp.SubmissionOrders);
Assert.Empty(grp.Slots); Assert.Empty(grp.Slots);
Assert.Empty(grp.LightSets); Assert.Empty(grp.LightSets);
Assert.Empty(grp.IndoorFlags); Assert.Empty(grp.IndoorFlags);