JavaScript 游戏循环:从入门到实践
游戏循环是任何实时交互应用(如游戏、动画、物理模拟)的核心机制。它本质上是一个无限循环,不断重复两个关键步骤:更新逻辑(Update)和渲染画面(Render)。在 JavaScript 中,最常见的实现方式有两种:setInterval 和 requestAnimationFrame。本文将深入讲解它们的工作原理,并提供经过优化的游戏循环实现方案。
1. 为什么需要游戏循环?
现实中的游戏需要让物体持续移动、响应键盘或鼠标事件、检查碰撞等。如果仅仅依靠事件触发(比如点击按钮才移动),无法实现流畅的动画。游戏循环保证了程序以稳定的频率“走一步,画一步”,就像电影胶片一帧帧连续播放。
一个最基本的游戏循环伪代码可以这样描述:
while (game_running) {
processInput(); // 处理输入
update(); // 更新游戏状态
render(); // 绘制画面
}但浏览器中的 JavaScript 是单线程的,我们不能用 while 无限循环阻塞主线程。因此需要利用浏览器提供的定时器或绘制回调来实现。
2. 使用 setInterval 的简单循环
setInterval 可以每隔固定毫秒执行一次回调函数。例如,我们想要 60 帧/秒(约 16.67 毫秒一帧)的效果:
function gameLoop() {
update();
render();
}
setInterval(gameLoop, 1000 / 60); // 大约16.67ms这种方式的缺陷很明显:
- 精度有限:浏览器会对
setInterval做一定的节流和最小延迟限制(通常不低于 4ms)。 - 帧率不稳定:如果循环中的
update或render执行时间较长,会导致回调堆积,画面跳帧。 - 无法感知时间差:不同设备的刷新率不同,固定时间间隔无法自适应。
因此,现代游戏开发更偏向使用 requestAnimationFrame。
3. 使用 requestAnimationFrame 的现代循环
requestAnimationFrame 是浏览器专为动画提供的 API,它会在下一次屏幕重绘之前调用传入的回调函数。浏览器会自动匹配显示器的刷新率(通常 60Hz 或 120Hz),并且在页面不可见时会自动暂停,节省资源。
基本用法:
function gameLoop() {
update();
render();
requestAnimationFrame(gameLoop); // 递归调用
}
requestAnimationFrame(gameLoop);虽然简单,但缺少时间控制。为了制作帧率无关的物理/逻辑更新,我们必须传入当前帧与上一帧的时间差(delta time)。
3.1 引入时间差 (deltaTime)
requestAnimationFrame 的回调函数会收到一个高精度时间戳(DOMHighResTimeStamp),单位毫秒。我们可以计算与上一帧的差值:
let lastTime = 0;
function gameLoop(timestamp) {
// timestamp 是 requestAnimationFrame 传入的当前帧时间
const deltaTime = timestamp - lastTime;
lastTime = timestamp;
// 更新逻辑(传递秒为单位的时间差)
update(deltaTime / 1000); // 转换为秒
render();
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);现在 update(seconds) 可以根据时间步长移动物体,例如让角色每秒移动 100 像素:
let x = 0;
function update(dt) {
x += 100 * dt; // 每秒100像素
}这样无论帧率如何波动,物体的移动速度都保持恒定。
4. 固定时间步长(Fixed Timestep)
上面使用的可变时间步长虽然简单,但在物理引擎或网络同步中可能会带来不稳定。固定时间步长保证每次逻辑更新都使用相同的模拟步长(例如 1/60 秒),而渲染仍然以真实帧率进行。
实现思路:累积真实时间,当累积量达到固定步长时,执行一次或多次逻辑更新,最后渲染当前状态。
const FIXED_DT = 1 / 60; // 固定步长 1/60 秒
let accumulator = 0;
let lastTimestamp = 0;
function gameLoop(timestamp) {
const frameTime = Math.min((timestamp - lastTimestamp) / 1000, 0.1); // 防止突然切换标签页导致巨大跳跃
lastTimestamp = timestamp;
accumulator += frameTime;
while (accumulator >= FIXED_DT) {
update(FIXED_DT); // 使用固定步长更新逻辑
accumulator -= FIXED_DT;
}
render(); // 渲染当前最新状态
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);注意 Math.min 的作用:当用户切回页面时,frameTime 可能非常大,我们限制最大为 0.1 秒,避免一次循环执行成千上万次更新导致崩溃。
5. 综合示例:一个可复用的游戏循环类
下面封装一个完整的游戏循环类,支持固定时间步长,并提供开始、暂停、停止功能。
class GameLoop {
constructor(update, render) {
this.update = update;
this.render = render;
this.isRunning = false;
this.lastTimestamp = 0;
this.accumulator = 0;
this.fixedDT = 1 / 60;
this.maxFrameTime = 0.1;
this.rafId = null;
}
start() {
if (this.isRunning) return;
this.isRunning = true;
this.lastTimestamp = performance.now();
this.accumulator = 0;
this.loop(this.lastTimestamp);
}
stop() {
this.isRunning = false;
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
pause() {
this.isRunning = false;
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
resume() {
this.start();
}
loop(timestamp) {
if (!this.isRunning) return;
const frameTime = Math.min((timestamp - this.lastTimestamp) / 1000, this.maxFrameTime);
this.lastTimestamp = timestamp;
this.accumulator += frameTime;
while (this.accumulator >= this.fixedDT) {
this.update(this.fixedDT);
this.accumulator -= this.fixedDT;
}
this.render();
this.rafId = requestAnimationFrame((ts) => this.loop(ts));
}
}
// 使用示例
const game = new GameLoop(
(dt) => {
// 更新逻辑,dt 为固定步长 1/60 秒
// 例如移动物体或处理物理
},
() => {
// 渲染画面,例如绘制精灵
}
);
game.start();
// 需要暂停时调用 game.pause()
// 恢复时调用 game.resume()6. 常见问题与优化
6.1 requestAnimationFrame 的回调中是否必须递归?
是的,为了继续下一帧,必须在回调末尾再次调用 requestAnimationFrame。但要注意避免在回调中同时调用 cancelAnimationFrame 导致竞争。
6.2 如何处理页面可见性变化?
可选方案:监听 visibilitychange 事件,在页面隐藏时暂停循环,显示时恢复。但通常 requestAnimationFrame 本身在隐藏时就不会触发,所以不需要额外处理。不过为了精确控制,可以加上暂停/恢复逻辑。
6.3 setTimeout 与 setInterval 的替代?
对于非游戏场景,比如简单的轮询,setInterval 仍然可用。但游戏动画请无条件使用 requestAnimationFrame,因为它与浏览器渲染同步,更省电、更流畅。
7. 总结
JavaScript 游戏循环的最佳实践是:
- 使用
requestAnimationFrame代替setInterval。 - 计算帧间时间差,使逻辑与帧率解耦。
- 采用固定时间步长(Fixed Timestep)来保证物理和逻辑稳定性。
- 封装可复用的循环类,提高代码的可维护性。
掌握了这些技术,你就可以构建任何类型的 2D/3D 游戏或实时交互应用了。开始编码吧!