feat(rendering): compare the incremental shadow scene

Construct Slice F's non-drawing scene only for lifecycle automation, drain accepted static and live deltas at the final update boundary, and compare exact current-path fingerprints at cadence and checkpoints. Publish bounded mismatch, journal, index, digest, and memory evidence without changing normal launches or draw submission.

Release: 8,211 passed, 5 skipped.
This commit is contained in:
Erik 2026-07-24 22:24:30 +02:00
parent ff5d86175f
commit 0eb6648589
21 changed files with 937 additions and 20 deletions

View file

@ -374,6 +374,20 @@ internal sealed class FrameRootCompositionPhase
&& d.Options.AutomationArtifactDirectory is not null
? new CurrentRenderSceneOracle()
: null;
if ((currentRenderSceneOracle is null)
!= (live.RenderSceneShadow is null))
{
throw new InvalidOperationException(
"Lifecycle automation must compose the current-path referee and shadow render scene together.");
}
RenderSceneShadowComparisonController? renderSceneShadowComparison =
currentRenderSceneOracle is not null
&& live.RenderSceneShadow is not null
? new RenderSceneShadowComparisonController(
live.RenderSceneShadow,
currentRenderSceneOracle,
message => d.Log("[UI-PROBE] " + message))
: null;
live.DrawDispatcher.SetCurrentRenderSceneObserver(
currentRenderSceneOracle);
live.SelectionScene.SetCurrentRenderSceneObserver(
@ -421,7 +435,8 @@ internal sealed class FrameRootCompositionPhase
d.FrameProfiler,
content.Dats,
foundation.Residency,
currentRenderSceneOracle);
currentRenderSceneOracle,
renderSceneShadowComparison);
lifecycleAutomation =
new WorldLifecycleAutomationController(
() => session.WorldReveal.Snapshot,
@ -463,6 +478,14 @@ internal sealed class FrameRootCompositionPhase
renderFrameResources,
settings.DevTools?.Presenter,
renderWeatherFrame);
IRenderFramePostDiagnosticsPhase postDiagnostics =
renderSceneShadowComparison is not null
&& lifecycleAutomation is not null
? new SerialRenderFramePostDiagnosticsPhase(
renderSceneShadowComparison,
lifecycleAutomation)
: (IRenderFramePostDiagnosticsPhase?)lifecycleAutomation
?? NullRenderFramePostDiagnosticsPhase.Instance;
var renderFrame = new RenderFrameOrchestrator(
host.GpuFrameFlights,
new FrameProfilerGpuMeasurement(d.FrameProfiler, d.Gl),
@ -470,8 +493,7 @@ internal sealed class FrameRootCompositionPhase
worldSceneRenderer,
privatePresentation,
live.FrameDiagnostics,
(IRenderFramePostDiagnosticsPhase?)lifecycleAutomation
?? NullRenderFramePostDiagnosticsPhase.Instance,
postDiagnostics,
(IRenderFrameFailureRecovery?)settings.DevTools?.Presenter
?? NullRenderFrameFailureRecovery.Instance);
Fault(FrameRootCompositionPoint.RenderRootCreated);

View file

