⚙️ Procedural Animation: Locomotion (Part 1)
This is the first article in a series on implementing procedural locomotion. I’m going to walk through step-by-step generating walkcycles for a pair of robo-pants. Part 1 will mostly be setup and preliminaries, but we’ll at least go far enough for the gestures to start exihibiting personality.
Download the model, if you’d like to follow along.
Character locomotion in games is typically two-step process: first we simulate the position of the character component in the world (sometimes called the root or capsule position), and then we blend and advance animations to match that movement with authored-clips in the character’s local component-space.
For polished 3D games, this is a labor-intensive process, because animators must provide complete animation sets (Idle, Walk, Jog, Run, Jump, etc), as well as various layered, additive, and interstitial animations to fill in corner-cases. Subsequently, indie devs on a budget are always seeking ways to automate these animations.
David Rosen’s 2014 GDC talk on animations in Overgrowth is the best presentation on this subject. He describes a method of generating animations from a small set of key-poses.
Because I don’t have an “animation team”, and I want to support a character creator where players can kitbash together new mecha in-game, I’ve been experimenting with generating animations synthetically with just code.
Kitbashed-character animation tests.
I’m taking direction from Richard Williams’ indispensible textbook, The Animator’s Survival Kit, which I highly recommend reading if you’re interested in animation at all.
Every time I re-read it I learn something new.
Let’s get started with a simple test Pawn in Unreal which just moves around on a flat plane (let’s not worry about collision or anything). Here’s our starting-code:
#pragma once
#include "GameFramework/Pawn.h"
#include "LocoPawn.generated.h"
UCLASS( Abstract )
class ALocoPawn : public APawn
{
GENERATED_BODY()
public:
/* Components */
UPROPERTY( VisibleAnywhere, BlueprintReadOnly )
class USkeletalMeshComponent* Skel;
/* Movement Configuration */
UPROPERTY( EditDefaultsOnly, BlueprintReadWrite )
float MovementSpeed = 1000.0f;
UPROPERTY( EditDefaultsOnly, BlueprintReadWrite )
float AccelSeconds = 0.5f;
UPROPERTY( EditDefaultsOnly, BlueprintReadWrite )
float DecelSeconds = 0.25f;
UPROPERTY( EditDefaultsOnly, BlueprintReadWrite )
float RotationSeconds = 0.1f;
UPROPERTY( EditDefaultsOnly, BlueprintReadWrite )
float CameraSpeed = 90.0f;
ALocoPawn();
/* APawn Interface */
virtual void SetupPlayerInputComponent( UInputComponent* Comp ) override;
virtual void BeginPlay() override;
virtual void Tick( float DeltaTime ) override;
private:
FVector Position, Velocity = FVector( 0.0f ), Accel = FVector( 0.0f );
float TargetYaw, Yaw, YawSpeed = 0.0f;
};
// LocoPawn.cpp
#include "LocoPawn.h"
#include "Components/SkeletalMeshComponent.h"
static const FName NAME_MoveX( "MoveX" );
static const FName NAME_MoveY( "MoveY" );
static const FName NAME_CamX( "CamX" );
void DampSpring( float& Value, float& Speed, float Target, float Duration, float DeltaTime )
{
const float Omega = 2.0f / Duration;
const float Exp = FMath::Exp( -Omega * DeltaTime );
const float Change = Value - Target;
const float Temp = ( Speed + Change * Omega ) * DeltaTime;
Speed = ( Speed - Temp * Omega ) * Exp;
Value = Target + ( Change + Temp ) * Exp;
}
ALocoPawn::ALocoPawn()
{
static const FName NAME_SkelComponent( "SkelComponent" );
Skel = CreateDefaultSubobject< USkeletalMeshComponent >( NAME_SkelComponent );
RootComponent = Skel;
}
/*virtual*/ void ALocoPawn::SetupPlayerInputComponent( UInputComponent* Comp ) /*override*/
{
Super::SetupPlayerInputComponent( Comp );
Comp->BindAxis( NAME_MoveX );
Comp->BindAxis( NAME_MoveY );
Comp->BindAxis( NAME_CamX );
}
/*virtual*/ void ALocoPawn::BeginPlay() /*override*/
{
Super::BeginPlay();
/* Initialize Movement */
Position = GetActorLocation();
Position.Z = 0.0f;
Velocity = FVector( 0.0f );
Yaw = TargetYaw = GetActorRotation().Yaw;
SetActorLocationAndRotation( Position, FRotator( 0, Yaw, 0 ) );
}
/*virtual*/ void ALocoPawn::Tick( float DeltaTime ) /*override*/
{
Super::Tick( DeltaTime );
/* Update Camera Rotation */
AddControllerYawInput( DeltaTime * CameraSpeed * GetInputAxisValue( NAME_CamX ) );
/* Update Root Movement */
FRotator ControlRotation ( 0, GetControlRotation().Yaw, 0 );
FVector Stick( GetInputAxisValue( NAME_MoveY ), GetInputAxisValue( NAME_MoveX ), 0.0f );
Stick = ControlRotation.RotateVector( Stick ).GetClampedToMaxSize( 1.0f );
float StickTilt = Stick.Size();
Accel = FVector( 0.0f );
if( StickTilt > 0.35f )
{
float AccelFriction = 4.0f / AccelSeconds;
Accel -= AccelFriction * Velocity;
Accel += AccelFriction * MovementSpeed * Stick;
TargetYaw = FMath::RadiansToDegrees( FMath::Atan2( Stick.Y, Stick.X ) );
}
else
{
float DecelFriction = 4.0f / DecelSeconds;
Accel -= DecelFriction * Velocity;
}
Velocity += Accel * DeltaTime;
Position += Velocity * DeltaTime;
float NormalizedSpeed = FMath::Min( 1.0f, Velocity.Size() / MovementSpeed );
if( NormalizedSpeed > 0.05f )
{
float DeltaAngle = FRotator::NormalizeAxis( TargetYaw - Yaw );
DampSpring( Yaw, YawSpeed, Yaw + DeltaAngle, RotationSeconds, NormalizedSpeed * DeltaTime );
}
SetActorLocationAndRotation( Position, FRotator( 0, Yaw, 0 ) );
}
Nothing really remarkable going on here, though I’ll highlight a few points of interest:
- The
DampSpring
method is the same one I described in my article on Trailing Joints. - The
4/T
approximation for the coefficient of friction has always been a nice rule-of-thumb. - Using
NormalizedSpeed
to scale theDeltaTime
argument in the rotation update is pretty clever, if I do say so myself 😎
With this in hand, we can make a child Blueprint which sets the appropriate assets.
I also added a camera on a spring-arm, just so we don’t have to code that up.
We can test at this point and confirm that we have some basic movement and camera controls, but the pants are pretty stiff since there’s not animation yet. We’ll proceed by adding secondary movement, layer by layer.
To start designing effects, we need a mental model of how locomotion actually works. Taking a cue from Williams, I’m thinking about it like this:
- We start walking by leaning forward, initiating a fall.
- We throw one of our feet ahead to catch ourselves (the “contact” keyframe)
- We then push ourselves back-up, while picking up our back foot (the “passing” keyframe)
- Rinse & Repeat.
So let’s start by “leaning” into our acceleration. The cross-product of up with the acceleration produces the correct rotation axis for the lean, so I started with that. I noticed it was a bit too snappy, though, so I smoothed it out with a low-pass filter (FDampedQuat
is just a helper struct I wrote to apply DampSpring
to a rotation).
/* Leaning Configuration */
UPROPERTY( EditDefaultsOnly, BlueprintReadWrite )
float LeanMulti = 0.64f;
UPROPERTY( EditDefaultsOnly, BlueprintReadWrite )
float MaxLeanAngle = 45.0f;
UPROPERTY( EditDefaultsOnly, BlueprintReadWrite )
float LeanSmoothingSeconds = 0.25f;
FDampedQuat SmoothLean;
// LocoPawn.cpp
/*virtual*/ void ALocoPawn::Tick( float DeltaTime ) /*override*/
{
...
FQuat TargetLean = FQuat::Identity;
// ^ is a shorthand for cross-product in Unreal
FVector Lean = FVector::UpVector ^ Accel;
float LeanAmount = Lean.Size();
if( LeanAmount > 1.0f )
{
FVector LeanAxis = Lean / LeanAmount;
float LeanAngle = LeanMulti * ( LeanAmount / 100.0f );
LeanAngle = FMath::Min( LeanAngle, MaxLeanAngle );
TargetLean = FQuat( LeanAxis, FMath::DegreesToRadians( LeanAngle ) );
}
SmoothLean.Update( TargetLean, LeanSmoothingSeconds, DeltaTime );
// note how we multiple on the left because the lean is in world-space
FQuat Rotation = SmoothLean.Value * FRotator( 0, Yaw, 0 ).Quaternion();
SetActorLocationAndRotation( Position, Rotation );
}
Already our character is looking more physical, and even catches itself when it stops 🤩
Normally I’d be more subtle, but I’ve exagerrated it for demonstration purposes.
At this point we need to start rotating joints, so let’s set up an Animation Blueprint. For this tutorial, I’m just using out-of-the-box AnimNodes that are built into Unreal. If you want to know more about Two Bone IK, you can read my breakdown.
My convention of stacking blueprint boxes isn’t idiomatic, but I prefer it because having statements top-to-bottom and expressions left-to-right matches how code looks in text.
This blueprint has four public parameters (seen on the bottom-elft in the image):
LeftFootPos
- the position of the left foot (in component-space)RightFootPos
- the position of the right foot (in component-space)HipOffset
- an adjustment to add to the hip locationHipRotation
- an adjustment to add to the hip rotation
These are applied in the pipeline on the right: first we start with the mesh-space ref pose. If you have “key-poses”, like in Overgrowth, you could hook those in here. I did that on Solar Ash to give procedurally-animated level-scale characters “stances” before the IK solvers adjusted them to conform to the environment.
Next we adjust the hips with a Modify-Bone node (I’ve hidden pins we don’t need). Lastly, we use the built-in IK nodes rotate the legs joints so the feet end up in the right place.
You can test these parameters but adjusting them in the Anim Preview Editor panel, and observing the character pose in the scene preview panel (wiggling HipOffset.Z
makes a little dance, which I can never resist trying).
To drive the animation from C++, we have to add some output parameters, and then “hook them together” in the Blueprint’s Update.
/* Outputs to Animation Instance */
UPROPERTY( BlueprintReadOnly )
FVector FootPosL = FVector( 0, -30, 0 );
UPROPERTY( BlueprintReadOnly )
FVector FootPosR = FVector( 0, 30, 0 );
UPROPERTY( BlueprintReadOnly )
FVector HipOffset = FVector( 0, 0, 0 );
UPROPERTY( BlueprintReadOnly )
FRotator HipRotation = FRotator( 0, 0, 0 );
This seems like boilerplate. Why do we have to declare everything twice?
The technical reason is that animations in Unreal evaluate on background threads in parallel and can’t access unsynchronized main-thread data, so we give them their own copy. It also lets multiple unrelated Actors share the same animation setup.
But the real virtue of this system becomes clear when you work in a big team: the Gameplay Programmers who maintain the Pawn and the Technical Animators who maintain the Animation Blueprint can keep their work separate, but use this update function as a “handshake” where you link up what the game provides with what the animation needs.
Anyway, let’s test this by adjusting the feet so they don’t leave the ground when the character leans (In a real game you’d do raycasts for this).
FVector FootRestPosL, FootRestPosR;
// LocoPawn.cpp
/*virtual*/ void ALocoPawn::BeginPlay() /*override*/
...
/* Initialize feet */
FootRestPosL = FootPosL;
FootRestPosR = FootPosR;
}
/*virtual*/ void ALocoPawn::Tick( float DeltaTime ) /*override*/
{
...
// reset feet to rest-position
FootPosL = FootRestPosL;
FootPosR = FootRestPosR;
// convert feet to world-space
FTransform ToWorld( Rotation, Position );
FootPosL = ToWorld.TransformPosition( FootPosL );
FootPosR = ToWorld.TransformPosition( FootPosR );
// snap feet to ground
FootPosL.Z = 0.0f;
FootPosR.Z = 0.0f;
// convert feet back to component-space
FootPosL = ToWorld.InverseTransformPosition( FootPosL );
FootPosR = ToWorld.InverseTransformPosition( FootPosR );
}
Nice little knee-bends while turning.
The last thing we’ll do in this first part is to begin giving the hips some motion. Checking back in with Williams’ textbook, we see his basic walkcycles moving the hips like a sine-wave, so let’s try that.
/* Hip Configuration */
UPROPERTY( EditDefaultsOnly, BlueprintReadWrite )
float HipPhaseSpeed = 2.0f;
UPROPERTY( EditDefaultsOnly, BlueprintReadWrite )
float HipAmplitudeDampSeconds = 0.5f;
UPROPERTY( EditDefaultsOnly, BlueprintReadWrite )
float HipOffsetZ = 20.0f;
UPROPERTY( EditDefaultsOnly, BlueprintReadWrite )
float HipBiasZ = -17.0f;
UPROPERTY( EditDefaultsOnly, BlueprintReadWrite )
float HipRoll = 8.0f;
float HipPhase = 0.0f;
float HipMulti = 0.0f;
float HipMultiSpeed = 0.0f;
// LocoPawn.cpp
/*virtual*/ void ALocoPawn::Tick( float DeltaTime ) /*override*/
{
...
DampSpring( HipMulti, HipMultiSpeed, StickTilt, HipAmplitudeDampSeconds, DeltaTime );
HipPhase += HipPhaseSpeed * NormalizedSpeed * DeltaTime;
HipOffset.Z = HipMulti * ( HipBiasZ + HipOffsetZ * FMath::Sin( HipPhase * 2.0f * PI ) );
HipRotation.Roll = HipMulti * HipRoll * FMath::Sin( 0.5f * HipPhase * 2.0f * PI );
}
I modulated the hip movement based on how much the stick is tilted (passed through a low-pass filter, first, like the lean). Then advanced phase of the sin wave based on the NormalizedSpeed
so it’s higher-frequency when we’re fast, and lower-frequency when we’re slow. Lastly, I swayed the hips with a roll at half the frequency, so there’s a left-right-left-right cadence as it oscillates.
Now we’re starting to get some personality!
That’s a good place to stop for today. You can find a full code listing for Part 1 here. Next time we’ll talk about taking steps.