剖析游戏结构

本文从技术角度分析了一般电子游戏的结构和工作流程,就此介绍主循环是如何运行的。它有助于初学者了解在现代游戏开发领域构建游戏时需要什么,以及理解像 JavaScript 这样的 Web 标准是如何成为可用于开发游戏的工具的。游戏开发经验丰富但不熟悉 Web 开发的开发者也能从本文中受益。

呈现、接收、解释、计算、重复

所有电子游戏的目标都是向用户呈现一个场景,接收他们的输入,将这些输入信号解释为动作,并计算出由这些动作产生的新场景。游戏不断地循环遍历,一遍又一遍,直到遇到某些终止条件(比如赢、输或者用户退出休息)。毫不奇怪,这种模式与游戏引擎的编程方式相呼应。

具体情况取决于游戏本身。

一些游戏通过用户输入来驱动这个循环。想象一下,你正在开发一种“找不同”类型的游戏。这些游戏向用户呈现两张图片、接收用户的点击(或触摸)、将用户输入解释为成功、失败、暂停、菜单交互等。最后,游戏根据用户的输入计算得到新的游戏场景。这种游戏是由用户的输入驱动,也就是说,它会在用户进行输入之后冻结画面,等待玩家进行新的输入。这是一种基于回合的游戏类型,它不需要每帧持续更新画面,只有当玩家作出反应时才会。

另一些游戏需要尽可能控制每一个细微的时间片段。与上述原理有点小区别:每个动画帧都将循环遍历,并在之后第一个可用的轮次捕获玩家输入的任何变化。这种每帧一次的模型是通过一个叫主循环的东西实现的。如果你的游戏循环是基于时间的,则必须保证对主循环精准的模拟。

但它也可能不需要逐帧控制。你的游戏循环可能类似于找不同的例子,并且以输入事件作为基础,那么它可能同时需要输入和模拟时间片段。它甚至可以基于其他的东西来循环。

正如我们将再下一节中所描述的,现代 JavaScript 可以轻松开发出一个高效的,逐帧执行的主循环。当然,你的游戏只会按照你所做的那样优化。如果某些东西看起来应该被添加到一个更少发生的事件里,那么将它从主循环中剥离出来通常是个好主意(但并非总是如此)。

在 JavaScript 中构建一个主循环

JavaScript 能很好的处理事件和回调函数。现代浏览器努力在需要的时候调用方法,并在间隙中闲下来(或做其他任务)。将你的代码附加到适合它们的时刻是一个很好的主意。考虑一下你的函数是需要在严格的时间周期内,还是每一帧,或者仅仅是在发生了其他情况之后执行。当你的函数需要被调用时,要更具体地使用浏览器,这样浏览器就可以在调用时进行优化。而且,这可能会让你的工作更轻松。

有些代码需要逐帧运行,所以应将其附加到浏览器的重绘周期中,没有比这更好的了!在 Web 中通常将 window.requestAnimationFrame() 方法作为大多数良好的逐帧循环的基础。在调用该方法时必须传入一个回调函数,这个回调函数将在下一次重新绘制之前执行。下面是一个简单的主循环的例子:

js
window.main = () => {
  window.requestAnimationFrame(main);

  // 你的主循环可以做你想要做的任何事情
};

main(); // 开始主循环

备注: 在这里讨论的每个 main() 方法中,在执行循环内容之前,我们会递归调用一个新的 requestAnimationFrame。这不是毫无根据而写的:这样做被认为是最佳实践。如果你的当前帧错过了它的垂直同步窗口的周期,你也在下一个帧通过 requestAnimationFrame 尽早的调用 main(),从而确保浏览器能够及时地执行。

上面的代码块有两个语句。第一个语句创建一个名为 main() 的全局变量的函数。这个函数会执行一些操作,同时告诉浏览器在下一帧通过 window.requestAnimationFrame() 调用本身。第二个语句调用第一个语句中定义的 main() 函数。因为 main() 中在第二个语句中被调用一次,而每次调用都将自身又放置到下一个帧的执行队列中,因此 main() 的调用与你的帧率同步。

当然,这个循环并不完美。不过在讨论如何改善之前,让我们先讨论一下它已经做到了什么。

