Talos docs

Animation: a first character that moves

Since 0.7Changed in next

The way in to animation in this engine: import a model, give it an animator, play its clips, switch between them, blend, loop, react to events, and rig a model that came with no skeleton.

Plain text

The way in to animation in this engine: import a model, give it an animator, play its clips, switch between them, blend, loop, react to events, and rig a model that came with no skeleton. Every example here is run by a test (crates/editor/tests/animation_guide.rs reads them out of this file), so what you copy works. The full reference is docs/API.md ("Animation — ..." sections); this page is the order to learn it in.

The examples use the mannequin from the third-person recipe, fixtures/recipes/third-person-character/scenes/models/mannequin.gltf: a stand-in character on a two-joint rig with the clips Idle, Run, Jump, Fall and Land. Copy it into your project's scenes/models/, or use your own model and its clip names.

1. Import a model

In the editor, Import on the toolbar (or drop the file on the window); an agent calls asset.import with the file's path. Either way the file is copied beside the scene and an asset entry is written that says what the model can do:

<!-- guide: asset-entry -->

"mannequin": { "kind": "glb", "path": "assets/mannequin.gltf", "rig": "custom",
  "clips": ["Idle", "Run", "Jump", "Fall", "Land"],
  "clipDurations": { "Idle": 2, "Run": 0.6, "Jump": 0.5, "Fall": 0.8, "Land": 0.4 },
  "skeleton": { "name": "rig", "joints": 2, "bones": ["hips", "chest"] } }
  • rig is custom for a model with a skin (a skeleton its vertices follow) and none for one without. Only a skinned model, or one with morph targets, has anything for an animator to move. A model with no skeleton can be given one: see Rig a model that has none.
  • clips are the names you write in the animator. They are the file's own animation names, spelt exactly.
  • The import also checks the model stands up +Y and faces -Z, the engine's forward. If it does not, it says so (rest-pose-orientation) with the fix: the editor's Import sets the asset's forward for a character authored facing +Z (glTF's own front, which most are); an agent sets "forward" from the warning's fix.forward. Without it the character runs backwards.
  • A model 100 times too big is usually in centimetres: import it with a unit scale of 0.01.

2. Make it move with no script

Give the entity an animator whose states are named after what the character controller does: idle, moving, jump and land. The controller raises those four events as it starts and stops moving, leaves the ground and lands, and the engine switches the animator to the state of the same name when it has one. Nothing else to write:

<!-- guide: zero-script -->

"components": [
  { "type": "mesh", "asset": "mannequin" },
  { "type": "collider", "shape": "capsule", "radius": 0.35, "height": 1.8 },
  { "type": "rigidbody", "kind": "kinematic" },
  { "type": "controller", "kind": "player", "speed": 6, "jump": 1.5 },
  { "type": "animator", "asset": "mannequin", "initial": "idle",
    "states": [
      { "name": "idle",   "clip": "Idle", "loop": true,  "transitions": [] },
      { "name": "moving", "clip": "Run",  "loop": true,  "transitions": [] },
      { "name": "jump",   "clip": "Jump", "loop": false, "transitions": [] },
      { "name": "land",   "clip": "Land", "loop": false, "transitions": [], "once": { "then": "idle" } } ] } ]

Press Play and push the stick (or WASD): moving plays Run, and letting go goes back to idle. A controller needs the scene's player spawn, which a new project's scene already has.

  • A state plays one clip (or one blend tree, below). loop: true repeats it; loop: false plays it once and holds the last frame.
  • initial is the state it starts in.
  • once makes a state a one-shot that hands over by itself when its clip ends: land plays Land once and goes back to idle.
  • The switch between states is a crossfade, 0.2 s unless a transition says otherwise (section 4).

3. Drive it from your own behaviour

Most games decide the state themselves. A behaviour gets the animator as ctx.animator: set(state) switches to a state, play(name) plays a state or a clip once and then hands back, and state reads the one playing. Setting the state every tick is what takes over from the controller's own switching in section 2 (it only switches where your script did not this tick).

