Turning

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

Mecanim

Like movement, turning is also handled in several steps every frame:

  1. Determine Desired Turn
  2. Can Turn
  3. Apply Turn
  4. Quick Turns
// In the PlayerController class:
void FixedUpdate()
{
    ...
    SetTargetRotation();

    if (IsOrientationUpdated() && IsMoveInput)
        UpdateOrientation();
    ...
}

It is unclear why SetTargetRotation gets called every update instead of only when the character is able to turn and trying to do so. Moving it inside the if block with UpdateOrientation makes no difference to the character's behaviour and would make more logical sense.

1. Determine Desired Turn

PlayerController.FixedUpdate calls SetTargetRotation which gets the desired movement from the PlayerInput script and calculates the rotation the character would need to face that direction:

void SetTargetRotation()
{
    // Create three variables, move input local to the player, flattened forward direction of the camera and a local target rotation.
    Vector2 moveInput = m_Input.MoveInput;
    Vector3 localMovementDirection = new Vector3(moveInput.x, 0f, moveInput.y).normalized;

    Vector3 forward = Quaternion.Euler(0f, cameraSettings.Current.m_XAxis.Value, 0f) * Vector3.forward;
    forward.y = 0f;
    forward.Normalize();

    Quaternion targetRotation;

    // If the local movement direction is the opposite of forward then the target rotation should be towards the camera.
    if (Mathf.Approximately(Vector3.Dot(localMovementDirection, Vector3.forward), -1.0f))
    {
        targetRotation = Quaternion.LookRotation(-forward);
    }
    else
    {
        // Otherwise the rotation should be the offset of the input from the camera's forward.
        Quaternion cameraToInputOffset = Quaternion.FromToRotation(Vector3.forward, localMovementDirection);
        targetRotation = Quaternion.LookRotation(cameraToInputOffset * forward);
    }

    // The desired forward direction of Ellen.
    Vector3 resultingForward = targetRotation * Vector3.forward;

    // If attacking try to orient to close enemies.
    if (m_InAttack)
    {
        // Find all the enemies in the local area.
        Vector3 centre = transform.position + transform.forward * 2.0f + transform.up;
        Vector3 halfExtents = new Vector3(3.0f, 1.0f, 2.0f);
        int layerMask = 1 << LayerMask.NameToLayer("Enemy");
        int count = Physics.OverlapBoxNonAlloc(centre, halfExtents, m_OverlapResult, targetRotation, layerMask);

        // Go through all the enemies in the local area...
        float closestDot = 0.0f;
        Vector3 closestForward = Vector3.zero;
        int closest = -1;

        for (int i = 0; i < count; ++i)
        {
            // ... and for each get a vector from the player to the enemy.
            Vector3 playerToEnemy = m_OverlapResult[i].transform.position - transform.position;
            playerToEnemy.y = 0;
            playerToEnemy.Normalize();

            // Find the dot product between the direction the player wants to go and the direction to the enemy.
            // This will be larger the closer to Ellen's desired direction the direction to the enemy is.
            float d = Vector3.Dot(resultingForward, playerToEnemy);

            // Store the closest enemy.
            if (d > k_MinEnemyDotCoeff && d > closestDot)
            {
                closestForward = playerToEnemy;
                closestDot = d;
                closest = i;
            }
        }

        // If there is a close enemy...
        if (closest != -1)
        {
            // The desired forward is the direction to the closest enemy.
            resultingForward = closestForward;

            // We also directly set the rotation, as we want snappy fight and orientation isn't updated in the UpdateOrientation function during an atatck.
            transform.rotation = Quaternion.LookRotation(resultingForward);
        }
    }

    // Find the difference between the current rotation of the player and the desired rotation of the player in radians.
    float angleCurrent = Mathf.Atan2(transform.forward.x, transform.forward.z) * Mathf.Rad2Deg;
    float targetAngle = Mathf.Atan2(resultingForward.x, resultingForward.z) * Mathf.Rad2Deg;

    m_AngleDiff = Mathf.DeltaAngle(angleCurrent, targetAngle);
    m_TargetRotation = targetRotation;
}

That method is far too long so most of it should have been split into separate methods: CalculateTargetRotation and UpdateAttackTurning, with only the last few lines remaining in SetTargetRotation itself. In particular, that would cut an entire page worth of code out of the main method which is only relevant when the character is attacking.

2. Can Turn

Then it checks if (IsOrientationUpdated() && IsMoveInput) to determine whether the Animator Controller is in a state that allows turning and the player is actually trying to move.

bool IsOrientationUpdated()
{
    bool updateOrientationForLocomotion = !m_IsAnimatorTransitioning && m_CurrentStateInfo.shortNameHash == m_HashLocomotion || m_NextStateInfo.shortNameHash == m_HashLocomotion;
    bool updateOrientationForAirborne = !m_IsAnimatorTransitioning && m_CurrentStateInfo.shortNameHash == m_HashAirborne || m_NextStateInfo.shortNameHash == m_HashAirborne;
    bool updateOrientationForLanding = !m_IsAnimatorTransitioning && m_CurrentStateInfo.shortNameHash == m_HashLanding || m_NextStateInfo.shortNameHash == m_HashLanding;

    return updateOrientationForLocomotion || updateOrientationForAirborne || updateOrientationForLanding || m_InCombo && !m_InAttack;
}

