【译】JavaScript 游戏循环详解

前言:游戏循环(Game Loop)是做游戏时绕不开的一个话题。网上已经有很多文章讲解了如何在网页中使用 JavaScript 实现游戏循环,但基本上都只提到 requestAnimationFrame 就完事了。这只能用来做个 demo ,对一个完整的游戏来说是远远不够的。正好之前毕设时查阅了相关资料,对比之下这篇文章讲的最为全面。因此我翻译了这篇文章,希望能给大家带来帮助。

原文链接:http://isaacsukin.com/news/2015/01/detailed-explanation-javascript-game-loops-and-timing

在任何状态随时间改变的应用中,主循环都是核心的部分。在游戏中,主循环一般被称为游戏循环,既要负责计算物理与 AI,也要把计算出来的画面渲染在屏幕上。很不幸,在网上能找到的大多数主循环的实现 —— 尤其是以 JavaScript 来编写的 —— 都存在着一些计时上的问题。不瞒你说,我自己也写过不少错误的实现。这篇文章旨在告诉你为什么这么多主循环需要被修正,以及如何实现一个正确的主循环。

如果你不想看讲解,而只是想直接拿正确的代码来用,可以使用我的开源库 MainLoop.js

初次尝试

我们即将来编写一个“游戏”。简单起见,只是来画一个会左右来回移动的方块。

<div id="box"></div>

让它展示出来:

#box {
    background-color: red;
    height: 50px;
    left: 150px;
    position: absolute;
    top: 10px;
    width: 50px;
}

看上去还不错。接下来我们来搭建 JavaScript 应用的脚手架。首先,我们的方块需要一些属性来控制它的位置和速度。我们用一个 draw() 函数来渲染它的位置。

var box = document.getElementById('box'), // 方块
    boxPos = 10, // 方块的位置
    boxVelocity = 2, // 方块的速度
    limit = 300; // 方块跑多远以后调头
 
function draw() {
    box.style.left = boxPos + 'px';
}

然后是游戏的逻辑。我们希望方块来回移动,所以要给它加个速度。你很快就会发现,从这里就慢慢开始出错了。

function update() {
    boxPos += boxVelocity;
    // 如果跑过头了,就让方块调头
    if (boxPos >= limit || boxPos <= 0) boxVelocity = -boxVelocity;
}

现在让我们把游戏跑起来。为了跑这个游戏,我们需要一个循环,不停地调用 update() 函数让方块移动,然后调用 draw() 函数把移动后的位置渲染在屏幕上。我们要怎么做到这一点呢?

如果你用其他语言写过游戏,你或许会想到使用 while 循环:

while (true) {
    update();
    draw();
}

然而,JavaScript 是单线程的,这意味着如果你这么写,那么浏览器在这个页面里就做不了其他任何事了。几秒钟后浏览器会卡住,然后告诉用户出错了,问是否要终止程序。你肯定不想你的游戏只能跑几秒钟,所以这么搞肯定不行。我们需要一种方法,让游戏循环把控制权移交给浏览器,直到浏览器准备好再次执行我们的工作。

如果你熟悉 JavaScript,你也许会想到 setTimeout() 或是 setInterval()。这两个方法允许你在指定时间之后继续执行代码。这看上去行得通,不过在浏览器中我们有更好的做法:requestAnimationFrame()。这是个较新的函数但已经得到了良好的浏览器支持(在本文写作时的 2015 年,除 IE9 及以下的浏览器都支持它)。你向这个函数传递一个回调,它会在浏览器下次准备执行渲染时执行这个回调。在一台 60Hz 显示器上,一个优化良好的应用每秒可以更新画面(绘制帧)60 次。所以,为了达到最佳的 60 帧帧率,你的主循环有 1 / 60 = 16.667 毫秒的时间做完一次循环的工作。在后文中我们会对 node.js/io.js 和低版本浏览器做兼容。

记住大多数显示器每秒不能展示大于 60 帧(FPS)。人类是否能够区分出高分辨率的区别要取决于应用类型,不过可以作为参考的是,电影一般是 24FPS,其他视频 30FPS,大多数游戏 30FPS 以上都可以接受,虚拟现实也许需要 75FPS 才能感觉自然。有些游戏显示器最高能到 144FPS。

好了,让我们用 requestAnimationFrame() 来实现主循环!

function mainLoop() {
    update();
    draw();
    requestAnimationFrame(mainLoop);
}
 