<!-- guide: own-behaviour-scene -->

"components": [
  { "type": "mesh", "asset": "mannequin" },
  { "type": "collider", "shape": "capsule", "radius": 0.35, "height": 1.8 },
  { "type": "rigidbody", "kind": "kinematic" },
  { "type": "controller", "kind": "player", "speed": 6, "jump": 1.5 },
  { "type": "animator", "asset": "mannequin", "initial": "idle",
    "states": [
      { "name": "idle", "clip": "Idle", "loop": true, "transitions": [] },
      { "name": "run",  "clip": "Run",  "loop": true, "transitions": [] } ] },
  { "type": "script", "module": "behaviors/hero" } ]

scenes/behaviors/hero.ts:

<!-- guide: own-behaviour -->

import type { Behavior } from "@argus/api"

const hero: Behavior = {
  update(ctx) {
    const v = ctx.controller!.velocity
    const speed = Math.hypot(v[0], v[2])
    ctx.animator!.set(speed > 0.5 ? "run" : "idle")
    // Once, then back to run or idle. `interrupt: []`: the set() above cannot cut it short.
    if (ctx.input!.pressed("jump")) ctx.animator!.play("Jump", { interrupt: [] })
  },
}
export default hero

play takes options: then (the state to go to afterwards), blend (the crossfade in), interrupt (the states that may cut it short; any by default, so a set every tick would end it at once, and [] lets nothing) and event (the bus event raised when it ends, animation-end by default; see section 6).

4. Transitions, and what fires them

A transition on a state says "on this event, go to that state, crossfading over this many seconds". Transitions are run by the built-in animator behaviour, so the entity needs it as a script component. It hears the controller's four events by itself; any other event name goes in its events prop, and then any behaviour can fire it with ctx.events.emit:

<!-- guide: transitions -->

"components": [
  { "type": "mesh", "asset": "mannequin" },
  { "type": "animator", "asset": "mannequin", "initial": "idle",
    "states": [
      { "name": "idle",  "clip": "Idle", "loop": true, "transitions": [{ "on": "alarm", "to": "panic", "blend": 0.3 }] },
      { "name": "panic", "clip": "Run",  "loop": true, "transitions": [{ "on": "all-clear", "to": "idle", "blend": 0.5 }] } ] },
  { "type": "script", "module": "animator", "props": { "events": ["alarm", "all-clear"] } } ]

<!-- guide: emit -->

ctx.events.emit("alarm") // from any behaviour: every animator listening for "alarm" takes it

The rules, all of them:

  • on is the name of a bus event. The animator behaviour hears moving, idle, jump and land with no prop, and other names only when they are in its events list.
  • Without the animator script component no transition is ever taken. The scene still loads and the state just sits there; the load says animator-transition-unheard, naming the events and the line to add.
  • blend is the crossfade time into the target, in seconds. ctx.animator.set uses it too when a transition reaches the state, so a transition kept only for its blend can say "on": "".
  • A transition is taken from the state playing now only. to must name a state (animator-transition-undefined otherwise, and the scene is refused).

So there are three ways a state changes, and they combine: the controller's events onto states of the same name (section 2), transitions through the animator behaviour, and set/play from your own behaviour, which wins on the tick it is called.

5. Blend by a number: walk to run

A state can play a blend tree instead of one clip: it mixes clips by a parameter. Here the parameter is bound to the controller's speed, so the engine writes it every tick and there is no script at all:

<!-- guide: blend-tree -->

