视频游戏的解剖
本文从技术角度探讨了普通视频游戏的解剖结构和工作流程,重点介绍了主循环的运行方式。它帮助现代游戏开发的初学者了解构建游戏需要什么以及 JavaScript 等 Web 标准如何作为工具发挥作用。对于刚接触 Web 开发的经验丰富的游戏程序员来说,这篇文章也是很有益处的。
呈现、接受、解释、计算、重复
每个视频游戏的目标都是呈现给用户一个情景,接受他们的输入,解释这些信号转化为动作,并计算由这些动作产生的新情景。游戏不断地循环往复这些阶段,直到出现某个结束条件(例如获胜、失败或退出游戏去睡觉)。毫不奇怪,这种模式对应着游戏引擎的编程方式。
具体细节取决于游戏。
有些游戏由用户输入驱动循环。想象一下,你正在开发一款“找出这两幅相似图片之间的差异”类型游戏。这些游戏会呈现两张图片给用户;它们接受用户的点击(或触控);它们解释输入作为成功、失败、暂停、菜单交互等;最后,它们计算从该输入产生的更新后的场景。游戏循环由用户的输入推动,并在用户提供输入之前处于休眠状态。这更像是一种回合制方法,不需要在每一帧都进行更新,而是在玩家做出反应时进行更新。
其他游戏则需要对每一个最小的可能的时间片进行控制。与上述原则相同,只是略有不同:每一帧动画都会推进循环,并且在第一个可用的回合中会捕获用户输入的任何变化。这种每帧一次的模型是在一个称为主循环的东西中实现的。如果你的游戏循环基于时间,那么这将是你的模拟将遵循的权威。
但是它可能不需要每帧控制。你的游戏循环可能类似于找出差异示例,并基于输入事件。它可能同时需要输入和模拟时间。它甚至可能基于其他东西来循环。
正如下一节中所述,现代 JavaScript 使得开发高效的、每帧执行一次的主循环变得容易。当然,你的游戏只能像你做的那样优化。如果某些东西看起来应该附加到更不频繁的事件,那么通常将它从主循环中分离出来是一个好主意(但并不总是)。
在 JavaScript 中构建主循环
JavaScript 最适合处理事件和回调函数。现代浏览器努力在需要的时候调用方法,并在间隙中保持空闲状态(或执行其他任务)。将你的代码附加到适合它们的时间点是一个好主意。考虑一下你的函数是否真的需要在严格的时间间隔内、每一帧或仅在其他事件发生后被调用。对浏览器更明确地告知你的函数何时需要被调用,可以让浏览器优化调用时间。此外,这可能也会让你的工作更容易。
一些代码需要逐帧运行,那么为什么要将该函数附加到除浏览器重绘调度之外的任何其他事件呢?在 Web 上,window.requestAnimationFrame()
将成为大多数编程良好的每帧主循环的基础。当调用它时,必须向它传递一个回调函数。该回调函数将在下一帧重绘之前合适的时机执行。这是一个简单的主循环示例
window.main = () => {
window.requestAnimationFrame(main);
// Whatever your main loop needs to do
};
main(); // Start the cycle
注意:在本文中讨论的每个 main()
方法中,我们在执行循环内容之前都安排了一个新的 requestAnimationFrame
。这不是偶然,并且被认为是最佳实践。尽早调用下一个 requestAnimationFrame
确保浏览器及时收到它以进行相应的规划,即使你当前帧错过了它的 VSync 窗口。
上面的代码块有两条语句。第一条语句创建了一个名为 main()
的全局变量函数。此函数执行一些工作,并告诉浏览器使用 window.requestAnimationFrame()
在下一帧调用自身。第二条语句调用了在第一条语句中定义的 main()
函数。由于 main()
在第二条语句中被调用一次,并且它的每次调用都会将其置于下一帧待办事项队列中,因此 main()
与你的帧速率同步。
当然,这个循环并不完美。在讨论如何改变它之前,让我们讨论一下它已经做得很好的地方。
将主循环的时间安排在浏览器绘制到显示器的时间,可以让你的循环尽可能快地运行浏览器想要绘制的次数。你可以控制每一帧动画。它也非常简单,因为 main()
是唯一被循环的函数。第一人称射击游戏(或类似的游戏)会在每一帧呈现一个新的场景。你无法真正比这更流畅和响应更快了。
但是不要立即假设动画需要逐帧控制。简单的动画可以轻松地执行,即使是 GPU 加速的,也可以使用 CSS 动画和其他浏览器中包含的工具来实现。有很多这样的工具,它们会让你的生活更轻松。
在 JavaScript 中构建更好的主循环
我们之前的 main 循环有两个明显的缺陷:main()
污染了 window
对象(所有全局变量都存储在那里),并且示例代码没有为我们提供一种停止循环的方法,除非整个标签被关闭或刷新。对于第一个问题,如果你只希望主循环运行并且不需要轻松(直接)访问它,你可以将其作为一个立即调用的函数表达式 (IIFE) 创建。
/*
* Starting with the semicolon is in case whatever line of code above this example
* relied on automatic semicolon insertion (ASI). The browser could accidentally
* think this whole example continues from the previous line. The leading semicolon
* marks the beginning of our new line if the previous one was not empty or terminated.
*/
;(() => {
function main() {
window.requestAnimationFrame(main);
// Your main loop contents
}
main(); // Start the cycle
})();
当浏览器遇到这个 IIFE 时,它会定义你的 main 循环,并立即将其排队到下一帧。它不会附加到任何对象,并且 main
(或方法的 main()
)将在应用程序的其余部分中成为一个有效的未使用名称,可以自由地定义为其他东西。
注意:在实践中,更常见的是使用 if 语句来阻止下一个 requestAnimationFrame()
,而不是调用 cancelAnimationFrame()
。
对于第二个问题,停止主循环,你需要使用 window.cancelAnimationFrame()
取消对 main()
的调用。你需要将 cancelAnimationFrame()
传递上次调用 requestAnimationFrame()
时返回的 ID 令牌。让我们假设你的游戏的函数和变量建立在一个你称之为 MyGame
的命名空间中。扩展我们之前的示例,主循环现在看起来像这样
/*
* Starting with the semicolon is in case whatever line of code above this example
* relied on automatic semicolon insertion (ASI). The browser could accidentally
* think this whole example continues from the previous line. The leading semicolon
* marks the beginning of our new line if the previous one was not empty or terminated.
*
* Let us also assume that MyGame is previously defined.
*/
;(() => {
function main() {
MyGame.stopMain = window.requestAnimationFrame(main);
// Your main loop contents
}
main(); // Start the cycle
})();
现在,我们在 MyGame
命名空间中声明了一个变量,我们称之为 stopMain
,它包含来自 main 循环最近一次调用 requestAnimationFrame()
的返回 ID。在任何时候,我们都可以通过告诉浏览器取消与我们的令牌对应的请求来停止主循环。
window.cancelAnimationFrame(MyGame.stopMain);
在 JavaScript 中编程主循环的关键是将其附加到应该驱动你的动作的任何事件,并注意所涉及的不同系统是如何交互的。你可能有多个组件由多种不同类型的事件驱动。这感觉像是不必要的复杂性,但它可能只是很好的优化(当然,也可能不是)。问题是,你没有编程一个典型的主循环。在 JavaScript 中,你使用浏览器的主循环,并且你试图有效地做到这一点。
在 JavaScript 中构建更优化的主循环
最终,在 JavaScript 中,浏览器运行自己的主循环,而你的代码存在于其中的某些阶段。以上各节描述了主循环,它们试图不要从浏览器那里抢夺控制权。这些 main 方法将自身附加到 window.requestAnimationFrame()
,它向浏览器请求对即将到来的帧的控制权。浏览器如何将这些请求与其主循环关联起来取决于浏览器。requestAnimationFrame 的 W3C 规范 并没有真正定义浏览器何时必须执行 requestAnimationFrame 回调。这可能是一个好处,因为它让浏览器供应商可以自由地尝试他们认为最好的解决方案,并在一段时间内进行调整。
现代版本的 Firefox 和 Google Chrome(以及可能的其他浏览器)尝试在帧时间片的开头将 requestAnimationFrame
回调连接到它们的 main 线程。因此,浏览器的 main 线程尝试看起来像下面这样
- 开始一个新的帧(同时由显示器处理前一帧)。
- 遍历
requestAnimationFrame
回调列表,并调用它们。 - 当上述回调函数停止控制主线程时,执行垃圾回收和其他每帧任务。
- 休眠(除非事件中断浏览器的睡眠),直到显示器准备好显示您的图像(垂直同步),然后重复。
您可以将实时应用程序的开发过程想象成一个时间预算。为了跟上 60Hz 的显示器,所有上述步骤都必须在每 16.5 毫秒内完成。浏览器会尽早调用您的代码,以给予它最大的计算时间。您的主线程通常会启动不在主线程上的工作负载(例如,WebGL 中的栅格化或着色器)。长计算可以在 Web Worker 或 GPU 上执行,同时浏览器使用其主线程来管理垃圾回收、其他任务或处理异步事件。
说到时间预算,许多 Web 浏览器都提供了一个名为“高分辨率时间”的工具。Date
对象不再是计时事件的认可方法,因为它精度很低,并且会受到系统时钟的影响。另一方面,高分辨率时间会计算自 navigationStart
(前一个文档卸载时)以来的毫秒数。该值以小数形式返回,精度为千分之一毫秒。它被称为 DOMHighResTimeStamp
,但就所有意图和目的而言,您可以将其视为浮点数。
注意:无法实现微秒精度的系统(硬件或软件)至少应提供毫秒精度。但是,如果系统能够实现 0.001 毫秒精度,则应提供该精度。
该值本身并没有太大用处,因为它相对于一个相当无趣的事件,但可以从另一个时间戳中减去它,以准确、精确地确定这两个时间点之间经过的时间。要获取其中一个时间戳,您可以调用 window.performance.now()
并将结果存储为变量。
const tNow = window.performance.now();
回到主循环的话题。您通常需要知道何时调用主函数。由于这是常见的,因此 window.requestAnimationFrame()
始终在执行回调函数时将 DOMHighResTimeStamp
作为参数提供给回调函数。这导致我们之前的主循环有了进一步的改进。
/*
* Starting with the semicolon is in case whatever line of code above this example
* relied on automatic semicolon insertion (ASI). The browser could accidentally
* think this whole example continues from the previous line. The leading semicolon
* marks the beginning of our new line if the previous one was not empty or terminated.
*
* Let us also assume that MyGame is previously defined.
*/
;(() => {
function main(tFrame) {
MyGame.stopMain = window.requestAnimationFrame(main);
// Your main loop contents
// tFrame, from "function main(tFrame)", is now a DOMHighResTimeStamp provided by rAF.
}
main(); // Start the cycle
})();
还有其他一些可能的优化,这实际上取决于您的游戏要实现什么。您的游戏类型显然会有所不同,但可能比这更细微。您可以分别在画布上绘制每个像素,也可以将 DOM 元素(包括多个具有透明背景的 WebGL 画布,如果您想要的话)层叠到一个复杂的层次结构中。每条路径都会带来不同的机会和限制。
是时候做出决定了…
您需要对主循环做出艰难的决定:如何模拟时间的准确进展。如果您需要每帧控制,那么您需要确定游戏的更新和绘制频率。您甚至可能希望更新和绘制以不同的速率发生。您还需要考虑如果用户的系统无法跟上工作负载,您的游戏将如何优雅地失败。让我们首先假设您将在每次绘制时处理用户输入并更新游戏状态。我们将在稍后分枝。
注意:更改主循环处理时间的方式是调试的噩梦,无处不在。在处理主循环之前,请仔细考虑您的需求。
大多数浏览器游戏的预期外观
如果您的游戏能够达到您支持的任何硬件的最大刷新率,那么您的工作就相当简单。您可以更新、渲染,然后什么也不做,直到垂直同步。
/*
* Starting with the semicolon is in case whatever line of code above this example
* relied on automatic semicolon insertion (ASI). The browser could accidentally
* think this whole example continues from the previous line. The leading semicolon
* marks the beginning of our new line if the previous one was not empty or terminated.
*
* Let us also assume that MyGame is previously defined.
*/
;(() => {
function main(tFrame) {
MyGame.stopMain = window.requestAnimationFrame(main);
update(tFrame); // Call your update method. In our case, we give it rAF's timestamp.
render();
}
main(); // Start the cycle
})();
如果无法达到最大刷新率,则可以调整质量设置以保持在时间预算内。这个概念最著名的例子是 id Software 的游戏 RAGE。这款游戏移除了用户的控制,以便将其计算时间保持在约 16 毫秒(或约 60fps)。如果计算时间过长,则渲染分辨率会降低,纹理和其他资产将无法加载或绘制,等等。此(非 Web)案例研究做了一些假设和权衡
- 每帧动画都考虑了用户输入。
- 不需要推算(猜测)任何帧,因为每次绘制都有自己的更新。
- 模拟系统基本上可以假设每次完整更新相隔约 16 毫秒。
- 让用户控制质量设置将是一场噩梦。
- 不同的显示器以不同的速率输入:30 FPS、75 FPS、100 FPS、120 FPS、144 FPS 等等。
- 无法跟上 60 FPS 的系统会降低视觉质量以保持游戏以最佳速度运行(最终会完全失败,如果质量变得太低)。
处理可变刷新率需求的其他方法
存在其他解决问题的方法。
一种常见的技术是:以恒定的频率更新模拟,然后尽可能多地(或尽可能少地)绘制实际帧。更新方法可以继续循环,而不必关心用户看到的内容。绘制方法可以查看上次更新以及更新发生的时间。由于绘制方法知道它何时表示以及上次更新的模拟时间,因此它可以预测一个合理的帧供用户绘制。无论是比官方更新循环更频繁(甚至更不频繁),都无关紧要。更新方法设置检查点,并在系统允许的频率下,渲染方法绘制围绕检查点的瞬间。在 Web 标准中,有许多方法可以分离更新方法
- 在
requestAnimationFrame
上绘制,并在setInterval()
或setTimeout()
上更新。- 这即使在未聚焦或最小化时也会使用处理器时间,占用主线程,这可能是传统游戏循环的产物(但它很简单)。
- 在
requestAnimationFrame
上绘制,并在 Web Worker 中的setInterval
或setTimeout
上更新。- 这与上述相同,只是更新不会占用主线程(主线程也不会占用它)。这是一个更复杂的解决方案,对于简单的更新来说可能过于繁重。
- 在
requestAnimationFrame
上绘制,并使用它来用要计算的刻度数(如果有)戳一个包含更新方法的 Web Worker。- 这会休眠,直到调用
requestAnimationFrame
,并且不会污染主线程,此外您不再依赖过时的方法。同样,这比前两种选项稍微复杂一些,并且启动每次更新将被阻塞,直到浏览器决定触发 rAF 回调函数。
- 这会休眠,直到调用
这些方法都有类似的权衡
- 用户可以根据其性能跳过渲染帧或插值额外的帧。
- 您可以相信所有用户都会以相同的恒定频率更新非美观的变量,减去故障。
- 比我们之前看到的基本循环更复杂。
- 用户输入完全被忽略,直到下次更新(即使用户拥有快速设备)。
- 强制插值会带来性能损失。
一个单独的更新和绘制方法可能类似于以下示例。为了演示,该示例基于第三个要点,只是为了可读性(以及,说实话,可写性)没有使用 Web Worker。
警告:具体来说,此示例需要技术审查。
/*
* Starting with the semicolon is in case whatever line of code above this example
* relied on automatic semicolon insertion (ASI). The browser could accidentally
* think this whole example continues from the previous line. The leading semicolon
* marks the beginning of our new line if the previous one was not empty or terminated.
*
* Let us also assume that MyGame is previously defined.
*
* MyGame.lastRender keeps track of the last provided requestAnimationFrame timestamp.
* MyGame.lastTick keeps track of the last update time. Always increments by tickLength.
* MyGame.tickLength is how frequently the game state updates. It is 20 Hz (50ms) here.
*
* timeSinceTick is the time between requestAnimationFrame callback and last update.
* numTicks is how many updates should have happened between these two rendered frames.
*
* render() is passed tFrame because it is assumed that the render method will calculate
* how long it has been since the most recently passed update tick for
* extrapolation (purely cosmetic for fast devices). It draws the scene.
*
* update() calculates the game state as of a given point in time. It should always
* increment by tickLength. It is the authority for game state. It is passed
* the DOMHighResTimeStamp for the time it represents (which, again, is always
* last update + MyGame.tickLength unless a pause feature is added, etc.)
*
* setInitialState() Performs whatever tasks are leftover before the main loop must run.
* It is just a generic example function that you might have added.
*/
;(() => {
function main(tFrame) {
MyGame.stopMain = window.requestAnimationFrame(main);
const nextTick = MyGame.lastTick + MyGame.tickLength;
let numTicks = 0;
// If tFrame < nextTick then 0 ticks need to be updated (0 is default for numTicks).
// If tFrame = nextTick then 1 tick needs to be updated (and so forth).
// Note: As we mention in summary, you should keep track of how large numTicks is.
// If it is large, then either your game was asleep, or the machine cannot keep up.
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; // Now lastTick is this tick.
update(MyGame.lastTick);
}
}
MyGame.lastTick = performance.now();
MyGame.lastRender = MyGame.lastTick; // Pretend the first draw was on first update.
MyGame.tickLength = 50; // This sets your simulation to run at 20Hz (50ms)
setInitialState();
main(performance.now()); // Start the cycle
})();
另一种选择是减少某些操作的频率。如果更新循环的一部分很难计算,但对时间不敏感,您可能需要考虑降低其频率,并且理想情况下将其分散到更长的时间段中。在 Artillery Games 的 Artillery 博客中发现了一个隐含的例子,他们 调整垃圾生成的速率 以优化垃圾回收。显然,清理资源并不紧急(尤其是在整理比垃圾本身更具破坏性时)。
这可能也适用于您自己的某些任务。当可用资源成为问题时,这些任务是进行节流的合适候选者。
总结
我想明确指出,以上任何方法,或者没有任何方法,都可能是最适合您的游戏的。正确的决定完全取决于您愿意(和不愿意)做出的权衡。主要的问题是切换到另一个选项。幸运的是,我对此没有任何经验,但听说这是一个令人痛苦的打地鼠游戏。
对于像 Web 这样的托管平台,要记住的一点是,您的循环可能会停止执行很长时间。这可能会发生在用户取消选择您的选项卡时,浏览器会睡眠(或降低)其 requestAnimationFrame
回调函数间隔。您有很多方法可以处理这种情况,这可能取决于您的游戏是单人游戏还是多人游戏。一些选择是
- 将差距视为“暂停”,并跳过时间。
- 您可能可以看出这对于大多数多人游戏来说是问题。
- 您可以模拟差距以赶上。
- 对于长时间的下降和/或复杂的更新来说,这可能是一个问题。
- 您可以从对等方或服务器中恢复游戏状态。
- 如果您的对等方或服务器也过时,或者它们不存在,因为游戏是单人游戏并且没有服务器,那么这将是无效的。
一旦您的主循环开发完毕,并且您已经决定了一组适合您游戏的假设和权衡,那么现在只需使用您的决定来计算任何适用的物理、AI、声音、网络同步以及您的游戏可能需要的任何其他内容即可。