将主循环的时机安排在浏览器每次的绘制显示中,允许你能像浏览器想要绘制的那样频繁的运行你的循环。你能够控制每一帧动画,并且 main() 是循环中唯一执行的函数,所以这很简单。主视角射击游戏(或类似的游戏)每一帧都会出现一个新的场景。没有比这种方法更平滑并绘制及时的了。

但是不要马上认为动画必需要帧帧控制。通过 CSS 动画或浏览器中的其他工具,我们也可以很容易实现简单的动画,甚至能用上 GPU 加速。还有有很多类似这样的工具能让我们的开发变得更加简单。

在 JavaScript 中构建一个更好的主循环

在前面的主循环中有两个明显的问题:main() 污染了 window 对象(所有全局变量存储的对象),并且示例代码没有给我们留下一个停止循环的方法(除非整个标签页被关闭或刷新)。对于第一个问题,如果你希望主循环只运行,并且不需要被简单(直接)地访问,你可以将它创建为一个立即调用的函数表达式(IIFE)。

js
/*
* 以分号开头是为了以防此示例上方的代码行依赖于自动分号插入(ASI)。 
* 浏览器可能会意外地认为整个示例会从上一行继续。
* 如果前一行不为空或终止,则前面的分号标志着新行的开始。
*/

;(() => {
  function main(){
    window.requestAnimationFrame(main);

    // 你的主循环内容。
  }

  main(); // 开始循环
})();

当浏览器遇到这个 IIFE 时,它将定义你的主循环,并立即将其加入下一个帧的更新队列中。main(或作为方法而言的 main())不会被附加到任何对象。它在应用程序的其他地方仍是一个有效的未使用的名称,仍可以自由地被定义为其他的东西。

备注: 在实践中,更常见的终止下一个 requestAnimationFrame() 方式是使用 if 语句,而不是调用 cancelAnimationFrame()

对于第二个问题,要终止循环,你需要调用 window.cancelAnimationFrame() 来取消 main() 的调用。该方法需要传入你最后一次调用 requestAnimationFrame() 时返回的 ID。让我们假设你的游戏的函数和变量是建立在你称为 MyGame 的名称空间上。扩展我们的最后一个例子,主循环现在看起来是这样的:

js
/*
* 以分号开头是为了以防此示例上方的代码行依赖于自动分号插入(ASI)。 
* 浏览器可能会意外地认为整个示例会从上一行继续。
* 如果前一行不为空或终止,则前面的分号标志着新行的开始。
*
* 让我们假设 MyGame 是之前定义的。
*/

;(() => {
  function main(){
    MyGame.stopMain = window.requestAnimationFrame(main);

    // 你的主循环内容
  }

  main(); // 开始循环
})();

现在,我们在 MyGame 名称空间中声明了一个变量 stopMain,其值为主循环最后调用 requestAnimationFrame() 时返回的 ID。任何时候,我们可以通过告诉浏览器取消与 ID 相对应的请求来停止主循环。

js
window.cancelAnimationFrame(MyGame.stopMain);

在 JavaScript 的中编写主循环的关键在于考虑到任何会驱动你行为的事件,同时要注意不同的系统是如何相互作用的。你可能拥有多个由多个不同类型的事件驱动的组件。这看起来像是不必要地变复杂了,但它可能也是一个很好的优化(当然也不一定)。问题是,你并不是在编写一个典型的主循环。在 JavaScript 中,你使用的是浏览器的主循环,并且你正在尝试这样做。

用 JavaScript 构建一个更优化的主循环

基本上,在 JavaScript 的中,浏览器有它自己的主循环,而你的代码存在于循环某些阶段。上面描述的主循环,试图避免脱离浏览器的控制。这种主循环附着于 window.requestAnimationFrame() 方法,该方法将在浏览器的下一帧中执行,具体取决于浏览器如何与将其自己的主循环关联起来。W3C 的 requestAnimationFrame 规范并没有真正定义什么时候浏览器必须执行 requestAnimationFrame 回调。这有一个好处,浏览器厂商可以自由地实现他们认为最好的解决方案,并随着时间的推移进行调整。