"components": [
  { "type": "mesh", "asset": "mannequin" },
  { "type": "collider", "shape": "capsule", "radius": 0.35, "height": 1.8 },
  { "type": "rigidbody", "kind": "kinematic" },
  { "type": "controller", "kind": "player", "speed": 6, "jump": 1.5 },
  { "type": "animator", "asset": "mannequin", "initial": "locomotion",
    "parameters": [{ "name": "speed", "value": 0, "source": "speed", "damp": 0.15 }],
    "states": [
      { "name": "locomotion", "loop": true, "transitions": [],
        "blend": { "kind": "linear", "parameters": ["speed"], "points": [
          { "clip": "Idle", "at": [0] }, { "clip": "Run", "at": [6] } ] } } ] } ]
  • parameters declares every number a tree reads. source binds one to a fact the controller measures (speed, strafeX, strafeY, verticalSpeed, grounded, and the input… ones); leave source off and a behaviour writes it with ctx.animator.param("speed", 3). damp eases it over that many seconds so the pose does not pop.
  • points put each clip at a value; between two points the two clips are mixed, past the ends the end clip plays. Clips inside a tree stay in step.
  • With a real walk, jog and sprint, put each point at the speed its clip was authored at (GET /feel?section=clips reads it); the third-person recipe's README shows the tree.

6. Clips that loop, play once, and say when

loop: true repeats a clip; loop: false plays it once and holds. A one-shot (once on the state, or ctx.animator.play) plays once and hands over by itself, and raises a bus event when it ends. Events mark moments inside a clip: at is a fraction of the clip, 0 to 1, and each is raised on the tick the clip passes it, with { entity, clip, at } as its data:

<!-- guide: events-scene -->

"components": [
  { "type": "mesh", "asset": "mannequin" },
  { "type": "animator", "asset": "mannequin", "initial": "run",
    "states": [
      { "name": "run",  "clip": "Run",  "loop": true,  "transitions": [] },
      { "name": "trip", "clip": "Fall", "loop": false, "transitions": [],
        "once": { "then": "run", "event": "got-up" } } ],
    "events": [
      { "clip": "Run", "at": 0.25, "event": "footstep" },
      { "clip": "Run", "at": 0.75, "event": "footstep" } ] },
  { "type": "script", "module": "behaviors/runner" } ]

scenes/behaviors/runner.ts:

<!-- guide: events -->

import type { Behavior } from "@argus/api"

const runner: Behavior = {
  init(ctx) {
    ctx.state.steps = 0
  },
  onEvent(ctx, ev) {
    const data = ev.data as { entity?: string }
    if (data?.entity !== ctx.entity.id) return // one listener hears every character's events
    if (ev.name === "footstep") {
      ctx.state.steps = (ctx.state.steps as number) + 1
      if (ctx.state.steps === 6) ctx.animator!.play("trip") // the one-shot above
    }
    if (ev.name === "got-up") ctx.log("back on its feet")
  },
}
export default runner
  • Events sit on the animator, not on a state: a clip's footsteps are the same whichever state plays it, inside a blend tree too (where the heaviest clip speaks).
  • An event with until is a window that opens and closes (data.phase begin/end), for an attack's active frames; ctx.animator.window(name) says whether it is open. See docs/API.md, "Windows".

