02 Uneven Ground

Difficulty: Intermediate - Recommended after Puppet

Location: Assets/Plugins/Animancer/Examples/08 Inverse Kinematics/02 Uneven Ground

Namespace: Animancer.Examples.InverseKinematics

This example demonstrates how you can use Unity's Inverse Kinematics system to adjust the height of a character's feet according to the terrain they are walking over. It requires Unity 2019.1+ because it uses the Animation Jobs system to access the Animation Curves that define how much the IK should control each foot at any given point in the animation.

IK Disabled IK Enabled
Without IK, the character is always walking as if on flat ground. While walking downhill it looks like they are planting their feet in the air and while walking uphill their feet go through the ground. With IK, the character adjusts the positioning of their feet as necessary. While walking downhill they stretch out their feet a bit further and while walking uphill they plant their feet a bit higher up than usual.

The actual implementation used here is not very good, the point is simply to prove that IK works essentially the same in Animancer as it does in Mecanim.

It uses a simple ObstacleTreadmill script to create a random series of objects and rearrange them each time the character moves far enough:

using System.Collections.Generic;
using UnityEngine;

public sealed class ObstacleTreadmill : MonoBehaviour
{
    [SerializeField] private float _SpawnCount = 10;
    [SerializeField] private Material _ObstacleMaterial;
    [SerializeField] private float _Length;
    [SerializeField] private float _RotationVariance = 45;
    [SerializeField] private float _BaseScale = 1;
    [SerializeField] private float _ScaleVariance = 0.1f;
    [SerializeField] private Transform _Target;

    private readonly List<Transform> Obstacles = new List<Transform>();

    private void Awake()
    {
        // Spawn a bunch of obstacles and randomise their layout.
        for (int i = 0; i < _SpawnCount; i++)
        {
            var obj = GameObject.CreatePrimitive(PrimitiveType.Capsule).transform;
            obj.GetComponent<Renderer>().sharedMaterial = _ObstacleMaterial;
            obj.parent = transform;
            Obstacles.Add(obj);
        }

        ScrambleObjects();
    }

    private void ScrambleObjects()
    {
        // Move and rotate each of the obstacles randomly.
        for (int i = 0; i < Obstacles.Count; i++)
        {
            var obj = Obstacles[i];
            obj.localPosition = new Vector3(UnityEngine.Random.Range(0, _Length), 0, 0);
            obj.localRotation = Quaternion.Euler(90, UnityEngine.Random.Range(-_RotationVariance, _RotationVariance), 0);
            obj.localScale = Vector3.one * (_BaseScale + UnityEngine.Random.Range(-_ScaleVariance, _ScaleVariance));
        }
    }

    private void FixedUpdate()
    {
        // When the target moves too far, teleport them back and randomize the obstacles again.
        var position = _Target.position;
        if (position.x < transform.position.x)
        {
            ScrambleObjects();

            position.x += _Length;

            // Adjust the height to make sure it is above the ground.
            position.y += 5;
            RaycastHit raycastHit;
            if (Physics.Raycast(position, Vector3.down, out raycastHit, 10))
                position = raycastHit.point;

            _Target.position = position;
        }
    }

    [SerializeField]
    private Transform _Ground;

    public float Slope
    {
        get => _Ground.localEulerAngles.z;
        set => _Ground.localEulerAngles = new Vector3(0, 0, value);
    }
}