@ -6,6 +6,7 @@ using AcDream.App.Interaction;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Sky;
using AcDream.App.Rendering.Vfx;
@ -80,6 +81,7 @@ internal sealed record LivePresentationResult(
RetailStaticAnimatingObjectScheduler StaticAnimationScheduler,
WorldGenerationAvailabilityState WorldAvailability,
GpuWorldState WorldState,
RenderSceneShadowRuntime? RenderSceneShadow,
LiveEntityRuntime LiveEntities,
ProjectileController ProjectileController,
LiveEntityProjectionWithdrawalController ProjectionWithdrawal,
@ -196,6 +198,20 @@ internal sealed class LivePresentationCompositionPhase
try
{
CompositionAcquisitionScope.CompositionAcquisitionLease<
RenderSceneShadowRuntime>? renderSceneShadowLease = null;
if (interaction.RetainedUi?.Screenshots is not null
&& d.Options.AutomationArtifactDirectory is not null)
{
renderSceneShadowLease = scope.Acquire(
"shadow render scene",
() => new RenderSceneShadowRuntime(
RenderSceneGeneration.FromRaw(1)),
static value => value.Dispose());
}
RenderSceneShadowRuntime? renderSceneShadow =
renderSceneShadowLease?.Resource;
var componentLifecycle =
new DeferredLiveEntityRuntimeComponentLifecycle();
var wbSpawnAdapter = new LandblockSpawnAdapter(foundation.MeshAdapter);
@ -333,20 +349,33 @@ internal sealed class LivePresentationCompositionPhase
"developer world-entity source",
devWorldEntities.BindOwned(worldState));
}
liveEntities = new LiveEntityRuntime(
worldState,
new CompositeLiveEntityResourceLifecycle(
var resourceOwners =
new List<CompositeLiveEntityResourceLifecycle.Owner>
{
new(
entity => _ = entitySpawnAdapter.OnCreate(entity),
entity => _ = entitySpawnAdapter.OnRemove(entity)),
new(
entityScriptActivator.OnCreate,
entityScriptActivator.OnRemove)),
entityScriptActivator.OnRemove),
};
if (renderSceneShadow is not null)
{
resourceOwners.Add(new(
renderSceneShadow.OnLiveResourceRegistered,
renderSceneShadow.OnLiveResourceUnregistered));
}
liveEntities = new LiveEntityRuntime(
worldState,
new CompositeLiveEntityResourceLifecycle(
[.. resourceOwners]),
componentLifecycle);
var liveRuntimeLease = scope.Own(
"canonical live-entity runtime",
liveEntities,
static runtime => runtime.Clear());
LiveRenderProjectionJournal? liveRenderProjections =
renderSceneShadow?.BindLiveRuntime(liveEntities);
Fault(LivePresentationCompositionPoint.CanonicalRuntimeCreated);
bindings.Adopt(
@ -376,6 +405,13 @@ internal sealed class LivePresentationCompositionPhase
liveEntities,
wbVisibility,
"WB projection visibility");
if (liveRenderProjections is not null)
{
bindings.BindProjectionVisibility(
liveEntities,
liveRenderProjections.OnProjectionVisibilityChanged,
"shadow render projection visibility");
}
Action<LiveEntityRecord, bool> particleVisibility = (record, visible) =>
{
if (record.WorldEntity is { } entity)
@ -511,6 +547,15 @@ internal sealed class LivePresentationCompositionPhase
bindings.BindProjectionPoseReady(
equippedLease.Resource,
lightsLease.Resource.OnAttachedPoseReady);
if (liveRenderProjections is not null)
{
bindings.BindProjectionPoseReady(
equippedLease.Resource,
liveRenderProjections.OnProjectionPoseReady);
bindings.BindProjectionRemoved(
equippedLease.Resource,
liveRenderProjections.OnProjectionRemoved);
}
bindings.Adopt(
"entity-effect animation hooks",
content.HookRegistrations.RegisterOwned(entityEffects));
@ -529,6 +574,8 @@ internal sealed class LivePresentationCompositionPhase
staticAnimationScheduler,
worldAvailability,
worldState,
renderSceneShadow,
renderSceneShadowLease,
liveEntities,
projectileController,
projectionWithdrawal,
@ -574,6 +621,9 @@ internal sealed class LivePresentationCompositionPhase
RetailStaticAnimatingObjectScheduler staticAnimationScheduler,
WorldGenerationAvailabilityState worldAvailability,
GpuWorldState worldState,
RenderSceneShadowRuntime? renderSceneShadow,
CompositionAcquisitionScope.CompositionAcquisitionLease<
RenderSceneShadowRuntime>? renderSceneShadowLease,
LiveEntityRuntime liveEntities,
ProjectileController projectileController,
LiveEntityProjectionWithdrawalController projectionWithdrawal,
@ -818,7 +868,8 @@ internal sealed class LivePresentationCompositionPhase
worldState,
landblockRetirementOwner,
landblockLoaded.OnLandblockLoaded,
landblockRenderPublisher.PrepareAfterRenderPins);
landblockRenderPublisher.PrepareAfterRenderPins,
renderSceneShadow?.StaticProjections);
Fault(LivePresentationCompositionPoint.LandblockPipelineCreated);
var clipFrameLease = scope.Acquire(
@ -965,6 +1016,7 @@ internal sealed class LivePresentationCompositionPhase
staticAnimationScheduler,
worldAvailability,
worldState,
renderSceneShadow,
liveEntities,
projectileController,
projectionWithdrawal,
@ -1010,6 +1062,7 @@ internal sealed class LivePresentationCompositionPhase
_publication.PublishLivePresentation(result);
liveRuntimeLease.Transfer();
renderSceneShadowLease?.Transfer();
lightsLease.Transfer();
equippedLease.Transfer();
presentationLease.Transfer();

View file

@ -5,6 +5,7 @@ using AcDream.App.Diagnostics;
using AcDream.App.Net;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.Settings;
@ -454,7 +455,8 @@ internal sealed class SessionPlayerCompositionPhase
new LiveEntityReadyPublisher(
live.LiveEntities,
live.EntityEffects,
live.Presentation),
live.Presentation,
live.RenderSceneShadow?.LiveProjections),
originCoordinator,
networkUpdateBridge,
localPhysicsTimestamps,
@ -603,7 +605,9 @@ internal sealed class SessionPlayerCompositionPhase
live.EntityEffects,
live.EquippedChildren,
content.ParticleSink,
live.Lights);
live.Lights,
live.RenderSceneShadow?.LiveProjections,
live.RenderSceneShadow);
var localPlayerAnimation = new LocalPlayerAnimationController(
live.LiveEntities,
d.PlayerIdentity,
@ -650,7 +654,9 @@ internal sealed class SessionPlayerCompositionPhase
live.AnimationPresenter,
d.AnimatedEntities,
live.EquippedChildren,
liveEffectFrame);
liveEffectFrame,
live.RenderSceneShadow?.LiveProjections,
live.RenderSceneShadow?.StaticProjections);
Fault(SessionPlayerCompositionPoint.UpdateLeavesCreated);
var playerMode = new PlayerModeController(
@ -812,7 +818,8 @@ internal sealed class SessionPlayerCompositionPhase
live.EntityEffects,
content.AnimationHookFrames,
live.Presentation,
d.RemoteMovementObservations),
d.RemoteMovementObservations,
live.RenderSceneShadow),
d.Log);
LiveSessionHost sessionHost = sessionRuntimeFactory.Create(liveSession);
Fault(SessionPlayerCompositionPoint.SessionHostCreated);

