视频游戏剖析
本文从技术角度探讨了普通视频游戏的剖析和工作流程,重点关注主循环的运行方式。它帮助现代游戏开发初学者了解构建游戏所需的内容,以及 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() 污染了 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())将在应用程序的其余部分成为一个有效的未使用名称,可以自由定义为其他内容。
注意:实际上,更常见的是使用 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 的变量,它包含了我们主循环最近一次调用 requestAnimationFrame() 返回的 ID。在任何时候,我们都可以通过告诉浏览器取消与我们的令牌对应的请求来停止主循环。
window.cancelAnimationFrame(MyGame.stopMain);
用 JavaScript 编程主循环的关键是将其附加到应驱动你操作的任何事件,并注意所涉及的不同系统如何相互作用。你可能有多个组件由多种不同类型的事件驱动。这感觉像是没必要的复杂性,但它可能只是很好的优化(当然,不一定)。问题是你不是在编程一个典型的主循环。在 JavaScript 中,你正在使用浏览器的主循环,并且你试图有效地这样做。
在 JavaScript 中构建更优化的主循环
最终,在 JavaScript 中,浏览器运行自己的主循环,而你的代码存在于其某些阶段中。上述部分描述了避免从浏览器手中夺取控制权的主循环。这些主方法将自身附加到 window.requestAnimationFrame(),后者要求浏览器控制即将到来的一帧。如何将这些请求与其主循环关联起来取决于浏览器。HTML 规范并没有真正精确定义浏览器何时必须执行 requestAnimationFrame 回调。这可能是一个优势,因为它让浏览器供应商可以自由地试验他们认为最好的解决方案并随着时间进行调整。
Firefox 和 Google Chrome 的现代版本(可能还有其他)尝试在帧时间片的开始时将 requestAnimationFrame 回调连接到它们的主线程。因此,浏览器的主线程试图看起来像下面这样:
- 开始新的一帧(同时上一帧由显示器处理)。
- 遍历
requestAnimationFrame回调列表并调用它们。 - 当上述回调停止控制主线程时,执行垃圾回收和其他每帧任务。
- 休眠(除非事件中断浏览器的休眠),直到显示器准备好显示你的图像(VSync)并重复。
你可以将实时应用程序的开发视为有一个时间预算来完成工作。所有上述步骤都必须每 16.5 毫秒发生一次,才能跟上 60 Hz 的显示器。浏览器会尽早调用你的代码,以便为其提供最大的计算时间。你的主线程通常会启动甚至不在主线程上的工作负载(例如 WebGL 中的光栅化或着色器)。长时间的计算可以在 Web Worker 或 GPU 上执行,同时浏览器使用其主线程管理垃圾回收、其他任务或处理异步事件。
谈到时间预算,许多网络浏览器都有一种称为“高精度时间”的工具。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 画布,如果你愿意)分层成复杂的层次结构。这些路径中的每一个都将带来不同的机会和限制。
是时候做出决定了……
你需要对主循环做出艰难的决定:如何模拟时间的精确流逝。如果你需要逐帧控制,那么你需要确定你的游戏更新和绘制的频率。你甚至可能希望更新和绘制以不同的速率发生。你还需要考虑如果用户系统无法跟上工作负载,你的游戏将如何优雅地失败。让我们首先假设你将在每次绘制时处理用户输入并更新游戏状态。我们稍后会进行分支。
注意:改变主循环处理时间的方式是各地调试的噩梦。在处理主循环之前,请仔细考虑你的需求。
大多数浏览器游戏应该是什么样子
如果你的游戏能够达到你支持的任何硬件的最大刷新率,那么你的工作就相当容易。你可以更新、渲染,然后什么也不做,直到 VSync。
/*
* 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 毫秒(或大约 60 帧/秒)。如果计算时间过长,则渲染分辨率会降低,纹理和其他资产将无法加载或绘制,等等。这个(非 Web)案例研究做了一些假设和权衡:
- 动画的每一帧都考虑用户输入。
- 不需要推断(猜测)任何帧,因为每次绘制都有自己的更新。
- 模拟系统基本上可以假设每次完全更新大约间隔 16 毫秒。
- 让用户控制质量设置将是一场噩梦。
- 不同的显示器输入速率不同:30 FPS、75 FPS、100 FPS、120 FPS、144 FPS 等。
- 无法跟上 60 FPS 的系统会损失视觉质量,以使游戏以最佳速度运行(如果质量变得太低,最终会完全失败。)
处理可变刷新率需求的其他方法
还存在其他解决问题的方法。
一种常见的技术是以恒定频率更新模拟,然后尽可能多(或尽可能少)地绘制实际帧。更新方法可以继续循环,而不关心用户看到的内容。绘制方法可以查看上次更新及其发生的时间。由于绘制知道它代表的时间以及上次更新的模拟时间,因此它可以预测一个合理的帧来为用户绘制。无论这是否比官方更新循环更频繁(甚至更不频繁)都无关紧要。更新方法设置检查点,并且渲染方法根据系统允许的频率绘制其周围的时间点。在 Web 标准中分离更新方法有多种方式:
-
在
requestAnimationFrame()上绘制,并在setInterval()或setTimeout()上更新。- 即使失去焦点或最小化,这也会占用处理器时间,占用主线程,并且可能是传统游戏循环的遗留问题(但它很简单)。
-
在
requestAnimationFrame()上绘制,并在setInterval()或setTimeout()上在 Web Worker 中更新。- 这与上面相同,只是更新不会占用主线程(主线程也不会占用它)。这是一个更复杂的解决方案,对于简单的更新来说可能开销太大。
-
在
requestAnimationFrame()上绘制,并用它来“戳”包含更新方法的 Web Worker,告知它需要计算的刻度数(如果有的话)。- 这会休眠直到
requestAnimationFrame()被调用,并且不会污染主线程,此外你也不依赖旧式方法。同样,这比前两个选项更复杂一些,并且每个更新的启动都将被阻塞,直到浏览器决定触发 rAF 回调。
- 这会休眠直到
这些方法各有优缺点:
- 用户可以根据他们的性能跳过渲染帧或插入额外的帧。
- 你可以指望所有用户以相同的恒定频率(减去卡顿)更新非外观变量。
- 比我们前面看到的基本循环编程要复杂得多。
- 在下一次更新之前,用户输入完全被忽略(即使用户拥有快速设备)。
- 强制插值会带来性能损失。
一个独立的更新和绘制方法可能看起来像下面的例子。为了演示的目的,该例子基于第三个要点,只是为了可读性(老实说,也是为了可写性)而没有使用 Web Workers。
警告:这个例子,特别是,需要技术审查。
/*
* 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、声音、网络同步以及你的游戏可能需要的其他一切。