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.
This commit is contained in:
Erik 2026-07-25 03:14:15 +02:00
parent 528dec6072
commit 6a026c5ab0
6 changed files with 52 additions and 4 deletions

View file

@ -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,

View file

@ -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();
}
/// <summary>

View file

@ -34,6 +34,17 @@ internal interface ILiveSpatialReconcilePhase
void Reconcile();
}
/// <summary>
/// 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.
/// </summary>
internal interface IRenderProjectionSyncPhase
{
void SynchronizeActiveSources();
}
internal interface IParticleRangeSource
{
float RangeMultiplier { get; }

View file

@ -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) =>

View file

@ -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();
}

View file

@ -57,6 +57,31 @@ public sealed class RecallTeleportAnimationTests
Assert.Equal(["network", "commands"], calls);
}
[Fact]
public void LiveFrame_WhileCellsAreBlocked_SynchronizesOnlyRenderProjection()
{
var calls = new List<string>();
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()
{