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:
parent
ff5d86175f
commit
0eb6648589
21 changed files with 937 additions and 20 deletions
|
|
@ -22,7 +22,7 @@ retail-faithful gameplay
|
|||
| F2 — static projection journal | complete | Static and EnvCell-shell projections now append ordered deltas only after the final activation receipt and exact detach receipt. Same-landblock rehydrate reconciles retained/new/omitted identities; stale Far completions are inert. Seven focused lifecycle tests pass inside the 3,708-App / 8,192-solution Release gate. The scene remains unconstructed and non-drawing in production. |
|
||||
| F3 — live/equipped projection | complete | Exact EntityReady/resource teardown, visibility, attachment pose/removal, and active-only final-frame seams now drive generation/incarnation-gated live records. Duplicate CreateObject, pending/loaded rebucket, hidden/appearance, reentrancy, session clear, attachment, and GUID-generation replacement pass 11 focused tests. Production still constructs no render scene. |
|
||||
| F4 — dynamic indices | complete | The contained scene now maintains outdoor/static, per-cell static/dynamic, special dynamic-route, translucent, selectable, light-candidate, and dirty indices incrementally. Final-frame live/equipped synchronization remains active-only, and active animated statics are synchronized from the scheduler's active workset rather than a resident-world scan. The production scene remains unconstructed and non-drawing. |
|
||||
| F5 | active | Continuous lifecycle-automation shadow comparison is the next unit; no production consumer has switched. |
|
||||
| F5 | active — connected gate | Lifecycle automation now constructs the otherwise-absent non-drawing scene, drains ordered static/live deltas after post-network spatial reconciliation, compares the accepted current partition every 30 rendered frames and at every canonical checkpoint, retains only the first field-level mismatch, and publishes scene/journal/index/memory evidence in checkpoint JSON. Focused and complete App suites are green; capped/uncapped nine-stop and dense-Arwic physical gates remain. |
|
||||
| G0–G5 | pending | No production consumer has switched. |
|
||||
|
||||
The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0 is
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ internal sealed record WorldLifecycleResourceSnapshot(
|
|||
int LiveEntities,
|
||||
int MaterializedLiveEntities,
|
||||
CurrentRenderSceneOracleSnapshot RenderSceneOracle,
|
||||
RenderSceneShadowComparisonSnapshot RenderSceneShadow,
|
||||
int PendingLiveTeardowns,
|
||||
int PendingLandblockRetirements,
|
||||
int ParticleEmitters,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
516
src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs
Normal file
516
src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs
Normal 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}";
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,12 +38,14 @@ public sealed class FrameRootCompositionTests
|
|||
AssertAppearsInOrder(
|
||||
source,
|
||||
"new RenderFrameResourceController(",
|
||||
"new RenderSceneShadowComparisonController(",
|
||||
"new WorldSceneRenderer(",
|
||||
"new WorldLifecycleAutomationController(",
|
||||
"\"world lifecycle automation owner\"",
|
||||
"\"world lifecycle automation binding\"",
|
||||
"new SerialRenderFramePostDiagnosticsPhase(",
|
||||
"new RenderFrameOrchestrator(",
|
||||
"(IRenderFramePostDiagnosticsPhase?)lifecycleAutomation",
|
||||
"postDiagnostics,",
|
||||
"new RetailLiveFrameCoordinator(",
|
||||
"new UpdateFrameOrchestrator(",
|
||||
"d.FrameGraphs.PublishOwned(",
|
||||
|
|
|
|||
|
|
@ -144,6 +144,15 @@ public sealed class WorldLifecycleAutomationControllerTests
|
|||
AbortedSelectionFrames: 0,
|
||||
SelectionPartCount: 7,
|
||||
SelectionDigest: new RenderSceneHash128(15, 16)),
|
||||
RenderSceneShadow =
|
||||
RenderSceneShadowComparisonSnapshot.Disabled with
|
||||
{
|
||||
Enabled = true,
|
||||
ComparisonCount = 19,
|
||||
SuccessfulComparisonCount = 19,
|
||||
ComparedOracleFrameSequence = 19,
|
||||
MatchedProjectionCount = 73,
|
||||
},
|
||||
TrackedGpuBytes = 1234,
|
||||
Residency = new ResidencySnapshot(
|
||||
[
|
||||
|
|
@ -214,6 +223,20 @@ public sealed class WorldLifecycleAutomationControllerTests
|
|||
0xFEDCBA9876543210uL,
|
||||
renderSceneOracle.GetProperty("digest").GetProperty("high")
|
||||
.GetUInt64());
|
||||
JsonElement renderSceneShadow = json.RootElement
|
||||
.GetProperty("resources")
|
||||
.GetProperty("renderSceneShadow");
|
||||
Assert.True(renderSceneShadow.GetProperty("enabled").GetBoolean());
|
||||
Assert.Equal(
|
||||
19,
|
||||
renderSceneShadow.GetProperty("comparisonCount").GetInt64());
|
||||
Assert.Equal(
|
||||
73,
|
||||
renderSceneShadow.GetProperty("matchedProjectionCount")
|
||||
.GetInt32());
|
||||
Assert.Equal(
|
||||
0,
|
||||
renderSceneShadow.GetProperty("mismatchCount").GetInt64());
|
||||
JsonElement residency = json.RootElement
|
||||
.GetProperty("resources")
|
||||
.GetProperty("residency");
|
||||
|
|
@ -353,6 +376,7 @@ public sealed class WorldLifecycleAutomationControllerTests
|
|||
|
||||
private static WorldLifecycleResourceSnapshot EmptyResources() => new(
|
||||
0, 0, 0, 0, 0, 0, 0, CurrentRenderSceneOracleSnapshot.Disabled,
|
||||
RenderSceneShadowComparisonSnapshot.Disabled,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0L, 0, 0L, 0L, 0L, 0L, 0, 0, 0, 0, 0, 0, 0, 0L, 0L,
|
||||
0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L,
|
||||
|
|
|
|||
|
|
@ -269,6 +269,7 @@ public sealed class LiveSessionHostTests
|
|||
InboundEventFifo = noop,
|
||||
LiveLiveness = noop,
|
||||
LiveRuntime = noop,
|
||||
RenderSceneProjection = noop,
|
||||
SessionIdentity = noop,
|
||||
RemoteTeleport = noop,
|
||||
NetworkEffects = noop,
|
||||
|
|
|
|||
|
|
@ -298,6 +298,7 @@ public sealed class LiveSessionResetPlanTests
|
|||
LiveRuntime = Stage(
|
||||
"live runtime",
|
||||
() => LiveSessionEntityRuntimeReset.ClearAndRequireConvergence(live)),
|
||||
RenderSceneProjection = Stage("render scene projection"),
|
||||
SessionIdentity = Stage("session identity", () =>
|
||||
{
|
||||
LiveSessionEntityRuntimeReset.RequireConvergence(live);
|
||||
|
|
@ -378,6 +379,7 @@ public sealed class LiveSessionResetPlanTests
|
|||
"inbound event fifo",
|
||||
"live liveness",
|
||||
"live runtime",
|
||||
"render scene projection",
|
||||
"session identity",
|
||||
"remote teleport",
|
||||
"network effects",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,224 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Scene;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class RenderSceneShadowRuntimeTests
|
||||
{
|
||||
private const uint Landblock = 0xA9B4FFFF;
|
||||
private const uint Cell = 0xA9B40170;
|
||||
|
||||
[Fact]
|
||||
public void ExactCurrentProjection_MatchesAndAcknowledgesDirtyWorkset()
|
||||
{
|
||||
WorldEntity entity = Entity(17, serverGuid: 0, parentCell: null);
|
||||
CurrentRenderSceneOracle oracle = Capture(
|
||||
entity,
|
||||
visibleCells: []);
|
||||
using var shadow = new RenderSceneShadowRuntime(Generation(1));
|
||||
Register(shadow, entity, RenderProjectionClass.OutdoorStatic);
|
||||
Assert.Equal(1, shadow.DrainUpdateBoundary().Registered);
|
||||
|
||||
var controller = new RenderSceneShadowComparisonController(
|
||||
shadow,
|
||||
oracle);
|
||||
RenderSceneShadowComparisonSnapshot snapshot =
|
||||
controller.CaptureCheckpointSnapshot();
|
||||
|
||||
Assert.True(snapshot.Enabled);
|
||||
Assert.Equal(1uL, snapshot.ComparisonCount);
|
||||
Assert.Equal(1uL, snapshot.SuccessfulComparisonCount);
|
||||
Assert.Equal(0, snapshot.MismatchCount);
|
||||
Assert.Equal(1, snapshot.MatchedProjectionCount);
|
||||
Assert.Equal(0, snapshot.PendingDeltaCount);
|
||||
Assert.Equal(0, snapshot.SceneIndexCounts.Dirty);
|
||||
Assert.Equal(1, snapshot.SceneCounts.Total);
|
||||
Assert.True(snapshot.Memory.TotalEstimatedBytes > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstExactMismatch_IsNamedAndLoggedOnlyOnce()
|
||||
{
|
||||
WorldEntity entity = Entity(18, serverGuid: 0, parentCell: null);
|
||||
CurrentRenderSceneOracle oracle = Capture(entity, []);
|
||||
using var shadow = new RenderSceneShadowRuntime(Generation(1));
|
||||
RenderProjectionRecord record = Project(
|
||||
entity,
|
||||
RenderProjectionClass.OutdoorStatic) with
|
||||
{
|
||||
Source = Project(
|
||||
entity,
|
||||
RenderProjectionClass.OutdoorStatic).Source with
|
||||
{
|
||||
TransformFingerprint = new RenderSceneHash128(91, 92),
|
||||
},
|
||||
};
|
||||
shadow.Journal.Register(in record);
|
||||
shadow.DrainUpdateBoundary();
|
||||
var logs = new List<string>();
|
||||
var controller = new RenderSceneShadowComparisonController(
|
||||
shadow,
|
||||
oracle,
|
||||
logs.Add);
|
||||
|
||||
controller.CompareNow();
|
||||
controller.CompareNow();
|
||||
RenderSceneShadowComparisonSnapshot snapshot = controller.Snapshot;
|
||||
|
||||
Assert.Equal(2uL, snapshot.ComparisonCount);
|
||||
Assert.Equal(0uL, snapshot.SuccessfulComparisonCount);
|
||||
Assert.Equal(2, snapshot.MismatchCount);
|
||||
Assert.Contains("sourceChannel=static", snapshot.FirstMismatch);
|
||||
Assert.Contains("field=transform", snapshot.FirstMismatch);
|
||||
Assert.Single(logs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonFloodedIndoorStatic_IsAValidRetainedSceneExtra()
|
||||
{
|
||||
WorldEntity entity = Entity(19, serverGuid: 0, parentCell: Cell);
|
||||
CurrentRenderSceneOracle oracle = Capture(entity, []);
|
||||
Assert.Empty(oracle.Projections);
|
||||
using var shadow = new RenderSceneShadowRuntime(Generation(1));
|
||||
Register(shadow, entity, RenderProjectionClass.IndoorCellStatic);
|
||||
shadow.DrainUpdateBoundary();
|
||||
var controller = new RenderSceneShadowComparisonController(
|
||||
shadow,
|
||||
oracle);
|
||||
|
||||
controller.CompareNow();
|
||||
|
||||
Assert.Equal(1uL, controller.Snapshot.SuccessfulComparisonCount);
|
||||
Assert.Equal(0, controller.Snapshot.MismatchCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawnOutdoorExtra_IsReportedAsStaleProjection()
|
||||
{
|
||||
WorldEntity expected = Entity(20, serverGuid: 0, parentCell: null);
|
||||
WorldEntity extra = Entity(21, serverGuid: 0, parentCell: null);
|
||||
CurrentRenderSceneOracle oracle = Capture(expected, []);
|
||||
using var shadow = new RenderSceneShadowRuntime(Generation(1));
|
||||
Register(shadow, expected, RenderProjectionClass.OutdoorStatic);
|
||||
Register(shadow, extra, RenderProjectionClass.OutdoorStatic);
|
||||
shadow.DrainUpdateBoundary();
|
||||
var controller = new RenderSceneShadowComparisonController(
|
||||
shadow,
|
||||
oracle);
|
||||
|
||||
controller.CompareNow();
|
||||
|
||||
Assert.Equal(1, controller.Snapshot.MismatchCount);
|
||||
Assert.Contains("expected=absent actual=drawn", controller.Snapshot.FirstMismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectedDelta_IsAComparisonFailureEvenWhenSceneIsEmpty()
|
||||
{
|
||||
CurrentRenderSceneOracle oracle = CaptureNoEntities();
|
||||
using var shadow = new RenderSceneShadowRuntime(Generation(1));
|
||||
WorldEntity missing = Entity(22, serverGuid: 0, parentCell: null);
|
||||
RenderProjectionRecord record = Project(
|
||||
missing,
|
||||
RenderProjectionClass.OutdoorStatic);
|
||||
shadow.Journal.Update(
|
||||
RenderProjectionDeltaKind.UpdateTransform,
|
||||
in record);
|
||||
RenderDeltaApplyResult apply = shadow.DrainUpdateBoundary();
|
||||
Assert.Equal(1, apply.RejectedMissing);
|
||||
var controller = new RenderSceneShadowComparisonController(
|
||||
shadow,
|
||||
oracle);
|
||||
|
||||
controller.CompareNow();
|
||||
|
||||
Assert.Equal(1, controller.Snapshot.MismatchCount);
|
||||
Assert.Contains("rejectedDeltas=1", controller.Snapshot.FirstMismatch);
|
||||
}
|
||||
|
||||
private static void Register(
|
||||
RenderSceneShadowRuntime shadow,
|
||||
WorldEntity entity,
|
||||
RenderProjectionClass projectionClass)
|
||||
{
|
||||
RenderProjectionRecord record = Project(entity, projectionClass);
|
||||
shadow.Journal.Register(in record);
|
||||
}
|
||||
|
||||
private static RenderProjectionRecord Project(
|
||||
WorldEntity entity,
|
||||
RenderProjectionClass projectionClass) =>
|
||||
RenderProjectionRecordFactory.ProjectEntity(
|
||||
StaticRenderProjectionJournal.StaticEntityId(
|
||||
Landblock,
|
||||
entity.Id),
|
||||
projectionClass,
|
||||
RenderOwnerIncarnation.FromRaw(entity.Id),
|
||||
Landblock,
|
||||
entity.ParentCellId ?? Landblock,
|
||||
entity,
|
||||
spatiallyVisible: true);
|
||||
|
||||
private static CurrentRenderSceneOracle Capture(
|
||||
WorldEntity entity,
|
||||
HashSet<uint> visibleCells)
|
||||
{
|
||||
var oracle = new CurrentRenderSceneOracle();
|
||||
var result = new InteriorEntityPartition.Result();
|
||||
InteriorEntityPartition.Partition(
|
||||
result,
|
||||
visibleCells,
|
||||
[
|
||||
(
|
||||
Landblock,
|
||||
Vector3.Zero,
|
||||
Vector3.One,
|
||||
(IReadOnlyList<WorldEntity>)[entity],
|
||||
(IReadOnlyDictionary<uint, WorldEntity>?)null),
|
||||
],
|
||||
oracle);
|
||||
return oracle;
|
||||
}
|
||||
|
||||
private static CurrentRenderSceneOracle CaptureNoEntities()
|
||||
{
|
||||
var oracle = new CurrentRenderSceneOracle();
|
||||
var result = new InteriorEntityPartition.Result();
|
||||
InteriorEntityPartition.Partition(
|
||||
result,
|
||||
[],
|
||||
Array.Empty<(
|
||||
uint LandblockId,
|
||||
Vector3 AabbMin,
|
||||
Vector3 AabbMax,
|
||||
IReadOnlyList<WorldEntity> Entities,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)>(),
|
||||
oracle);
|
||||
return oracle;
|
||||
}
|
||||
|
||||
private static RenderSceneGeneration Generation(ulong value) =>
|
||||
RenderSceneGeneration.FromRaw(value);
|
||||
|
||||
private static WorldEntity Entity(
|
||||
uint id,
|
||||
uint serverGuid,
|
||||
uint? parentCell) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(1, 2, 3),
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs =
|
||||
[
|
||||
new MeshRef(
|
||||
0x01000001u,
|
||||
Matrix4x4.CreateTranslation(0.25f, 0.5f, 0.75f)),
|
||||
],
|
||||
ParentCellId = parentCell,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue