动画循环
本教程共 40 篇 · 第 7 篇 · 更新于 2026-08-14 · 约 7 分钟阅读
本节目标:理解 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动画速度统一用”每秒”来描述:每秒转多少度、每秒移动多少米。这样换设备、调帧率,行为都不变。这也是后面做相机控制、粒子动画时的通用习惯。