View file

@ -40,6 +40,7 @@ internal sealed record WorldLifecycleResourceSnapshot(
int LiveEntities,
int MaterializedLiveEntities,
CurrentRenderSceneOracleSnapshot RenderSceneOracle,
RenderSceneShadowComparisonSnapshot RenderSceneShadow,
int PendingLiveTeardowns,
int PendingLandblockRetirements,
int ParticleEmitters,

View file

@ -41,6 +41,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource
private readonly ResidencyManager _residency;
private readonly ICurrentRenderSceneOracleSnapshotSource?
_renderSceneOracle;
private readonly IRenderSceneShadowSnapshotSource?
_renderSceneShadow;
public WorldLifecycleResourceSnapshotSource(
GpuWorldState world,
@ -59,7 +61,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource
FrameProfiler frameProfiler,
IDatReaderWriter dats,
ResidencyManager residency,
ICurrentRenderSceneOracleSnapshotSource? renderSceneOracle = null)
ICurrentRenderSceneOracleSnapshotSource? renderSceneOracle = null,
IRenderSceneShadowSnapshotSource? renderSceneShadow = null)
{
_world = world ?? throw new ArgumentNullException(nameof(world));
_animations = animations
@ -87,6 +90,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource
_residency = residency
?? throw new ArgumentNullException(nameof(residency));
_renderSceneOracle = renderSceneOracle;
_renderSceneShadow = renderSceneShadow;
}
public WorldLifecycleResourceSnapshot Capture(RenderFrameOutcome outcome)
@ -129,6 +133,9 @@ internal sealed class WorldLifecycleResourceSnapshotSource
MaterializedLiveEntities: _liveEntities.MaterializedCount,
RenderSceneOracle: _renderSceneOracle?.Snapshot
?? CurrentRenderSceneOracleSnapshot.Disabled,
RenderSceneShadow:
_renderSceneShadow?.CaptureCheckpointSnapshot()
?? RenderSceneShadowComparisonSnapshot.Disabled,
PendingLiveTeardowns: _liveEntities.PendingTeardownCount,
PendingLandblockRetirements: _streaming.PendingRetirementCount,
ParticleEmitters: _particles.ActiveEmitterCount,

View file

@ -33,6 +33,7 @@ internal sealed class LiveSessionResetBindings
public required Action InboundEventFifo { get; init; }
public required Action LiveLiveness { get; init; }
public required Action LiveRuntime { get; init; }
public required Action RenderSceneProjection { get; init; }
public required Action SessionIdentity { get; init; }
public required Action RemoteTeleport { get; init; }
public required Action NetworkEffects { get; init; }
@ -81,6 +82,10 @@ internal static class LiveSessionResetManifest
new("inbound event fifo", bindings.InboundEventFifo),
new("live liveness", bindings.LiveLiveness),
new("live runtime", bindings.LiveRuntime),
// Canonical teardown appends exact projection removals. Drain them
// before identity changes so no prior-session scene record or
// journal entry can carry into the next character generation.
new("render scene projection", bindings.RenderSceneProjection),
// Identity must remain A until live teardown has converged so
// player-specific teardown can still classify the old record.
new("session identity", bindings.SessionIdentity),

View file

@ -5,6 +5,7 @@ using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Settings;
@ -86,7 +87,8 @@ internal sealed record LiveSessionWorldRuntime(
EntityEffectController EntityEffects,
AnimationHookFrameQueue AnimationHookFrames,
LiveEntityPresentationController Presentation,
RemoteMovementObservationTracker RemoteMovementObservations);
RemoteMovementObservationTracker RemoteMovementObservations,
RenderSceneShadowRuntime? RenderSceneShadow);
/// <summary>
/// Builds the exact per-generation route/reset graph for the canonical live
@ -177,6 +179,8 @@ internal sealed class LiveSessionRuntimeFactory
InboundEventFifo = _world.InboundEvents.Clear,
LiveLiveness = _world.Liveness.Clear,
LiveRuntime = ResetLiveRuntime,
RenderSceneProjection =
() => _world.RenderSceneShadow?.DrainUpdateBoundary(),
SessionIdentity = ResetIdentity,
RemoteTeleport = _world.RemoteTeleport.Clear,
NetworkEffects = _world.EntityEffects.ClearNetworkState,

