Visitar URL original
fix: Release anim evaluator targets when clips are removed by ankit1324 · Pull Request #9861 · playcanvas/engine · GitHub
Skip to content

fix: Release anim evaluator targets when clips are removed - #9861

Open
ankit1324 wants to merge 1 commit into
playcanvas:mainfrom
ankit1324:fix/anim-evaluator-remove-clip
Open

ankit1324 wants to merge 1 commit into
playcanvas:mainfrom
ankit1324:fix/anim-evaluator-remove-clip

Conversation

@ankit1324

Copy link
Copy Markdown

Description

AnimEvaluator.removeClip resolved each curve path through the binder again and decremented curves on the binder's AnimTarget. That object is not the evaluator's own target record, so the count became NaN and never reached 0. This had two effects:

  • The removed clip's targets stayed in the evaluator and were written with their last value every frame.
  • AnimTargetValue.layerCounter was never decremented. Every AnimEvaluator.rebind() raised it, whether it came from layer.rebind() or from assignAnimation() while playing. The per-frame reset in update() then ran only every other frame, and layers with weight < 1 oscillated (5 -> 7.5 -> 5 in the tests).

User-facing change: removed or replaced clips no longer keep writing their last pose, so later script changes to those properties now stick. This applies to AnimComponent and to the legacy AnimationComponent with anim tracks.

Changes:

  • AnimEvaluator.removeClip walks the clip's own output records. When a record's last curve is removed, it deletes the record and, on the AnimTargetValue that addClip counted towards, decrements layerCounter and marks the layer as no longer animating the target. The record keeps a reference to that value because AnimComponent.rebind() replaces its targets map.
  • The frame reset in update() uses counter >= layerCounter. A layer that stops animating a target lowers layerCounter while the counter for that frame can already be ahead.
  • AnimTargetValue now records which layers animate it (one Int8Array per target value, allocated in the constructor). addClip and removeClip update it through the new setLayerBound(), which recomputes the masks. With normalizeWeights, a layer is masked out while any overwrite layer above it animates the target; otherwise each layer's mask is just whether it animates the target, as before. setMask is unchanged.

Once removal releases targets, layers start and stop animating a target on every state change, so the mask has to come from the current set of layers. Main's setMask only cleared lower layers when an overwrite layer bound, so the result depended on bind order. With this change it does not: a lower layer that starts animating a target again stays masked while an overwrite layer above it still animates it, and it takes over once that layer stops.

One behaviour change: with normalized weights, an overwrite layer with weight 0 that animates a target masks the layers below it in every bind order, so nothing writes the target. On main this happened only when the overwrite layer bound the target after the lower layer.

Binder targets stay cached, as before. Releasing them through DefaultAnimBinder.unresolve is out of scope on purpose: that method has never run and has its own bugs. It can be a follow-up.

Not changed: a paused layer that still holds clips does not advance its counter, so other layers blending the same target are still affected. AnimComponent.loadStateGraph replaces the layers without removing the old evaluators' clips (pre-existing, possible follow-up).

Tests (AnimEvaluator, AnimComponent and AnimationComponent):

  • a removed clip no longer writes its target, including after layer.play() without a graph transition, and after legacy AnimationComponent.play() both without blending and through the blend path that removes all but the last clip
  • a target shared by two clips stays animated until both are removed
  • partially weighted layers stay stable after assignAnimation() and rebinds
  • layers stop contributing once they no longer animate a target, with and without normalized weights
  • with normalized weights and three overwrite layers, the highest layer that animates the target wins after the top layer stops and the bottom layer re-binds
  • with normalized weights, a lower layer takes over when the overwrite layer above it stops animating the target, and stays masked while that layer still animates it

On main, 12 of the 15 new tests fail. The other 3 guard behaviour that already worked: world transforms keep updating after removals, and the overwrite mask after a lower layer changes state. npm test shows 4550 passing and 0 failing (4535 on main), and npm run lint, npm run build:types and npm run test:types pass.

Fixes #6150

Checklist

  • I have read the contributing guidelines
  • My code follows the project's coding standards
  • This PR focuses on a single change

AnimEvaluator.removeClip re-resolved each curve path and decremented the
binder's AnimTarget instead of the evaluator's own target record, so
removed targets were never released. Removed clips kept writing their last
value and every rebind added to the layer counters of the anim component
targets, which broke layer blending.

removeClip now releases the evaluator's own records and undoes the layer
counter of the anim component target value. The target value tracks which
layers animate it and, with normalized weights, masks out every layer below
the highest overwrite layer that animates it, so the result no longer
depends on the order in which layers start or stop animating the target.

Fixed playcanvas#6150
@mvaligursky

Copy link
Copy Markdown
Contributor

Thanks, the diagnosis is right, and walking _outputs[index] with the layerTarget reference looks like the correct fix. I found one regression in the layer counter, plus a small cleanup.

A lower layer's contribution is lost when it starts animating a target again

The frame reset still depends on counter >= layerCounter, which assumes every frame starts at the same layer. Before this PR, records were never released, so layerCounter almost never changed after the first bind. Now it changes on every state change that drops or picks up a target. That breaks the reset when the layer picking up a target sits below every layer that already animates it.

Here's what happens if you extend stops blending a layer once it no longer animates the target with a return to Move:

root.anim.baseLayer.transition('Move');
app.systems.anim.onAnimationUpdate(0.1);
app.systems.anim.onAnimationUpdate(0.1);
expect(root.getLocalPosition().x).to.be.closeTo(12.5, 1e-5);
  • While Base is in Idle, only Upper writes the target, so layerCounter is 1 and counter is 1 at the end of each frame.
  • transition('Move') adds the clip back, so layerCounter becomes 2 while counter is still 1.
  • On the next frame, Base sees 1 < 2, skips the reset and blends onto last frame's result. Upper then sees 2 >= 2, resets to the base value and blends its own value.
  • This repeats every frame, so the reset always happens one layer too late and Base's contribution is overwritten. x stays at 10 instead of 12.5. On main this sequence gives 12.5, because Base's record was never released.

When the layer that picks the target up again is above or between the existing layers, the counter recovers on the next frame, so only the lowest-layer case breaks. The re-acquire tests in this PR all have that lower layer masked by an overwrite layer, which hides the problem.

One way to fix it is to detect the first write of a frame without counting layers. For example, reset when binder.layerIndex <= the index of the last layer that wrote the target (layers update in index order), or stamp target values with a per-component update id. With either approach layerCounter is no longer needed for the reset. It would also fix the existing case on main where an upper layer binds a target before the lower layer does.

Minor

AnimTargetValue.setMask has no callers in src any more; only anim-target-value.test.mjs uses it. Calling it would also put mask out of step with _bound. Could it be removed, with those tests moved over to setLayerBound?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Invalid Anim curves counting

2 participants