Check a clip before you use it. The asset gate's motion command reads a skinned GLB's clips the way the engine plays them and judges each from its motion alone, before anyone has to watch it. It reports one JSON line: pass, the rig (height, feet, whether it is a canonical humanoid) and, per clip, its numbers and checks:

  • motion-loop-seam: a clip that must loop (--loop, or --intent idle, walk or run) meets itself at the wrap, by the same test as the runtime's animator-loop-seam warning.
  • motion-foot-slide: a planted foot stays put (or, in a clip that walks in place, moves with the floor).
  • motion-spike: no joint pops out and back in one frame or snaps by a large angle, and the root does not jump.
  • motion-bone-coverage: a walk or run on the canonical humanoid animates every bone the built-in clips need.
  • motion-bad-keys, motion-root-drift (an idle that wanders off), motion-speed (with --speed, the clip's ground speed against the speed you tagged), motion-length and motion-size; warnings for the hips' height, a foot under the floor and a rig that is not a person's height in metres.

Lengths are judged as a share of the rig's height, so a file in centimetres is held to the same standard as one in metres. Every limit is provisional until real clips have set it. Each reject ends with a Next: saying what to fix.

7. Rig a model that has none

Characters from an AI model generator or a scan are usually one rigid mesh: no skin, nothing to animate. Tick Give it a simple body rig in the Import modal (an agent passes bodyRig: true to asset.import). The copy gets five joints up its middle and twenty-six body clips: Idle, LeanLeft, LeanRight, Punch, Throw, ThrowBack, PointLeft, PointRight, Cheer, and the reactions HighFive, HangLeft, HangRight, HitReact, LeanBack, FistPump, Sag, Slump, LookAround, Fidget, HoldOn, GripLeanLeft and GripLeanRight (leans for a rider whose hands are on bars: the chest rolls and the hands stay near where they rest), HighFiveLeft, SagHold (a breathing loop to play after Sag), and GripSag and GripSagHold (the same with the hands kept on the bars). Your file is not changed. Then animate it like any other:

<!-- guide: body-rig -->

"components": [
  { "type": "mesh", "asset": "statue" },
  { "type": "animator", "asset": "statue", "initial": "idle",
    "states": [
      { "name": "idle",  "clip": "Idle",  "loop": true,  "transitions": [] },
      { "name": "cheer", "clip": "Cheer", "loop": false, "transitions": [], "once": { "then": "idle" } } ] } ]

A body has no arms to wave, so the clips are leans, twists and hops. A model that already has a skeleton is refused the option, and so is one with compressed geometry.

8. Models that move parts, not a skeleton

A door on a hinge, a fan, a chest lid: many models are animated by moving their parts (glTF nodes) rather than a skeleton. They play through the same animator. Import gives such a model an animator that plays its clips, and its asset entry says "rig": "none" with the clips it has. Each part rides its node, and states, one-shots, events and speed work as in the sections above, with the parts' node names standing in for bones (blend trees and layers too: docs/API.md, "Node animation"). Here a door imported as door, whose clip Open swings it on its hinge; the state plays it once and holds it open:

<!-- guide: node-animation -->

"components": [
  { "type": "mesh", "asset": "door" },
  { "type": "animator", "asset": "door", "initial": "open",
    "states": [{ "name": "open", "clip": "Open", "loop": false, "transitions": [] }] } ]
  • Only an entity with the animator draws the parts moving. Without one the model stands still, and colliders, picking and the navmesh always read it at rest.
  • A skinned character can carry parts too. A part outside the skin that a clip moves, or that carries a mesh of its own with no skin (a sword in a hand, a lid on a backpack), rides the skeleton and plays its own clips.
  • A clip that turns only the node a skinned mesh hangs on still moves nothing: a skinned mesh follows its bones, not its node. The import says so (node-animation); skin those parts in your modelling tool, give them a mesh of their own, or move the entity from a behaviour.

9. Common mistakes

You didWhat you seeThe fix
Transitions with no animator script component, or an event missing from its eventsthe state never changes; animator-transition-unheard at loadadd {"type": "script", "module": "animator", "props": {"events": [...]}}, as the message says
A clip name spelt differently from the asset's clipsthe model holds its rest pose; animator-clip-missing at load, animator-invalid from the criticcopy the name from the asset entry's clips
ctx.animator.set("Run") with the clip's name instead of the state'snothing changes; animator-unknown at runtime, counted on GET /feelset takes a state name; play takes a state or a clip
ctx.animator.param("sped", 1)throws AnimatorParamError (animator-param-unknown)declare it in parameters, spelt the same
An event at: 12 (a frame, or seconds)the scene is refused: animator-event-invalidat is a fraction 0 to 1; for a frame number write frame
loop: true on a one-shotthe scene is refused: animator-once-invalida one-shot has loop: false
An animator on a model with no skinit never moves; has no skin; nothing to animate at loadimport it again with the body rig (section 7)
A character that runs backwards or lies downrest-pose-orientation at importset the asset's forward (or the import's up axis) from the warning's fix