08-02 Uneven Ground

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

Recommended After: Puppet

Learning Outcomes: in this example you will learn:

How to adjust the height of a character's feet according to the terrain.

How to read the value of a custom AnimationCurve from an animation.

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're walking over.

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.

Overview

The IK control values used in this example haven't been completely fine tuned, the point is simply to prove that IK works the same in Animancer as it does in Mecanim, with two notable differences:

  • The way that IK is enabled (as explained in the IK Pass section).
  • The way that the values of the IK Curves are accessed by the script (as explained in the Animated Properties section).

The general code structure is very simple, with the scripts being controlled by UI elements:

  • A UI Toggle controls whether IK is currently being applied or not.
  • A UI Slider controls the slope of the ground.

As with the Puppet example, playing animations isn't the focus here so it uses a NamedAnimancerComponent to keep the animation out of the example scripts.

Obstacle Treadmil

The ObstacleTreadmill script creates a random series of objects and rearranges them each time the character moves far enough.

using System.Collections.Generic;
using UnityEngine;

public sealed class ObstacleTreadmill : MonoBehaviour
{

Its fields use various Units Attributes to make the Inspector show what kind of units they're measured in:

Code Inspector
    [SerializeField]
    private float _SpawnCount = 10;

    [SerializeField]
    private Material _ObstacleMaterial;

    [SerializeField, Meters]
    private float _Length;

    [SerializeField, Degrees]
    private float _RotationVariance = 45;

    [SerializeField, Multiplier]
    private float _BaseScale = 1;

    [SerializeField, Multiplier]
    private float _ScaleVariance = 0.1f;

    [SerializeField]
    private Transform _Target;

On startup, it spawns a bunch of obstacles and adds them to a List:

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

    private void Awake()
    {
        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);
        }

It also moves and rotates each of them randomly so the terrain is different every time:

        ScrambleObjects();
    }

    private void ScrambleObjects()
    {
        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));
        }
    }

Every FixedUpdate, it checks if the _Target (the character) has moved too far and if so, it randomizes the obstacles again and teleports them back:

    private void FixedUpdate()
    {
        var position = _Target.position;
        if (position.x < transform.position.x)
        {
            ScrambleObjects();

            position.x += _Length;

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

            _Target.position = position;
        }
    }

It also exposes a public property for a UI Slider to control the Slope of the ground:

    [SerializeField]
    private Transform _Ground;

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

IK Curves

Unlike the Puppet example, this example doesn't want the character's feet to always be exactly at the target point. Instead, they should 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.

Rather than going fully to 0 weight, the curves used in this example only go down to 0.2 so that the height the foot reaches when fully lifted up is still slightly influenced (20%) by the IK rather than being fully driven by the animation. The exact dimensions and shape of the curve would need to be adjusted to suit each animation and character in a real game.

Startup

The custom AnimationCurves can be accessed using Animancer's AnimatedFloat class which we initialize 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. 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);
}

On Animator IK

In OnAnimatorIK it simply uses the AnimatedFloat to read the target curves at the current animation time (via _FootWeights[x]):

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);

    ...
}

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, Meters] private float _RaycastOriginY = 0.5f;
[SerializeField, Meters] 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);
}

Conclusion

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 a better character controller, 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).