关于 threejs-animation
threejs-animation 是面向 three.js r186 的动画参考技能,涵盖关键帧和程序化运动、骨骼模型、变形目标,以及基于 CPU 或 GPU 的动画方式,适用于 three.js 开发者。
主要功能
- 01AnimationClip、Mixer 和 Action
- 02关键帧轨道与插值
- 03骨骼与变形目标动画
- 04剪辑混合、淡入淡出与叠加
- 05WebGPU 和 TSL 动画
- 06动画性能与资源清理
适用场景
- 01为模型制作关键帧动作
- 02播放并混合 glTF 动画
- 03程序化控制骨骼或变形目标
- 04在 WebGPU 中添加顶点动画
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ cloudai-x/ threejs-skills --skill threejs-animation安装后在 Claude Code 中输入/threejs-animation确认已加载。
详细介绍
Three.js 动画
适用于 three.js r186。示例使用 WebGLRenderer;WebGPU 和 TSL 部分使用 WebGPURenderer。
快速开始
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);
系统如何协同工作
- AnimationClip:关键帧数据——由时间和值组成的轨道,不包含播放状态。
- AnimationMixer:在一个根对象及其所有后代上播放片段。
- AnimationAction:控制一个片段在一个混合器上的播放。
每一帧,mixer.update(delta) 都会对活动片段进行采样,并将值写入普通对象属性。该系统在 CPU 上运行,从不接触渲染器,因此无论使用哪种渲染器,它驱动模型的方式都相同。
AnimationClip 和关键帧轨道
// 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
轨道名称遵循 nodeName.objectName[objectIndex].propertyName[propertyIndex] 格式;除属性外,所有部分都是可选的。.position 绑定根对象的位置,.material.opacity 绑定材质属性,.position[y] 绑定一个分量,.morphTargetInfluences[smile] 绑定一个变形目标,Hips.position 绑定一个具名后代——蒙皮模型中的骨骼就是这样寻址的。对象名称可以是 material、materials、bones 或 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]);
插值
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
淡入淡出与交叉淡化
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
播放 glTF 动画
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);
骨骼动画
// 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);
程序化骨骼控制应在 mixer.update() 之后运行,否则,动画同一骨骼的片段会覆盖它:
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);
骨骼是 Object3D,因此附加意味着建立父子关系:
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);
}
变形目标
// 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;
手动添加变形目标:
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
驱动变形目标:
// 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();
按权重混合片段
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
加性动画
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
片段工具
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
程序化运动
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);
性能
- 同一个 AnimationClip 可以提供给任意数量的混合器——让多个角色共享一组片段,而不是复制它。
- 只对可见或即将可见的角色调用
mixer.update();未更新的混合器会保持最后一个姿势。 - SkinnedMesh 会在裁剪或射线检测首次需要时计算一次包围球,因此,如果片段将其移动到远离该姿势的位置,它可能会在屏幕边缘被裁剪:在
mixer.update()后调用mixer.getRoot().updateMatrixWorld(),然后调用skinnedMesh.computeBoundingSphere()(对每个顶点执行一次 CPU 遍历),或设置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 和 TSL
以上所有内容——混合器、动作、骨骼变换、变形权重——都在 CPU 上计算,并以相同方式驱动 WebGPURenderer 下的模型。TSL 可在 GPU 上添加动画:time(经过的秒数)和 deltaTime(自上一帧以来经过的秒数)是渲染器每帧都会刷新的浮点 uniform;振荡器 oscSine、oscSquare、oscTriangle 和 oscSawtooth 接收时间节点并返回周期为一秒、范围为 0 到 1 的波形,默认使用 time(oscSine(time.mul(0.5)) 的周期为两秒)。逐顶点运动需要节点材质和细分网格。
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);
对于蒙皮网格,应基于 positionLocal 构建位移:蒙皮、变形目标、批处理和实例化都会在 positionNode 运行前应用于它,因此混合器驱动的姿势会保留。positionGeometry 是原始静止姿势属性——不要将它用作动画网格位移的基准。
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;
常见错误
const clock = new THREE.Clock();→const timer = new THREE.Timer(); timer.connect(document);:Clock 类已在 r183 中弃用,Timer 是其替代品。const elapsed = clock.getElapsedTime(); const delta = clock.getDelta();→timer.update(); const delta = timer.getDelta(); const elapsed = timer.getElapsed();:getElapsedTime() 内部会调用 getDelta(),因此后续读取的 delta 接近于零,导致混合器停滞。requestAnimationFrame(animate);→renderer.setAnimationLoop(animate);:两个渲染器的文档都建议使用它来代替手动循环,而且 WebXR 会话启动时,它会将帧交给该会话。import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";→import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";:在标准导入映射下,examples/jsm 指定符无法解析。new THREE.NumberKeyframeTrack(".material.opacity", [0, 1, 2], [1, 0])→new THREE.NumberKeyframeTrack(".material.opacity", [0, 1], [1, 0]):数值数量必须等于 times.length × valueSize;比率为小数时,play() 将永不返回,而其他不匹配情况会导致采样值错误。new THREE.StringKeyframeTrack(".morphTargetInfluences[smile]", [0, 1], ["0", "1"])→new THREE.NumberKeyframeTrack(".morphTargetInfluences[smile]", [0, 1], [0, 1]):形态目标影响值是数字,而字符串轨道会将字符串 "0"/"1" 写入数组。headBone.rotation.y = Math.sin(t) * 0.3; mixer.update(delta);→mixer.update(delta); headBone.rotation.y = Math.sin(t) * 0.3;:update() 会用动画片段的姿势覆盖在它之前设置的任何值。clip.resetDuration()→action.reset():resetDuration() 只会根据最长轨道重新计算动画片段的时长,而 reset() 会将动作倒回时间 0,并在单次播放结束后重新启用它。material.positionNode = positionGeometry.add(offset)→material.positionNode = positionLocal.add(offset):positionGeometry 是原始属性,在其基础上构建会丢失蒙皮、形态目标、批处理和实例化效果。const uTime = uniform(0); uTime.value = timer.getElapsed(); const wave = uTime.mul(2).sin();→const wave = time.mul(2).sin();:渲染器每帧都会推进 TSL 时间节点,因此使用 Timer 驱动 uniform 会造成多余的逐帧 JavaScript 运算。const model2 = model.clone();→const model2 = SkeletonUtils.clone(model);:克隆蒙皮模型时,副本会共享同一个骨架;每个副本都需要独立的骨架。import { clone } from "three/addons/utils/SkeletonUtils.js";→import * as SkeletonUtils from "three/addons/utils/SkeletonUtils.js";:使用命名空间可以将调用写成 SkeletonUtils.clone(model),以免与共享骨架的 model.clone() 混淆。new Promise((resolve, reject) => loader.load(url, resolve, undefined, reject))→await loader.loadAsync(url):加载器已经提供 Promise 形式,而 loadAsync 的第二个参数是进度回调。
另请参阅
threejs-fundamentals— 渲染循环、Timer、渲染器和场景设置threejs-loaders— 加载 glTF 模型及其动画片段threejs-shaders— 使用 TSL 或 ShaderMaterial 实现 GPU 顶点位移