// 入口点
requestAnimationFrame(mainLoop);

跑起来了!

注意 draw() 方法是在 update() 方法之后调用的,这是因为我们希望能尽可能渲染出应用最新的状态。(注:有些基于 canvas 的应用需要在首帧,即任何更新都没有发生之前,渲染出应用的的初始状态。我们会在后文中探讨一种实现方式。)网上有些文章猜测在 requestAnimationFrame 的回调中先渲染再做逻辑会使屏幕绘制更快,但实际上并非如此。即使是,那也只是在绘制当前帧更快和下一帧更快之间做一个权衡。当 requestAnimationFrame 下一次调用时就无所谓了,毕竟一次只能更新一帧,但我觉得这样在逻辑上是更顺畅的。

计时问题

目前为止,我们的 update() 方法有个问题,便是它依赖于帧率。换句话说,如果你的游戏跑的慢(即每秒内能够执行的帧数较少),那么方块移动的也越慢;而如果你的游戏跑的快(每秒内能够执行的帧数较多),那么方块移动的也越快。我们不希望出现如此不可预测的行为,尤其是在多人游戏中。没人会希望他们的游戏角色行动迟缓,只因他们电脑的配置没那么好。即使是在单机游戏中,游戏速度也会显著影响难度。在考验反应的游戏里,游戏速度越低就会更简单,而速度越高就会难,甚至没法玩下去。

针对这个问题,我们来加入一个控制 FPS 的能力。我们可以利用 requestAnimationFrame() 函数的能力,为回调提供一个时间戳。每次循环执行时,我们确认下是否达到了一段最小时间。如果是,我们就渲染这一帧,如果不是,我们就跳过这次循环,等待下一帧。

var lastFrameTimeMs = 0, // 上一轮循环运行的时间
    maxFPS = 10; // 我们想要限制的最大 FPS
 
function mainLoop(timestamp) {
    // 控制帧率   
    if (timestamp < lastFrameTimeMs + (1000 / maxFPS)) {
        requestAnimationFrame(mainLoop);
        return;
    }
    lastFrameTimeMs = timestamp;
 
    // ...
}

你可以看到现在它的移动如此之慢:

我们可以做的更好。这个问题在于我们的应用并不和现实时间挂钩,该怎么去修正呢?

首先让我们尝试用速度乘以两帧之间的时间差(delta)。我们把 boxPos += boxVelocity 替换成 boxPos += boxVelocity * delta。修改 update 方法,从主循环中接收 delta 这个参数:

// 调整速度,使它不与 FPS 挂钩
var boxVelocity = 0.08,
    delta = 0;
 
function update(delta) { // 增加 delta 参数
    boxPos += boxVelocity * delta; // 速度现在与时间相关
    // ...
}
 
function mainLoop(timestamp) {
    // ...
 
    delta = timestamp - lastFrameTimeMs; // 获取当前时间与上一帧的时间差 delta
    lastFrameTimeMs = timestamp;
 
    update(delta); // 传入 delta 参数
    // ...
}

结果相当不错!现在我们的方块看上去不受帧率影响,随时间移动恒定的距离。

如果它的表现与你预期不符……好吧,接着看。

物理问题

尝试把速度值上调,例如 0.8 。你会注意到有几秒种这个方块运动得很不平稳,也许会从可视范围里跑出去。这是不应该出现的。是哪里有问题呢?

问题在于,之前方块每帧运动相同的距离,而现在我们加入了 delta 以后,每帧运动的距离都有所不同,而这些距离有时会相当大。在游戏中,这一现象可能会让玩家穿墙,或是阻碍他们跳过障碍物。

另一个问题是,因为方块每帧移动的距离不同,在计算过程中一些小的四舍五入的误差,会随着时间推移而累积,造成方块位置的漂移。没有两个人可以玩到相同的游戏,因为他们的帧率不同,造成的误差也不同。这听起来挺微不足道的,但是实践中,哪怕是在正常帧率下,也只要几秒钟就能让误差变得可被玩家感知。这不仅对玩家不好,也对测试不好,因为我们希望给定相同的输入时,程序也能给出相同的输出。换句话说,我们希望程序是具有确定性 的。

一种解决方案

解决这一物理问题的关键点在于我们想要两全其美:既要在每次执行 update 方法时模拟相等的游戏时间,又要模拟两帧之间并不每次都相等的现实时间。事实证明我们可以做到,只需在两帧之间以固定大小的 delta 值多次运行 update 方法,直到我们模拟完整段现实时间。让我们稍稍调整下主循环:

