valorware

Hi, I'm having a weird issue with mixingFrom as a linked list. It's very difficult for me to explain and give repro steps but I'll do my best.
This Spine-Unity runtime works with data exported from Spine Editor version: 4.1.xx
Package version: spine-unity-4.1-2022-10-04.unitypackage
Imagine the Scenario:

1) Play "Roll" Animtion
2) Once finished, play "Idle" animation with MixDuration of 0.5 seconds (quite high!)
3) After 0.25s, play "Run" animation with MixDuration of 0.5 seconds
then repeat steps 2 and 3 over and over again, so you're constantly playing Idle/Run animations 0.25s apart with 0.5s mix time

The bug:
the "Roll" animation seems to retain it's place in the mixingFrom linked list, it never expires, even though it was only played once at the start. I get weird visuals from the blending trying to pull from the Roll animation still, indefinitely (so i still see a fragment of the Roll animation after like 5 minutes of not playing it, aslong as i keep playing idle/walk in the pattern mentioned above)

I suspect it has something to do with mixingFrom linked list not updating properly, specifically the old ones. I never used to have this issue, I assume because mixingFrom is fairly new-ish? Seen from this thread: How to disable multipleMixing in spine3.8

I'm looking suspectly specifically at this snippet of code in AnimationState, although I don't quite fully understand it, I'm having difficulty seeing how the Roll animation would ever expire/finish from the above^

Is this something you could take a look at and try to figure out if I'm correct or not?

If I change the blend time to lower than the 0.25 switching value above, it's fine - it just seems to be anything over the time i am switching between new animations

It would also be nice if there was a way to disable the linked list behaviour of mixingFrom, without that workaround posted in the thread i linked above. I understand there would be a little "animation jump" when mixing between 3 animations, but I'd rather have that than this behaviour and possibly would favor the performance of non-linked-list mixing anyway
/// <summary>Returns true when all mixing from entries are complete.</summary>
private bool UpdateMixingFrom (TrackEntry to, float delta) {
TrackEntry from = to.mixingFrom;
if (from == null) return true;

bool finished = UpdateMixingFrom(from, delta);

from.animationLast = from.nextAnimationLast;
from.trackLast = from.nextTrackLast;

// Require mixTime > 0 to ensure the mixing from entry was applied at least once.
if (to.mixTime > 0 && to.mixTime >= to.mixDuration) {
// Require totalAlpha == 0 to ensure mixing is complete, unless mixDuration == 0 (the transition is a single frame).
if (from.totalAlpha == 0 || to.mixDuration == 0) {
to.mixingFrom = from.mixingFrom;
if (from.mixingFrom != null) from.mixingFrom.mixingTo = to;
to.interruptAlpha = from.interruptAlpha;
queue.End(from);
}
return finished;
}

from.trackTime += delta * from.timeScale;
to.mixTime += delta;
return false;
}
EDIT

I just created a little function for myself inside AnimationState to prevent mixingFrom linkedList to never exceed more than 1 mixing from and it seems to solve my problems, i.e. by disabling the linked list behaviour entirely. I would much rather have an official way to do this if possible!!
public void ClearMixingFromDepth()
{
// Ensure no linked list
if (mixingFrom != null) mixingFrom.mixingFrom = null;
}
valorware
  • Posts: 21

Nate

AnimationState can be difficult to follow and has an enormous number of edge cases. The state required to get rid of a "mixing from" entry is in updateMixingFrom. A mixing from entry may be kept even though the mixing is complete. This is to avoid "dipping" (the HOLD_MIX private flag).

That said, it's difficult to speculate without a code sample. Can you write code for the AnimationState calls so we can run it and see the behavior you described? It's easiest to use an example project, like spineboy. The animations used don't need to make much sense, it could be eg run and jump.
User avatar
Nate

Nate
  • Posts: 12213

valorware

Nate wrote:AnimationState can be difficult to follow and has an enormous number of edge cases. The state required to get rid of a "mixing from" entry is in updateMixingFrom. A mixing from entry may be kept even though the mixing is complete. This is to avoid "dipping" (the HOLD_MIX private flag).

That said, it's difficult to speculate without a code sample. Can you write code for the AnimationState calls so we can run it and see the behavior you described? It's easiest to use an example project, like spineboy. The animations used don't need to make much sense, it could be eg run and jump.
Thanks, I managed to reproduce it in a Spine example scene. Repro steps here:

