This page is part of the 3D Game Kit sample.
Mecanim
Movement is handled in several steps every frame:
Mecanim Forward Movement
PlayerController.FixedUpdate
calls CalculateForwardMovement
which gets the desired movement from the PlayerInput
script, accelerates the current speed towards the desired speed, and sets the ForwardSpeed
parameter in the Animator Controller.
void CalculateForwardMovement()
{
// Cache the move input and cap it's magnitude at 1.
Vector2 moveInput = m_Input.MoveInput;
if (moveInput.sqrMagnitude > 1f)
moveInput.Normalize();
// Calculate the speed intended by input.
m_DesiredForwardSpeed = moveInput.magnitude * maxForwardSpeed;
// Determine change to speed based on whether there is currently any move input.
float acceleration = IsMoveInput ? k_GroundAcceleration : k_GroundDeceleration;
// Adjust the forward speed towards the desired speed.
m_ForwardSpeed = Mathf.MoveTowards(m_ForwardSpeed, m_DesiredForwardSpeed, acceleration * Time.deltaTime);
// Set the animator parameter to control what animation is being played.
m_Animator.SetFloat(m_HashForwardSpeed, m_ForwardSpeed);
}
Mecanim Blend Tree
The Locomotion
Blend Tree uses the ForwardSpeed
parameter to calculate the weights of its animations, which determines how they are blended.
Note how the last two animations in the Blend Tree are the same, with the second one having its speed set to 2 so that once your ForwardSpeed
gets above 7.09... it will increase the speed of that animation proportionally rather than blending with a second animation. The actual maximum ForwardSpeed
that the PlayerController
script will allow is set to 8.
Mecanim On Animator Move
Then Unity calls PlayerController.OnAnimatorMove
which modifies the way Root Motion is applied to the CharacterController
.
void OnAnimatorMove()
{
Vector3 movement;
// If Ellen is on the ground...
if (m_IsGrounded)
{
// ... raycast into the ground...
RaycastHit hit;
Ray ray = new Ray(transform.position + Vector3.up * k_GroundedRayDistance * 0.5f, -Vector3.up);
if (Physics.Raycast(ray, out hit, k_GroundedRayDistance, Physics.AllLayers, QueryTriggerInteraction.Ignore))
{
// ... and get the movement of the root motion rotated to lie along the plane of the ground.
movement = Vector3.ProjectOnPlane(m_Animator.deltaPosition, hit.normal);
// Also store the current walking surface so the correct audio is played.
Renderer groundRenderer = hit.collider.GetComponentInChildren<Renderer>();
m_CurrentWalkingSurface = groundRenderer ? groundRenderer.sharedMaterial : null;
}
else
{
// If no ground is hit just get the movement as the root motion.
// Theoretically this should rarely happen as when grounded the ray should always hit.
movement = m_Animator.deltaPosition;
m_CurrentWalkingSurface = null;
}
}
else
{
// If not grounded the movement is just in the forward direction.
movement = m_ForwardSpeed * transform.forward * Time.deltaTime;
}
// Rotate the transform of the character controller by the animation's root rotation.
m_CharCtrl.transform.rotation *= m_Animator.deltaRotation;
// Add to the movement with the calculated vertical speed.
movement += m_VerticalSpeed * Vector3.up * Time.deltaTime;
// Move the character controller.
m_CharCtrl.Move(movement);
// After the movement store whether or not the character controller is grounded.
m_IsGrounded = m_CharCtrl.isGrounded;
// If Ellen is not on the ground then send the vertical speed to the animator.
// This is so the vertical speed is kept when landing so the correct landing animation is played.
if (!m_IsGrounded)
m_Animator.SetFloat(m_HashAirborneVerticalSpeed, m_VerticalSpeed);
// Send whether or not Ellen is on the ground to the animator.
m_Animator.SetBool(m_HashGrounded, m_IsGrounded);
}
That method is long enough that it should really have been split into multiple methods. The first part where it determines the movement
based on whether the character is grounded or not would do well as a separate method called something like GetMovementThisFrame
which simply returns the Vector3
.
Animancer
Animancer's implementation is also split into several steps in LocomotionState
and CharacterMovement
:
Animancer Mixer
The Animancer character uses a Mixer State in place of the Blend Tree:
// LocomotionState.cs:
[SerializeField] private LinearMixerTransition _LocomotionMixer;
protected virtual void OnEnable()
{
Animancer.Play(_LocomotionMixer);
}
There are a few notable differences between this Mixer and the original Blend Tree (pictured in 2. Blend Tree above):
- It's defined in the Inspector instead of in an Animator Controller.
- It has the Synchronization toggle disabled for the Idle animation where the Blend Tree will always synchronise all its animations.
- It only has 3 animations and
Extrapolate Speed
enabled instead of the 4 used in the Blend Tree. The Character'sMax Speed
is set to 8, which is slightly above the threshold of the 3rd animation (~7). The Blend Tree used another copy of the EllenRunForward animation with its speed set to 2 and its threshold twice as high (~14) so that when the parameter reaches 8 it will essentially just be playing that animation slightly faster than normal. We could do the exact same thing in the Mixer, but theExtrapolate Speed
toggle allows it to automatically achieve the same result without manually adding a copy of the last animation.
Animancer Fixed Update
We also have a FixedUpdate
method like the Mecanim character, but instead of one massive method that does lots of different things depending on your state this one only gets called while the character is in this state so its logic is much easier to understand:
It starts with the same calls to Character.CheckMotionState
and UpdateSpeedControl
that the Idle state used:
// LocomotionState.cs:
protected virtual void FixedUpdate()
{
if (Character.CheckMotionState())
return;
Character.Movement.UpdateSpeedControl();
After updating the character's speed according to its Brain
, we send that value to the Mixer so it can control the blending:
_LocomotionMixer.State.Parameter = Character.Parameters.ForwardSpeed;
The rest of the FixedUpdate
method concerns Turning and audio.
Animancer On Animator Move
The character has more than one state which moves them around, so rather than putting that logic in the LocomotionState
, the Animancer character manages it in a CharacterMovement
script:
// Character.cs:
public class Character : MonoBehaviour
{
[SerializeField]
private CharacterMovement _Movement;
public CharacterMovement Movement => _Movement;
}
The Mecanim On Animator Move method is far too long, so our implementation is split into several methods to make it much easier to understand and maintain:
// CharacterMovement.cs:
public class CharacterMovement : MonoBehaviour
{
[SerializeField] private Character _Character;
[SerializeField] private CharacterController _CharacterController;
private void OnAnimatorMove()
{
Vector3 movement = GetRootMotion();
CheckGround(ref movement);
UpdateGravity(ref movement);
_CharacterController.Move(movement);
IsGrounded = _CharacterController.isGrounded;
transform.rotation *= _Character.Animancer.Animator.deltaRotation;
}
Note that it also Redirects the Root Motion from the character's model to the root object where the CharacterController
is located.
Get Root Motion
The Mecanim character's PlayerController
class was responsible for determining what root motion to use depending on its state. Specifically, when grounded it used the root motion from animations and when airborne it simply applied the character's forward speed. This was bad because it meant that class needed intimate knowledge of every state. Adding a jumping attack would require modifying that method. Adding a glide ability would require modifying that method. And so on.
But since each of our states is a separate class, we can use the power of Polymorphism to give the base CharacterState
class a virtual
property with a default implementation so that all states will use root motion from animations by default:
// CharacterState.cs:
public virtual Vector3 RootMotion => Animancer.Animator.deltaPosition;
Then any states that want different behaviour such as the AirborneState
can override
that property:
// AirborneState.cs:
public override Vector3 RootMotion
=> Character.Parameters.MovementDirection * (Character.Parameters.ForwardSpeed * Time.deltaTime);
And now the CharacterMovement
class can just access that property on whatever the current state happens to be, without having any specific knowledge about that state:
// CharacterMovement.cs:
private Vector3 GetRootMotion() => _Character.StateMachine.CurrentState.RootMotion;
Note that there is actually more to the GetRootMotion
method than just that, which will be explained in the Full Movement Control section.
Check Ground
Once we have the desired movement, we use a raycast to determine the orientation of the ground and rotate the movement onto that plane so we can move at a consistent speed up and down hills. This method is essentially the same as the first part of the Mecanim PlayerController.OnAnimatorMove
method.
// CharacterMovement.cs:
private void CheckGround(ref Vector3 movement)
{
if (!CharacterController.isGrounded)
return;
const float GroundedRayDistance = 1f;
Ray ray = new Ray(transform.position + Vector3.up * GroundedRayDistance * 0.5f, -Vector3.up);
if (Physics.Raycast(ray, out RaycastHit hit, GroundedRayDistance, Physics.AllLayers, QueryTriggerInteraction.Ignore))
{
// Rotate the movement to lie along the ground vector.
movement = Vector3.ProjectOnPlane(movement, hit.normal);
// Store the current walking surface so the correct audio is played.
Renderer groundRenderer = hit.collider.GetComponentInChildren<Renderer>();
GroundMaterial = groundRenderer ? groundRenderer.sharedMaterial : null;
}
else
{
GroundMaterial = null;
}
}
In the 3D Game Kit the GroundMaterial
would be used by the LocomotionState
and LandingState
to play appropriate sounds for the surface the character is on, however the 3D Game Kit Lite (which we are using because it's far smaller so it can be downloaded easier) only has a single ground material anyway so this mechanic never comes into effect. Also, the Script Referencing issue prevents us from calling the necessary method to specify the Material
when playing the sounds because UnityEvent
s only support methods with one parameter.
Update Gravity
The Mecanim PlayerController
had a bool m_IsGrounded
field in addition to the ability to access CharacterController.isGrounded
so that m_IsGrounded
could be set to false when the character jumps even though they will actually still be on the ground until after the next physics update is able to move them upwards. This avoids cancelling out the jump velocity with the standard downward speed that gets applied while grounded. That is certainly a simple solution to the problem, but that field is referenced in 12 places throughout the script so its actual purpose is a little hard to discern without thorough inspection.
Instead, we are using Polymorphism again to make a very specifically named StickToGround
property with an obvious purpose that can be easily traced:
// CharacterState.cs:
public virtual bool StickToGround => true;
// AirborneState.cs:
public override bool StickToGround => false;
Now CharacterMovement.UpdateGravity
can just check that property when needed:
// CharacterMovement.cs:
private void UpdateGravity(ref Vector3 movement)
{
if (CharacterController.isGrounded && _Character.StateMachine.CurrentState.StickToGround)
_Character.Parameters.VerticalSpeed = -_Stats.Gravity * _Stats.StickingGravityProportion;
else
_Character.Parameters.VerticalSpeed -= _Stats.Gravity * Time.deltaTime;
movement.y += _Character.Parameters.VerticalSpeed * Time.deltaTime;
}
Note how the grounded speed is constantly set using =
while the airborne speed accelerates downwards every frame using -=
.
Full Movement Control
The Mecanim character continues playing the forward movement animations while turning (not quick turning), causing the root motion to move the character around in an arc towards the direction the player actually wants to go. This means that not only do you move in a direction you don't want to go during the turn, but also that you can't reliably predict which direction that will actually be because it could choose to turn either way, which is especially bad when changing direction repeatedly. The resulting movement is very frustrating for the player because it imposes an arbitrary lack of control on the character. This is the largest problem with the Mecanim Character.
Mecanim (raw root motion) | Animancer (full movement control) |
---|---|
The following videos were recorded using the exact same inputs scripted to move left and right repeatedly, swapping every 0.5 seconds and not using any forward or backward input at all. The Mecanim character clearly moves forward with each cycle while the Animancer character simply moves back and forth along the starting line. Note that the same phenomenon also occurs when the character is Airborne due to the way they implemented movement even though it's entirely scripted. | |
In the worst case with the right input timing the character might run almost entirely perpendicular to the direction you want to go because each change is within about 90 degrees of the target direction so it does not cross the ~145 degree threshold required to trigger a Quick Turn animation. Again, this is simulating only left and right input with no forward input. | |
The Animancer character solves this issue in the Character.GetRootMotion
method by making it so that most states will only apply root motion in the exact direction the Character.Brain
is actually trying to go and ignore any portion of the movement which is perpendicular to that direction. The result is a far more responsive character, but in exchange the root motion isn't exactly what the animations would normally apply so it's trading visual realism for smoother controls.
// CharacterMovement.cs:
// Inspector toggle so you can easily compare raw root motion with controlled motion.
[SerializeField] private bool _FullMovementControl = true;
private Vector3 GetRootMotion()
{
Vector3 motion = _Character.StateMachine.CurrentState.RootMotion;
if (!_FullMovementControl ||// If Full Movement Control is disabled in the Inspector.
!_Character.StateMachine.CurrentState.FullMovementControl)// Or the current state does not want it.
return motion;// Return the raw Root Motion.
// If the Brain is not trying to move, we don't move.
Vector3 direction = _Character.Parameters.MovementDirection;
direction.y = 0;
if (direction == Vector3.zero)
return rawMotion;
// Otherwise calculate the Root Motion only in the specified direction.
float magnitude = direction.magnitude;
direction /= magnitude;
Vector3 controlledMotion = direction * Vector3.Dot(direction, rawMotion);
// Interpolate towards that based on the desired movement magnitude (i.e. control stick tilt).
// 0 tilt = use only the raw motion (would have already returned above to skip these calculations).
// 1 tilt = use only the controlled motion.
// And values in between give proportional motion between those values.
return Vector3.Lerp(rawMotion, controlledMotion, magnitude);
}
Acceleration
The way acceleration is handled by the Mecanim character is somewhat strange and this sample simply replicates that aspect of its behaviour to avoid diverging too much from it.
- One of the main reasons people use a
CharacterController
instead of aRigidbody
is to give the player perfect control over their character, allowing them to start and stop moving immediately instead of using forces to accelerate gradually. But then thePlayerController
script implements its own acceleration anyway in theCalculateForwardMovement
method. - The forward speed only gets used in the Locomotion and Airborne states, but since
CalculateForwardMovement
runs everyFixedUpdate
regardless of what state the Animator Controller is actually in, the speed keeps getting updated behind the scenes when it isn't being used. So if you're Idle and you start moving, it will transition to the Locomotion Blend Tree and at the same time it will gradually increase theForwardSpeed
parameter towards the desired speed. But if you're moving at full speed and perform an Attack, theForwardSpeed
parameter will still stay at the desired speed (and accelerate towards a new value if it changes) during the attack animation so that when it ends and transitions back to Locomotion you're already at that speed even though you weren't really moving during the attack. The gradual transition between animations in both cases makes this effect a bit hard to spot, but it means that visually, accelerating from Idle happens slower than accelerating from an Attack (or any other action).