首页 / Three.js 入门教程 / 动画循环

Three.js 入门教程

动画循环

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

Three.js动画动画循环requestAnimationFrameClock帧率delta

本节目标:理解 requestAnimationFrame 驱动的渲染循环,学会用 Clock 和 delta 时间让动画速度与帧率无关,掌握”每秒转多少度”这类通用写法。

一帧画面是怎么来的

renderer.render(scene, camera) 只画一帧。画面是静态的。要让物体动起来,得反复渲染,每帧之间改变物体的状态。

这个反复渲染的机制,就是动画循环(animation loop)。浏览器每一帧都调用我们注册的函数,函数里更新物体、渲染场景。电影也是一样的原理:24 张静态画面连播,就成了动态影像。

动画循环不只是给旋转用的。物体移动、相机跟拍、粒子飘动、控制器响应,全都挂在循环里。它是整个应用的”心跳”:一帧一帧地推进世界状态。

requestAnimationFrame:浏览器的节拍器

requestAnimationFrame 是浏览器提供的原生 API,three.js 的循环就建立在它之上:

function animate() {
  requestAnimationFrame(animate); // 先预约下一帧
  cube.rotation.y += 0.01;        // 更新物体
  renderer.render(scene, camera); // 渲染当前帧
}
animate(); // 启动循环

浏览器会在下一帧绘制前调用 animate。60Hz 屏幕上约 16.7 毫秒一次,也就是每秒约 60 次。

requestAnimationFrame 有两个好特性:

  • 与屏幕刷新率同步,画面不撕裂、不卡顿。
  • 页面切到后台时自动暂停,不浪费电量。

想停下来,手写循环得自己保存回调句柄再取消,有点绕:

let handle;
function animate() {
  handle = requestAnimationFrame(animate);
  renderer.render(scene, camera);
}
animate();
// 停止:cancelAnimationFrame(handle)

所以实际项目中,更常用的是后面要讲的 setAnimationLoop,停起来干净利落。

Note

不要用 setInterval 做动画。它不跟刷新率对齐,后台也不暂停,还容易累积掉帧,动画会一顿一顿的。

帧率:动画速度的隐形变量

帧率(FPS)是每秒渲染的帧数。60Hz 屏幕目标帧率是 60,144Hz 屏是 144,性能差的设备可能只有 30。

电影和电视的帧率是固定的:24fps、30fps,拍多少放多少。实时渲染完全不一样,帧率随时在变——设备性能波动、后台任务抢资源,都会让某一帧变慢。所以 three.js 应用必须假设”每帧耗时不确定”,这就是 delta 存在的意义。

另外注意,渲染循环跑在浏览器主线程上。循环里不要做重的同步操作(加载大模型、读大文件),否则整帧都会卡住,动画直接掉帧。

问题来了。如果写成 cube.rotation.y += 0.01,每帧转 0.01 弧度:

  • 60 帧的屏幕,每秒转 0.6 弧度。
  • 144 帧的屏幕,每秒转 1.44 弧度。

同一段代码,在不同设备上速度差两倍多。帧率还会波动:系统忙一下,这一帧就慢了。

动画速度必须和帧率解耦。不管一帧花了 8 毫秒还是 30 毫秒,物体每秒移动的距离应该一样。做法是:用”每帧耗时”缩放每次的移动量。

想确认当前帧率,打开浏览器的 Performance 面板录制一段就能看到,也可以用第 24 章会讲的 stats.js 在页面上直接显示 FPS。

Clock:测量每帧耗时

Clock 是 three.js 提供的计时器,专门用来测量时间间隔:

const clock = new THREE.Clock();

function animate() {
  requestAnimationFrame(animate);
  const delta = clock.getDelta(); // 上一帧的耗时,单位秒
  // 用 delta 更新物体
  renderer.render(scene, camera);
}

getDelta() 返回距离上次调用的秒数。在每帧开头调用一次,得到的就是上一帧的耗时。第一帧调用时,时钟会自动启动。

Note

getDelta 一帧里只能调用一次。同一帧里再调,会返回接近 0 的值,动画会突然停住。
另外,r183 起整个 Clock 类已标记弃用,r185 下 new THREE.Clock() 会在控制台打印弃用警告(不影响运行)。新代码推荐使用 THREE.Timer,用法一致(getDelta/getElapsedTime)。本书为兼容多数老教程仍用 Clock,看到警告属正常。

用 delta 让速度与帧率无关

把”每帧移动量”换成”每秒移动量 × delta”:

// 每秒转 45°,换算成弧度后乘 delta
cube.rotation.y += THREE.MathUtils.degToRad(45) * delta;

MathUtils.degToRad(45) 把角度换成弧度(three.js 的旋转属性用弧度,第 9 章会细讲)。乘上 delta 之后:

  • 跑 240 帧的屏幕,每帧只转一点点,一秒合计 45°。
  • 跑 30 帧的屏幕,每帧转得多一些,一秒合计还是 45°。

速度与帧率彻底无关。这是 three.js 动画最核心的写法,后面的章节会反复出现。

getElapsedTime:做周期运动

getElapsedTime() 返回时钟启动以来的总秒数。它适合做循环往复的运动:

const elapsed = clock.getElapsedTime();
cube.position.y = Math.sin(elapsed * 2) * 0.8; // 每秒上下浮动两次

sin(t) 的结果在 -1 到 1 之间来回摆动,乘 0.8 就是浮动幅度。摆动速度由参数控制,同样与帧率无关。

更省事的写法:setAnimationLoop

three.js 从 r139 起提供了 renderer.setAnimationLoop,内部封装了 requestAnimationFrame:

renderer.setAnimationLoop(() => {
  renderer.render(scene, camera);
});

// 停止循环
renderer.setAnimationLoop(null);

它和手写循环效果一样,额外的好处是对 WebXR 等场景更友好。传 null 就能干净地停掉循环,不用自己保存句柄再取消。本书示例两种写法都会出现,作用相同。

可运行示例:转动的立方体

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>7. 动画循环</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">旋转:每秒 45° · 浮动:每秒两次(都与帧率无关)</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, 5);
  camera.lookAt(0, 0, 0);

  const cube = new THREE.Mesh(
    new THREE.BoxGeometry(1, 1, 1),
    new THREE.MeshBasicMaterial({ color: 0xffa94d })
  );
  scene.add(cube);

  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(innerWidth, innerHeight);
  document.body.append(renderer.domElement);

  // 每帧开头调用一次 getDelta,得到上一帧耗时(秒)
  const clock = new THREE.Clock();

  function animate() {
    requestAnimationFrame(animate);

    const delta = clock.getDelta();
    const elapsed = clock.getElapsedTime();

    // 每秒旋转 45°,换算成弧度再乘 delta —— 速度与帧率无关
    cube.rotation.y += THREE.MathUtils.degToRad(45) * delta;

    // 用总运行时间做周期运动:每秒上下浮动两次
    cube.position.y = Math.sin(elapsed * 2) * 0.8;

    renderer.render(scene, camera);
  }
  animate();
  </script>
</body>
</html>

运行后可以看到:立方体一边匀速旋转,一边上下浮动。试着把 0.8 改成 2,浮动幅度变大;把 2 改成 0.5,浮动变慢。

想看帧率差异的影响,可以去掉 * delta,改用固定增量 += 0.01,再用开发者工具的 Performance 面板把 CPU 降速到 4 倍,观察旋转速度明显变慢——这就是帧率耦合的样子。

Tip

动画速度统一用”每秒”来描述:每秒转多少度、每秒移动多少米。这样换设备、调帧率,行为都不变。这也是后面做相机控制、粒子动画时的通用习惯。