CanTurn would have been a much better method name.

That method effectively illustrates one of the main problems with having the state machine defined separately from the code. At first it might seem useful for non-programmers to be able to set things up visually, but eventually the state machine and code end up being so strongly dependant on each other that neither one can be touched without knowing how the other works anyway. A non-programmer cannot simply look at a state in the Animator Controller and use a toggle to determine if they want that state to allow the player to control their turning because the specific states are hard coded into this method on line 397 of a 680 line script.

There are also several things that could be improved about the way that method was written:

  • Many coding standards would recommend using brackets around the conditions to clarify their evaluation order. Newer programmers in particular may not realise that && takes priority so it will evaluate as (is not transitioning && current state is target) || next state is target rather than is not transitioning && (current state is target || next state is target).
  • Instead of repeating the same line 3 times, that condition should be separated into another method so it can just call IsInState(m_HashLocomotion) || IsInState(m_HashAirborne) ....
  • All those conditions should be part of the return statement (using multiple lines so they do not get too long) instead of being evaluated beforehand. That way as soon as any of the conditions is met it can return without continuing to check the rest of them. In this case the performance improvement would be negligible, but it would have also been faster to write without needing to declare any local variables.
protected bool IsMoveInput
{
    get { return !Mathf.Approximately(m_Input.MoveInput.sqrMagnitude, 0f); }
}

That property also has a few things worth noting:

  • It should be in the PlayerInput class rather than PlayerController since the only thing it does is check input.
  • A more descriptive name like IsTryingToMove would be better.
  • The size of the "dead zone" provided by Mathf.Approximately where a small amount of tilt on a joystick will count as none is so small that it will be totally useless. Unity's Input.GetAxis method already accounts for a dead zone which can be customised in the project's Input Settings. In this project the dead zone is set to 0.19 which is several orders of magnitude larger than the value Mathf.Approximately uses.
  • Unity's Vector2 == operator also already has a dead zone built in (see its source code below). But again, that dead zone is too small to have any practical use so it actually tends to cause more problems than it solves.
public static bool operator ==(Vector2 lhs, Vector2 rhs)
{
    return (lhs - rhs).sqrMagnitude < 9.99999944E-11f;
}

3. Apply Turn

If those conditions are both true, it calls PlayerController.UpdateOrientation which sets the AngleDeltaRad parameter (the angle between the current rotation and desired rotation in radians) in the Animator Controller and rotates the character towards the desired rotation.

void UpdateOrientation()
{
    m_Animator.SetFloat(m_HashAngleDeltaRad, m_AngleDiff * Mathf.Deg2Rad);

    Vector3 localInput = new Vector3(m_Input.MoveInput.x, 0f, m_Input.MoveInput.y);
    float groundedTurnSpeed = Mathf.Lerp(maxTurnSpeed, minTurnSpeed, m_ForwardSpeed / m_DesiredForwardSpeed);
    float actualTurnSpeed = m_IsGrounded ? groundedTurnSpeed : Vector3.Angle(transform.forward, localInput) * k_InverseOneEighty * k_AirborneTurnSpeedProportion * groundedTurnSpeed;
    m_TargetRotation = Quaternion.RotateTowards(transform.rotation, m_TargetRotation, actualTurnSpeed * Time.deltaTime);

    transform.rotation = m_TargetRotation;
}

4. Quick Turns

The Locomotion Blend Tree state has a pair of transitions to quick turn animations that get triggered when the character is moving fast enough and tries to turn sharply.

It is unclear why the angles are converted to radians since most people have a much better understanding of degrees. The 2.5 radian threshold uses is equal to roughly 143 degrees.

The OnAnimatorMove method above also applies any root motion rotation from the current animation. During the quick turn animations, the IsOrientationUpdated method to returns false so the script stops turning the character while they use their root motion.

void OnAnimatorMove()
{
    ...
    m_CharCtrl.transform.rotation *= m_Animator.deltaRotation;
    ...
}

This setup has the same issue described in 2. Can Turn:

  • A non-programmer could not add these quick turn animations on their own because they require the code to calculate and set the speed and angle delta parameters.
  • They also rely on the IsOrientationUpdated method to return false during these animations so that UpdateOrientation stops rotating the character during the quick turn, leaving the animations to perform the rotation using their root motion (in OnAnimatorMove).
  • It is impossible to figure out what will be moving and rotating the character in any given state without going back and forth between the code and Animator Controller.
  • So a system that is supposed to allow non-programmers to work without code does not actually let them do so and at the same time it convolutes the overall logic to make everything harder for the programmers as well.

Animancer