1) Create new Unity project and import Spine unity runtime package including examples, I used 4.1.2022
2) Open example scene "Getting Started, 2 Controlling Animation"
3) Remove the "Spine Beginner 2" script from SpineBoy object
4) Attach the test script below to SpineBoy object and drag the SkeletonAnimation to into the inspector. The 3 animations should default to "portal", "idle" and "walk".
5) Press play and observe portal animation, but then script will toggle between idle/walk - which MIGHT look fine to you- however!!!! what is actually playing is incorrect, as it is still pulling from some portion of the portal animation.
6) Uncheck "m_DoTheFirstAnimation" from the test script and you can see the difference - this is what idle/walk switching is supposed to look like!

Test Script:
using Spine.Unity;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class spineTest : MonoBehaviour
{
// Settable
public SkeletonAnimation m_SkeletonAnimation;

public bool m_DoTheFirstAnimation = true;

public string m_Anim_First = "portal";

public string m_Anim_Idle = "idle";
public string m_Anim_Walk = "walk";


// Runtime
[System.NonSerialized] private float m_TimeDelay = 0.3f;
private bool m_HasDoneFirstAnimation = false;
private bool m_IsDoingWalk = false;

// Const
private const float MIX_TIME = 0.5f;

//////////////////////////////////
/// METHODS -

void PlayAnim(string animNameIn, bool loopIn)
{
//
var track = m_SkeletonAnimation.AnimationState.SetAnimation(0, animNameIn, loopIn);
track.MixDuration = MIX_TIME;

}

// Update is called once per frame
void Update()
{
// Check
if (m_TimeDelay > 0f)
{
m_TimeDelay -= Time.deltaTime;
return;
}

// Check
if (m_DoTheFirstAnimation && m_HasDoneFirstAnimation == false)
{
// Play first animation
PlayAnim(m_Anim_First, false);
m_HasDoneFirstAnimation = true;

// Wait a bit before doing idle/walk
m_TimeDelay = 0.7f;
}
else
{
// Switch between idle/walk
PlayAnim(m_IsDoingWalk ? m_Anim_Idle : m_Anim_Walk, true);

// Toggle
m_IsDoingWalk = !m_IsDoingWalk;

// Delay
m_TimeDelay = 0.35f;
}
}
}
A comparison video of the two, left is with portal animation played first and therefore contaminating future animations. This is obviously incorrect as the left should look like the right (after a short time)

https://youtu.be/UMIZUY37alQ

---

The reason this is important - first of all its really affecting my combat system where im sometimes playing animations quicker than they blend (imagine a quick sword slash, back to idle, then player immediately wants to do another sword slash, etc), and therefore get noticeable stray rotations from old animations (roll, played ages ago)

Secondly, players could do some sort of cheat or unwanted behaviour/bugs where they could execute a certain animation and then quickly toggle between idle/move to carry out some sort of exploit or just general jankiness. Obviously this is a very small chance but it does seem like a weak point in the spine runtime

Thank you
valorware
  • Posts: 21

Nate

Sorry for the delay!

