经纬志
首页 / three.js / 教程

动画系统

先看结论

three.js 有两套动画方式:模型自带的关键帧由 AnimationMixer 播放, 代码驱动的位移、旋转、透明度变化走时间步进。混用时要保证两者共用同一个节奏。

最后验证 2026-11-03 · three.js 0.160.0 · 难度:进阶 · 核验 2026-10-29 · 引用本文

关键帧:AnimationMixer

glTF 里的动画会被解析成 AnimationClip,每个剪辑包含若干轨道。 AnimationMixer 绑定到一个根对象,按时间推进所有激活的剪辑。

import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

const mixers = [];

new GLTFLoader().load('/models/turbine.glb', function (gltf) {
  const model = gltf.scene;
  scene.add(model);

  const mixer = new THREE.AnimationMixer(model);
  mixers.push(mixer);

  gltf.animations.forEach(function (clip) {
    const action = mixer.clipAction(clip);
    action.setLoop(THREE.LoopRepeat, Infinity);
    action.clampWhenFinished = false;
    action.play();
  });
});

const clock = new THREE.Clock();

renderer.setAnimationLoop(function () {
  const dt = clock.getDelta();
  mixers.forEach(function (m) { m.update(dt); });
  renderer.render(scene, camera);
});

mixer.update(dt) 的参数是秒,不是毫秒。 用 Date.now() 差值直接传入会让动画快上千倍, 必须换成秒或使用 clock.getDelta()。

剪辑之间的切换

多个动作(行走、待机、旋转)并存时,直接 play() 会同时叠加。切换状态要用 crossFadeTo 做过渡, 并在结束后停掉旧动作。

// 承接上一段:gltf 已加载,mixer 已创建
const actions = {};

gltf.animations.forEach(function (clip) {
  actions[clip.name] = mixer.clipAction(clip);
});

function switchTo(name, duration) {
  const next = actions[name];
  if (!next) return;

  Object.keys(actions).forEach(function (key) {
    const action = actions[key];
    if (action !== next && action.isRunning()) {
      action.crossFadeTo(next, duration || 0.4, false);
    }
  });

  next.reset().setEffectiveWeight(1).fadeIn(duration || 0.4).play();
}

switchTo('rotate', 0.6);

先 reset() 再 fadeIn(), 否则切回一个播放完毕的动作时会停在最后一帧。 crossFadeTo 的第三个参数为 warp,用于对齐不同时长的动作。

需求方式要点
循环播放setLoop(LoopRepeat, Infinity)默认就是循环
只播一次setLoop(LoopOnce, 1)配合 clampWhenFinished = true
往返播放LoopPingPong适合来回摆动的机械动作
动作切换crossFadeTo需要先 reset 目标动作
整体变速mixer.timeScale负数表示倒放

程序动画:自己算时间

没有关键帧的内容(旋转的风机、呼吸的标注、流动的材质)按时间累加计算。 用 clock.getDelta() 保证不同刷新率下速度一致。

const animClock = new THREE.Clock();
const rotors = [];

function addRotor(mesh) {
  mesh.userData.phase = Math.random() * Math.PI * 2;
  rotors.push(mesh);
}

renderer.setAnimationLoop(function () {
  const dt = animClock.getDelta();
  const elapsed = animClock.getElapsedTime();

  rotors.forEach(function (rotor) {
    rotor.rotation.y += dt * 0.8;                       // 匀速旋转
    rotor.position.y = 40 + Math.sin(elapsed + rotor.userData.phase) * 1.5;
  });

  renderer.render(scene, camera);
});

一个时钟,两种动画。关键帧与程序动画都使用同一帧的 clock.getDelta(),否则模型动作与位移速度会各走各的, 在低帧率设备上尤其明显。

注意事项

  • 切换页面或暂停时调用 mixer.stopAllAction(),避免后台继续消耗时间。
  • setAnimationLoop 在 XR 会话中同样工作,比 requestAnimationFrame 更通用。
  • 模型克隆后动画不能共享同一个 mixer,每个实例需要独立的 AnimationMixer。
  • 剪辑名称来自建模软件,可能带有命名空间前缀,取用前先打印 gltf.animations。
  • 大量同时播放的动画会带来明显 CPU 开销,远处的实例可以停止更新。
引用本文

经纬志(2026)。《动画系统》。https://www.jingweizhi.com/three/tutorial/animation.html(访问日期 2026-11-03)。