The LocomotionState script is responsible for both the regular turning and the logic to trigger the quick turn animations. This puts all the fields in one place where they are all visible in the Inspector at once instead of splitting them across two states and transitions which can only be selected one at a time to compare them manually:

[SerializeField] private ClipState.Transition _QuickTurnLeft;
[SerializeField] private ClipState.Transition _QuickTurnRight;
[SerializeField] private float _QuickTurnMoveSpeed = 2;
[SerializeField] private float _QuickTurnAngle = 145;

On startup we set their OnEnd callback to return to the main _LocomotionBlendTree state. Note how these are ClipState.Transitions so they will assign their OnEnd callback to their actual state every time it is played (since AnimancerState.OnEnd is cleared every time). This improves performance by avoiding the creation of a new Delegate object every time it is used.

private void Awake()
{
    _QuickTurnLeft.Events.Sequence.OnEnd =
        _QuickTurnRight.Events.Sequence.OnEnd =
        () => Creature.Animancer.Play(_LocomotionBlendTree);
}

Every FixedUpdate this state calls UpdateRotation which returns early if the _LocomotionBlendTree is not active (at full Weight or currently fading in) because that means the character must already be performing a quick turn, so we do not do any scripted turning and just let the Creature.OnAnimatorMove apply the root motion rotation from that animation.

private void UpdateRotation()
{
    if (!_LocomotionBlendTree.State.IsActive)
        return;

The rest of the method is comprised of several steps:

  1. Get Turn Angles
  2. Quick Turns
  3. Apply Turn

1. Get Turn Angles

Then it gets the current rotation of the creature and the desired angle according to the Creature.Brain.Movement. And again it has an opportunity to return early to prevent rotation if the brain isn't actually trying to move at the moment (though that should never actually happen in this case because Creature.CheckMotionState would have already changed it to the Idle state).

    // In UpdateRotation:
    float currentAngle, targetAngle;
    if (!Creature.GetTurnAngles(Creature.Brain.Movement, out currentAngle, out targetAngle))
        return;

// Creature.cs:
public bool GetTurnAngles(Vector3 direction, out float currentAngle, out float targetAngle)
{
    if (direction == Vector3.zero)
    {
        currentAngle = float.NaN;
        targetAngle = float.NaN;
        return false;
    }

    var transform = this.transform;
    currentAngle = transform.eulerAngles.y;
    targetAngle = Mathf.Atan2(direction.x, direction.z) * Mathf.Rad2Deg;
    return true;
}

2. Quick Turns

In the Mecanim character, the PlayerController script would calculate the forward speed and rotation needed to face the desired direction and then send those values to the Animator Controller for it to check if any transitions should be started based on those parameters. That made it difficult to figure out the actual flow of logic that would trigger the quick turn animations. But in the LocomotionState script, that same logic only takes a few lines of code which is much easier to understand.

    // If we are moving fast enough.
    if (Creature.ForwardSpeed > _QuickTurnMoveSpeed)
    {
        // And turning sharp enough.
        var deltaAngle = Mathf.DeltaAngle(currentAngle, targetAngle);
        if (Mathf.Abs(deltaAngle) > _QuickTurnAngle)
        {
            // Determine which way we are turning.
            var turn = deltaAngle < 0 ? _QuickTurnLeft : _QuickTurnRight;
    
            // Make sure the desired turn isn't already active so we do not keep using it repeatedly.
            if (turn.State == null || turn.State.Weight == 0)
            {
                Animancer.Play(turn);

                // Now that we are quick turning, we do not want to apply the scripted turning below.
                return;
            }
        }
    }

Having the logic entirely defined in code also makes it far easier to get help with. If you posted that code online, programmers would be able to easily read through it and explain the logic to you. Even if they were unfamiliar with Animancer, they could probably make an educated guess about the meaning of the last few lines. But in the Mecanim Animator Controller setup you would need to post the code along with screenshots of various parts of the Animator Controller and even then it is still hard for other people to get any real idea of how it is structured.

3. Apply Turn

Lastly, as long as we did not just start a quick turn animation, turn towards the direction we want to face:

    // In UpdateRotation:
    Creature.TurnTowards(currentAngle, targetAngle, Creature.CurrentTurnSpeed);
}

// Creature.cs.
public void TurnTowards(float currentAngle, float targetAngle, float speed)
{
    currentAngle = Mathf.MoveTowardsAngle(currentAngle, targetAngle, speed * Time.deltaTime);

    transform.eulerAngles = new Vector3(0, currentAngle, 0);
}

Note that other states such as Airborne and Attack want to use the same logic for turning but do not have quick turn animations so there is a second TurnTowards method which calls both GetTurnAngles and TurnTowards in one go for convenience.

One thing which this example does not replicate from the Mecanim is the automatic turning towards nearby enemies while attacking in PlayerController.SetTargetRotation because this example doesn't include enemies. But if it did, that logic would be located in the AttackState class instead of being mixed in with the general turning logic.