// 每次调用 update 时只模拟 1000 ms / 60 FPS = 16.667 ms 的时间
var timestep = 1000 / 60;
 
function mainLoop(timestamp) {
    // ...
 
    // 计算我们累积下来的还没有被模拟过的时间
    delta += timestamp - lastFrameTimeMs; // 注意这里是 += 
    lastFrameTimeMs = timestamp;
 
    // 以固定大小的步长模拟整段 delta 时间
    while (delta >= timestep) {
        update(timestep);
        delta -= timestep;
    }
    draw();
    requestAnimationFrame(mainLoop);
}

我们把两帧之间的现实时间分隔成了小段,多次传递给 update() 方法。update() 方法本身无需修改,只要改变传递给它的参数,这样每次 update() 时都会模拟相等的游戏时间。update() 方法会在一帧之内调用多次以模拟完这一帧距离上一帧经过的真实时间。(如果这一帧距离上一帧经过的时间比单次 update 模拟的时间还要小,我们在这一帧就不调用 update() 方法了。如果有剩余未被模拟的时间,我们就把它累积起来放到下一帧去模拟。这就是我们需要通过 += 计算 delta,而不是直接赋值给它的原因,因为我们需要记录上一帧剩余下来没被模拟的时间。)这种方式避免了四舍五入不一致导致的误差错误,也保证了两帧之间不会出现大到能穿墙的巨大跨越。

让我们实际看一看:

如果你调整 boxVelocity 的值,你会看到方块会正确地呆在它应该在的地方。不错!

注意: timestep 值的选择并不是任意的。分母有效地限制了用户能够感知的每秒帧数(除非绘制是插值的,如后文所述)。当实际 FPS 低于最大值时,降低 timestep 会增加可感知到的最大 FPS,但代价是每一帧执行 update() 的次数会更多。由于执行 update() 越多,消耗的时间也越多,这就又会降低帧率。如果帧率降的太多,就可能会陷入一种死亡螺旋。

死亡螺旋

遗憾的是我们又引入了一个新问题。在我们的初次尝试中,如果一帧花费了比较长的时间执行更新和渲染,帧率就会自然地降低,直到每一帧花费的时间足够更新和渲染完成。然而,现在我们的更新依赖于时间。如果某一帧花费了较长时间来模拟,那么下一帧会需要模拟更长的时间。这意味着我们需要更多次执行 update() 方法,而这进一步意味着这新的一帧需要花费更多的时间来模拟,如此往复……直到整个应用无法响应然后挂掉。这就是死亡螺旋。

一般来说,只要我们把 timestep 的值设的足够高,每次执行 update 的开销都比它模拟的时间要短,这样就不会有问题。但执行开销在不同的硬件和负载上都是不一样的。而且我们讨论的是 JavaScript 环境,意味着我们对执行环境只有很微小的控制权。如果用户切换到另一标签页,浏览器就会停止当前标签页的渲染,当用户再切回来时,我们就累积了很长一段时间要去做模拟。如果这消耗了太多时间,浏览器就会挂起。

合理性检查

我们需要一个逃生通道。让我们在 update 的循环中加入一个合理性检查:

    var numUpdateSteps = 0;
    while (delta >= timestep) {
        update(timestep);
        delta -= timestep;
        // Sanity check
        if (++numUpdateSteps >= 240) {
            panic(); // 出现了异常情况,需要做修复
            break;   // 跳出循环
        }
    }

在我们的 panic 方法中要做些什么呢?这得看情况。

回合制多人游戏用了一种叫“锁步(lockstep,亦称为帧同步)”的网络技术,它可以保证所有的玩家以相同步调进行游戏。这意味着所有玩家体验到的都是最慢的那个人的速度。如果一个玩家实在落后太多,他就会掉线,这样就不会拖慢其他玩家。因此,在锁步的游戏中,这个玩家就掉线了。

在一个没有使用锁步的多人游戏,比如第一人称射击游戏中,一般都会有一个服务器维持着游戏的“权威状态”。也就是说,服务器会接收所有玩家的输入,并计算出游戏世界应有的样子,以避免玩家作弊。如果一个玩家的本地游戏距离权威状态太远,即 panic 的状态,那么这个玩家需要被拉回权威状态。(在实践中,如果直接把玩家拉回去会令人很迷惑,所以一般会有一个替代的 update 方法,让客户端能够更加平滑地回到服务端的权威状态。)一旦我们把用户拉回了最新的状态,我们就不需要再去本地模拟这一堆剩下的时间了,因为我们已经把这些时间高效快进掉了。因此我们可以把它们丢弃掉:

