Runtime Animation State Event in Unity
Although an avid game player, I hardly ever paid attention to human-like humanoid animations featured by prominent AAA game studios in recent games. When I started to develop my own game, it took little time to realize that imitating human motions in a game is extremely difficult. I noticed that the realistic animations are not given for free, but are fruits achieved by investing time and money.
One of the hardship for implementing realistic humanoid animations was to make a humanoid model interact with objects; picking, grabbing, and releasing things with hands. The interactions are not done as simply as in reality. The most problematic situation in my game project emerged when I was trying to implement a reloading motion of a rifle. The reloading procedure looked much more complicated than I initially expected.
- Split the entire reloading motion into three separate clips; grabbing the magazine, changing the magazine, and pulling the bolt.
- Normally, the rifle is a child of the right hand and the magazine is a child of the rifle.
- Once a soldier grabs the magazine, the magazine moves to his grip. That is, the magazine that was previously a child of the rifle has now become a child of the left hand.
- Play the magazine switching motion. Once the clip finishes, put the magazine back in its original position. The magazine now has become a child of the rifle once again.
- Now, grab the rifle with the left hand and pull the bolt with the right hand.
- Finally, grab the rifle with the right hand and the initial state has been restored.
The point here is that transfers of the magazine and the rifle take place at the start or at the end of each clip. How can we move the magazine on time?
Alternatives
‘yield return’ Method
One immediate idea is to write a code like this.
animator = GetComponent<Animator>();
AnimatorStateInfo stateInfo = animator.GetCurrentAnimatorStateInfo(0);
...
// (1) Play the grabbing motion
yield return new WaitUntil(() => !stateInfo.IsName("Grabbing magazine")); // (2) Wait until the grabbing clip ends
// (3) Transfer the magazine here
This yield return
method works for this purpose in a coroutine block. However, the control flow can pass by the yield statement whenever the animator is not in the “Grabbing magazine” state. There is no guarantee that the grabbing motion has been played even though the control has already arrived at (3). So one more yield statement should take place to prevent this kind of trespassing.
animator = GetComponent<Animator>();
AnimatorStateInfo stateInfo = animator.GetCurrentAnimatorStateInfo(0);
...
// (1) Play the grabbing motion
yield return new WaitUntil(() => stateInfo.IsName("Grabbing magazine")); // (2) Wait until the grabbing clip starts
yield return new WaitUntil(() => !stateInfo.IsName("Grabbing magazine")); // (3) Wait until the grabbing clip ends
// (4) Transfer the magazine here
Now the grabbing motion is guaranteed to play and the migration of the magazine happens at the moment the clip ends. But doing this for all the complicated states that a human can perform ‘yields’ a bunch of mistakes. The ‘yield return’ is often unblocked inadvertently and following lines are shot at unwanted timing.
StateMachineBehaviour
StateMachineBehaviour
is a Unity script that can be attached to a state in an animator controller to define custom behaviors that are executed when a transition happens between states. When attached to a state in a state machine, a StateMachineBehaviour
script provides a set of methods that are called at specific points in the state machine’s lifecycle; OnStateEnter
, OnStateUpdate
, OnStateExit
, OnStateMove
, and OnStateIK
. The Unity manual contains a perfect description of when they are called. One example of using StateMachineBehaviour
from that reference looks like this.
using UnityEngine;
public class AttackBehaviour : StateMachineBehaviour
{
public GameObject particle;
public float radius;
public float power;
protected GameObject clone;
override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
}
override public void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
Destroy(clone);
}
override public void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
Debug.Log("On Attack Update ");
}
override public void OnStateMove(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
Debug.Log("On Attack Move ");
}
override public void OnStateIK(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
Debug.Log("On Attack IK ");
}
}
We could write some codes inside those function bodies, and certainly, they will be executed on time. This time, we can be certain that each callback will be executed at the beginning or end of each state.
However, I decided not to make do something directly in these callbacks for the following reason; stateInfo only includes the hash of the target state, not its name. It is not possible to retrieve a state name directly from its stateInfo. If we want to track the name of the state this animator controller is on, a series of if-statements are required like below.
override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
// Track which state we are entering
if (stateInfo.IsName("Grabbing magazine")) // Check if the state has name "Grabbing magazine"
{
// Behaviours when "Grabbing magazine" starts
}
else if (stateInfo.IsName("Switching magazine")) // Check if the state has name "Switching magazine"
{
// Behaviours when "Switching magazine" starts
}
...
else if (stateInfo.IsName("Walking"))
{
// Behaviours when "Walking" starts
}
}
Whenever I add a new state to the animator controller, I have to add an else if
to each callback. Definitely a nonsense.
What makes doing something inside the StateMachineBehaviour
worse is that each component attached to a state is reset once the Animator
is disabled, then is enabled again. This makes it difficult for StateMachineBehaviour
to manage internal states.
StateEvent and AnimatorEvent
To complement the weaknesses of StateMachineBehaviour
, another normal MonoBehaviour
script named is required. Name the class inherited from StateMachineBehaviour
as StateEvent
and the usual Monobehaviour
class as AnimatorEvent
. Our strategy is to first let AnimatorEvent
accept listeners for each state and store them in an internal data structure. StateEvent
does nothing but signal the AnimatorEvent
class to invoke the registered callbacks.
StateEvent
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using System.Linq;
public enum EventType { Enter, Exit, Update, Move, IK }
public class StateEvent : StateMachineBehaviour
{
private AnimatorEvent animatorEvent;
public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
if (animatorEvent == null) animatorEvent = animator.GetComponent<AnimatorEvent>();
animatorEvent.OnState(animator, stateInfo, layerIndex, EventType.Enter);
}
public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
if (animatorEvent == null) animatorEvent = animator.GetComponent<AnimatorEvent>();
animatorEvent.OnState(animator, stateInfo, layerIndex, EventType.Exit);
}
public override void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
if (animatorEvent == null) animatorEvent = animator.GetComponent<AnimatorEvent>();
animatorEvent.OnState(animator, stateInfo, layerIndex, EventType.Update);
}
public override void OnStateMove(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
if (animatorEvent == null) animatorEvent = animator.GetComponent<AnimatorEvent>();
animatorEvent.OnState(animator, stateInfo, layerIndex, EventType.Move);
}
public override void OnStateIK(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
if (animatorEvent == null) animatorEvent = animator.GetComponent<AnimatorEvent>();
animatorEvent.OnState(animator, stateInfo, layerIndex, EventType.IK);
}
}
StateEvent
does only two things whenever each StateMachineBehaviour
callback is executed.
- It tries to cache
AnimatorEvent
every time each callback is called since the caching field might have been invalidated after disabling and enabling the animator theStateEvent
is working with. - Conveys the parameters to the corresponding method of
AnimatorEvent
.
AnimatorEvent
...
[RequireComponent(typeof(Animator))]
public class AnimatorEvent : MonoBehaviour
{
public class CallbackEvent : UnityEvent<AnimatorStateInfo, EventType, int>
{
}
private Animator animator;
private int layerCount;
private int[] fullIndices; // Includes all layer indices
private Dictionary<int, Dictionary<EventType, List<CallbackEvent>>> tagEvents = new Dictionary<int, Dictionary<EventType, List<CallbackEvent>>>(); // tagHash -> eventType -> layerIndex -> event
...
private void Awake()
{
animator = GetComponent<Animator>();
layerCount = animator.layerCount;
fullIndices = Enumerable.Range(0, layerCount).ToArray();
}
// ==================== Handle listeners with a tag ====================
public void AddListenerWithTypeAndTag(UnityAction<Animator, AnimatorStateInfo, int, EventType> action, EventType eventType, string tag, params int[] layerIndices)
{
layerIndices = layerIndices.Length == 0 ? fullIndices : layerIndices;
int tagHash = Animator.StringToHash(tag);
if (!tagEvents.ContainsKey(tagHash))
{
IEnumerable<CallbackEvent> unityEvents = Enumerable.Range(0, layerCount).Select(i => new CallbackEvent());
tagEvents[tagHash] = new Dictionary<EventType, List<CallbackEvent>>()
{
{ EventType.Enter, unityEvents.ToList() },
{ EventType.Exit, unityEvents.ToList() },
{ EventType.Update, unityEvents.ToList() },
{ EventType.Move, unityEvents.ToList() },
{ EventType.IK, unityEvents.ToList() },
};
}
foreach (int layerIndex in layerIndices)
{
tagEvents[tagHash][eventType][layerIndex].AddListener(action);
}
}
public void ClearListenersWithTypeAndTag(EventType eventType, string tag, params int[] layerIndices)
{
layerIndices = layerIndices.Length == 0 ? fullIndices : layerIndices;
int tagHash = Animator.StringToHash(tag);
if (tagEvents.ContainsKey(tagHash))
{
foreach (int layerIndex in layerIndices)
{
tagEvents[tagHash][eventType][layerIndex].RemoveAllListeners();
}
}
}
public void ClearAllListenersWithTag(string tag, params int[] layerIndices)
{
layerIndices = layerIndices.Length == 0 ? fullIndices : layerIndices;
int tagHash = Animator.StringToHash(tag);
if (tagEvents.ContainsKey(tagHash))
{
foreach (var kv in tagEvents[tagHash])
{
foreach (int layerIndex in layerIndices)
{
kv.Value[layerIndex].RemoveAllListeners();
}
}
}
}
...
// ==================== StateEvent calls this to invoke an event ====================
public void OnState(Animator animator, AnimatorStateInfo stateInfo, int layerIndex, EventType eventType)
{
if (tagEvents.ContainsKey(stateInfo.tagHash))
{
tagEvents[stateInfo.tagHash][eventType][layerIndex].Invoke(animator, stateInfo, layerIndex, eventType);
}
...
}
}
AnimatorEvent
carries out the actual work we are trying to achieve.
tagEvents
stores and classifies callbacks with the tag and layer of the state and the event type(enter, exit, …)AddListenerWithTypeAndTag
provides an interface so that clients can subscribe to the state events by registering listener callbacks. It actually adds the listener totagEvents
.ClearListenersWithTypeAndTag
andClearAllListenersWithTag
provide a way to remove listeners with type and tag, or only with a tag respectively.OnState
is called byStateEvent
whenever eachStateMachineBehaviour
callback is executed. It invokes a suitable event stored intagEvents
with incoming parameters, forwarding the arguments to the event.
Although I omitted here, I also added equivalent methods that designates states to add and remove listeners by their name.
BeforeTransition and AfterTransition
One pitfall of the state events is the timing of Exit
and Enter
events. When transitions with non-zero durations are involved, Unity does not fire them at the moments we expect.
From the above figure, an Enter
event for the Sleeping
state is triggered on ‘Duration in’, while an Exit
event for the Patrolling
state is triggered on ‘Duration out’. There could be a scenario where we want to detect the points where the Patrolling
clip ends and Sleeping
clip begins, ruling out the transition period? Let’s introduce two new events to indicate those timings; BeforeTransition
for Patrolling
and AfterTransition
for Sleeping
, respectively.
Implementing those events starts with a simple idea of tweaking Exit
and Enter
events. The idea is visualized in the following diagram.
This works perfectly fine for most of the scenarios, until we set the transition duration to zero, in which case the state changes instantly. In such cases, an Exit
event for Patrolling
is triggered right before an Enter
event for Sleeping
. This inconsistency of order disallows our aforementioned naive implementation.
Tracking the count of overlapped states makes it feasible to distinguish those two cases. The overlapping count increments or decrements by the three simple rules.
- Initialized to 0.
- Increments by one on
Enter
event. - Decrements by one on
Exit
event.
With the overlapping count, we can now handle ‘Exit
- Enter
(transition duration is zero)’ and ‘Enter
- Exit
(transition duration is non-zero)’ cases separately.
- On
Exit
forPatrolling
- Overlapping count == 1:
Exit
-Enter
- Overlapping count == 2:
Enter
-Exit
- Overlapping count == 1:
- On
Enter
forSleeping
- Overlapping count == 0:
Exit
-Enter
- Overlapping count == 1:
Enter
-Exit
- Overlapping count == 0:
In the Exit
- Enter
cases, we may
- Trigger
BeforeTransition
forPatrolling
onExit
forPatrolling
. - Trigger
AfterTransition
forSleeping
onEnter
forSleeping
.
Quite simple, isn’t it?
Note that in the real implementation, those overlapping count is tracked independently for each layer. With this in mind, the additional code for BeforeTransition
and AfterTransition
sounds straightforward.
public void OnState(Animator animator, AnimatorStateInfo stateInfo, int layerIndex, EventType eventType)
{
// ========== Invoke BeforeTransition and AfterTransition events ==========
if (eventType == EventType.Enter)
{
Debug.Assert(0 <= overlappingCounts[layerIndex] && overlappingCounts[layerIndex] <= 2, $"Invalid overlapping count on Enter: {overlappingCounts[layerIndex]}");
bool ExitBeforeEnter = (overlappingCounts[layerIndex] == 0);
++overlappingCounts[layerIndex];
EventType transitionEventType = ExitBeforeEnter ? EventType.AfterTransition : EventType.BeforeTransition;
// Invoke BeforeTransition event on the previous transition state
int? transitionStateTagHash = ExitBeforeEnter ? stateInfo.tagHash : transitionStateTagHashes[layerIndex];
if (transitionStateTagHash != null && tagEvents.ContainsKey((int)transitionStateTagHash))
{
tagEvents[(int)transitionStateTagHash][transitionEventType][layerIndex].Invoke(animator, stateInfo, layerIndex, transitionEventType);
}
transitionStateTagHashes[layerIndex] = stateInfo.tagHash; // Update transition state
// Same for the fullPath
transitionStateFullPathHash = ExitBeforeEnter ? stateInfo.fullPathHash : transitionStateFullPathHash;
if (transitionStateFullPathHash != null && fullPathEvents.ContainsKey((int)transitionStateFullPathHash))
{
fullPathEvents[(int)transitionStateFullPathHash][transitionEventType].Invoke(animator, stateInfo, layerIndex, transitionEventType);
}
transitionStateFullPathHash = stateInfo.fullPathHash;
}
else if (eventType == EventType.Exit)
{
Debug.Assert(0 <= overlappingCounts[layerIndex] && overlappingCounts[layerIndex] <= 2, $"Invalid overlapping count on Exit: {overlappingCounts[layerIndex]}");
bool ExitBeforeEnter = (overlappingCounts[layerIndex] == 1);
--overlappingCounts[layerIndex];
EventType transitionEventType = ExitBeforeEnter ? EventType.BeforeTransition : EventType.AfterTransition;
// Invoke BeforeTransition event on the previous transition state
int? transitionStateTagHash = ExitBeforeEnter ? stateInfo.tagHash : transitionStateTagHashes[layerIndex];
if (transitionStateTagHash != null && tagEvents.ContainsKey((int)transitionStateTagHash))
{
tagEvents[(int)transitionStateTagHash][transitionEventType][layerIndex].Invoke(animator, stateInfo, layerIndex, transitionEventType);
}
// Same for the fullPath
transitionStateFullPathHash = ExitBeforeEnter ? stateInfo.fullPathHash : transitionStateFullPathHash;
if (transitionStateFullPathHash != null && fullPathEvents.ContainsKey((int)transitionStateFullPathHash))
{
fullPathEvents[(int)transitionStateFullPathHash][transitionEventType].Invoke(animator, stateInfo, layerIndex, transitionEventType);
}
}
// As before
...
}
Using AnimatorEvent
Before using AnimatorEvent
, we have to make sure that StateEvent
has been added to every single state belonging to the animator controller we are using, otherwise, there is no way to be signaled by the animator controller through the StateMachineBehaviour
callbacks. So, I added a custom editor window that makes it easy to add and remove StateEvent
components to the entire states inside an AnimatorController
.
After adding StateEvent
s, we can now use AnimatorEvent
in our game scripts. Let’s go back to the ‘grabbing a magazine’ task. Our objective was to wait until the magazine-grabbing animation ends and grab the magazine. We can achieve this by using AddListenerWithTypeAndTag
method of AnimatorEvent
.
AnimatorEvent animatorEvent = GetComponent<AnimatorEvent>();
...
animatorEvent.AddListenerWithTypeAndTag
(
() =>
{
// Grab the magazine here
},
EventType.Exit,
ArmMotionType.grabMagazine.ToString()
);
Result
These reloading motions were implemented using AnimatorEvent
.
Conclusion
The combination of StateEvent
and AnimatorEvent
does not suffer accidental unblocking anymore. We can also easily add listeners to each state during the runtime through an ordinary Monobehaviour
game script. But the best thing about using them is that we don’t have to write a separate coroutine that tracks animator states and handles behaviors to do. We merely add a callback function that will listen from the AnimatorEvent
object at the initialization step of the character class, and later leave out the control flows. What a fire-and-forget.
Being a reliable and efficient way to synchronize code and animator states, it can be put to use not only for making reloading motions but also for other tasks, such as playing a stepping sound when the character starts to walk and stopping it when a character halts.
animatorEvent.AddListenerWithTypeAndTag(PlaySound, EventType.Enter, MotionType.run.ToString());
animatorEvent.AddListenerWithTypeAndTag(StopSound, EventType.Exit, MotionType.run.ToString());
References
- [1] https://docs.unity3d.com/ScriptReference/StateMachineBehaviour.html
- [2] https://docs.unity3d.com/Manual/class-Transition.html
Leave a comment