View file

@ -1,6 +1,7 @@
using AcDream.Core.Plugins;
using AcDream.App.Composition;
using AcDream.App.Physics;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Wb;
using AcDream.App.Settings;
using AcDream.App.World;
@ -57,6 +58,7 @@ public sealed class GameWindow :
private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter;
private AcDream.App.Rendering.Vfx.EntityScriptActivator? _entityScriptActivator;
private RetailStaticAnimatingObjectScheduler? _staticAnimationScheduler;
private RenderSceneShadowRuntime? _renderSceneShadow;
private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
private AcDream.App.Rendering.Selection.RetailSelectionScene? _retailSelectionScene;
private AcDream.App.Interaction.WorldSelectionQuery? _worldSelectionQuery;
@ -949,6 +951,7 @@ public sealed class GameWindow :
|| _wbEntitySpawnAdapter is not null
|| _entityScriptActivator is not null
|| _staticAnimationScheduler is not null
|| _renderSceneShadow is not null
|| _wbDrawDispatcher is not null
|| _retailSelectionScene is not null
|| _worldSelectionQuery is not null
@ -985,6 +988,7 @@ public sealed class GameWindow :
_entityScriptActivator = result.EntityScriptActivator;
_staticAnimationScheduler = result.StaticAnimationScheduler;
_worldState = result.WorldState;
_renderSceneShadow = result.RenderSceneShadow;
_liveEntities = result.LiveEntities;
_projectileController = result.ProjectileController;
_liveEntityProjectionWithdrawal = result.ProjectionWithdrawal;
@ -1589,6 +1593,7 @@ public sealed class GameWindow :
_streamer,
_equippedChildRenderer,
_liveEntities,
_renderSceneShadow,
_livePresentationBindings,
_entityEffectAdvance,
_entityEffects,

View file

@ -6,6 +6,7 @@ using AcDream.App.Input;
using AcDream.App.Net;
using AcDream.App.Physics;
using AcDream.App.Rendering.Sky;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.Settings;
@ -79,6 +80,7 @@ internal sealed record LiveShutdownRoots(
LandblockStreamer? Streamer,
EquippedChildRenderController? EquippedChildren,
LiveEntityRuntime? LiveEntities,
RenderSceneShadowRuntime? RenderSceneShadow,
LivePresentationRuntimeBindings? PresentationBindings,
DeferredEntityEffectAdvanceSource EffectAdvance,
EntityEffectController? EntityEffects,
@ -377,6 +379,9 @@ internal static class GameWindowShutdownManifest
new ResourceShutdownStage("live entities",
[
Hard("live entity runtime", () => live.LiveEntities?.Clear()),
Hard(
"shadow render scene",
() => live.RenderSceneShadow?.Dispose()),
]),
new ResourceShutdownStage("effect dispatch edges",
[

View file

@ -91,6 +91,36 @@ internal sealed class NullRenderFramePostDiagnosticsPhase :
}
}
/// <summary>
/// Fixed-order post-diagnostic fan-out. The shadow referee must run before
/// lifecycle checkpoint publication so each artifact observes the comparison
/// made against that exact completed render frame.
/// </summary>
internal sealed class SerialRenderFramePostDiagnosticsPhase :
IRenderFramePostDiagnosticsPhase
{
private readonly IRenderFramePostDiagnosticsPhase[] _phases;
public SerialRenderFramePostDiagnosticsPhase(
params IRenderFramePostDiagnosticsPhase[] phases)
{
ArgumentNullException.ThrowIfNull(phases);
if (phases.Length == 0 || phases.Any(static phase => phase is null))
{
throw new ArgumentException(
"At least one non-null post-diagnostic phase is required.",
nameof(phases));
}
_phases = [.. phases];
}
public void Process(RenderFrameInput input, RenderFrameOutcome outcome)
{
for (int i = 0; i < _phases.Length; i++)
_phases[i].Process(input, outcome);
}
}
/// <summary>
/// Closes presentation transactions which may have opened before a later
/// render phase failed. Recovery is idempotent so failures before the

View file

@ -863,6 +863,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
hash.Add(record.Source.GeometryFingerprint.High);
hash.Add(record.Source.AppearanceFingerprint.Low);
hash.Add(record.Source.AppearanceFingerprint.High);
hash.Add(record.Source.CurrentProjectionFlags);
}
private readonly record struct ProjectionIdentity(RenderProjectionId Id);

View file

