UI Skill

threejs-animation

"Keyframe, skeletal and morph-target animation, procedural motion and clip blending, on WebGL or with WebGPU and TSL. Use when animating objects, playing glTF animations, posing bones, driving morph targets, or mixing and crossfading clips."

Path: cloudai-x/threejs-animationStars: 0Author: cloudai-x更新于:2026/05/01 10:05

目录

  1. Quick Start
  2. How the System Fits Together
  3. AnimationClip and Keyframe Tracks
  4. Interpolation
  5. AnimationMixer
  6. AnimationAction
  7. Fades and Crossfades
  8. Playing glTF Animations
  9. Skeletal Animation
  10. Morph Targets
  11. Blending Clips by Weight
  12. Additive Animation
  13. Clip Utilities
  14. Procedural Motion
  15. Performance
  16. WebGPU and TSL
  17. Common Mistakes
  18. See Also

Three.js Animation

Targets three.js r186. Examples use WebGLRenderer; the WebGPU and TSL section uses WebGPURenderer.

Quick Start

import * as THREE from "three";

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();

const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 0.5, 3);

const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshNormalMaterial());
scene.add(mesh);

const timer = new THREE.Timer();
timer.connect(document); // also keeps deltas small after the tab was hidden

function animate() {
  timer.update(); // first thing each frame: fixes getDelta()/getElapsed() for this frame
  const delta = timer.getDelta();
  const elapsed = timer.getElapsed();

  mesh.rotation.y += delta; // frame-rate-independent spin
  mesh.position.y = Math.sin(elapsed * 2) * 0.25; // bob on absolute time

  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

How the System Fits Together

  • AnimationClip: keyframe data — tracks of times and values, no playback state.
  • AnimationMixer: plays clips on one root object and all its descendants.
  • AnimationAction: playback control for one clip on one mixer.

Each frame, mixer.update(delta) samples the active clips and writes the values into ordinary object properties. The system runs on the CPU and never touches the renderer, so it drives models the same way under either renderer.

AnimationClip and Keyframe Tracks

// One track per animated property. values.length must be times.length * valueSize.
const times = [0, 1, 2]; // keyframe times in seconds
const values = [0, 1, 0]; // one scalar per keyframe for a Number track

const track = new THREE.NumberKeyframeTrack(".position[y]", times, values); // the root's position.y

const clip = new THREE.AnimationClip("bounce", 2, [track]); // duration in seconds
// pass -1 as the duration to derive it from the tracks

Track names follow nodeName.objectName[objectIndex].propertyName[propertyIndex]; all parts except the property are optional. .position binds the root's position, .material.opacity a material property, .position[y] one component, .morphTargetInfluences[smile] one morph target, Hips.position a named descendant — that is how bones are addressed in a skinned model. Object names may be material, materials, bones or map.

// Number track (value size 1); create the material with transparent: true, or opacity is ignored
new THREE.NumberKeyframeTrack(".material.opacity", [0, 1], [1, 0]);

// Vector track (value size 3: x, y, z per keyframe)
new THREE.VectorKeyframeTrack(".position", [0, 1, 2], [0, 0, 0, 1, 2, 0, 0, 0, 0]);

// Rotation track (value size 4): quaternion x, y, z, w per keyframe, not Euler angles
const q2 = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI);
new THREE.QuaternionKeyframeTrack(
  ".quaternion",
  [0, 1],
  [0, 0, 0, 1, q2.x, q2.y, q2.z, q2.w], // identity, then q2
);

// Color track (value size 3)
new THREE.ColorKeyframeTrack(".material.color", [0, 1], [1, 0, 0, 0, 1, 0]);

// Boolean track (always discrete)
new THREE.BooleanKeyframeTrack(".visible", [0, 1], [true, false]);

Interpolation

const track = new THREE.VectorKeyframeTrack(
  ".position",
  [0, 1, 2],
  [0, 0, 0, 1, 2, 0, 0, 0, 0],
);

track.setInterpolation(THREE.InterpolateLinear); // default
track.setInterpolation(THREE.InterpolateSmooth); // cubic through the keyframes
track.setInterpolation(THREE.InterpolateDiscrete); // step, no in-between values

