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); // 当标签页被隐藏后,也能保持 delta 值很小
function animate() {
timer.update(); // 每帧首先执行:固定了本帧的 getDelta()/getElapsed() 值
const delta = timer.getDelta();
const elapsed = timer.getElapsed();
mesh.rotation.y += delta; // 帧率无关的旋转
mesh.position.y = Math.sin(elapsed * 2) * 0.25; // 基于绝对时间的上下浮动
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
系统结构
- AnimationClip: 关键帧数据——时间和值的轨道,不含播放状态。
- AnimationMixer: 在一个根对象及其所有子对象上播放剪辑。
- AnimationAction: 针对一个混音器上一个剪辑的播放控制。
每帧调用 mixer.update(delta) 会采样活动剪辑,并将值写入普通对象属性。该系统在 CPU 上运行,从不接触渲染器,因此在任一渲染器下都以相同方式驱动模型。
AnimationClip 和关键帧轨道
// 每个动画属性对应一个轨道。values.length 必须等于 times.length * valueSize。
const times = [0, 1, 2]; // 关键帧时间(秒)
const values = [0, 1, 0]; // 每个关键帧一个标量,用于 Number 轨道
const track = new THREE.NumberKeyframeTrack(".position[y]", times, values); // 根对象的 position.y
const clip = new THREE.AnimationClip("bounce", 2, [track]); // 持续时间(秒)
// 将持续时间设为 -1 可从轨道自动推导
轨道名称遵循 nodeName.objectName[objectIndex].propertyName[propertyIndex];除属性外所有部分均可选。.position 绑定根对象的位置,.material.opacity 绑定材质属性,.position[y] 绑定一个分量,.morphTargetInfluences[smile] 绑定一个形态目标,Hips.position 绑定一个命名子对象——这就是在蒙皮模型中寻址骨骼的方式。对象名称可以是 material、materials、bones 或 map。
// Number 轨道(值大小 1);创建材质时需设置 transparent: true,否则 opacity 会被忽略
new THREE.NumberKeyframeTrack(".material.opacity", [0, 1], [1, 0]);
// Vector 轨道(值大小 3:每个关键帧的 x, y, z)
new THREE.VectorKeyframeTrack(".position", [0, 1, 2], [0, 0, 0, 1, 2, 0, 0, 0, 0]);
// Rotation 轨道(值大小 4):每个关键帧的四元数 x, y, z, w,非欧拉角
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], // 单位四元数, 然后 q2
);
// Color 轨道(值大小 3)
new THREE.ColorKeyframeTrack(".material.color", [0, 1], [1, 0, 0, 0, 1, 0]);
// Boolean 轨道(始终离散)
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); // 默认
track.setInterpolation(THREE.InterpolateSmooth); // 通过关键点的三次样条插值
track.setInterpolation(THREE.InterpolateDiscrete); // 跳跃,无中间值
// Bezier 使用绝对 (时间, 值) 控制点,每个分量每个关键帧两个数字:
// 一个跨度为 1 的双关键帧轨道每个数组需要四个条目
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]);
// 每个动画根对象一个混音器;它作用于根对象及其所有后代
const mixer = new THREE.AnimationMixer(mesh);
const action = mixer.clipAction(clip); // 缓存:再次为同一剪辑和根对象调用时返回相同的 action
action.play();
const timer = new THREE.Timer();
timer.connect(document);
function animate() {
timer.update();
mixer.update(timer.getDelta()); // 必须:推进每个活动 action
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
// 事件在混音器更新时触发;"finished" 需要 LoopOnce 或有限重复次数
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(); // 调度并开始
action.reset(); // 回退到时间 0 并取消淡入淡出
action.stop(); // 取消调度;未被其他已调度 action 动画的属性恢复原始值
action.halt(0.3); // 在 0.3 秒内减速停止
action.time = 0.5; // 在剪辑内定位
action.timeScale = -1; // 负值表示倒放
action.paused = true; // 冻结当前位置
action.setLoop(THREE.LoopOnce, 1); // 一次过后停止(或保持)
action.setLoop(THREE.LoopRepeat, Infinity); // 默认
action.setLoop(THREE.LoopPingPong, 4); // 每次循环交替方向
action.clampWhenFinished = true; // 保持最终姿势而非重置
淡入淡出和交叉淡入淡出
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();
// 在 0.4 秒内从 idle 过渡到 walk;第三个参数为 true 时也会调整两者时间尺度,使循环长度在淡入淡出期间匹配
idle.crossFadeTo(walk, 0.4, false);
// 单向调用的相同过渡(使用一种形式,不要两种同时使用)
walk.reset().fadeIn(0.4).play(); // 权重从 0 开始递增
idle.fadeOut(0.4); // 权重递减到 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 默认材质 (MeshStandardMaterial) 需要光照:无灯光时模型呈现黑色
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 是一个已解析的 AnimationClip 数组
const clips = gltf.animations;
const mixer = new THREE.AnimationMixer(model);
const walkClip = THREE.AnimationClip.findByName(clips, "Walk");
if (walkClip) mixer.clipAction(walkClip).play();
// 第二个角色:克隆蒙皮模型,给副本自己的混音器
const model2 = SkeletonUtils.clone(model);
model2.position.x = 1.5; // 在第一个旁边
scene.add(model2);
const mixer2 = new THREE.AnimationMixer(model2);
if (walkClip) mixer2.clipAction(walkClip).play(); // 同一剪辑,两个混音器
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 是已加载的模型根对象;找到它的第一个 SkinnedMesh
const skinnedMesh = mesh.getObjectByProperty("type", "SkinnedMesh");
const skeleton = skinnedMesh.skeleton;
console.log(skeleton.bones.map((b) => b.name));
// 获取一个骨骼以设置姿态或附加对象
const handBone = skeleton.bones.find((b) => b.name === "RightHand");
// 可视化骨骼
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); // 在它上面播放模型的剪辑(参见“播放 glTF 动画”)
const timer = new THREE.Timer();
timer.connect(document);
function animate() {
timer.update();
mixer.update(timer.getDelta()); // 剪辑在此写入骨骼姿态
// 之后覆盖:update() 会覆盖播放剪辑所动画的每个属性
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.position.set(0, 0, 0.25); // 在骨骼空间中的偏移
weapon.rotation.set(0, Math.PI / 2, 0);
}
形态目标
// mesh 携带形态目标(已加载或手动添加)
const names = Object.keys(mesh.morphTargetDictionary); // 名称 -> 索引
console.log(names, mesh.morphTargetInfluences); // 每个目标一个权重
// 按索引或名称混合目标;0 = 基础形状,1 = 完整目标
mesh.morphTargetInfluences[0] = 0.5;
const smileIndex = mesh.morphTargetDictionary["Mouth_Smile"];
if (smileIndex !== undefined) mesh.morphTargetInfluences[smileIndex] = 1;
手动添加形态目标:
const geometry = mesh.geometry;
// 形态目标是与基础属性大小相同的完整位置(或法线)属性
const raised = geometry.attributes.position.array.slice(); // 相同顶点数
for (let i = 1; i < raised.length; i += 3) {
raised[i] += 0.5; // y 是每第三个分量:提升每个顶点
}
const target = new THREE.Float32BufferAttribute(raised, 3);
target.name = "raised"; // 命名目标可从轨道寻址
geometry.morphAttributes.position = [target];
mesh.updateMorphTargets(); // 重建 morphTargetInfluences 和字典
驱动形态目标:
// 程序化,在 timer.update() 之后的渲染循环中:
// mesh.morphTargetInfluences[0] = (Math.sin(timer.getElapsed() * 2) + 1) / 2;
// 关键帧:一个 Number 轨道按名称驱动一个目标——绝不使用 String 轨道
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();
// 两者每帧都采样;权重混合它们的姿势
function setBlend(speed) {
const t = Math.min(speed / 5, 1); // 0 = idle, 5 及以上 = 完全 run
idle.setEffectiveWeight(1 - t);
run.setEffectiveWeight(t);
}
setBlend(2.5); // 一半 idle,一半 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]),
]);
// 将所有轨道基于其第 0 帧重新计算基准,并将剪辑标记为叠加
THREE.AnimationUtils.makeClipAdditive(breathClip);
mixer.clipAction(walkClip).play(); // 正常基础剪辑
mixer.clipAction(breathClip).play(); // 将其偏移叠加在基础姿势之上
剪辑工具
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"); // 名称查找
// 保留帧 [0, 31) 以 30 fps 的现有关键帧(时间 0 和 1);不会重新采样
const cut = THREE.AnimationUtils.subclip(clip, "half", 0, 31, 30);
const copy = clip.clone(); // 独立的深拷贝
clip.optimize(); // 删除重复时间的关键帧,以及在任何非平滑轨道(贝塞尔切线不会被压缩)上等于相邻值的关键帧
clip.resetDuration(); // 从最长轨道重新计算持续时间——它不回退到时间 0
console.log(clip.validate()); // 值到时间比率非整数、空轨道、时间未排序或包含 NaN 时返回 false
程序化运动
const timer = new THREE.Timer();
timer.connect(document);
// 指数趋近;lambda 越大,速度越快。帧率无关。