cloudai-x/threejs-skills已通過檢查
SKILL DETAIL
threejs-animation
cloudai-x/threejs-skills/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.
安裝量 · 287查看來源
Installation
npx skills add https://github.com/cloudai-x/threejs-skills --skill threejs-animation
技能檔案
SKILL.md
最近同步 · 2026年10月23日
SKILL.md›
---
name: threejs-animation
description: "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."
---
# Three.js Animation
Targets three.js r186. Examples use WebGLRenderer; the WebGPU and TSL section uses WebGPURenderer.
## Quick Start
```javascript
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
```javascript
// 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`.
```javascript
// 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
```javascript
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
```javascript
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
```javascript
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
```javascript
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
```javascript
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
```javascript
// 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:
```javascript
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:
```javascript
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
```javascript
// 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:
```javascript
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:
```javascript
// 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
```javascript
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
```javascript
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
```javascript
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
```javascript
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`.
```javascript
// 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.
```javascript
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.
```javascript
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