// Bezier takes absolute (time, value) control points, two numbers per keyframe per
// component: a stride-1 two-keyframe track needs four entries per array
const morphTrack = new THREE.NumberKeyframeTrack(
  ".morphTargetInfluences[smile]",
  [0, 1],
  [0, 1],
);
morphTrack.setInterpolation(THREE.InterpolateBezier);
morphTrack.settings = { inTangents: [0, 0, 1, 1], outTangents: [0, 1, 1, 0] };

AnimationMixer

const track = new THREE.NumberKeyframeTrack(".rotation[y]", [0, 2], [0, Math.PI * 2]);
const clip = new THREE.AnimationClip("spin", 2, [track]);

// One mixer per animated root; it reaches the root and all descendants
const mixer = new THREE.AnimationMixer(mesh);
const action = mixer.clipAction(clip); // cached: calling it again for this clip and root returns the same action
action.play();

const timer = new THREE.Timer();
timer.connect(document);

function animate() {
  timer.update();
  mixer.update(timer.getDelta()); // required: advances every active action
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

// Events fire on the mixer while it updates; "finished" needs LoopOnce or finite repetitions
mixer.addEventListener("finished", (e) => console.log("finished:", e.action.getClip().name));
mixer.addEventListener("loop", (e) => console.log("looped:", e.action.getClip().name));

AnimationAction

const mixer = new THREE.AnimationMixer(mesh);
const clip = new THREE.AnimationClip("spin", 2, [
  new THREE.NumberKeyframeTrack(".rotation[y]", [0, 2], [0, Math.PI * 2]),
]);
const action = mixer.clipAction(clip);

action.play(); // schedule and start
action.reset(); // rewind to time 0 and cancel fades
action.stop(); // unschedule; properties no other scheduled action animates return to their original values
action.halt(0.3); // decelerate to a stop within 0.3 s

action.time = 0.5; // scrub within the clip
action.timeScale = -1; // negative plays backward
action.paused = true; // freeze in place

action.setLoop(THREE.LoopOnce, 1); // stop (or hold) after one pass
action.setLoop(THREE.LoopRepeat, Infinity); // the default
action.setLoop(THREE.LoopPingPong, 4); // alternate direction each pass
action.clampWhenFinished = true; // hold the final pose instead of resetting

Fades and Crossfades

const mixer = new THREE.AnimationMixer(mesh);
const idleClip = new THREE.AnimationClip("idle", 2, [
  new THREE.NumberKeyframeTrack(".position[y]", [0, 1, 2], [0, 0.05, 0]),
]);
const walkClip = new THREE.AnimationClip("walk", 1, [
  new THREE.NumberKeyframeTrack(".rotation[y]", [0, 1], [0, Math.PI]),
]);

const idle = mixer.clipAction(idleClip).play();
const walk = mixer.clipAction(walkClip).play();

// Hand over from idle to walk over 0.4 s; true as the third argument also warps
// both time scales so their cycle lengths match during the fade
idle.crossFadeTo(walk, 0.4, false);

// The same hand-over from the one-sided calls (use one form, not both)
walk.reset().fadeIn(0.4).play(); // ramp weight up from 0
idle.fadeOut(0.4); // ramp weight down to 0

Playing glTF Animations

import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import * as SkeletonUtils from "three/addons/utils/SkeletonUtils.js";

const gltf = await new GLTFLoader().loadAsync("model.glb");
const model = gltf.scene;
scene.add(model);

// GLTFLoader's default material (MeshStandardMaterial) is lit: without lights the model renders black
const sun = new THREE.DirectionalLight(0xffffff, 2);
sun.position.set(3, 5, 2);
scene.add(sun, new THREE.HemisphereLight(0xffffff, 0x444444, 1.5));

// gltf.animations is an array of parsed AnimationClips
const clips = gltf.animations;

const mixer = new THREE.AnimationMixer(model);
const walkClip = THREE.AnimationClip.findByName(clips, "Walk");
if (walkClip) mixer.clipAction(walkClip).play();

// A second character: clone the skinned model, give the copy its own mixer
const model2 = SkeletonUtils.clone(model);
model2.position.x = 1.5; // beside the first one
scene.add(model2);
const mixer2 = new THREE.AnimationMixer(model2);
if (walkClip) mixer2.clipAction(walkClip).play(); // same clip, two mixers

const timer = new THREE.Timer();
timer.connect(document);

function animate() {
  timer.update();
  const delta = timer.getDelta();
  mixer.update(delta);
  mixer2.update(delta);
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

Skeletal Animation

// mesh is the loaded model root; find its first SkinnedMesh
const skinnedMesh = mesh.getObjectByProperty("type", "SkinnedMesh");
const skeleton = skinnedMesh.skeleton;
console.log(skeleton.bones.map((b) => b.name));

// Grab a bone to pose or attach to
const handBone = skeleton.bones.find((b) => b.name === "RightHand");

// Visualize the rig
const helper = new THREE.SkeletonHelper(mesh);
scene.add(helper);

Procedural bone control runs after mixer.update(), or a clip that animates the same bone overwrites it:

const skinnedMesh = mesh.getObjectByProperty("type", "SkinnedMesh");
const headBone = skinnedMesh.skeleton.bones.find((b) => b.name === "Head");
const mixer = new THREE.AnimationMixer(mesh); // play the model's clips on it (see Playing glTF Animations)

const timer = new THREE.Timer();
timer.connect(document);

function animate() {
  timer.update();
  mixer.update(timer.getDelta()); // clips write their bone pose here

  // Override afterwards: update() overwrites every property the playing clips animate
  if (headBone) headBone.rotation.y = Math.sin(timer.getElapsed()) * 0.3;
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

Bones are Object3Ds, so attaching means parenting:

const skinnedMesh = mesh.getObjectByProperty("type", "SkinnedMesh");
const handBone = skinnedMesh.skeleton.bones.find((b) => b.name === "RightHand");

const weapon = new THREE.Mesh(
  new THREE.BoxGeometry(0.08, 0.08, 0.5),
  new THREE.MeshNormalMaterial(),
);
if (handBone) {
  handBone.add(weapon); // weapon now follows the hand's bone transform
  weapon.position.set(0, 0, 0.25); // offset within the bone's space
  weapon.rotation.set(0, Math.PI / 2, 0);
}

Morph Targets

// mesh carries morph targets (loaded, or added by hand below)
const names = Object.keys(mesh.morphTargetDictionary); // name -> index
console.log(names, mesh.morphTargetInfluences); // one weight per target

// Blend a target by index or by name; 0 = base shape, 1 = full target
mesh.morphTargetInfluences[0] = 0.5;
const smileIndex = mesh.morphTargetDictionary["Mouth_Smile"];
if (smileIndex !== undefined) mesh.morphTargetInfluences[smileIndex] = 1;

Adding a morph target by hand:

const geometry = mesh.geometry;

// A morph target is a full-size position (or normal) attribute beside the base one
const raised = geometry.attributes.position.array.slice(); // same vertex count
for (let i = 1; i < raised.length; i += 3) {
  raised[i] += 0.5; // y is every third component: lift every vertex
}

const target = new THREE.Float32BufferAttribute(raised, 3);
target.name = "raised"; // named targets stay addressable from tracks

geometry.morphAttributes.position = [target];
mesh.updateMorphTargets(); // rebuilds morphTargetInfluences and the dictionary

Driving morph targets:

// Procedural, inside the render loop after timer.update():
// mesh.morphTargetInfluences[0] = (Math.sin(timer.getElapsed() * 2) + 1) / 2;

// Keyframed: a Number track drives one target by name — never a String track
const track = new THREE.NumberKeyframeTrack(
  ".morphTargetInfluences[raised]",
  [0, 0.5, 1],
  [0, 1, 0],
);
const clip = new THREE.AnimationClip("pulse", 1, [track]);
const mixer = new THREE.AnimationMixer(mesh);
mixer.clipAction(clip).play();

Blending Clips by Weight

const mixer = new THREE.AnimationMixer(mesh);
const idleClip = new THREE.AnimationClip("idle", 2, [
  new THREE.NumberKeyframeTrack(".position[y]", [0, 1, 2], [0, 0.05, 0]),
]);
const runClip = new THREE.AnimationClip("run", 1, [
  new THREE.NumberKeyframeTrack(".position[y]", [0, 0.5, 1], [0, 0.3, 0]),
]);

const idle = mixer.clipAction(idleClip).play();
const run = mixer.clipAction(runClip).play();

// Both sample every frame; the weights mix their poses
function setBlend(speed) {
  const t = Math.min(speed / 5, 1); // 0 = idle, 5 and up = full run
  idle.setEffectiveWeight(1 - t);
  run.setEffectiveWeight(t);
}

setBlend(2.5); // half idle, half run

Additive Animation

const mixer = new THREE.AnimationMixer(mesh);
const walkClip = new THREE.AnimationClip("walk", 1, [
  new THREE.NumberKeyframeTrack(".rotation[y]", [0, 1], [0, Math.PI]),
]);
const breathClip = new THREE.AnimationClip("breath", 2, [
  new THREE.NumberKeyframeTrack(".position[y]", [0, 1, 2], [0, 0.02, 0]),
]);

// Re-bases every track on its frame 0 and marks the clip additive
THREE.AnimationUtils.makeClipAdditive(breathClip);

mixer.clipAction(walkClip).play(); // normal base clip
mixer.clipAction(breathClip).play(); // adds its offsets on top of the base pose

Clip Utilities

const clip = new THREE.AnimationClip("walk", 2, [
  new THREE.NumberKeyframeTrack(".rotation[y]", [0, 1, 2], [0, Math.PI, Math.PI * 2]),
]);

const walk = THREE.AnimationClip.findByName([clip], "walk"); // name lookup
// Keeps the existing keyframes in frames [0, 31) at 30 fps (times 0 and 1); it never resamples
const cut = THREE.AnimationUtils.subclip(clip, "half", 0, 31, 30);
const copy = clip.clone(); // independent deep copy

clip.optimize(); // drops duplicate-time keys, and keys equal to both neighbours on any non-smooth track (Bezier tangents are not compacted)
clip.resetDuration(); // recomputes duration from the longest track — it does not seek
console.log(clip.validate()); // false for a fractional values-to-times ratio, an empty track, unsorted times or NaN

Procedural Motion

const timer = new THREE.Timer();
timer.connect(document);

// Exponential approach; a higher lambda snaps faster. Frame-rate independent.
// The same shape drives positions, rotations or any scalar each frame.
let scale = 0;

function animate() {
  timer.update();
  scale = THREE.MathUtils.damp(scale, 1, 4, timer.getDelta());
  mesh.scale.setScalar(0.5 + scale * 0.5);
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

Performance

  • The same AnimationClip can feed any number of mixers — share one clip set across characters instead of copying it.
  • Call mixer.update() only for characters that are visible or about to be; an un-updated mixer simply leaves the last pose in place.
  • A SkinnedMesh computes its bounding sphere once, the first time culling or raycasting needs it, so a clip that moves it far from that pose can get it culled at the screen edge: after mixer.update() call mixer.getRoot().updateMatrixWorld() then skinnedMesh.computeBoundingSphere() (a CPU pass over every vertex) or set skinnedMesh.frustumCulled = false.
// Free a mixer and its cached bindings when its model leaves for good
const mixer = new THREE.AnimationMixer(mesh);
mixer.stopAllAction();
mixer.uncacheRoot(mesh); // releases the property bindings created for the model

WebGPU and TSL

Everything above — mixer, actions, bone transforms, morph weights — is computed on the CPU and drives a model under WebGPURenderer unchanged. TSL adds animation on the GPU: time (elapsed seconds) and deltaTime (seconds since the previous frame) are float uniforms the renderer refreshes every frame, and the oscillators oscSine, oscSquare, oscTriangle and oscSawtooth return 0-to-1 waves with a one-second period from a time node, defaulting to time (oscSine(time.mul(0.5)) takes two seconds). Per-vertex motion needs a node material and a subdivided mesh.

import * as THREE from "three/webgpu";
import { positionLocal, time, vec3 } from "three/tsl";

const renderer = new THREE.WebGPURenderer({ antialias: true });
await renderer.init(); // before any other use of the renderer
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111418); // the WebGPU canvas is transparent by default

const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 2, 3);
camera.lookAt(0, 0, 0);

// Ripple a subdivided plane along its normal (local z) on the GPU; time drives it, no per-frame JS
const material = new THREE.MeshBasicNodeMaterial({ wireframe: true });
material.positionNode = positionLocal.add(
  vec3(0, 0, positionLocal.x.mul(2).sin().mul(time.mul(2).cos()).mul(0.2)),
);
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(3, 3, 32, 32), material);
mesh.rotation.x = -Math.PI / 2;
scene.add(mesh);

function animate() {
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

On a skinned mesh, build displacement on positionLocal: skinning, morph targets, batching and instancing are applied to it before positionNode runs, so the mixer-driven pose survives. positionGeometry is the raw rest-pose attribute — never the base for displacement on an animated mesh.

import * as THREE from "three/webgpu";
import { color, mix, oscSine, positionLocal, vec3 } from "three/tsl";

// The pulse rides on top of the skinned pose: positionLocal already carries it
const material = new THREE.MeshBasicNodeMaterial();
material.positionNode = positionLocal.add(vec3(0, oscSine().mul(0.05), 0));

// Oscillators animate any node slot, not just positions
material.colorNode = mix(color(0x202030), color(0xffd24a), oscSine());

const skinnedMesh = scene.getObjectByProperty("type", "SkinnedMesh"); // a loaded animated model
if (skinnedMesh) skinnedMesh.material = material;

Common Mistakes

  • const clock = new THREE.Clock(); → const timer = new THREE.Timer(); timer.connect(document);: the Clock class is deprecated in r183 and Timer is its replacement.
  • const elapsed = clock.getElapsedTime(); const delta = clock.getDelta(); → timer.update(); const delta = timer.getDelta(); const elapsed = timer.getElapsed();: getElapsedTime() calls getDelta() internally, so the following delta read is about zero and the mixer stalls.
  • requestAnimationFrame(animate); → renderer.setAnimationLoop(animate);: both renderers' docs advise it over a manual loop, and it hands frames to the WebXR session when one starts.
  • import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; → import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";: the examples/jsm specifier does not resolve under the standard import map.
  • new THREE.NumberKeyframeTrack(".material.opacity", [0, 1, 2], [1, 0]) → new THREE.NumberKeyframeTrack(".material.opacity", [0, 1], [1, 0]): values must hold times.length × valueSize entries; with a fractional ratio play() never returns, and any other mismatch samples wrong values.
  • new THREE.StringKeyframeTrack(".morphTargetInfluences[smile]", [0, 1], ["0", "1"]) → new THREE.NumberKeyframeTrack(".morphTargetInfluences[smile]", [0, 1], [0, 1]): morph influences are numbers, and a string track writes "0"/"1" strings into the array.
  • headBone.rotation.y = Math.sin(t) * 0.3; mixer.update(delta); → mixer.update(delta); headBone.rotation.y = Math.sin(t) * 0.3;: update() writes the clip's pose over any value set before it.
  • clip.resetDuration() → action.reset(): resetDuration() only recomputes the clip's duration from its longest track, while reset() rewinds the action to time 0 and re-enables it after a one-shot finished.
  • material.positionNode = positionGeometry.add(offset) → material.positionNode = positionLocal.add(offset): positionGeometry is the raw attribute, so building on it drops skinning, morph targets, batching and instancing.
  • const uTime = uniform(0); uTime.value = timer.getElapsed(); const wave = uTime.mul(2).sin(); → const wave = time.mul(2).sin();: the renderer already advances the TSL time node every frame, so a Timer-fed uniform is redundant per-frame JavaScript.
  • const model2 = model.clone(); → const model2 = SkeletonUtils.clone(model);: cloning a skinned model shares its skeleton between the copies; each copy needs its own.
  • import { clone } from "three/addons/utils/SkeletonUtils.js"; → import * as SkeletonUtils from "three/addons/utils/SkeletonUtils.js";: the namespace keeps the call as SkeletonUtils.clone(model), which cannot be mistaken for the skeleton-sharing model.clone().
  • new Promise((resolve, reject) => loader.load(url, resolve, undefined, reject)) → await loader.loadAsync(url): loaders already expose the promise form, and loadAsync takes the progress callback as its second argument.

See Also

  • threejs-fundamentals — render loop, Timer, renderer and scene setup
  • threejs-loaders — loading glTF models and their animation clips
  • threejs-shaders — GPU vertex displacement with TSL or ShaderMaterial