fix(animation): harden presentation phase handoffs

Own every borrowed PartArray frame across callbacks, version presentation state across appearance rebinding, reject recursive phase consumption, and preserve static MotionDone when only a stale visual pose is discarded.
This commit is contained in:
Erik 2026-07-21 10:01:25 +02:00
parent a2fd61684a
commit 833520253a
10 changed files with 657 additions and 140 deletions

View file

@ -19,6 +19,7 @@ internal sealed class LiveEntityAnimationPresenter
private readonly AnimationPresentationDiagnostics _diagnostics;
private readonly int _hidePartIndex;
private readonly List<LiveEntityRecord> _snapshot = new();
private bool _isPresenting;
public LiveEntityAnimationPresenter(
Func<LiveEntityRuntime?> liveEntities,
@ -79,94 +80,107 @@ internal sealed class LiveEntityAnimationPresenter
{
ArgumentNullException.ThrowIfNull(schedules);
LiveEntityRuntime? runtime = _liveEntities();
if (runtime is null)
if (runtime is null || _isPresenting)
return;
// Private snapshot: EffectPoseChanged is synchronous and may perform a
// nested runtime-view enumeration. Never borrow that view's scratch.
runtime.CopySpatialRootObjectRecordsTo(_snapshot);
foreach (LiveEntityRecord record in _snapshot)
// EffectPoseChanged is synchronous. Presentation is one consuming
// object-frame phase; recursive entry cannot consume legacy elapsed or
// a static prepared frame a second time.
_isPresenting = true;
try
{
if (!TryGetCurrent(runtime, record, out WorldEntity entity, out LiveEntityAnimationState animation))
continue;
PrepareAnimation(record, animation);
if (!TryGetCurrent(runtime, record, entity, animation))
continue;
schedules.TryGetValue(entity.Id, out LiveEntityAnimationSchedule schedule);
bool hasOrdinarySchedule = IsCurrentSchedule(runtime, schedule, record, entity, animation);
IReadOnlyList<PartTransform>? sequenceFrames = hasOrdinarySchedule
? schedule.SequenceFrames
: null;
bool composeParts = hasOrdinarySchedule && schedule.ComposeParts;
float legacyAdvanceSeconds = hasOrdinarySchedule
? schedule.LegacyAdvanceSeconds
: 0f;
ulong objectClockEpoch = record.ObjectClockEpoch;
ulong projectionVersion = record.ProjectionMutationVersion;
if (_staticFrames()?.TryTakeLivePartFrames(
record,
entity,
animation,
objectClockEpoch,
projectionVersion,
out IReadOnlyList<PartTransform> staticPartFrames) == true)
runtime.CopySpatialRootObjectRecordsTo(_snapshot);
foreach (LiveEntityRecord record in _snapshot)
{
if (!TryGetCurrent(
if (!TryGetCurrent(runtime, record, out WorldEntity entity, out LiveEntityAnimationState animation))
continue;
PrepareAnimation(record, animation);
if (!TryGetCurrent(runtime, record, entity, animation))
continue;
schedules.TryGetValue(entity.Id, out LiveEntityAnimationSchedule schedule);
bool hasOrdinarySchedule = IsCurrentSchedule(runtime, schedule, record, entity, animation);
IReadOnlyList<PartTransform>? sequenceFrames = hasOrdinarySchedule
? schedule.SequenceFrames
: null;
bool composeParts = hasOrdinarySchedule && schedule.ComposeParts;
float legacyAdvanceSeconds = hasOrdinarySchedule
? schedule.LegacyAdvanceSeconds
: 0f;
ulong objectClockEpoch = record.ObjectClockEpoch;
ulong projectionVersion = record.ProjectionMutationVersion;
ulong presentationRevision = animation.PresentationRevision;
if (_staticFrames()?.TryTakeLivePartFrames(
record,
entity,
animation,
objectClockEpoch,
projectionVersion,
presentationRevision,
out IReadOnlyList<PartTransform> staticPartFrames) == true)
{
if (!TryGetCurrent(
runtime,
record,
entity,
animation,
objectClockEpoch,
projectionVersion,
presentationRevision))
{
continue;
}
sequenceFrames = staticPartFrames;
composeParts = true;
}
if (animation.Sequencer is not null)
{
EmitSequenceDiagnostics(record, animation, sequenceFrames);
}
else
{
int span = animation.HighFrame - animation.LowFrame;
bool hiddenLegacy = (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0;
if (span <= 0 && !hiddenLegacy)
continue;
if (span > 0 && legacyAdvanceSeconds > 0f)
{
animation.CurrFrame += legacyAdvanceSeconds * animation.Framerate;
if (animation.CurrFrame > animation.HighFrame)
{
float over = animation.CurrFrame - animation.LowFrame;
animation.CurrFrame = animation.LowFrame + (over % (span + 1));
}
else if (animation.CurrFrame < animation.LowFrame)
{
animation.CurrFrame = animation.LowFrame;
}
}
}
if (!composeParts
|| !TryGetCurrent(
runtime,
record,
entity,
animation,
objectClockEpoch,
projectionVersion))
projectionVersion,
presentationRevision))
{
continue;
}
sequenceFrames = staticPartFrames;
composeParts = true;
}
if (animation.Sequencer is not null)
{
EmitSequenceDiagnostics(record, animation, sequenceFrames);
ComposeAndPublish(runtime, record, entity, animation, sequenceFrames,
objectClockEpoch, projectionVersion, presentationRevision);
}
else
{
int span = animation.HighFrame - animation.LowFrame;
bool hiddenLegacy = (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0;
if (span <= 0 && !hiddenLegacy)
continue;
if (span > 0 && legacyAdvanceSeconds > 0f)
{
animation.CurrFrame += legacyAdvanceSeconds * animation.Framerate;
if (animation.CurrFrame > animation.HighFrame)
{
float over = animation.CurrFrame - animation.LowFrame;
animation.CurrFrame = animation.LowFrame + (over % (span + 1));
}
else if (animation.CurrFrame < animation.LowFrame)
{
animation.CurrFrame = animation.LowFrame;
}
}
}
if (!composeParts
|| !TryGetCurrent(
runtime,
record,
entity,
animation,
objectClockEpoch,
projectionVersion))
{
continue;
}
ComposeAndPublish(runtime, record, entity, animation, sequenceFrames,
objectClockEpoch, projectionVersion);
}
finally
{
_isPresenting = false;
}
}
@ -177,7 +191,8 @@ internal sealed class LiveEntityAnimationPresenter
LiveEntityAnimationState animation,
IReadOnlyList<PartTransform>? sequenceFrames,
ulong objectClockEpoch,
ulong projectionVersion)
ulong projectionVersion,
ulong presentationRevision)
{
int partCount = animation.PartTemplate.Count;
EmitPartDiagnostics(record, animation, sequenceFrames, partCount);
@ -224,11 +239,11 @@ internal sealed class LiveEntityAnimationPresenter
});
}
if (!TryGetCurrent(runtime, record, entity, animation, objectClockEpoch, projectionVersion))
if (!TryGetCurrent(runtime, record, entity, animation, objectClockEpoch, projectionVersion, presentationRevision))
return;
entity.MeshRefs = meshRefs;
entity.SetIndexedPartPoses(rigidPoses, animation.PartAvailability);
if (!TryGetCurrent(runtime, record, entity, animation, objectClockEpoch, projectionVersion))
if (!TryGetCurrent(runtime, record, entity, animation, objectClockEpoch, projectionVersion, presentationRevision))
return;
_effectPoses.Publish(entity, rigidPoses, animation.PartAvailability);
}
@ -415,7 +430,8 @@ internal sealed class LiveEntityAnimationPresenter
entity,
animation,
schedule.ObjectClockEpoch,
schedule.ProjectionMutationVersion);
schedule.ProjectionMutationVersion,
schedule.PresentationRevision);
private static bool TryGetCurrent(
LiveEntityRuntime runtime,
@ -448,8 +464,10 @@ internal sealed class LiveEntityAnimationPresenter
WorldEntity entity,
LiveEntityAnimationState animation,
ulong objectClockEpoch,
ulong projectionVersion) =>
ulong projectionVersion,
ulong presentationRevision) =>
TryGetCurrent(runtime, record, entity, animation)
&& record.ObjectClockEpoch == objectClockEpoch
&& record.ProjectionMutationVersion == projectionVersion;
&& record.ProjectionMutationVersion == projectionVersion
&& animation.PresentationRevision == presentationRevision;
}