Animation
A character is animated by one object. createAnimator({ root, expressionSpace }) binds a skinned rig root to four layers, and update(dt) evaluates all four and refreshes four output buffers in place. Nothing in the per-frame path allocates.
ts
import { createAnimator, validateCharacterState } from '@aosengine/animation';
const animator = createAnimator({
root: rigRoot,
expressionSpace: { kind: 'arkit52', dim: 52 },
locomotion: locomotionIndex,
});
animator.addClip('idle', idleClip);
animator.addClip('walk', walkClip);
animator.setState(validateCharacterState(frame.character));
animator.update(dt, { camera });The four layers
Body base. An AnimationMixer. Its weights come from one of three sources, checked in this order:
- a blueprint graph, if
setGraphwas called —playClip,blend,selectand a rule-guarded state machine, evaluated into clip weights; state.clips, when the guest drives clips explicitly by name and weight;- otherwise the locomotion blend, from
state.velocity.
Additive / gesture. One slot. A gesture is a clip registered with { additive: true } and played through animator.gesture.play(name, { durationMs }), which runs a fade-in, hold, fade-out envelope over it. It is applied after the base layer's weight pass and before the mixer advances, so the base layer cannot overwrite the envelope.
Face. Face clips are per-frame ARKit-52 blendshape weights, blended by drive weight and interpolated across the loop boundary. The result is mapped into the bundle's expression space and published as animator.expression. A guest that computes its own expression vector can supply one on state.expression instead.
Procedural. Blink overlays the ARKit blink channels. Head aim turns the neck and head toward state.lookAt — or the camera — and publishes the rotations as animator.jointOverrides. Whatever the neck could not reach is handed to the eyes as animator.gaze, [pitchL, yawL, pitchR, yawR] in radians.
The locomotion blend
Locomotion clips are CC0, retargeted offline onto the canonical armature by packages/animation/tools/retarget_locomotion.mjs, and indexed in a locomotion.json that tags each clip with the ground speed it was authored at.
At runtime the character's PLANAR speed — Y is excluded, because a falling character is not sprinting — picks the two clips bracketing it and crossfades them. Outside the bracketed range the blend clamps to the nearest clip and scales its playback instead: a 4 m/s run clip played at 6 m/s runs at 1.5x, so the stride keeps up with the ground and the feet do not skate.
Expression spaces
Face clips are always ARKit-52. A bundle whose head is a GNM model wants 383 coefficients, or the reduced 68. That mapping belongs to the bundle, not to the engine — two characters in one scene can be in different spaces — so expressionSpace carries it:
ts
{ kind: 'gnm', dim: 383, map: (arkit, out) => { /* bundle-supplied */ } }createAnimator throws if a non-ARKit space arrives without a map. The procedural blink composes in ARKit space, before the map runs.
Head aim clamps against the body
The head look-at clamps yaw relative to the body's current forward, not to world +Z. With a world-anchored clamp, locomotion — which yaws the whole character to face the walk direction — lets the head reach body-yaw plus the neck limit, which is a neck that rotates a great deal further than a neck can. Clamping relative to the torso keeps the head within its limit whichever way the body faces.
The aim is split across neck_01, neck_02 and head, and each override is a parent-local delta that pre-multiplies the bone's animated rotation. animator.bodyPose already has them folded in; animator.jointOverrides is for rig backends that take joint overrides as their own input. Use one or the other.
Additive clips are baked, not layered at runtime
Unreal computes an additive delta when the animation asset is built, not at the ApplyAdditive graph node. bakeAdditive reproduces that: it takes the additive type, base pose type and reference frame index that the FBX to glTF conversion strips, and rewrites the clip's keyframes once at load time. Nothing about additive layering runs per frame.
See also
- Characters
- Play an animation
packages/animation/README.md— @aosengine/animation