首页 / Three.js 入门教程 / 动画系统

Three.js 入门教程

动画系统

本教程共 40 篇 · 第 26 篇 · 更新于 2026-08-14 · 约 8 分钟阅读

Three.js动画AnimationClipAnimationMixerAnimationAction关键帧glTF

本节目标:理解动画系统的三个核心类——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]
);

根据值类型选不同的轨道子类:

轨道类动画属性说明
NumberKeyframeTrackopacity、zoom 等单值值数组与时间一一对应
VectorKeyframeTrackposition、scale每个时间点 3 个值(x, y, z)
QuaternionKeyframeTrackquaternion旋转只能用四元数,每个时间点 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 角色,但套到一个结构不同的模型上会错乱(骨骼名字对不上)。加载外部动画前,先确认骨骼结构一致。