• Unity
  • changing animation on complete plays an unwanted frame

Related Discussions
...

Hi,
I'm implementing an animation variator component that defines some base animations, and some variants that can be played after a random number of loops.

The way I do this is by reacting to the "complete" event, then checking if the animation that just completed has any variants, and set the animation to that variant.

I recently noticed some weird behaviour between two animations, and what it seems to boil down to, is that the old animation is looped one last time, playing its first keyframe before the animation is changed, causing undesired results.

I could remove blending but that would lead to other problems.

how can I properly set the new animation on exactly the last frame of the previous animation?

Edit: I realise some things may not be very clear in my explanations, so I'll add a bit of explanation on my logic.

    
//on start I subscribe to the animation start event private void Start() { OnAnimationStart(skeletonAnimation.AnimationState.GetCurrent(0)); skeletonAnimation.AnimationState.Start += OnAnimationStart; }
//when a new animation starts I check if it has any variations. I then determine how many times I want to loop the animation before I play a variant.
//OnAnimationStart will only be called when the animation starts its first loop. Subsequent loops of the same animation won't enter this part of the code.
    private void OnAnimationStart(TrackEntry trackentry)
    {
        if (_currentTrack != null)
            _currentTrack.Complete -= OnAnimationComplete;
        _currentTrack = trackentry;
        if (_variationsDictionary.TryGetValue(_currentTrack.Animation.Name, out var variation))
        {
            _remainingLoops = Random.Range(variation.minLoops, variation.maxLoops+1);
            _currentTrack.Complete += OnAnimationComplete;
        }
    }
//whenever a loop of a base animation is finished, I decrement the loop counter. if this was the last loop, I set the animation to be a random variant. 
private void OnAnimationComplete(TrackEntry trackentry)
    {
        if (_variationsDictionary.TryGetValue(skeletonAnimation.AnimationName, out var variation))
        {
            _remainingLoops

---

;
            if (_remainingLoops <0 && variation.GetRandomVariation(out var variant))
            {
                _remainingLoops = Random.Range(variant.minLoops, variant.maxLoops+1);
                _currentTrack = skeletonAnimation.AnimationState.SetAnimation(0, variant.animation, true);
                if (variant.useAsBaseAnimation == false)
                    _currentTrack.Complete += e => OnVariantComplete(variation);
            }
        }
    }
    
// variants are treated a bit differently. once we've looped the variant a desired amount of times, we bet back to the base animation. private void OnVariantComplete(SpineAnimationVariation variation) { _remainingLoops --- ; if (_remainingLoops < 0) skeletonAnimation.AnimationState.SetAnimation(0, variation.baseAnimation, true); }

This is documented here:
AnimationStateListener complete
So you need to call AnimationState.Update() once to apply any SetAnimation() calls issued in the same frame:

skeletonAnimation.AnimationState.Update(0);

Complete and any other events are firing at the end of AnimationState.Update(), after playback time has "reached over" into the next loop iteration, which means past the first frame again already. Now if you don't call AnimationState.Update(), the animation state is left at the first animation frame and your SetAnimation call will be applied in the next frame's skeletonAnimation.Update() call, which is too late.

Well, I did read the doc, I tried this, and it didn't work. I also tried with and without Apply(mySkeleton), and different combinations of update and apply.
I ended up doing a bunch of extra shenanigans to use AddAnimation when the penultimate loop completes, and manipulate the trackEntry's "looping" value.

private void OnAnimationComplete(TrackEntry trackentry)
{
    if (_variationsDictionary.TryGetValue(skeletonAnimation.AnimationName, out var variation))
    {
        _remainingLoops

---

;
        if (_remainingLoops <= 0 && variation.GetRandomVariation(out var variant))
        {
            _remainingLoops = Random.Range(variant.minLoops, variant.maxLoops+1);
            //this avoids looping to the first frame after the next loop
            trackentry.Loop = false;
            _currentTrackEntry = skeletonAnimation.AnimationState.AddAnimation(0, variant.animation, _remainingLoops > 0, 0);
            if (variant.useAsBaseAnimation == false)
                _currentTrackEntry.Complete += e => OnVariantComplete(variation);
        }
    }
}