@ -27,7 +27,8 @@ internal static class RenderProjectionRecordFactory
ownerLandblockId,
entity);
RenderProjectionFlags flags = RenderProjectionFlags.Selectable;
if (spatiallyVisible
if (entity.MeshRefs.Count > 0
&& spatiallyVisible
&& entity.IsDrawVisible
&& entity.IsAncestorDrawVisible)
{
@ -76,7 +77,8 @@ internal static class RenderProjectionRecordFactory
entity.BuildingShellAnchorCellId ?? 0,
fingerprint.Transform,
fingerprint.Geometry,
fingerprint.Appearance));
fingerprint.Appearance,
fingerprint.Flags));
}
private static (Vector3 Minimum, Vector3 Maximum) CalculateBounds(

View file

@ -11,6 +11,7 @@ internal readonly record struct RenderProjectionId
internal static RenderProjectionId FromRaw(ulong value) => new(value);
internal ulong RawValue => _value;
internal byte Domain => (byte)(_value >> 56);
internal RenderSortKey ToSortKey() => new(_value);
public int CompareTo(RenderProjectionId other) =>
@ -182,7 +183,8 @@ internal readonly record struct RenderSourceMetadata(
uint BuildingShellAnchorCellId,
RenderSceneHash128 TransformFingerprint,
RenderSceneHash128 GeometryFingerprint,
RenderSceneHash128 AppearanceFingerprint);
RenderSceneHash128 AppearanceFingerprint,
uint CurrentProjectionFlags = 0);
internal readonly record struct RenderProjectionRecord(
RenderProjectionId Id,

View file

@ -0,0 +1,516 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Scene.Arch;
using AcDream.App.World;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Scene;
internal readonly record struct RenderSceneShadowComparisonSnapshot(
bool Enabled,
ulong RenderFrameSequence,
ulong ComparisonCount,
ulong SuccessfulComparisonCount,
ulong ComparedOracleFrameSequence,
int MatchedProjectionCount,
long MismatchCount,
string? FirstMismatch,
int PendingDeltaCount,
ulong DrainSequence,
RenderDeltaApplyResult LastApply,
RenderDeltaApplyResult CumulativeApply,
RenderProjectionCounts SceneCounts,
RenderSceneIndexCounts SceneIndexCounts,
RenderSceneMemoryAccounting Memory,
RenderSceneDigest Digest)
{
public static RenderSceneShadowComparisonSnapshot Disabled { get; } =
new(
Enabled: false,
RenderFrameSequence: 0,
ComparisonCount: 0,
SuccessfulComparisonCount: 0,
ComparedOracleFrameSequence: 0,
MatchedProjectionCount: 0,
MismatchCount: 0,
FirstMismatch: null,
PendingDeltaCount: 0,
DrainSequence: 0,
LastApply: default,
CumulativeApply: default,
SceneCounts: default,
SceneIndexCounts: default,
Memory: default,
Digest: default);
}
internal interface IRenderSceneShadowSnapshotSource
{
RenderSceneShadowComparisonSnapshot CaptureCheckpointSnapshot();
}
/// <summary>
/// Diagnostic-only owner of Slice F's non-drawing render projection. The
/// normal runtime never constructs this type. Accepted world/live lifecycle
/// edges append to one ordered journal, and the update thread drains it only
/// after current-frame animation, network, and attachment reconciliation.
/// </summary>
internal sealed class RenderSceneShadowRuntime : IDisposable
{
private readonly ArchRenderScene _scene;
private readonly RenderProjectionJournal _journal;
private readonly RenderSceneDigestBuffer _digestBuffer = new();
private LiveRenderProjectionJournal? _live;
private RenderDeltaApplyResult _lastApply;
private RenderDeltaApplyResult _cumulativeApply;
private ulong _drainSequence;
private bool _disposed;
public RenderSceneShadowRuntime(RenderSceneGeneration generation)
{
_scene = new ArchRenderScene(generation);
_journal = new RenderProjectionJournal(generation);
StaticProjections = new StaticRenderProjectionJournal(_journal);
}
public StaticRenderProjectionJournal StaticProjections { get; }
public ILiveRenderProjectionSink? LiveProjections => _live;
public int PendingDeltaCount => _journal.Count;
public ulong DrainSequence => _drainSequence;
public RenderDeltaApplyResult LastApply => _lastApply;
public RenderDeltaApplyResult CumulativeApply => _cumulativeApply;
public RenderProjectionCounts Counts => _scene.Counts;
internal RenderProjectionJournal Journal => _journal;
internal RenderSceneQuery Query => _scene.OpenQuery();
public LiveRenderProjectionJournal BindLiveRuntime(
LiveEntityRuntime runtime)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(runtime);
if (_live is not null)
{
throw new InvalidOperationException(
"The shadow render scene is already bound to a live runtime.");
}
_live = new LiveRenderProjectionJournal(runtime, _journal);
return _live;
}
public void OnLiveResourceRegistered(WorldEntity entity)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(entity);
// EntityReady is the exact registration edge after all render,
// script, effect, and presentation owners have committed.
}
public void OnLiveResourceUnregistered(WorldEntity entity)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(entity);
(_live ?? throw new InvalidOperationException(
"The shadow render scene was not bound before live teardown."))
.OnResourceUnregister(entity);
}
public RenderDeltaApplyResult DrainUpdateBoundary()
{
ObjectDisposedException.ThrowIf(_disposed, this);
_lastApply = _journal.DrainTo(_scene);
_cumulativeApply = Add(_cumulativeApply, _lastApply);
_drainSequence = checked(_drainSequence + 1);
return _lastApply;
}
public RenderSceneMemoryAccounting CaptureMemory()
{
ObjectDisposedException.ThrowIf(_disposed, this);
RenderSceneMemoryAccounting memory = _scene.Memory;
return memory with
{
EstimatedJournalBufferBytes = checked(
(long)_journal.Capacity
* Unsafe.SizeOf<RenderProjectionDelta>()),
EstimatedSynchronizationSourceBytes = checked(
(long)(StaticProjections.ProjectionCount
+ (_live?.ProjectionCount ?? 0))
* Unsafe.SizeOf<RenderProjectionRecord>()),
};
}
public RenderSceneDigest BuildDigest()
{
ObjectDisposedException.ThrowIf(_disposed, this);
return _scene.BuildDigest(_digestBuffer);
}
public void ClearDirty()
{
ObjectDisposedException.ThrowIf(_disposed, this);
_scene.ClearDirty();
}
public void Clear(RenderSceneGeneration replacementGeneration)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_live?.ResetTracking();
StaticProjections.Clear(replacementGeneration);
_scene.Clear(replacementGeneration);
_lastApply = default;
_cumulativeApply = default;
_drainSequence = 0;
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_live?.ResetTracking();
_scene.Dispose();
}
private static RenderDeltaApplyResult Add(
RenderDeltaApplyResult left,
RenderDeltaApplyResult right) =>
new(
Applied: checked(left.Applied + right.Applied),
Registered: checked(left.Registered + right.Registered),
Updated: checked(left.Updated + right.Updated),
Replaced: checked(left.Replaced + right.Replaced),
Unregistered: checked(left.Unregistered + right.Unregistered),
RejectedGeneration: checked(
left.RejectedGeneration + right.RejectedGeneration),
RejectedOutOfOrderSequence: checked(
left.RejectedOutOfOrderSequence
+ right.RejectedOutOfOrderSequence),
RejectedStaleIncarnation: checked(
left.RejectedStaleIncarnation
+ right.RejectedStaleIncarnation),
RejectedMissing: checked(
left.RejectedMissing + right.RejectedMissing));
}
/// <summary>
/// Post-render referee between the exact current partition output and the
/// non-drawing incremental scene. It records only the first mismatch, so a
/// persistent fault cannot create an unbounded log storm.
/// </summary>
internal sealed class RenderSceneShadowComparisonController :
IRenderFramePostDiagnosticsPhase,
IRenderSceneShadowSnapshotSource
{
private const ulong ComparisonCadenceFrames = 30;
private const byte StaticProjectionDomain = 1;
private const byte EnvCellProjectionDomain = 2;
private const byte LiveProjectionDomain = 3;
private readonly RenderSceneShadowRuntime _shadow;
private readonly CurrentRenderSceneOracle _oracle;
private readonly Action<string>? _log;
private readonly List<RenderProjectionRecord> _sceneRecords = [];
private readonly HashSet<RenderProjectionId> _expectedIds = [];
private ulong _renderFrameSequence;
private ulong _comparisonCount;
private ulong _successfulComparisonCount;
private long _mismatchCount;
private string? _firstMismatch;
private int _matchedProjectionCount;
private ulong _comparedOracleFrameSequence;
public RenderSceneShadowComparisonController(
RenderSceneShadowRuntime shadow,
CurrentRenderSceneOracle oracle,
Action<string>? log = null)
{
_shadow = shadow ?? throw new ArgumentNullException(nameof(shadow));
_oracle = oracle ?? throw new ArgumentNullException(nameof(oracle));
_log = log;
Snapshot = BuildSnapshot();
}
public RenderSceneShadowComparisonSnapshot Snapshot { get; private set; }
public void Process(RenderFrameInput input, RenderFrameOutcome outcome)
{
_renderFrameSequence = checked(_renderFrameSequence + 1);
if (outcome.World.NormalWorldDrawn
&& _renderFrameSequence % ComparisonCadenceFrames == 0)
{
Compare(force: false);
}
else
{
Snapshot = BuildSnapshot();
}
}
public RenderSceneShadowComparisonSnapshot CaptureCheckpointSnapshot()
{
Compare(force: true);
return Snapshot;
}
internal void CompareNow() => Compare(force: true);
private void Compare(bool force)
{
CurrentRenderSceneOracleSnapshot current = _oracle.Snapshot;
if (current.CompletedFrameSequence == 0
|| (!force
&& current.CompletedFrameSequence
== _comparedOracleFrameSequence))
{
Snapshot = BuildSnapshot();
return;
}
_comparisonCount = checked(_comparisonCount + 1);
_comparedOracleFrameSequence = current.CompletedFrameSequence;
_matchedProjectionCount = 0;
string? mismatch = CompareCurrentProjection();
if (mismatch is null)
{
_successfulComparisonCount =
checked(_successfulComparisonCount + 1);
}
else
{
_mismatchCount = checked(_mismatchCount + 1);
if (_firstMismatch is null)
{
_firstMismatch = mismatch;
_log?.Invoke(
"[render-scene-shadow] first mismatch: " + mismatch);
}
}
_shadow.ClearDirty();
Snapshot = BuildSnapshot();
}
private string? CompareCurrentProjection()
{
if (_shadow.PendingDeltaCount != 0)
{
return $"sourceChannel=journal pendingDeltas={_shadow.PendingDeltaCount}";
}
if (_shadow.CumulativeApply.Rejected != 0)
{
return "sourceChannel=journal rejectedDeltas="
+ _shadow.CumulativeApply.Rejected;
}
RenderSceneQuery query = _shadow.Query;
_expectedIds.Clear();
IReadOnlyList<CurrentRenderProjectionFingerprint> expected =
_oracle.Projections;
for (int i = 0; i < expected.Count; i++)
{
CurrentRenderProjectionFingerprint fingerprint = expected[i];
RenderProjectionId id = ExpectedId(in fingerprint);
_expectedIds.Add(id);
if (!query.TryGet(id, out RenderProjectionRecord actual))
{
return $"sourceChannel={Channel(id)} id={id} field=presence "
+ "expected=present actual=missing";
}
string? mismatch = CompareProjection(
in fingerprint,
in actual);
if (mismatch is not null)
return mismatch;
_matchedProjectionCount++;
}
int total = query.Counts.Total;
if (_sceneRecords.Capacity < total)
_sceneRecords.Capacity = total;
CollectionsMarshal.SetCount(_sceneRecords, total);
int copied = query.CopyTo(CollectionsMarshal.AsSpan(_sceneRecords));
if (copied != total)
{
return $"sourceChannel=scene field=count expected={total} "
+ $"actual={copied}";
}
for (int i = 0; i < copied; i++)
{
RenderProjectionRecord record = _sceneRecords[i];
if (_expectedIds.Contains(record.Id)
|| Domain(record.Id) == EnvCellProjectionDomain)
{
continue;
}
bool indoorStatic =
record.ProjectionClass
is RenderProjectionClass.IndoorCellStatic
|| (record.ProjectionClass
is RenderProjectionClass.ActiveAnimatedStatic
&& InteriorEntityPartition.IsIndoorCellId(
record.Source.ParentCellId));
if (indoorStatic
|| (record.Flags & RenderProjectionFlags.Draw) == 0)
{
continue;
}
return $"sourceChannel={Channel(record.Id)} id={record.Id} "
+ "field=presence expected=absent actual=drawn";
}
return null;
}
private static string? CompareProjection(
in CurrentRenderProjectionFingerprint expected,
in RenderProjectionRecord actual)
{
RenderProjectionId id = actual.Id;
string channel = Channel(id);
string? classMismatch = CompareClass(
expected.ProjectionClass,
in actual);
if (classMismatch is not null)
return Prefix(channel, id, "projectionClass", classMismatch);
if (actual.Residency.OwnerLandblockId != expected.LandblockId)
{
return Prefix(
channel,
id,
"landblock",
$"{expected.LandblockId:X8}/{actual.Residency.OwnerLandblockId:X8}");
}
if (actual.Source.LocalEntityId != expected.EntityId)
return Values(channel, id, "entityId", expected.EntityId, actual.Source.LocalEntityId);
if (actual.Source.ServerGuid != expected.ServerGuid)
return Values(channel, id, "serverGuid", expected.ServerGuid, actual.Source.ServerGuid);
if (actual.Source.SourceId != expected.SourceId)
return Values(channel, id, "sourceId", expected.SourceId, actual.Source.SourceId);
if (actual.Source.ParentCellId != expected.ParentCellId)
return Values(channel, id, "parentCell", expected.ParentCellId, actual.Source.ParentCellId);
if (actual.Source.EffectCellId != expected.EffectCellId)
return Values(channel, id, "effectCell", expected.EffectCellId, actual.Source.EffectCellId);
if (actual.Source.BuildingShellAnchorCellId
!= expected.BuildingShellAnchorCellId)
{
return Values(
channel,
id,
"buildingShellAnchor",
expected.BuildingShellAnchorCellId,
actual.Source.BuildingShellAnchorCellId);
}
if (actual.Source.CurrentProjectionFlags != expected.Flags)
return Values(channel, id, "flags", expected.Flags, actual.Source.CurrentProjectionFlags);
if (actual.MeshSet.MeshCount != expected.MeshCount)
return Values(channel, id, "meshCount", expected.MeshCount, actual.MeshSet.MeshCount);
if (actual.Source.TransformFingerprint != expected.Transform)
return Prefix(channel, id, "transform", $"{expected.Transform}/{actual.Source.TransformFingerprint}");
if (actual.Source.GeometryFingerprint != expected.Geometry)
return Prefix(channel, id, "geometry", $"{expected.Geometry}/{actual.Source.GeometryFingerprint}");
if (actual.Source.AppearanceFingerprint != expected.Appearance)
return Prefix(channel, id, "appearance", $"{expected.Appearance}/{actual.Source.AppearanceFingerprint}");
return null;
}
private static string? CompareClass(
InteriorEntityPartition.ProjectionClass expected,
in RenderProjectionRecord actual)
{
bool matches = expected switch
{
InteriorEntityPartition.ProjectionClass.OutdoorStatic =>
actual.ProjectionClass
is RenderProjectionClass.OutdoorStatic
or RenderProjectionClass.ActiveAnimatedStatic
&& !InteriorEntityPartition.IsIndoorCellId(
actual.Source.ParentCellId),
InteriorEntityPartition.ProjectionClass.CellStatic =>
actual.ProjectionClass
is RenderProjectionClass.IndoorCellStatic
or RenderProjectionClass.ActiveAnimatedStatic
&& InteriorEntityPartition.IsIndoorCellId(
actual.Source.ParentCellId),
InteriorEntityPartition.ProjectionClass.Dynamic =>
actual.ProjectionClass
is RenderProjectionClass.LiveDynamicRoot
or RenderProjectionClass.EquippedChild,
_ => false,
};
return matches
? null
: $"{expected}/{actual.ProjectionClass}";
}
private RenderSceneShadowComparisonSnapshot BuildSnapshot()
{
RenderSceneQuery query = _shadow.Query;
return new RenderSceneShadowComparisonSnapshot(
Enabled: true,
RenderFrameSequence: _renderFrameSequence,
ComparisonCount: _comparisonCount,
SuccessfulComparisonCount: _successfulComparisonCount,
ComparedOracleFrameSequence: _comparedOracleFrameSequence,
MatchedProjectionCount: _matchedProjectionCount,
MismatchCount: _mismatchCount,
FirstMismatch: _firstMismatch,
PendingDeltaCount: _shadow.PendingDeltaCount,
DrainSequence: _shadow.DrainSequence,
LastApply: _shadow.LastApply,
CumulativeApply: _shadow.CumulativeApply,
SceneCounts: query.Counts,
SceneIndexCounts: query.IndexCounts,
Memory: _shadow.CaptureMemory(),
Digest: _shadow.BuildDigest());
}
private static RenderProjectionId ExpectedId(
in CurrentRenderProjectionFingerprint fingerprint) =>
fingerprint.ServerGuid == 0
? StaticRenderProjectionJournal.StaticEntityId(
fingerprint.LandblockId,
fingerprint.EntityId)
: LiveRenderProjectionJournal.ProjectionId(
fingerprint.EntityId);
private static byte Domain(RenderProjectionId id) =>
id.Domain;
private static string Channel(RenderProjectionId id) =>
Domain(id) switch
{
StaticProjectionDomain => "static",
EnvCellProjectionDomain => "envCell",
LiveProjectionDomain => "live",
byte domain => $"unknown:{domain}",
};
private static string Values<T>(
string channel,
RenderProjectionId id,
string field,
T expected,
T actual) =>
Prefix(channel, id, field, $"{expected}/{actual}");
private static string Prefix(
string channel,
RenderProjectionId id,
string field,
string values) =>
$"sourceChannel={channel} id={id} field={field} "
+ $"expected/actual={values}";
}

