Skip to content

Animation

Models authored with animation bring it along when imported. A glTF’s keyframed animations arrive as Animation objects on the loaded node, and you play one by instantiating it as an AnimationClip. Clips carry their own playback state, so the same animation can run at different speeds and blends on different subtrees.

The demo below loads a model with several imported animations and plays three of them at once. Drag the weights to blend between idling, walking, and running.

Three imported animations blended by weight on one skinned model.

A loaded model lists its imported animations on parsedAnimations, and findAnimationByName looks one up.

final dash = await loadScene('assets/dash.glb');
scene.add(dash);
for (final animation in dash.parsedAnimations) {
print(animation.name); // Idle, Walk, Run, ...
}

createAnimationClip binds an animation to the node’s subtree and returns the clip. Set it looping and start it.

final walk = dash.createAnimationClip(dash.findAnimationByName('Walk')!)
..loop = true
..play();

The engine advances playing clips every frame; there is nothing to pump from your own tick. A non-looping clip pauses itself at its final keyframe.

A clip’s playback controls cover the states you would expect from a media player.

walk.pause(); // hold the current pose
walk.seek(0.4); // move the playhead to 0.4s (stays paused)
walk.play(); // resume from wherever the playhead is
walk.gotoAndPlay(0.4); // seek and play in one call
walk.stop(); // pause and rewind to the beginning
walk.replay(); // restart from the beginning and play

seek moves the playhead without changing whether the clip is playing, so a paused clip poses the model at that time, which is how you drive an animation from something like a scroll position or a slider. gotoAndPlay combines the seek with starting playback, handy for playing a specific moment on demand (a reaction, a door opening from the halfway point). replay is the fresh-start variant for non-looping clips that were left paused at their end.

playbackTime is the playhead in seconds; reading it tells you where the clip is, and assigning it is equivalent to seek. playbackTimeScale multiplies playback speed; 2 is double speed and negative values play in reverse.

walk.playbackTime = 0.4; // same as walk.seek(0.4)
walk.playbackTimeScale = -1.0; // play backward

The clip’s duration comes from its animation’s endTime, and seeks are clamped to it.

Multiple clips playing on the same node blend by their weight, from 0 to 1. An internal AnimationPlayer mixes them each frame and normalizes the weights when they sum past 1, so any mix of slider values is valid. That is all the demo above does: three looping clips, three weights.

final idle = dash.createAnimationClip(dash.findAnimationByName('Idle')!)
..loop = true
..play();
final run = dash.createAnimationClip(dash.findAnimationByName('Run')!)
..loop = true
..weight = 0
..play();
// Cross-fade by moving weight between clips over time.
idle.weight = 1.0 - t;
run.weight = t;

Weighting is also how you cross-fade a state change smoothly: keep both clips playing and animate the weights over a fraction of a second rather than swapping clips outright.

Clips survive model hot reload. When an edited model is reloaded in place (see Assets and loading), each clip re-binds to the new subtree by node name and keeps its playhead, weights, and playing state, so an animation keeps running across an asset edit.

Animation, AnimationClip, AnimationPlayer, Node.parsedAnimations, Node.findAnimationByName, and Node.createAnimationClip.