Preserve motion binding order when removing motions - #295
Draft
eugene-doobu wants to merge 1 commit into
Draft
Conversation
eugene-doobu
marked this pull request as draft
September 10, 2026 08:11
Keep completed bindings without swap-back overwrites. Not-tested: Player builds
eugene-doobu
force-pushed
the
fix/sequential-completed-value-updates
branch
from
September 10, 2026 08:55
ec07b50 to
3aea006
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replace swap-back removal in
MotionStorage.RemoveAll()with stable batch compaction so unrelated removals cannot reverse the binding order of surviving motions.The diff is limited to one runtime file,
MotionStorage.cs(23 additions / 34 deletions).Preserve(), completed-motion binding, and handle lifetime are left unchanged. No new API, setting, dependency, per-component pool, or package version change is introduced.Tradeoff: stable removal has a wider runtime scope than the Sequential symptom and increases the cost of sparse early deletions in large storages. The measurements and limitations below are part of this proposal, not a claim of a free performance improvement.
Problem and triggering conditions
A Sequential
LitMotionAnimationcan play consecutive motions that write to the same property, such as a linear float FadeIn (0 -> 1) followed by FadeOut (1 -> 0).Each step's handle is preserved. A completed preserved step remains in storage, and
UpdateRunnercontinues invoking its binding on subsequent updates. This lets the later step win while it is processed after the earlier step.However, the original
RemoveAt()fills a removed dense slot with the last entry. Removing an unrelated motion can therefore change survivor order:With swap-back, FadeOut writes its interpolated value and the completed FadeIn immediately overwrites it with
1. The fade can appear absent even though its motion advances and completes.The unrelated motion may target another object. Handles and targets are not mixed up: the error is the changed order of bindings that already target the same property. Sequential step-start order is distinct from the dense storage's binding order.
Deterministic reproduction
Using one
ManualMotionDispatcherand the same float options, adapter, and scheduler:Xfirst.0 -> 1over exactly1s, then FadeOut1 -> 0over1s.dispatcher.Update(1.0): FadeIn completes and FadeOut is registered.X, then calldispatcher.Update(0.0)to remove it.dispatcher.Update(0.25).Expected:
0.75. Original upstream:1. With this patch:0.75.The completed FadeIn binding still executes, its original handle remains active, and it is not canceled. It simply remains before FadeOut. Advancing the remaining
0.75sreaches0, with one completion callback for each step in this reproduction.Implementation
RemoveAll()now:Version == 0sentinel, and finds the first removed dense index.DenseIndex.The job-produced removal list contains unique dense indices but can be unordered because it is populated by parallel jobs. Marking all removals before moving data handles that ordering without sorting or another scratch buffer.
Compaction uses the storage's current tail, including motions appended by completion callbacks after job completion and before removal. The original free-list reuse order is retained.
The old
RemoveAt()helper and temporaryNativeArray<SparseIndex>copy are deleted. No additional allocation is introduced in the removal implementation.Compatibility and scope
UpdateRunnerbinding/callback conditions are unchanged from upstream.Validation
Baseline: upstream
ab6e92bfe78ff911def2fd3c4e9c79bb5a186946.Environment: Windows, Unity Editor
6000.3.17f1PlayMode, Test Framework1.6.0, Collections2.6.7, Burst1.8.29, Mathematics1.3.3; isolated local UPM harness.Time, stale-handle rejection after slot reuse, original free-list reuse order, unchanged input list, and cleared tails.CallbackTest.Test_WithCancelOnError:System.Exception : Test.DelayTest.Test_Delay_SkipValuesDuringDelay: expected less than0.899999976, actual1.SchedulerTest.Test_Scheduler_FixedUpdate: expectedTrue, actualFalse.git diff --checkpasses.The focused fixtures, invariant fixture, and benchmark are local validation only and are not included in this runtime-only PR. The full upstream suite, Player/IL2CPP builds, and minimum supported dependency versions have not been validated. Both original and patched full harness runs also reported 55 persistent individual allocations on Editor shutdown; their underlying allocator-lifecycle cause is outside this change.
Performance tradeoff
Original removal is O(k) swap-back work for k removals. Stable removal is O(k + affected suffix), up to O(n) for a single early removal. This can be a meaningful regression when a large shared storage frequently loses entries near its front.
The following are isolated RemoveAll median times in microseconds, not whole-frame times or device results. The actual method was bound to a delegate once; reflection, motion creation/reset, and assertions were outside the timed region. Each case used 5 warmups and 31 samples, float motions with a shared no-op binding, and a frame boundary between samples to release the original temporary scratch allocation.
For 16,384 motions / first-entry removal, isolated p95 increased from 15.0 to 1,751.0 us. A separate whole-
ManualMotionDispatcher.Updatemeasurement for that case increased from a median 1,040.3 to 1,868.9 us; job scheduling and host noise make those whole-update measurements less isolated.Timed sections reported 0 current-thread managed allocated bytes in both implementations; this does not measure native or worker-thread allocations. Deletion-free and dense-removal cases can improve, but they do not negate the sparse-removal cost. These Editor microbenchmarks should be evaluated against representative Player workloads before adopting the global ordering change.