View file

@ -238,13 +238,15 @@ internal sealed class LiveSpatialPresentationReconciler : ILiveSpatialReconcileP
private readonly ParticleHookSink _particleSink;
private readonly LiveEntityLightController _lights;
private readonly ILiveRenderProjectionSink? _renderProjections;
private readonly RenderSceneShadowRuntime? _renderSceneShadow;
public LiveSpatialPresentationReconciler(
EntityEffectController entityEffects,
EquippedChildRenderController equippedChildren,
ParticleHookSink particleSink,
LiveEntityLightController lights,
ILiveRenderProjectionSink? renderProjections = null)
ILiveRenderProjectionSink? renderProjections = null,
RenderSceneShadowRuntime? renderSceneShadow = null)
{
_entityEffects = entityEffects ?? throw new ArgumentNullException(nameof(entityEffects));
_equippedChildren = equippedChildren
@ -252,6 +254,7 @@ internal sealed class LiveSpatialPresentationReconciler : ILiveSpatialReconcileP
_particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink));
_lights = lights ?? throw new ArgumentNullException(nameof(lights));
_renderProjections = renderProjections;
_renderSceneShadow = renderSceneShadow;
}
public void Reconcile()
@ -261,5 +264,6 @@ internal sealed class LiveSpatialPresentationReconciler : ILiveSpatialReconcileP
_renderProjections?.SynchronizeActiveSources();
_particleSink.RefreshAttachedEmitters();
_lights.Refresh();
_renderSceneShadow?.DrainUpdateBoundary();
}
}