现代版的 Firefox 和 Google Chrome(可能还有其他浏览器)都尝试图在框架的时间片段的开始时尝试requestAnimationFrame 回调与它们的主线程进行连接。因此,浏览器的主线程看起来就像下面这样:

  1. 启动一个新帧(而之前的帧由显示器处理)。
  2. 遍历 requestAnimationFrame 回调列表并调用它们。
  3. 当上面的回调停止控制主线程时,执行垃圾收集和其他帧任务。
  4. 睡眠(除非事件打断了浏览器的睡眠),直到显示器准备好你的图像(垂直同步)并重复。

你可以考虑开发实时应用程序,因为有时间做工作。所有上述步骤都必须在每 16 毫秒内进行一次,以跟上 60Hz 的显示器。浏览器会尽可能早地调用你的代码,从而给它最大的计算时间。你的主线程通常会启动一些甚至不在主线程上的工作负载(如 WebGL 的中的光栅化或着色器)。在浏览器使用其主线程管理垃圾收集,其他任务或处理异步事件时,可以在 Web Worker 或 GPU 上执行长时间的计算。

当我们讨论预算时间时,许多 Web 浏览器都有一个称为高解析度时间的工具。Date 对象不再是公认的事件计时方法,因为它非常不精确,可以由系统时钟进行修改。另一方面,高解析度时间计算自 navigationStart(当上一个文档被卸载时)的毫秒数。这个值以小数的精度返回,精确到千分之一毫秒。它被称为 DOMHighResTimeStamp,但是,无论出于什么意图和目的,都认为它是一个浮点数。

备注: 系统(硬件或软件)不能达到微秒精度,则至少提供毫秒级的精度。不过,如果能够达到微秒的精度,那就应该提供这一精度。

这个值本身并不太有用,因为它与一个相当无趣的事件相关,但它可以从另一个时间戳中减去,以便准确准确地确定这两个点之间的时间间隔。要获得这些时间戳中的一个,你可以调用 window.performance.now() 并将结果存储为一个变量。

js
const tNow = window.performance.now();

回到主循环的主题。你将经常想知道何时调用主函数。因为这是常见的,window.requestAnimationFrame() 总是在执行回调函数时提供 DOMHighResTimeStamp 作为其参数。这将为我们之前的主循环带来另一个增强。

js
/*
* 以分号开头是为了以防此示例上方的代码行依赖于自动分号插入(ASI)。 
* 浏览器可能会意外地认为整个示例会从上一行继续。
* 如果前一行不为空或终止,则前面的分号标志着新行的开始。
*
* 我们还假设 MyGame 是以前定义的。
*/

;(() => {
  function main(tFrame){
    MyGame.stopMain = window.requestAnimationFrame(main);

    // 你的主循环内容
    // tFrame,来源于“function main(tFrame)”,现在变为是由 rAF 提供的 DOMHighResTimeStamp。
  }

  main(); // 开始循环
})();

其他一些优化是可能的,这取决于你的游戏想要完成什么。你的游戏类型显然会有所不同,但它甚至可能比这更微妙。你可以在画布上单独绘制每个像素,也可以将 DOM 元素(包括具有透明背景的多个 WebGL 的画布)放入复杂的层次结构中。每条路径都将带来不同的机会和约束。

决定……时间

你将需要对你的主循环作出艰难的决定:如何模拟准确的时间进度。如果你想要控制每一帧,那么你需要确定你的游戏更新和绘制的频率,你甚至可能希望以不同的速率进行更新和绘制。你还需要考虑如果用户的系统无法跟上工作负载,那么你还需考虑如何优雅降级,以保证性能。让我们首先假定你会在每次绘制时,同时处理用户的输入,并更新游戏状态。我们稍后再细讲。

备注: 改变主循环如何处理时间是非常困难的,在进行主循环之前,请仔细考虑你的需求。

大多数浏览器游戏应该是什么样的

如果你的游戏可以达到你所支持的任何硬件的最大刷新率,那么你的工作就变得相当容易了。你可以简单地进行更新、渲染,然后在垂直同步之前什么都不用做。

