Animation Blending and Root Motion

This page describes part of the Python framework that comes in the acron wheel. It has no C++ counterpart; a C++ program blends animations with the AnimControl and PartBundle API directly.

An Actor can play several animations at once with blending turned on, each at a weight (see Actor Animations). What it does not give you is the machinery that decides those weights – blend spaces, cross-fading states, one-shots layered over a walk – or a way to take a character’s motion out of its animation. acron.anim adds those, and acron.keyframes samples plain keyframe tracks for everything that is not a skeleton.

All of it computes weights and times in Python and leaves the posing to the Actor, so it works with any Actor the engine loads. The names follow Godot’s AnimationTree, whose behaviour the classes reproduce, so a ported game’s animation code reads like its source.

Weights for a blend

The helpers produce weight maps – {animation name: weight} – and apply_weights puts one onto an Actor in blend mode. It starts each clip that gains a weight, sets every clip’s control effect and stops the clips that drop to zero; playing is a set it keeps up to date for you:

from direct.actor.Actor import Actor
from acron.anim import BlendSpace2D, TransitionSM, apply_weights

robot = Actor('robot.glb')
robot.enable_blend()
playing = set()

locomotion = BlendSpace2D([
    ('idle', (0, 0)),
    ('walk', (0.5, 0)),
    ('run', (1, 0)),
])

states = TransitionSM(xfade=0.2)
states.add_state('move', lambda speed: locomotion.weights(speed, 0))
states.add_state('fall', lambda speed: {'fall': 1.0})


def animate(dt, speed, on_floor):
    states.request('move' if on_floor else 'fall')
    states.update(dt)
    apply_weights(robot, states.weights(speed), playing)
BlendSpace2D

Animations placed at points of a 2D space. weights(x, y) blends the three corners of the triangle the sample falls in, by barycentric weight, and every other animation gets exactly zero; outside the points’ hull it blends the two ends of the nearest edge. The points are triangulated automatically, or by the triangles you pass.

TransitionSM

Named states, each a callable that returns a weight map. request(name) starts a cross-fade to another state over xfade seconds, update(dt) advances it, and weights(...) – whose arguments go to the states’ callables – returns the faded mix. The first state added is the current one.

OneShot

One clip faded in over whatever plays underneath and out again at its end, like a flinch on a walking character: request() fires it, update(dt) advances it and mix(base) returns base with the shot blended over it.

layered(base, layer, amount)

The mix of two weight maps at amount, which OneShot uses and a filtered blend (an upper-body layer, say) can use directly.

Keeping the clips in step

A looped clip runs on the global clock, at the display’s rate, whatever else is in the blend. For a blend that must hold together – a game on a fixed step, a walk and a run crossing over, a skeleton split into subparts – BlendClock keeps one time per clip instead. advance(clips, dt) takes (name, controls, rate) for every clip in the blend, advances each clip’s own time by dt * rate, wraps it at the clip’s length and poses every control the clip has, so the parts of a multi-part Actor land on one frame. A clip that leaves the blend forgets its time, and starts from its first frame when it comes back.

AudioTrack keys sounds to a clip’s own time, the way footsteps belong to a walk cycle: feed advance() the clip’s time (BlendClock.time_of(name)) once per step, and it returns the sounds whose keys were passed, in order – playing them is up to the game.

Root motion

A character animated with root motion has its travel in the animation: the root joint moves forward through the walk cycle. The engine has no notion of a root-motion track – every channel poses its joint – so a game that moves the body by that motion while the clip also moves the joint moves the character twice, and the model snaps back at the end of every cycle.

RootMotionSampler handles both halves. Constructing it pins the root joint to its rest pose (detach_root_motion), so the clips play in place, and advance(control, dt) poses the dominant clip and returns the root’s movement over the step as (delta_pos, delta_quat), in the joint’s frame, ignoring the jump back at a loop:

from acron.anim import RootMotionSampler

motion = RootMotionSampler(robot, root_joint='root')


def step(dt):
    walk = robot.get_anim_control('walk')
    delta_pos, delta_quat = motion.advance(walk, dt)
    speed = delta_pos.length() / dt
    body.set_y(body, speed * dt)      # move along the facing direction

Move the body along its facing at the measured speed, rather than by the raw vector: the per-step delta is noisy, and turning it through the model every frame makes the direction wobble. Pass detach=False for a rig whose root channel is the pose itself, such as a prop or a camera clip; and pass the other parts’ controls for the same clip as mirrors when the skeleton is split into subparts.

When a clip’s speed matters more than its per-step delta – to set a character’s speed from its walk, or to choose the playback rate that keeps the feet from sliding – ask for the clip’s authored motion instead, read from its own keys and the same whatever else is playing:

  • motion.cycle_speed(control) – ground speed in units per second;

  • motion.cycle_translation(control) – the root’s travel over one cycle;

  • motion.cycle_heading(control) – its turn over one cycle, in degrees;

  • motion.cycle_seconds(control) – the length of one cycle.

authored_cycle_motion(bundle, root_joint) gives the same reading for an AnimBundle. The per-step delta of a blend-mode Actor is the weighted average of everything playing, so it shrinks, and can reverse, as other layers fade in; these readings do not.

Keyframe tracks

acron.keyframes samples animation that is not on a skeleton: a sprite’s position, a light’s colour, a door’s angle. A Track is a target path, a property, an optional single axis and a list of (time, value) keys, where a value is a number, a pair or an RGBA tuple; with handles the keys are joined by cubic Bézier curves, otherwise linearly.

from acron.keyframes import (Track, sample_track, wrap_time,
                             LOOP_PINGPONG)

glow = Track('lamp', 'energy', None, [(0.0, 0.2), (1.0, 1.0)])


def update(clock):
    t = wrap_time(clock, 1.0, LOOP_PINGPONG)
    lamp.set_color_scale(1, 1, 1, sample_track(glow, t))

sample_track clamps at the first and last key. wrap_time folds an ever-growing clock into a clip of the given length: LOOP_NONE stops at the end, LOOP_LINEAR repeats and LOOP_PINGPONG runs back and forth. acron.gdformat.anim2d reads Godot AnimationPlayer tracks into this shape.