From 6a026c5ab04b3a0148045490fcb33d86084e0c0d Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Jul 2026 03:14:15 +0200 Subject: [PATCH] fix(rendering): synchronize hidden live projections Inbound state continues while portal reveal blocks ordinary world simulation. Synchronize only the derived render projection after those hidden-frame network and command phases so the first revealed draw cannot observe a stale live transform, without advancing effects, particles, attachments, or lights. --- .../Composition/FrameRootComposition.cs | 3 ++- .../Scene/LiveRenderProjectionJournal.cs | 4 +-- .../Update/LiveObjectFrameController.cs | 11 ++++++++ .../World/RetailLiveFrameCoordinator.cs | 7 +++++- .../FramePhaseTestDoubles.cs | 6 +++++ .../World/RecallTeleportAnimationTests.cs | 25 +++++++++++++++++++ 6 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 8ecad939..d7c8bad1 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -517,7 +517,8 @@ internal sealed class FrameRootCompositionPhase session.LiveSession, session.LocalPlayerFrame, session.LiveSpatialReconciler, - live.WorldAvailability); + live.WorldAvailability, + live.RenderSceneShadow?.LiveProjections); var cameraFrame = new CameraFrameController( host.CameraController, d.InputCapture, diff --git a/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs b/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs index 7d89d859..ba99e608 100644 --- a/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs +++ b/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs @@ -1,16 +1,16 @@ +using AcDream.App.Update; using AcDream.App.World; using AcDream.Core.World; namespace AcDream.App.Rendering.Scene; -internal interface ILiveRenderProjectionSink +internal interface ILiveRenderProjectionSink : IRenderProjectionSyncPhase { bool OnEntityReady(LiveEntityReadyCandidate candidate); void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible); void OnProjectionPoseReady(uint serverGuid); void OnProjectionRemoved(uint localEntityId); void OnResourceUnregister(WorldEntity entity); - void SynchronizeActiveSources(); } /// diff --git a/src/AcDream.App/Update/LiveObjectFrameController.cs b/src/AcDream.App/Update/LiveObjectFrameController.cs index 8a3f6a7c..fe1120cf 100644 --- a/src/AcDream.App/Update/LiveObjectFrameController.cs +++ b/src/AcDream.App/Update/LiveObjectFrameController.cs @@ -34,6 +34,17 @@ internal interface ILiveSpatialReconcilePhase void Reconcile(); } +/// +/// Keeps the derived render projection synchronized after inbound mutation +/// while normal world simulation/presentation is blocked by portal reveal. +/// This is deliberately narrower than full spatial reconciliation: hidden +/// frames must not advance particles, effects, attachments, or lights. +/// +internal interface IRenderProjectionSyncPhase +{ + void SynchronizeActiveSources(); +} + internal interface IParticleRangeSource { float RangeMultiplier { get; } diff --git a/src/AcDream.App/World/RetailLiveFrameCoordinator.cs b/src/AcDream.App/World/RetailLiveFrameCoordinator.cs index 6fb0cb44..6c6cb11f 100644 --- a/src/AcDream.App/World/RetailLiveFrameCoordinator.cs +++ b/src/AcDream.App/World/RetailLiveFrameCoordinator.cs @@ -25,6 +25,7 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase private readonly IPostNetworkCommandFramePhase _localPlayer; private readonly ILiveSpatialReconcilePhase _spatialReconciler; private readonly IWorldGenerationAvailability _availability; + private readonly IRenderProjectionSyncPhase? _renderProjectionSync; public RetailLiveFrameCoordinator( ILiveObjectFramePhase objects, @@ -32,7 +33,8 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase ILiveSessionFramePhase session, IPostNetworkCommandFramePhase localPlayer, ILiveSpatialReconcilePhase spatialReconciler, - IWorldGenerationAvailability? availability = null) + IWorldGenerationAvailability? availability = null, + IRenderProjectionSyncPhase? renderProjectionSync = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState)); @@ -41,6 +43,7 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase _spatialReconciler = spatialReconciler ?? throw new ArgumentNullException(nameof(spatialReconciler)); _availability = availability ?? AlwaysAvailableWorldGeneration.Instance; + _renderProjectionSync = renderProjectionSync; } public void Tick(float deltaSeconds) @@ -53,6 +56,8 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase _localPlayer.RunPostNetworkCommandPhase(); if (_availability.IsWorldAvailable) _spatialReconciler.Reconcile(); + else + _renderProjectionSync?.SynchronizeActiveSources(); } public static double NormalizeDeltaSeconds(double deltaSeconds) => diff --git a/tests/AcDream.App.Tests/FramePhaseTestDoubles.cs b/tests/AcDream.App.Tests/FramePhaseTestDoubles.cs index 50812877..1ef2419f 100644 --- a/tests/AcDream.App.Tests/FramePhaseTestDoubles.cs +++ b/tests/AcDream.App.Tests/FramePhaseTestDoubles.cs @@ -25,3 +25,9 @@ internal sealed class TestLiveSpatialReconcilePhase(Action reconcile) { public void Reconcile() => reconcile(); } + +internal sealed class TestRenderProjectionSyncPhase(Action synchronize) + : IRenderProjectionSyncPhase +{ + public void SynchronizeActiveSources() => synchronize(); +} diff --git a/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs b/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs index f65f50ee..c13f04e2 100644 --- a/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs +++ b/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs @@ -57,6 +57,31 @@ public sealed class RecallTeleportAnimationTests Assert.Equal(["network", "commands"], calls); } + [Fact] + public void LiveFrame_WhileCellsAreBlocked_SynchronizesOnlyRenderProjection() + { + var calls = new List(); + var availability = new WorldGenerationAvailabilityState(); + availability.Begin(1); + var frame = new RetailLiveFrameCoordinator( + new TestLiveObjectFramePhase(_ => calls.Add("objects")), + new GpuWorldState(availability: availability), + new TestLiveSessionFramePhase(() => calls.Add("network")), + new TestPostNetworkCommandFramePhase( + () => calls.Add("commands")), + new TestLiveSpatialReconcilePhase( + () => calls.Add("spatial")), + availability, + new TestRenderProjectionSyncPhase( + () => calls.Add("render-projection"))); + + frame.Tick(1f / 60f); + + Assert.Equal( + ["network", "commands", "render-projection"], + calls); + } + [Fact] public void LiveFrame_InvalidHostDeltaCannotBlockNetworkDispatch() {