js
/*
* 以分号开头是为了以防此示例上方的代码行依赖于自动分号插入(ASI)。 
* 浏览器可能会意外地认为整个示例从上一行继续。
* 如果前一行不为空或终止,则前面的分号标志着新行的开始。
*
* 我们还假设 MyGame 是以前定义的。
*/

;(() => {
  function main(tFrame) {
    MyGame.stopMain = window.requestAnimationFrame(main);

    update(tFrame); // 调用 update 方法。在我们的例子中,我们给它 rAF 的时间戳。
    render();
  }

  main(); // 开始循环
})();

如果无法达到最大刷新率,可以调整画面质量设置以保持你的时间预算。这个概念最有名的例子是 id Software 的游戏——狂怒。这个游戏取消了用户的控制权,以使其计算时间保持在大约 16ms(或大约 60fps)。如果计算时间过长,渲染的分辨率就会降低,纹理和其他资源将无法加载或绘制等。这个(非 Web 的)案例研究做了一些假设和折衷:

  • 每个动画帧都考虑用户输入。
  • 没有帧需要外推(猜测),因为每个绘图都有自己的更新。
  • 仿真系统基本上可以假定每次完全更新间隔约 16ms。
  • 给用户控制质量设置将是一场噩梦。
  • 不同的监视器以不同的速率输入:30FPS、75FPS、100FPS、120FPS、144FPS 等
  • 无法跟上 60 FPS 的系统会失去视觉质量,以保持游戏运行的最佳速度(最终如果质量太低,则会是彻底的失败)。

处理可变刷新率需求的其他方法

存在其他解决问题的方法。

一种常见的技术是以恒定的频率更新模拟,然后绘制尽可能多的(或尽可能少的)实际帧。更新方法可以继续循环,而不用考虑用户看到的内容。绘图方法可以查看最后的更新以及发生的时间。由于绘制知道何时表示,以及上次更新的模拟时间,它可以预测为用户绘制一个合理的框架。这是否比官方更新循环更频繁(甚至更不频繁)无关紧要。更新方法设置检查点,并且像系统允许的那样频繁地,渲染方法画出周围的时间。在 Web 标准中分离更新有很多种方法:

  • requestAnimationFrame() 中绘制,并在 setInterval()setTimeout() 中更新。

    • 即使在未聚焦或最小化的情况下,使用处理器时间,也可能是主线程,并且可能是传统游戏循环的工件(但是很简单)。
  • requestAnimationFrame() 中绘制,并在 Web WorkersetInterval()setTimeout() 中对其进行更新。

    • 这与上述相同,除了更新不会使主线程(主线程也没有)。这是一个更复杂的解决方案,并且对于简单更新可能会有太多的开销。
  • requestAnimationFrame() 中绘制,并使用它来戳一个包含更新方法的 Web Worker,其中包含要计算的刻度数(如果有的话)。

    • 这个睡眠直到 requestAnimationFrame() 被调用并且不会污染主线程,加上你不依赖于老式的方法。再次,这比以前的两个选项更复杂一些,并且开始每个更新将被阻止,直到浏览器决定启动 rAF 回调。

这些方法中的每一种都有类似的权衡:

  • 用户可以跳过渲染帧或根据其性能插入额外的帧。
  • 你可以指望所有用户以相同的固定频率更新非修饰性的变量,而不会卡顿。
  • 程序比我们前面看到的基本循环要复杂得多。
  • 用户输入完全被忽略,直到下次更新(即使用户具有快速设备)。
  • 强制插帧具有性能损失。

单独的更新和绘图方法可能类似于下面的示例。为了演示,该示例基于第三点,只是没有使用 Web Worker 以提高可读性(老实说,也包含可写性)。

警告: 这个例子需要进行单独的技术审查。

