影片遊戲剖析

本文從技術角度探討了普通影片遊戲的剖析和工作流程,重點關注主迴圈的執行方式。它幫助現代遊戲開發初學者瞭解構建遊戲所需的內容,以及 JavaScript 等 Web 標準如何作為工具。對於不熟悉 Web 開發的經驗豐富的遊戲程式設計師也能從中受益。

呈現、接受、解釋、計算、重複

每個影片遊戲的目標都是向用戶呈現一個場景,接受他們的輸入,將這些訊號解釋為動作,並計算這些動作所產生的新場景。遊戲會不斷地迴圈這些階段,一遍又一遍,直到出現某種結束條件(例如獲勝、失敗或退出睡覺)。毫不奇怪,這種模式與遊戲引擎的程式設計方式相對應。

具體情況取決於遊戲。

有些遊戲透過使用者輸入來驅動這個迴圈。想象一下,你正在開發一款“找出兩張相似圖片之間的不同之處”型別的遊戲。這些遊戲會向用戶呈現兩張圖片;它們接受使用者的點選(或觸控);它們將輸入解釋為成功、失敗、暫停、選單互動等;最後,它們計算根據該輸入更新的場景。遊戲迴圈由使用者的輸入推動,並在使用者提供輸入之前一直處於休眠狀態。這更像是一種回合制方法,不需要每一幀都持續更新,只有當玩家做出反應時才需要。

其他遊戲則要求對最小的單個時間片進行控制。上述原理略有不同:動畫的每一幀都會推動迴圈,並且使用者輸入的任何變化都會在第一個可用回合被捕獲。這種每幀一次的模型透過所謂的主迴圈實現。如果你的遊戲是基於時間迴圈的,那麼它將是你模擬所遵循的權威。

但它可能不需要逐幀控制。你的遊戲迴圈可能類似於“找不同”的例子,並基於輸入事件。它可能需要輸入和模擬時間。它甚至可能完全基於其他東西進行迴圈。

值得慶幸的是,如接下來的章節所述,現代 JavaScript 使開發一個高效、每幀執行一次的主迴圈變得容易。當然,你的遊戲最佳化程度只取決於你如何做。如果某個東西看起來應該附加到一個不那麼頻繁的事件上,那麼通常最好將其從主迴圈中分離出來(但並非總是如此)。

在 JavaScript 中構建主迴圈

JavaScript 最適合事件和回撥函式。現代瀏覽器努力在需要時呼叫方法,並在空閒時(或執行其他任務)暫停。將你的程式碼附加到適當的時刻是一個很好的主意。考慮一下你的函式是否真的需要在一個嚴格的時間間隔內、每一幀都呼叫,或者只在某些事情發生後才呼叫。更具體地告訴瀏覽器何時需要呼叫你的函式,可以幫助瀏覽器最佳化其呼叫時機。此外,這可能會讓你的工作更輕鬆。

有些程式碼需要逐幀執行,那麼為什麼不將該函式附加到瀏覽器的重繪計劃上呢?在 Web 上,window.requestAnimationFrame() 將成為大多數良好程式設計的逐幀主迴圈的基礎。呼叫時必須傳入一個回撥函式。該回調函式將在下一次重繪之前的合適時間執行。以下是一個簡單主迴圈的示例:

js
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)。

js
/*
 * 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 的名稱空間上。擴充套件我們上一個示例,主迴圈現在看起來像這樣:

js
/*
 * 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。在任何時候,我們都可以透過告訴瀏覽器取消與我們的令牌對應的請求來停止主迴圈。

js
window.cancelAnimationFrame(MyGame.stopMain);

用 JavaScript 程式設計主迴圈的關鍵是將其附加到應驅動你操作的任何事件,並注意所涉及的不同系統如何相互作用。你可能有多個元件由多種不同型別的事件驅動。這感覺像是沒必要的複雜性,但它可能只是很好的最佳化(當然,不一定)。問題是你不是在程式設計一個典型的主迴圈。在 JavaScript 中,你正在使用瀏覽器的主迴圈,並且你試圖有效地這樣做。

在 JavaScript 中構建更最佳化的主迴圈

最終,在 JavaScript 中,瀏覽器執行自己的主迴圈,而你的程式碼存在於其某些階段中。上述部分描述了避免從瀏覽器手中奪取控制權的主迴圈。這些主方法將自身附加到 window.requestAnimationFrame(),後者要求瀏覽器控制即將到來的一幀。如何將這些請求與其主迴圈關聯起來取決於瀏覽器。HTML 規範並沒有真正精確定義瀏覽器何時必須執行 requestAnimationFrame 回撥。這可能是一個優勢,因為它讓瀏覽器供應商可以自由地試驗他們認為最好的解決方案並隨著時間進行調整。

Firefox 和 Google Chrome 的現代版本(可能還有其他)嘗試在幀時間片的開始時將 requestAnimationFrame 回撥連線到它們的主執行緒。因此,瀏覽器的主執行緒試圖看起來像下面這樣:

  1. 開始新的一幀(同時上一幀由顯示器處理)。
  2. 遍歷 requestAnimationFrame 回撥列表並呼叫它們。
  3. 當上述回撥停止控制主執行緒時,執行垃圾回收和其他每幀任務。
  4. 休眠(除非事件中斷瀏覽器的休眠),直到顯示器準備好顯示你的影像(VSync)並重復。

你可以將即時應用程式的開發視為有一個時間預算來完成工作。所有上述步驟都必須每 16.5 毫秒發生一次,才能跟上 60 Hz 的顯示器。瀏覽器會盡早呼叫你的程式碼,以便為其提供最大的計算時間。你的主執行緒通常會啟動甚至不在主執行緒上的工作負載(例如 WebGL 中的光柵化或著色器)。長時間的計算可以在 Web Worker 或 GPU 上執行,同時瀏覽器使用其主執行緒管理垃圾回收、其他任務或處理非同步事件。

談到時間預算,許多網路瀏覽器都有一種稱為“高精度時間”的工具。Date 物件不再是公認的事件計時方法,因為它非常不精確,並且可以被系統時鐘修改。另一方面,高精度時間計算自 navigationStart(前一個文件解除安裝時)以來的毫秒數。這個值以十進位制數返回,精確到毫秒的千分之一。它被稱為 DOMHighResTimeStamp,但就所有意圖和目的而言,可以將其視為浮點數。

注意:不具備微秒精度的系統(硬體或軟體)被允許提供至少毫秒精度。然而,如果它們具備能力,應該提供 0.001 毫秒的精度。

這個值單獨使用不是很有用,因為它相對於一個相當不有趣的事件,但它可以從另一個時間戳中減去,以準確精確地確定這兩個點之間經過了多少時間。要獲取其中一個時間戳,你可以呼叫 window.performance.now() 並將結果儲存為一個變數。

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

回到主迴圈的話題。你通常會想知道你的主函式何時被呼叫。由於這種情況很常見,window.requestAnimationFrame() 在回撥函式執行時總是將一個 DOMHighResTimeStamp 作為引數提供給它們。這導致我們之前的主迴圈又得到了一個增強。

js
/*
 * 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。

js
/*
 * 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。

警告:這個例子,特別是,需要技術審查。

js
/*
 * 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、聲音、網路同步以及你的遊戲可能需要的其他一切。