And the main RaycastFootIK script looks like this (with the comments removed since we're about to explain how it works):

using Animancer;
using UnityEngine;

public sealed class RaycastFootIK : MonoBehaviour
{
    [SerializeField] private AnimancerComponent _Animancer;
    [SerializeField] private AnimationClip _Animation;
    [SerializeField] private float _RaycastOriginY = 0.5f;
    [SerializeField] private float _RaycastEndY = -0.2f;

    private Transform _LeftFoot;
    private Transform _RightFoot;

    private AnimatedFloat _FootWeights;

    public bool ApplyAnimatorIK
    {
        get => _Animancer.Layers[0].ApplyAnimatorIK;
        set => _Animancer.Layers[0].ApplyAnimatorIK = value;
    }

    private void Awake()
    {
        _LeftFoot = _Animancer.Animator.GetBoneTransform(HumanBodyBones.LeftFoot);
        _RightFoot = _Animancer.Animator.GetBoneTransform(HumanBodyBones.RightFoot);

        _FootWeights = new AnimatedFloat(_Animancer, "LeftFootIK", "RightFootIK");

        _Animancer.Play(_Animation);

        ApplyAnimatorIK = true;
    }

    private void OnAnimatorIK(int layerIndex)
    {
        UpdateFootIK(_LeftFoot, AvatarIKGoal.LeftFoot, _FootWeights[0], _Animancer.Animator.leftFeetBottomHeight);
        UpdateFootIK(_RightFoot, AvatarIKGoal.RightFoot, _FootWeights[1], _Animancer.Animator.rightFeetBottomHeight);
    }

    private void UpdateFootIK(Transform footTransform, AvatarIKGoal goal, float weight, float footBottomHeight)
    {
        var animator = _Animancer.Animator;
        animator.SetIKPositionWeight(goal, weight);
        animator.SetIKRotationWeight(goal, weight);

        if (weight == 0)
            return;

        var rotation = animator.GetIKRotation(goal);
        var localUp = rotation * Vector3.up;

        var position = footTransform.position;
        position += localUp * _RaycastOriginY;

        var distance = _RaycastOriginY - _RaycastEndY;

        if (Physics.Raycast(position, -localUp, out var hit, distance))
        {
            position = hit.point;
            position += localUp * footBottomHeight;
            animator.SetIKPosition(goal, position);

            var rotAxis = Vector3.Cross(localUp, hit.normal);
            var angle = Vector3.Angle(localUp, hit.normal);
            rotation = Quaternion.AngleAxis(angle, rotAxis) * rotation;

            animator.SetIKRotation(goal, rotation);
        }
        else
        {
            position += localUp * (footBottomHeight - distance);
            animator.SetIKPosition(goal, position);
        }
    }
}

IK Curves

Unlike the Puppet example, we do not want the character's feet to always be exactly at the target point. Instead, we want them to use the animation to lift their feet (0 IK weight) and start using the IK when their feet are actually supposed to be near the ground (1 IK weight). This example uses a copy of the usual Walk animation with two additional AnimationCurves (LeftFootIK and RightFootIK) which determine how much weight we want the IK to have at any given point in the animation.

These custom curves can be accessed using Animancer's AnimatedFloat class which we initialise on startup:

private AnimatedFloat _FootWeights;

private void Awake()
{
    _FootWeights = new AnimatedFloat(_Animancer, "LeftFootIK", "RightFootIK");

Then we access them below in OnAnimatorIK.

We also need to do a few other things on startup:

  1. Play the Walk animation:
    _Animancer.Play(_Animation);
  1. Enable IK so that Unity will call OnAnimatorIK and apply it to the character. Normally we could just set ApplyAnimatorIK directly, but for this example we want a UI Toggle to turn it on and off so we need a public Property for it to access:
public bool ApplyAnimatorIK
{
    get => _Animancer.Layers[0].ApplyAnimatorIK;
    set => _Animancer.Layers[0].ApplyAnimatorIK = value;
}

private void Awake()
{
    ...

    ApplyAnimatorIK = true;
  1. Get the foot bones from the Animator and store them so we do not have to get them again every frame:
private Transform _LeftFoot;
private Transform _RightFoot;

private void Awake()
{
    ...

    _LeftFoot = _Animancer.Animator.GetBoneTransform(HumanBodyBones.LeftFoot);
    _RightFoot = _Animancer.Animator.GetBoneTransform(HumanBodyBones.RightFoot);
}

Then in OnAnimatorIK we simply use the AnimatedFloat objects to evaluate the target curves at the current animation time:

private void OnAnimatorIK(int layerIndex)
{
    UpdateFootIK(_LeftFoot, AvatarIKGoal.LeftFoot, _FootWeights[0], _Animancer.Animator.leftFeetBottomHeight);
    UpdateFootIK(_RightFoot, AvatarIKGoal.RightFoot, _FootWeights[1], _Animancer.Animator.rightFeetBottomHeight);
}

private void UpdateFootIK(Transform footTransform, AvatarIKGoal goal, float weight, float footBottomHeight)
{
    var animator = _Animancer.Animator;
    animator.SetIKPositionWeight(goal, weight);
    animator.SetIKRotationWeight(goal, weight);

    // TODO: set the actual IK position and rotation.
}

Raycasting

In order to find out where the ground actually is, we need to do some Raycasting. We use the footTransforms we retrieved on startup to determine where to start each ray with some additional fields to offset the origin above the current position and set the distance to go a bit lower than the original position. Starting too high would allow it to hit objects above the character's legs and allowing the distance to be too large would attempt to stretch their legs further than a person naturally would if the ground is too far away.

[SerializeField] private float _RaycastOriginY = 0.5f;
[SerializeField] private float _RaycastEndY = -0.2f;

private void UpdateFootIK(Transform footTransform, AvatarIKGoal goal, float weight, float footBottomHeight)
{
    var animator = _Animancer.Animator;
    animator.SetIKPositionWeight(goal, weight);
    animator.SetIKRotationWeight(goal, weight);

    if (weight == 0)
        return;

    var rotation = _Animancer.Animator.GetIKRotation(goal);
    var localUp = rotation * Vector3.up;

    var position = footTransform.position;
    position += localUp * _RaycastOriginY;

    var distance = _RaycastOriginY - _RaycastEndY;

Then we do a raycast downwards from that point. If it hits something, we use the RaycastHit.point and the footBottomHeight (the distance from the transform to the bottom of the foot model calculated by Unity) to determine the desired position for that foot and the RaycastHit.normal to determine the desired rotation:

if (Physics.Raycast(position, Vector3.down, out var hit, distance))
{
    position = hit.point;
    position += localUp * footBottomHeight;
    _Animancer.Animator.SetIKPosition(goal, position);

    var rotAxis = Vector3.Cross(localUp, hit.normal);
    var angle = Vector3.Angle(localUp, hit.normal);
    rotation = Quaternion.AngleAxis(angle, rotAxis) * rotation;

    _Animancer.Animator.SetIKRotation(goal, rotation);
}

Otherwise if nothing was hit, we simply stretch the leg out to the end of the ray:

else
{
    position += localUp * (footBottomHeight - distance);
    _Animancer.Animator.SetIKPosition(goal, position);
}

The full RaycastFootIK script looks like this:

using Animancer;
using UnityEngine;

public sealed class RaycastFootIK : MonoBehaviour
{
    [SerializeField] private AnimancerComponent _Animancer;
    [SerializeField] private AnimationClip _Animation;
    [SerializeField] private float _RaycastOriginY = 0.5f;
    [SerializeField] private float _RaycastEndY = -0.2f;

    private Transform _LeftFoot;
    private Transform _RightFoot;

    private AnimatedFloat _FootWeights;

    public bool ApplyAnimatorIK
    {
        get => _Animancer.Layers[0].ApplyAnimatorIK;
        set => _Animancer.Layers[0].ApplyAnimatorIK = value;
    }

    private void Awake()
    {
        _LeftFoot = _Animancer.Animator.GetBoneTransform(HumanBodyBones.LeftFoot);
        _RightFoot = _Animancer.Animator.GetBoneTransform(HumanBodyBones.RightFoot);

        _FootWeights = new AnimatedFloat(_Animancer, "LeftFootIK", "RightFootIK");

        _Animancer.Play(_Animation);

        ApplyAnimatorIK = true;
    }

    private void OnAnimatorIK(int layerIndex)
    {
        UpdateFootIK(_LeftFoot, AvatarIKGoal.LeftFoot, _FootWeights[0], _Animancer.Animator.leftFeetBottomHeight);
        UpdateFootIK(_RightFoot, AvatarIKGoal.RightFoot, _FootWeights[1], _Animancer.Animator.rightFeetBottomHeight);
    }

    private void UpdateFootIK(Transform footTransform, AvatarIKGoal goal, float weight, float footBottomHeight)
    {
        var animator = _Animancer.Animator;
        animator.SetIKPositionWeight(goal, weight);
        animator.SetIKRotationWeight(goal, weight);

        if (weight == 0)
            return;

        var rotation = animator.GetIKRotation(goal);
        var localUp = rotation * Vector3.up;

        var position = footTransform.position;
        position += localUp * _RaycastOriginY;

        var distance = _RaycastOriginY - _RaycastEndY;

        if (Physics.Raycast(position, -localUp, out var hit, distance))
        {
            position = hit.point;
            position += localUp * footBottomHeight;
            animator.SetIKPosition(goal, position);

            var rotAxis = Vector3.Cross(localUp, hit.normal);
            var angle = Vector3.Angle(localUp, hit.normal);
            rotation = Quaternion.AngleAxis(angle, rotAxis) * rotation;

            animator.SetIKRotation(goal, rotation);
        }
        else
        {
            position += localUp * (footBottomHeight - distance);
            animator.SetIKPosition(goal, position);
        }
    }
}

Now we have a character that looks slightly more realistic when walking on uneven ground and slopes:

IK Disabled IK Enabled

As was mentioned at the start, this is a basic proof of concept which could be improved by using better animations, playing around with the shape of the IK curves, and trying different ways of determining where to raycast (you might even try using a raycast for the front and back of each foot for additional stability).