function panic() {
    delta = 0; // 丢弃未模拟的时间
    // ... 把玩家同步到权威状态
}

如果在服务端这么干,会引起不确定性的行为,不过只有服务端需要保证游戏运行的确定性,所以在多人游戏的客户端这么做是完全可以的。

在单机游戏中,我们可以让游戏继续运行一会看看游戏运行速度能不能赶上来。但当游戏在中间状态过渡时,游戏会看上去在几帧之内运行得飞快。另一种可以接受的方法是,直接丢弃未模拟的时间,就像我们前面在非锁步的多人游戏中做的那样。这会引入不确定性的行为,不过你可能觉得这是个极端情况,可以另当别论。

无论如何,如果游戏持续出现 panic 的情况,而且也不是因为标签页在后台引起的,这可能提示了游戏的主循环运行得实在太慢了。也许你需要增大 timestep 的值。

FPS 控制

另一种可以避免死亡螺旋(总的来说,避免低帧率)的方法是监控游戏的运行帧率,并在帧率过低时,调整主循环中的行为。往往在 panic 状态发生前,我们就能检测到掉帧的情况,可以由此来预防 panic 。

有许多监测帧率的方式,其一是在一段时间内(比如最近 10 秒)持续监测每秒渲染的帧数,并取平均值。然而这稍微有点吃性能,而且我们希望最近几秒钟的帧数能获得更高的权重。一种简单做法是,使用加权平均来计算权重:

var fps = 60,
    framesThisSecond = 0,
    lastFpsUpdate = 0;
 
function mainLoop(timestamp) {
    // ...
 
    if (timestamp > lastFpsUpdate + 1000) { // 每秒更新一次
        fps = 0.25 * framesThisSecond + (1 - 0.25) * fps; // 计算新 FPS
 
        lastFpsUpdate = timestamp;
        framesThisSecond = 0;
    }
    framesThisSecond++;
 
    // ...
}

这里的 0.25 是衰减系数 - 这本质上体现了最近几秒钟的权重有多大。

fps 变量保存了我们估算出来的 FPS。我们该拿它做什么用呢?首先可以想到的是把它显示出来:

// 假设我们在 HTML 中增加了 <div id="fpsDisplay"></div> 这个元素
var fpsDisplay = document.getElementById('fpsDisplay');
 
function draw() {
    box.style.left = boxPos + 'px';
    fpsDisplay.textContent = Math.round(fps) + ' FPS'; // 展示 FPS
}

成功了:

我们可以把 FPS 派上更多的用场。如果 FPS 太低了,我们可以退出游戏,降低画质,停止或减少主循环之外的行为,例如事件处理器、音频播放;降低非关键更新的频率,或增加 timestep。(注意这是最不得已的情况,因为这会导致每次 update() 调用模拟更多的时间,进而使程序有更多的不确定性,因此应谨慎使用。)如果 FPS 回升了,就恢复这些行为。

开始与结束

在主循环开始和结束时分别调用一个回调方法(让我们叫它们 begin()end() 方法)来做初始化和清理工作是很有用的。一般来说,begin() 可以用于在 update 执行前处理输入(例如当玩家按下开火键时刷出子弹)。如果需要对用户输入执行长时间运行的操作,那么在主循环中分块处理这些操作,而不是在事件处理程序中一次处理这些操作,可以避免帧的延迟。而 end() 方法则可以增量地执行不受时间影响的、需要较长运行时间的更新,以及根据 FPS 的变化作调整。

选择 timestep

一般来说,1000/60 在大多数情况是个好选择,因为大多数显示器以 60Hz 运行,如果你发现你的程序十分吃性能,也许你可以将其设为 1000/30。这有效地限制了你的可感知帧率为 30FPS(除非使用了插值,如后文所述)。注意帧率会根据你的显示器和显卡驱动进行调节,因此你设置的最大值可能与实际运行时测得的值不相同。如果你的游戏运行流畅,且你希望模拟得更加精确,你可以考虑使用高端游戏显示屏的 FPS,如 75、90、120 和 144。再高的话最终运行速度可能反而就会变慢了。

性能考量

