Airborne

This page is part of the 3D Game Kit sample.

Mecanim

Rather than having individual states for Jump and Fall animations, the character only has one Airborne Blend Tree which uses the character's AirborneVerticalSpeed to control its blending. This is a useful setup because it means that you can tweak the physics details like gravity and jump speed and the animation will still be correct for any given point in the jump arc.

Note that it actually has two vertical speed parameters: VerticalSpeed and AirborneVerticalSpeed. The latter is used in the Airborne Blend Tree and various other places while the former is not set by any script and seems to be unused, however there is no way to actually make sure without manually going through every transition in the Animator Controller.

Every PlayerController.FixedUpdate calls CalculateVerticalMovement which is responsible for enforcing a constant downward speed while grounded to stick to the ground, checking if the PlayerInput wants to jump, applying gravity while airborne, and applying additional downward acceleration if the player jumps and releases the button before they reach the peak of the jump:

// LocomotionState.cs:
void CalculateVerticalMovement()
{
    // If jump is not currently held and Ellen is on the ground then she is ready to jump.
    if (!m_Input.JumpInput && m_IsGrounded)
        m_ReadyToJump = true;

    if (m_IsGrounded)
    {
        // When grounded we apply a slight negative vertical speed to make Ellen "stick" to the ground.
        m_VerticalSpeed = -gravity * k_StickingGravityProportion;

        // If jump is held, Ellen is ready to jump and not currently in the middle of a melee combo...
        if (m_Input.JumpInput && m_ReadyToJump && !m_InCombo)
        {
            // ... then override the previously set vertical speed and make sure she cannot jump again.
            m_VerticalSpeed = jumpSpeed;
            m_IsGrounded = false;
            m_ReadyToJump = false;
        }
    }
    else
    {
        // If Ellen is airborne, the jump button is not held and Ellen is currently moving upwards...
        if (!m_Input.JumpInput && m_VerticalSpeed > 0.0f)
        {
            // ... decrease Ellen's vertical speed.
            // This is what causes holding jump to jump higher that tapping jump.
            m_VerticalSpeed -= k_JumpAbortSpeed * Time.deltaTime;
        }

        // If a jump is approximately peaking, make it absolute.
        if (Mathf.Approximately(m_VerticalSpeed, 0f))
        {
            m_VerticalSpeed = 0f;
        }

        // If Ellen is airborne, apply gravity.
        m_VerticalSpeed -= gravity * Time.deltaTime;
    }
}

Checking when the vertical speed is approximately 0 to set it to exactly 0 seems totally pointless because the threshold is so small that it will rarely happen and even if it does it will only be for a single frame and would not look visibly different anyway.

After that method, OnAnimatorMove adds the m_VerticalSpeed to the movement it executes that frame and also sets it as the AirborneVerticalSpeed parameter in the Animator Controller to control the Blend Tree.

Animancer

Animancer manages the above logic in the AirborneState script which uses a Mixer to achieve the same thing as that Blend Tree, allowing it to be set up in the Inspector instead of in an Animator Controller:

using Animancer;
using Animancer.Units;
using UnityEngine;

public class AirborneState : CharacterState
{
    [SerializeField] private LinearMixerTransition _Animations;
    [SerializeField, MetersPerSecond] private float _JumpSpeed = 10;
    [SerializeField, MetersPerSecond] private float _JumpAbortSpeed = 10;
    [SerializeField, Multiplier] private float _TurnSpeedProportion = 5.4f;
    [SerializeField] private LandingState _LandingState;
    [SerializeField] private UnityEvent _PlayAudio;

    private bool _IsJumping;

    protected virtual void OnEnable()
    {
        _IsJumping = false;
        Character.Animancer.Play(_Animations);
    }

    public override bool StickToGround => false;

    public override Vector3 RootMotion
        => Character.Parameters.MovementDirection * (Character.Parameters.ForwardSpeed * Time.deltaTime);

    protected virtual void FixedUpdate()
    {
        if (_IsJumping)
        {
            if (Character.Parameters.VerticalSpeed <= 0)
                _IsJumping = false;
        }
        else
        {
            if (_LandingState != null)
            {
                if (Character.StateMachine.TrySetState(_LandingState))
                    return;
            }
            else
            {
                if (Character.CheckMotionState())
                    return;
            }

            if (Character.Parameters.VerticalSpeed > 0)
                Character.Parameters.VerticalSpeed -= _JumpAbortSpeed * Time.deltaTime;
        }

        _Animations.State.Parameter = Character.Parameters.VerticalSpeed;

        Character.Movement.UpdateSpeedControl();

        Vector3 input = Character.Parameters.MovementDirection;

        float turnSpeed = Vector3.Angle(Character.transform.forward, input) * (1f / 180) *
            _TurnSpeedProportion *
            Character.Movement.CurrentTurnSpeed;

        Character.Movement.TurnTowards(input, turnSpeed);
    }

    public bool TryJump()
    {
        if (Character.Movement.IsGrounded &&
            Character.StateMachine.TryResetState(this))
        {
            _IsJumping = true;
            Character.Parameters.VerticalSpeed = _JumpSpeed;

            _PlayAudio.Invoke();

            return true;
        }

        return false;
    }