Sorry, after having a closer look I just noticed that it is required to also apply the AnimationState to the skeleton (which is also a part of SkeletonAnimation.Update). The animation events are raised from the end of the AnimationState.Apply() method, so this needs to be called to re-apply in the same frame and not wait one more frame.

So to resolve this, you need to either call:

skeletonAnimation.AnimationState.Update(0);
skeletonAnimation.AnimationState.Apply(skeletonAnimation.skeleton);

or (with a bit more unnecessary overhead):

skeletonAnimation.Update(0);

I remember having a similar issue which on my side was caused by two separate things:

  1. As described by Harald, but I went with full update:

    spineSkeletonAnimation.state.SetAnimation(0, ANIMATION_IDLE, true);
    spineSkeletonAnimation.Update(0f);
    

    Harald, wouldn't a manual calls also require a call to skeleton.UpdateWorldTransform() ?

  2. I was sometime spuriously getting two complete events very close next to one another (my idle loop animation has a duration of 1 second, and the time between the two events were < 100-200ms). It turned out this is caused during a new SetAnimation which caused a loop complete during the mix duration.

private void SpineAnimationOnComplete(TrackEntry entry)
{
    if (spineSkeletonAnimation.state.GetCurrent(0) != entry) {
        // this is a case where during the mix of two animations
        // we get a complete event of the old animation
        return;
    }
... real code below ...
vhristov wrote
  1. I was sometime spuriously getting two complete events very close next to one another (my idle loop animation has a duration of 1 second, and the time between the two events were < 100-200ms).

Very interesting, I have a somewhat related problem, where one transitional animation is <1s and sometimes but not always results in weird stuff happening. My supposition was that there may be some blending issues between the animations, but it never occurred to me that two "complete" events could potentially be raised during the same frame. Thanks for pitching in.

vhristov wrote

Harald, wouldn't a manual calls also require a call to skeleton.UpdateWorldTransform() ?

Thanks for chiming in on the subject.
skeleton.UpdateWorldTransform() should not be necessary to be called, since this line is called in ApplyAnimation() after event callbacks are raised here, so it will anyway be called in this line here in the same frame.
So only methods before the event callback need to be called again manually in the same frame.

vhristov wrote

2. I was sometime spuriously getting two complete events very close next to one another (my idle loop animation has a duration of 1 second, and the time between the two events were < 100-200ms). It turned out this is caused during a new SetAnimation which caused a loop complete during the mix duration.

Do you perhaps have a small reproduction project available for us that we could use for reproducing this behaviour? It would be great if you could send us a Unity project as a zip package to contact@esotericsoftware.com (briefly also mentioning this forum thread so that we know the context), then we can take a look at it and check what's going wrong.

Harald wrote

Do you perhaps have a small reproduction project available for us that we could use for reproducing this behaviour? It would be great if you could send us a Unity project as a zip package to contact@esotericsoftware.com (briefly also mentioning this forum thread so that we know the context), then we can take a look at it and check what's going wrong.

I believe I could be able to create a simple 100% reproducable project. I will give it a try.
I am just not 100% sure this is per-se a bug.

Basically you can see what happens in the following diagram:

For the sake of simpler illustration the diagram above presents:

  • Idle animation with duration 1s
  • Attack animation with duration 1s
  • Mix duration between the two animations of 0.66s

According to the documentation

Invoked every time this entry's animation completes a loop

And the track entry for the "Idle" animation does really completes at the time of the event.
Even though I haven't tested it I would suspect if I have a mix duration of more than 1.5 seconds I might get the Idle callback two times, and that to be expected.

Edit: I just sent you a repro unity project
I am attaching the script used here for reference to others too. I used Spine-boy with this script and the timings constants are for it. To reproduce the multiple events: Select "OnComplete" game object and check "trigger" in the inspector. The script will set the animation at the appropriate time to trigger multiple events.
(And I was right, having a mix of more than the animation duration resulted in more than 1 OnComplete events)

Thanks for sending the reproduction package and the detailed description. We just had a look at the project, however could not find anything unexpected happening. When we extend your debug output by the current absolute time, all timepoints are where they are expected, delta time between two consecutive OnComplete events for idleT is 1.66 seconds, delta time between two jumpT times is 1.33 seconds, with offset from the start being the 0.87 seconds (which the trigger event shows), and first animation ending after 4.985699 which is correctly located after 0.87 + 5.0 (start + mix duration):

triggered T: 0,8725948
On Complete idleT: 1,653958 T since previous: 1,653958
On Complete jumpT: 2,20225 T since previous: 0,5482914
On Complete idleT: 3,314261 T since previous: 1,112012
On Complete jumpT: 3,531734 T since previous: 0,2174721
On Complete jumpT: 4,870266 T since previous: 1,338533
On Complete idleT: 4,985699 T since previous: 0,1154327
On Complete jumpT: 6,204452 T since previous: 1,218752
On Complete jumpT: 7,532204 T since previous: 1,327752
On Complete jumpT: 8,866884 T since previous: 1,334681
On Complete jumpT: 10,20843 T since previous: 1,341548
On Complete jumpT: 11,539 T since previous: 1,330564

So every OnComplete event is located at starttime + n * duration as expected, with MixDuration keeping the first animation playing until starttime + MixDuration.

Maybe we misunderstood what you were seeing as unexpected timepoints, could you please describe which event timepoint values were unexpected for you?

Sorry I wasn't clear enough. As I said earlier I am not sure this is exactly a bug, but more of not clear enough documentation.

According to the documentation:

Invoked every time this entry's animation completes a loop

And this is exactly what happens. Every 1.66 seconds there is OnComplete for Idle animation and every 1.33 seconds there is an OnComplete event for the Jump animation. It is more of a documentation understanding issue on my side.

While reading the documentation I just got the wrong assumption that after SetAnimation(jump) the first OnComplete event will be about the jump animation, not possibly about the previous animation that is fading out in the mix.

triggered T: 0,8725948
On Complete idleT: 1,653958 T since previous: 1,653958 <

---

 can occur "randomly" based on the time of the trigger and mix duration
On Complete jumpT: 2,20225 T since previous: 0,5482914

After re-reading the docs and the source code a few times I found out that the OnComplete on every successful loop completion (independent of the fact it fades out, which makes sense now to me). What I would suggest is just a bit of extra clarification about this case in the documentation.

On my side I have the following character logic:

  • Idle animation
  • Fire animation (sends spine event when the bullet shall leave the muzzle)

And the following (pseudo) code:

CharacterState state;
void SpineAnimationOnComplete(TracEvent event)
{
    if (state != CharacterState.Idle) {
        spine.SetAnimation(0, "Idle", true);
        state = CharacterState.Idle;
    }
}

void StartFire()
{
    spine.SetAnimation(0, "Fire", false);
    state = CharacterState.Fire;
}

void SpineAnimationOnEvent(TrackEntry entry, Spine.Event e)
{
    if (e.Data.Name == "Fire") {
        SendBulletFromMuzzle();
    }
}

In the above case the mix duration was 0.2s and the "Fire" event was sent at 0.48s from the "Fire" animation's start.
Let's assume the following:

Case1 (bad):
T 0.0 -> SetAnimation(Idle) (from startup code)
T 0.8 -> SetAnimation(Fire) (from user pressing fire button and calling StartFire())
T 1.0 -> OnComplete -> from idle, but my state is fire, so I set SetAnimation(idle)
T 1.2 -> mix for Fire->Idle completed (exactly 0.4 seconds after the Fire started, no time for the event to fire)

Case2 (good):
T 0.0 -> SetAnimation(Idle) (from startup code)
T 0.7 -> SetAnimation(Fire) (from user pressing fire button and calling StartFire())
T 0.9 -> mix for Idle->Fire completed (before a new OnComplete event)
T 1.18 -> OnEvent() -> bullet is spawned
T 1.36 -> OnComplete -> from "fire", SetAnimation(Idle)

As you can see this created a race condition where the bullet was not spawned everytime.

This is what I mean by "unexpected" OnComplete. It is not "unexpected" from Spine's point of view. It was "unexpected" from my point of view.

And even though I am not sure gofiguregames is having the same issue, it seems like a potential source of a problem if miscounting the number of loops in a similar way.

P.S. I hope I made it more clear, not more confusing.

Thanks for the detailed and clear writeup, this makes a lot of sense and looks like a dangerous pitfall indeed! We will have a look at how we can improve the documentation to at least warn about this situation.