如果程序的性能并不尽如人意,你可以使用插值绘制和 Web Worker 这两种重构方式来获得实实在在的性能提升。

插值绘制

在每次 update 结束之后,在 delta 中经常会有一段小于一整个 timestep 的剩余时间。将这段尚未被模拟的剩余时间所占 timestep 的百分比传入 draw() 方法就可以在两帧之间做一个插值。即使在高帧率下,这种视觉上的平滑效果也有助于降低画面的卡顿。

卡顿之所以会出现,是因为 update() 方法模拟的时间与两个 draw() 方法经过的时间往往是不同的。进一步说,假如 update() 发生在下面第一行的每条竖线所代表的时间点,而 draw() 发生在下面第二行的每条竖线所代表的时间点,那么在 draw() 方法发生渲染的时间点,总会有一些剩余时间还没有被 update() 方法所模拟:

update() timesteps:  |  |  |  |  |  |  |  |  |
draw() calls:        |   |   |   |   |   |   |

为了使 draw() 对方块移动做插值以进行渲染,必须保留上次 update() 之后对象的状态,并将其用于计算中间状态。注意这意味着渲染最多落后一次 update() 。这仍然比外推(推测对象在下一次 update() 之后的状态)要好,因为后者可能会产生奇怪的结果。要注意存储多个状态实现起来比较困难,而且这个过程也是耗时操作,可能会导致帧率下降。因此除非你观察到了卡顿现象,否则这么做很可能是不值得的。

我们可以这样对我们的方块进行插值:

var boxLastPos = 10;
 
function update(delta) {
    boxLastPos = boxPos; // 保存上一次 update 时方块的位置
    boxPos += boxVelocity * delta;
    // ...
}
 
function draw(interp) {
    box.style.left = (boxLastPos + (boxPos - boxLastPos) * interp) + 'px'; // 进行插值
    // ...
}
 