js
/*
* 以分号开头是为了以防此示例上方的代码行依赖于自动分号插入(ASI)。 
* 浏览器可能会意外地认为整个示例从上一行继续。
* 如果前一行不为空或终止,则前面的分号标志着新行的开始。
*
* 我们还假设 MyGame 是以前定义的。
*
* MyGame.lastRender 跟踪上一次提供的 requestAnimationFrame 时间戳。
* MyGame.lastTick 跟踪上次更新时间。始终以 tickLength 递增。
* MyGame.tickLength 是游戏状态更新的频率。这里是 20 Hz(50ms)。
*
* timeSinceTick 是 requestAnimationFrame 回调和上一次更新之间的时间。
* numTicks 是这两个呈现帧之间应该发生的更新次数。
*
* render() 传入 tFrame, 因为 render 方法可能需要计算
* tFrame 距离最近的更新已经过去了多久,通过外推的方式
* 来获得场景数据。(对于快速设备,render 方法是纯装饰性的)。
* 用以绘制场景。
*
* update() 根据给定时间点计算游戏状态。通常需要用 tickLength
* 作为循环参数,递增更新。来保证游戏状态的严谨。传入 DOMHighResTimeStamp
* 代表当前时间。(除非需要增加暂停功能,传入的时间应该总是
* 上次更新时间 + 游戏的 tick 间隔。)
*
* setInitialState() 执行在运行主循环之前需要的任何任务。
* 它只是一个你可能添加的通用示例函数。
*/

;(() => {
  function main(tFrame) {
    MyGame.stopMain = window.requestAnimationFrame(main);
    const nextTick = MyGame.lastTick + MyGame.tickLength;
    let numTicks = 0;

    // 如果 tFrame < nextTick,则需要更新 0 个 tick(对于 numTicks,默认为 0)。
    // 如果 tFrame = nextTick,则需要更新 1 tick(等等)。
    // 备注:正如我们在总结中提到的那样,你应该跟踪 numTicks 的大小。
    // 如果它很大,要么你的游戏是卡住了,要么机器无法跟上。
    if (tFrame > nextTick) {
      const timeSinceTick = tFrame - MyGame.lastTick;
      numTicks = Math.floor(timeSinceTick / MyGame.tickLength);
    }

    queueUpdates(numTicks);
    render(tFrame);
    MyGame.lastRender = tFrame;
  }

  function queueUpdates(numTicks) {
    for (let i = 0; i < numTicks; i++) {
      MyGame.lastTick += MyGame.tickLength; // 现在 lastTick 应是这一时间。
      update(MyGame.lastTick);
    }
  }

  MyGame.lastTick = performance.now();
  MyGame.lastRender = MyGame.lastTick; // 假装第一次绘制是在第一次更新。
  MyGame.tickLength = 50; // 这将使你的模拟运行在 20Hz(50ms)

  setInitialState();
  main(performance.now()); // 开始循环
})();

另一个选择是简单地做一些事情不那么频繁。如果你的更新循环的一部分难以计算但对时间不敏感,则可以考虑缩小其频率,理想情况下,在延长的时间段内将其扩展成块。这是一个隐含的例子,在火炮博物馆的炮兵游戏中,他们调整垃圾发生率来优化垃圾回收。显然,清理资源不是时间敏感的(特别是如果整理比垃圾本身更具破坏性)。

这也可能适用于你自己的一些任务。那些是当可用资源成为关注点时的好候选人。

总结

我知道上述的任何一种,或许没有适合你的游戏。正确的决定完全取决于你愿意(和不愿意)做出的权衡。主要关心的是切换到另一个选项。幸运的是,我没有任何经验,但我听说这是一个令人痛苦的打地鼠游戏。

像 Web 这样的受管理平台,要记住的一件重要的事情是,你的循环可能会在相当长的一段时间内停止执行。当用户取消选择你的标签并且浏览器休眠(或减慢)其 requestAnimationFrame 回调间隔时,可能会发生这种情况。你有很多方法来处理这种情况,这可能取决于你的游戏是单人游戏还是多人游戏。一些选择是:

  • 考虑差距“暂停”并跳过时间。

    • 你可能会看到这对大多数多人游戏来说都是有问题的。
  • 你可以模拟差距赶上。

    • 这可能是长时间丢失和/或复杂更新的问题。
  • 你可以从联机设备或服务器恢复游戏状态。

    • 除非联机设备/服务器的游戏状态已经过期,或者这是一个没有没服务器的纯单机游戏。

一旦你的主循环被开发出来,你已经决定了一套适合你的游戏的假设和权衡,现在只需要用你的决定来计算任何适用的物理、AI、声音、网络同步,以及其他任何你的游戏可能需要的。