    public void CancelJump() => _IsJumping = false;
}

Updates

There are several aspects to this state every update.

Firstly, we override the StickToGround property to always be false and the RootMotion property to go whichever direction the character is trying to move as explained on the Movement page.

public override bool StickToGround => false;

public override Vector3 RootMotion
    => Character.Parameters.MovementDirection * (Character.Parameters.ForwardSpeed * Time.deltaTime);

Then the FixedUpdate method executes the main logic of this state.

It starts by making sure that when you jump, it does not start checking if you have landed until you stop going up:

protected virtual void FixedUpdate()
{
    if (_IsJumping)
    {
        if (Character.Parameters.VerticalSpeed <= 0)
            _IsJumping = false;
    }

Otherwise, if there is a Landing state we try to enter it, which that script will not allow until the CharacterController is grounded. Or if we do not have a specific state for landing, just check the default transitions to Idle or Locomotion.

    else
    {
        if (_LandingState != null)
        {
            if (Character.StateMachine.TrySetState(_LandingState))
                return;
        }
        else
        {
            if (Character.CheckMotionState())
                return;
        }

Note that the LandingState isn't referenced by any other scripts. It is not a standard state that every Character needs and nothing else needs to directly know about it.

Here we also apply some additional downward acceleration (on top of the regular gravity applied in CharacterMovement.OnAnimatorMove) if the player releases the jump button early (this is still inside the block where _IsJumping is false, meaning the jump was cancelled):

        if (Character.Parameters.VerticalSpeed > 0)
            Character.Parameters.VerticalSpeed -= _JumpAbortSpeed * Time.deltaTime;
    }

Lastly, we send the vertical speed to the mixer parameter and update the Character's speed and turning. Since we do not have quick turn animations like the LocomotionState, we just increase the turn speed when the direction we want to go is further away from the direction we are currently facing.

    _Animations.State.Parameter = Character.Parameters.VerticalSpeed;

    Character.Movement.UpdateSpeedControl();

    Vector3 input = Character.Parameters.MovementDirection;

    float turnSpeed = Vector3.Angle(Character.transform.forward, input) * (1f / 180) *
        _TurnSpeedProportion *
        Character.Movement.CurrentTurnSpeed;

    Character.TurnTowards(input, turnSpeed);
}

Jumping

The Mecanim character's implementation of jumping was extremely disorganised and hard to follow:

  1. PlayerInput.Update checks for a button press to set a bool field with a public wrapper property.
  2. Searching for references to that property finds one in PlayerController.CalculateVerticalMovement which makes sure the button had been released at some point since the last jump (PlayerInput should have handled that).
  3. It also checks to make sure the character is not attacking.
  4. Then it sets the m_VerticalSpeed = jumpSpeed; and m_IsGrounded = false;.
  5. If you search for the actual call to CalculateVerticalMovement you'll find it in the middle of FixedUpdate. Note that this means it can miss fast keypresses if they happen during one Update and another one occurs before the next FixedUpdate.
  6. That method does not actually tell the Animator Controller anything, but if you search for references to m_IsGrounded you can find that OnAnimatorMove (which is called separately after FixedUpdate is done) passes it on to the Animator Controller.
  7. If you look at the Animator Controller you can find that once the IsGrounded parameter is set, the Animator Controller will transition to the Airborne state ... except that sometimes it does not.
  8. At the end of the FixedUpdate it calls PlayAudio which checks if (!m_IsGrounded && m_PreviouslyGrounded && m_VerticalSpeed > 0f) in order to play the jump sound.

The Animancer implementation is much easier to follow:

  1. GameKitCharacterBrain has an AirborneState _Jump field which happens to reference the same AirborneState that the character already uses for falling.
  2. GameKitCharacterBrain.UpdateActions checks for a button press to call _Jump.TryJump();.
  3. That method clearly defines everything involved in jumping:

You can only jump while grounded:

public bool TryJump()
{
    if (Character.Movement.IsGrounded &&

Then we try to enter this state. We did not override CanEnterState to check if the Character is grounded because this state is also used if you walk off a ledge, so instead we only check that condition here when specifically attempting to jump. That means this state will always allow itself to be entered, so we are only depending on the previous state's CanExitState to return true. Unlike the Mecanim character, this method does not specifically care if the character is attacking, it simply lets the state machine ask if the previous state will allow the change. If you add some other ability or action that does not want to be interrupted, this method will continue working properly without needing to be modified.

        Character.StateMachine.TryResetState(this))

If the state is changed, it will enable this script (because it inherits from StateBehaviour) which will call its OnEnable method.

Now that we are in this state, flag it as a jump (so it can be cancelled to apply additional downward acceleration), apply the jump speed, and play a jump sound:

    {
        _IsJumping = true;
        Character.Parameters.VerticalSpeed = _JumpSpeed;
        _PlayAudio.Invoke();

And finally, use the return value to indicate whether the jump was successful:

        return true;
    }

    return false;
}

Then if you want to know what happens over time during the jump, you can simply examine the rest of this class, particularly the FixedUpdate method.