function mainLoop(timestamp) {
    // ...
    draw(delta / timestep); // 传入插值的百分比

把所有的代码放在一起:

使用 Web Worker 来更新

与主循环中的任何事物一样,update() 方法的执行时间直接影响了帧率。如果 update() 方法花费的时间足够长,以至于帧率低于预期,那么我们可以将 update() 方法中无需在每帧之间执行的部分放入 Web Worker 中。(网上的很多地方有时会建议使用 setTimeout()setInterval() 来进行调度。这些方法只需对现有的代码进行较小的改动,但由于 JavaScript 是单线程的,这些改动仍然会阻止渲染并降低帧率。使用 Web Worker 需要做更大的改动,但它们在单独的线程中执行,故可以为主循环释放出更多时间。)

这里列举了部分在使用 Web Worker 时需考虑的内容:

  • 在迁移到 Web Worker 前分析你的代码。也许渲染过程才是瓶颈,此时你首先应当考虑的是降低场景的视觉复杂度。
  • update() 中的所有内容都迁移到 Web Worker 中是不可取的,除非你的 draw() 方法能够像我们之前讨论的那样进行插值。最容易移出 update() 的是后台更新(比如在城镇建造游戏中计算市民的幸福指数)、不影响场景的物理效果(比如风中飘动的旗帜)和任何被遮挡或是离场景很远的事物。
  • 如果 draw() 方法需要基于 Web Worker 中的行为对物理做插值,那么 Web Worker 需要把插值结果传回主线程,使其在 draw() 方法中可用。
  • Web Worker 不能访问主线程中的状态,因此它们不能直接修改你场景中的物体。与 Web Worker 之间传递数据是一个痛点。最简单的办法是使用 Transferable Objects:你可以传递一个 ArrayBuffer 给 Web Worker,并销毁原始引用。

你可以在 HTML5 Rocks 中了解更多有关于 Web Worker 和 Transferable Objects 的信息。

启动与停止

目前,一旦我们的游戏启动了主循环,我们是没有任何方法停止的。让我们引入 start()stop() 函数来管理游戏的运行。首先我们要找到停止 requestAnimationFrame 的方法。一种方式是维持一个 running 的布尔变量来控制主循环下一次是否要继续调用 requestAnimationFrame。一般来说没啥问题,不过如果游戏开始后立马停止,那么无论如何都有一帧会被运行,无法取消。因此我们需要一个能够真正取消渲染循环的方法。幸运的是我们有 cancelAnimationFrame() 方法。当调用 requestAnimationFrame 时会返回一个 frame ID,可以传递给cancelAnimationFrame() 方法。

frameID = requestAnimationFrame(mainLoop);

切记如果你做了 FPS 控制,别忘了在 FPS 控制的节流条件中也做下改动,记录 frame ID。

现在我们来实现 stop() 方法:

var running = false,
    started = false;
 
function stop() {
    running = false;
    started = false;
    cancelAnimationFrame(frameID);
}

此外,当主循环暂停时,也要暂停事件处理和其他的后台任务(比如通过 setInterval() 执行的任务或是 Web Worker 中的任务)。这通常不难,因为它们只要检查下 running 变量就可以决定自身是否要执行了。另一个需要注意的点是,在多人游戏中暂停会导致该玩家的客户端失去同步,因此一般要让他们退出游戏,或是在主循环再次启动时把玩家拉到最新的位置(在确认玩家是否真的想要暂停之后)。

开始游戏循环会更为棘手一些。我们需要关注以下四点。首先,如果主循环已经在运行,我们不能允许它再次运行,否则会导致同时请求多帧渲染,降低运行速度。其次,我们需要确保快速地切换游戏的开始和停止不会造成错误。再次,我们需要在游戏尚未发生任何更新时渲染出游戏的初始状态,因为我们主循环的 draw 是在 update 之后调用的。最后,当游戏暂停时,我们需要重置一些变量的值,以防暂停时我们记录的需要模拟的时间仍然在流逝。另外,在游戏重新启动后,事件处理和后台任务也要恢复运行。这是我们的代码:

function start() {
    if (!started) { // 防止多次启动
        started = true;
        // 第一帧来获取时间戳,并绘制初始画面.
        // 记录 frame ID 用于停止
        frameID = requestAnimationFrame(function(timestamp) {
            draw(1); // 首次渲染
            running = true;
            // 重置一些记录时间相关的变量
            lastFrameTimeMs = timestamp;
            lastFpsUpdate = timestamp;
            framesThisSecond = 0;
            // 真正启动主循环
            frameID = requestAnimationFrame(mainLoop);
        });
    }
}

效果:

Node.js/IO.js 与 IE9 支持

现在我们代码的主要问题,是 requestAnimationFrame()cancelAnimationFrame() 缺乏对 node.js/IO.js 环境,和 IE9 以及更早的浏览器的兼容(如果你还关心那些浏览器的话)。我们可以利用 timer 做一个 polyfill:

// 代码来自于 MIT 协议的 https://github.com/underscorediscovery/realtime-multiplayer-in-html5
var requestAnimationFrame = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (function() {
        var lastTimestamp = Date.now(),
            now,
            timeout;
        return function(callback) {
            now = Date.now();
            timeout = Math.max(0, timestep - (now - lastTimestamp));
            lastTimestamp = now + timeout;
            return setTimeout(function() {
                callback(now + timeout);
            }, timeout);
        };
    })(),
 
    cancelAnimationFrame = typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame : clearTimeout;

我们使用了 Date.now() 以减少兼容性问题,但这在 IE8 中是不支持的。如果你真的需要支持 IE8,可以使用 +new Date() 代替,但这会产生一堆临时对象,增加垃圾回收的负载,进而导致你的游戏卡顿。此外 IE8 本身也很慢,很难支持有较多 JavaScript 逻辑的应用运行。

总结

我们需要考虑的东西很多。如果你想简单地实现游戏循环,只要用我的 MainLoop.js 开源库就可以了。这样你就不用担心上面这么多的问题。

如果你自己动手,那么还可以做一些通用性的代码优化。例如,可以把小方块封装在自己单独的类中。整个脚本应该被包裹在一个 IIFE(立即执行函数) 中,仅暴露接口给外部,以防止污染浏览器的全局命名空间,或是把代码打包成 CommonJS 或 AMD 的模块。MainLoop.js 已经把这些都做好了(甚至做得更好),不过总而言之我们已经做得相当不错了。

最后,我要感谢 Glenn Fiedler 编写了经典的 Fix your timestep! 一文,它是本文中如此多工作的起点。也感谢 Ian Langworth 为我审阅了我在关于制作 3D 页游的书中包含的本文的简略版本,并提出了 Web Worker 相关的一些建议。最后的最后,如果你仍然想寻找关于这个话题的更多资源,也许可以考虑看下 Game Programming Patterns 这本书,或者 MDN 上一篇相对简略的文章。

TAGS:  JavaScriptGameLoop
正在加载,请稍候……