Thanks for the reproduction code, it is helpful. For posterity, here's simplified code to repro in the SV/reference runtime:
float delay;
...
if
(delay > 0f)
delay -= Gdx.graphics.getDeltaTime() * ui.speedSlider.getValue();
else if (state.getCurrent(0) == null) {
state.setAnimation(0, "portal", false);
delay = 0.7f;
} else if (!Gdx.input.isTouched()) {
TrackEntry entry = state.setAnimation(0, "idle", false);
entry.mixDuration = 0.5f;
delay = 0.35f;
}
First some words about the problem, mostly in case it helps future me comprehend without needing to start from scratch. It comes from both HOLD_FIRST and HOLD_MIX behavior:
"
A -> B -> C -> D where A, B, and C have a timeline setting same property, but D does not. When A is applied, to avoid "dipping" A is not mixed out, however D (the first entry that doesn't set the property) mixing in is used to mix out A (which affects B and C). Without using D to mix out, A would be applied fully until mixing completes, then snap to the mixed out position.
The problem is D never finishes mixing in because new animations are set continuously. HOLD_MIX is used for portal -> idle and new idle animations are set such that there is always an idle animation mixing in or out. An idle animation never mixes in completely, so the influence from portal persists.


To avoid dipping we need an animation to provide a "reasonable pose" that the other animations are mixing on top of. That's why A (portal) is kept around until an animation mixes in all the way, effectively taking over. Getting rid of A before another animation mixes in all the way will cause snapping because the setup pose would instantly become the "reasonable pose". That may be more reasonable in many cases, but could also be just as bad.

If that is acceptable, meaning snapping is acceptable, one solution could be to limit the "mixing from" linked list to only 2 entries. The way to do that is:
TrackEntry entry = state.setAnimation(0, "idle", false);
entry.mixDuration = 0.5f;
if (entry.mixingFrom != null && entry.mixingFrom.mixingFrom != null) entry.mixingFrom.setMixDuration(0);
Exactly the same thing could be done before setting the new entry, and may be a little easier to understand:
TrackEntry current = state.getCurrent(0);
if (current != null && current.mixingFrom != null) current.setMixDuration(0);
TrackEntry entry = state.setAnimation(0, "idle", false);
entry.mixDuration = 0.5f;
This code (either one) means that if a mix is in progess, eg A -> B, and animation C is set, A is discarded so you have B -> C, where B is mixing out to the setup pose instead of mixing out to A.

Setting the mix duration to 0 is the best way to terminate a mix because it allows the track entry objects to be cleaned up (some runtimes keep a pool) the next time AnimationState update is called. I'll add this to the docs.

Another option might be TrackEntry holdPrevious. That means for a mix A -> B, normally A would be mixed out while B is mixed in. With holdPrevious, A is applied as normal, then B is mixed in. When the mix is complete, A is no longer applied, which means usually you want to key everything in B that you keyed in A. Since you likely change between many different animations, that can quickly lead to needing to key nearly everything in every animation.

The original problem is actually worse with holdPrevious = true because the linked list grows indefinitely: A -> B where B has holdPrevious, meaning A is applied as normal while B mixes in. Currently we keep A until there are no mixing from entries, so for A -> B -> C we keep A until both A and B mixes are complete. If new animations are added, mixing doesn't complete and everything is kept.

This could be changed in updateMixingFrom by adding a condition to if (from.totalAlpha == 0 || to.mixDuration == 0) {. To get rid of A as soon as the A -> B mix (with holdPrevious) is complete:
if (from.totalAlpha == 0 || to.mixDuration == 0 || (finished && to.holdPrevious)) {
I'm not sure we want to make this change, as someone could be relying on the old behavior, but you could try it out by modifying your runtime source. You'd then set your animation like:
TrackEntry entry = state.setAnimation(0, "idle", false);
entry.mixDuration = 0.5f;
entry.holdPrevious = true;
Lastly, while of course seeing such weird animation is bad, it shouldn't affect gameplay. Ideally the MVC or similar design pattern is used to decouple the animation system from the game state. That can simplify game logic and managing game state and also helps if you need to serialize the game state. You can Google for the "MVC design pattern". Super Spineboy uses it:
https://github.com/EsotericSoftware/spine-superspineboy/tree/master/src/com/esotericsoftware/spine/superspineboy
User avatar
Nate

Nate
  • Posts: 12213

valorware

Thanks Nate, I didn't expect the problem to be that complicated so I appreciate the detailed response.

I think for my situation I'll go with "limit the "mixing from" linked list to only 2 entries." - any snapping in my circumstance should be acceptable.
Thanks for the solution

-

I understand that this may be an edge case and it shouldn't affect gameplay, but I do worry for future devs also encountering this problem and not knowing how to diagnose it - it took me a good few hours to investigate and find the issue. I do wonder if a built-in way to limit the linked list easily would be suitable, such as setting "linked list depth = 2" or whatever.

Visually, what I was seeing was my character coming out of a roll animation, and then kinda being stuck upside-down as i flick between idle/attack after the roll. Similarly, I suspect this may be the case in other people's games if they come out of a jump animation and idle/attack/walk and just seeing their legs still in the air from the jump animation.

Thank you anyway, I can proceed with my situation. Thanks for the swift response!
valorware
  • Posts: 21


Return to Bugs