动画系统
本教程共 40 篇 · 第 26 篇 · 更新于 2026-08-14 · 约 8 分钟阅读
本节目标:理解动画系统的三个核心类——AnimationClip、AnimationMixer、AnimationAction,学会播放 glTF 模型自带的动画,并掌握播放、暂停、循环、交叉淡入淡出等控制方式。
从手动动画到动画系统
第 25 章的手动动画适合简单运动。角色走路、跑步、跳舞这类复杂动画,是美术在 Blender 里一帧帧调好的,运行时靠代码播放。three.js 的动画系统就是干这个的:它是一台”动画调音台”,可以同时播放多个动画、按权重混合、平滑切换。
这套系统由三部分组成:AnimationClip(剪辑,存关键帧数据)、AnimationMixer(播放器,驱动剪辑)、AnimationAction(动作,控制播放)。三者配合,缺一不可。
关键帧:动画的最小单元
关键帧(keyframe)描述”某个属性在某个时刻的值”:
- 0 秒时 position 是 (0, 0, 0)
- 3 秒时 position 是 (2, 2, 2)
- 6 秒时 position 是 (0, 0, 0)
两个关键帧之间怎么过渡?系统自动补间(tweening):线性插值或按缓动曲线平滑过渡。关键帧不需要很多,一秒几个就足够平滑——屏幕刷新率通常 60Hz 或更高(120/144Hz 已普及),关键帧密度超过显示刷新率即无增益。
KeyframeTrack:关键帧轨道
一条轨道(track)负责一个属性的所有关键帧,由两个数组组成:times(时间点)和 values(对应值)。
import { NumberKeyframeTrack } from 'three';
// 0 秒透明度 0,1 秒透明度 1,2 秒透明度 0 —— 闪烁一次
const blink = new NumberKeyframeTrack(
'.material.opacity',
[0, 1, 2],
[0, 1, 0]
);
根据值类型选不同的轨道子类:
| 轨道类 | 动画属性 | 说明 |
|---|---|---|
| NumberKeyframeTrack | opacity、zoom 等单值 | 值数组与时间一一对应 |
| VectorKeyframeTrack | position、scale | 每个时间点 3 个值(x, y, z) |
| QuaternionKeyframeTrack | quaternion | 旋转只能用四元数,每个时间点 4 个值 |
| BooleanKeyframeTrack | 开关类属性 | 只在真假间跳变 |
| StringKeyframeTrack | 字符串属性 | 少用 |
注意:旋转不能用 rotation(欧拉角)做轨道,必须用 quaternion。轨道不绑定具体对象,一条 .position 轨道可以驱动任何有 position 属性的物体。
import { VectorKeyframeTrack } from 'three';
// 0 秒在原点,3 秒到 (2,2,2),6 秒回原点
const move = new VectorKeyframeTrack(
'.position',
[0, 3, 6],
[0, 0, 0, 2, 2, 2, 0, 0, 0]
); // 注意 values 是 9 个数:3 个时间点 × 3 个分量
AnimationClip:把轨道打包成剪辑
一个动画往往由多条轨道组成:跳舞角色有几十条轨道,分别控制左右脚、膝盖、脖子。把这些轨道打包,就是一个剪辑:
import { AnimationClip } from 'three';
const clip = new AnimationClip('move-n-blink', -1, [move, blink]);
三个参数:名字、时长、轨道数组。时长传 -1 表示自动按最长轨道计算。剪辑只存数据,不绑定对象——它可以用在任何”结构相同”的模型上(比如 Mixamo 的角色动画可以套用其他 Mixamo 角色)。
Tip大多数时候不用手写剪辑。GLTFLoader 加载 glTF 时,会自动把文件里的动画生成 AnimationClip,放在
gltf.animations数组里,直接拿去用即可。
AnimationMixer:每帧推进动画
剪辑是数据,得有个播放器驱动它,这就是 AnimationMixer:
const mixer = new THREE.AnimationMixer(model); // 给模型配一个播放器
一个 mixer 对应一个被动画的模型。模型内部的多个动画(走、跑、跳)共用这一个 mixer;场景里多个角色,就建多个 mixer。
mixer 必须在动画循环里每帧推进,否则动画不会动:
const delta = clock.getDelta(); // 上一帧耗时
mixer.update(delta); // 所有绑定在这个 mixer 上的动画前进 delta 秒
AnimationAction:控制播放
剪辑连上 mixer 后得到一个动作(action),所有播放控制都在动作上:
const action = mixer.clipAction(clip); // 同一个 clip 永远返回同一个 action
action.play();
常用控制:
play()/pause()/stop()/reset():基本播放控制。setLoop(mode, repetitions):循环方式。LoopRepeat无限循环(默认)、LoopOnce只播一次、LoopPingPong来回播。clampWhenFinished:配合LoopOnce,播完停在最后一帧而不是跳回起点。timeScale:播放速度,负值倒放。setDuration(秒)直接改单次时长。weight:动作权重,0~1,多个动作同时播放时按权重混合。fadeIn(duration)/fadeOut(duration):渐入渐出。crossfadeTo(otherAction, duration):当前动作淡出、目标动作淡入,角色切换动作时的标准做法(走→跑)。
角色”走”和”跑”两个动作平滑切换,官方示例的经典写法:
function fadeToAction(name, duration) {
const prev = activeAction;
activeAction = mixer.clipAction(AnimationClip.findByName(clips, name));
if (prev !== activeAction) prev.fadeOut(duration);
activeAction.reset().fadeIn(duration).play();
}
动作播完会触发事件,监听 mixer 的 finished 事件可以做”播完恢复”之类的逻辑:
mixer.addEventListener('finished', () => {
// 一次性动作播完了,切回循环动作
});
播放 glTF 模型自带动画
标准四步流程:
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const loader = new GLTFLoader();
loader.load('model.glb', (gltf) => {
const model = gltf.scene;
scene.add(model);
// 1. 从加载结果里取剪辑
const clip = gltf.animations[0];
// 2. 给模型建播放器
const mixer = new THREE.AnimationMixer(model);
// 3. 创建动作并播放
mixer.clipAction(clip).play();
// 4. 每帧推进(放到动画循环里)
mixers.push(mixer);
});
动画循环里统一推进所有 mixer:
mixers.forEach((m) => m.update(delta));
可运行示例:代码创建的剪辑
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>26. 动画系统</title>
<style>
* { margin: 0; padding: 0; }
body { overflow: hidden; background: #1a1a2e; }
canvas { display: block; }
#info {
position: fixed; left: 12px; top: 12px;
color: #fff; font: 14px/1.6 sans-serif;
background: rgba(0, 0, 0, 0.45); padding: 8px 12px; border-radius: 6px;
}
</style>
</head>
<body>
<div id="info">左:位置轨道(循环)· 右:透明度轨道(乒乓循环)</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.185.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.185.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 1.5, 6);
camera.lookAt(0, 0, 0);
scene.add(new THREE.AmbientLight(0xffffff, 1));
const dirLight = new THREE.DirectionalLight(0xffffff, 2);
dirLight.position.set(2, 4, 3);
scene.add(dirLight);
// 两个方块:一个做位置动画,一个做透明度动画
const cubeA = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshStandardMaterial({ color: 0x4dabf7 })
);
cubeA.position.x = -1.5;
scene.add(cubeA);
const matB = new THREE.MeshStandardMaterial({ color: 0xf783ac, transparent: true });
const cubeB = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), matB);
cubeB.position.x = 1.5;
scene.add(cubeB);
// 轨道 1:位置。0s 原点 → 1.5s 升高 → 3s 回原点
const moveTrack = new THREE.VectorKeyframeTrack(
'.position',
[0, 1.5, 3],
[0, 0, 0, 0, 2, 0, 0, 0, 0]
);
const moveClip = new THREE.AnimationClip('jump', -1, [moveTrack]);
// 轨道 2:透明度。0s 全透明 → 1s 不透明 → 2s 全透明
const fadeTrack = new THREE.NumberKeyframeTrack(
'.material.opacity',
[0, 1, 2],
[0, 1, 0]
);
const fadeClip = new THREE.AnimationClip('blink', -1, [fadeTrack]);
// 各自独立的 mixer,每帧都要 update
const mixerA = new THREE.AnimationMixer(cubeA);
const actionA = mixerA.clipAction(moveClip);
actionA.setLoop(THREE.LoopRepeat).play();
const mixerB = new THREE.AnimationMixer(cubeB);
const actionB = mixerB.clipAction(fadeClip);
actionB.setLoop(THREE.LoopPingPong).play();
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.append(renderer.domElement);
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
mixerA.update(delta);
mixerB.update(delta);
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>
左方块按位置轨道上下跳,右方块透明度循环闪烁。试试把 actionB 的循环改成 THREE.LoopOnce 并设 clampWhenFinished = true,方块会停在完全透明那一帧——这是做”一次性动作”的标配。
调试动作细节时,把 mixer.timeScale 调到 0.25 慢放,比逐帧断点高效得多。
注意两点
动画系统播放时,会覆盖手动设置的同名属性。比如轨道驱动 position,你再写 cube.position.y = 1 就无效了,动画系统每帧都会把值改回去。同一个属性,手动动画和动画系统只能二选一。
另外,剪辑是针对特定模型结构制作的:Mixamo 的跳舞剪辑能套用别的 Mixamo 角色,但套到一个结构不同的模型上会错乱(骨骼名字对不上)。加载外部动画前,先确认骨骼结构一致。