MDN wants to learn about developers like you: https://qsurvey.mozilla.com/s3/MDN-dev-survey

翻譯不完整。請協助 翻譯此英文文件

呈現、接受、編譯、計算、重複

所有遊戲的最終目標,就是要為玩家呈現某一情境、接受玩家輸入、將輸入訊號編譯為動作、由這些動作計算新的情境。遊戲其實就是這些流程不斷重複的迴圈,直到發生某些終止條件 (如勝\敗或睡覺時間到了關機)。可別驚訝,這種形式與遊戲引擎的設計方式息息相關。

某些特殊條件又根據遊戲而有所不同。

某些遊戲是依照玩家的輸入訊號來進行此循環。試想你正開發「找出 2 張相似圖片的不同之處」類型的遊戲。這種遊戲就是為玩家呈現 2 張圖片、接受玩家點擊 (或觸碰)、編譯輸入訊號為成功\失敗\暫停\選單互動等、計算更新過的圖片呈現本次輸入的結果。遊戲迴圈可因玩家輸入進行更多動作,或直接休眠等玩家後續輸入;不同於「回合制」的遊戲迴圈。回合制只有在玩家反應時才會更新畫面,而不會持續更新各個幀像。

其他遊戲就會控制每一段儘可能最短的獨立時間片段 (Timeslice),並套用上述的相同原則。些微的差異在於:動畫的每一幅幀像均將帶動整個週期,且任何玩家輸入訊號中的變動,都將由第一個可用的回合抓取。這種「各幅幀像用一次」的模式,即建置於所謂的「主迴圈 (Main loop)」之中。如果你的遊戲迴圈是以時間為基礎,則  this 將是其權限,且你的模擬作業也將遵循此一原則。

但主迴圈可能不需「各幅幀像」的控制方式。你的遊戲迴圈可能類似上述「找出不同點」的範例,即以輸入事件為基礎。這種主迴圈就可能同時需要輸入與模擬時間,甚至是完全依賴其他事件的迴圈。

還好,現今的 JavaScript (即如下一段所敘述) 可輕鬆開發有效率、各幅幀像執行一次的主迴圈。當然,你的遊戲只有等你自己完成最佳化。如果某樣東西看起來應該附掛於更罕見的事件上,則最好將之置於主迴圈之外 (並非一定如此)。

於 JavaScript 中建構主迴圈

JavaScript 能與「事件 (Event)」以及「回呼 (Callback)」函式達到最佳效果。新款瀏覽器會立刻呼叫所需的函式,並趁機在間隔之間閒置 (或執行其他作業)。找到合適時機附加自己的程式碼是不錯的想法。試想,你可能真的必須依照嚴格的時間間隔、各幅幀像,或在特定事件發生之後,才能呼叫自己的函式。若搭配瀏覽器的限制就更嚴格了,而且呼叫函式的時機可讓瀏覽器進行最佳化。同樣的,如此可能讓你能更輕鬆作業。

某些程式碼必須順著各幅幀像執行,所以這類函式為何需要附掛至瀏覽器 redraw schedule 以外的地方呢?在 Web 上,window.requestAnimationFrame() 就可以作為大部分 well-programmed per-frame 主迴圈的基礎。一旦呼叫 it,就必須將回呼函式送入其內。而在下次重新繪製之前,會在合適的時間回呼此函式。下列為簡易主迴圈的範例:

window.main = function () {
  window.requestAnimationFrame( main );
  
  // Whatever your main loop needs to do.
};

main(); //Start the cycle.

注意:此處所提及的每一個 main() 函式,我們都會在執行迴圈內容之前,排定新的 requestAnimationFrame。這並非意外得來,而是考慮過的最佳實作。接著可早點呼叫下個 requestAnimationFrame 以確保瀏覽器準時接收,即使你目前的幀像沒趕上其 VSync,也能依序規劃進行。

上面的程式碼片段共有 2 組陳述式。第一組陳述式所建立的函式可作為 main() 全域變數。此函式除了執行某些作業之外,也會告知瀏覽器要透過 window.requestAnimationFrame(),在下個幀像時呼叫函式本身。第二組即為 main() 函式,並透過第一組函式所定義而成。因為 main() 會在第二組陳述式中呼叫一次,且每次呼叫都會將 main() 本身置入下個幀像應進行事件的佇列之中,所以 main() 會和你的幀率同步。

此迴圈當然還有許多有待改進之處。但在我們討論之前,先來說說已經算是完備的地方。

Timing the main loop to when the browser paints to the display,可讓你隨著瀏覽器的繪製作業而一同執行迴圈。因為 main() 是唯一納入迴圈的函式,所以你其實可控制動畫的所有幀像。第一人稱射擊遊戲 (或其他簡單遊戲) 就是每幅幀像都呈現一次新的場景。目前應該就是最順暢、最具反應度的遊戲了。

但請別立刻假設:動畫都需要各幅幀像逐一控制。透過 CSS 動畫與瀏覽器內的其他工具,即使需要 GPU 加速的簡易動畫,也都能輕鬆執行。現在已經有許多工具可讓你更輕鬆。

在 Javascript 內建構更好的主迴圈

在前面的main loop中有兩個明顯的問題:main()函式影響 window 對象(當所有全域變數儲存時)並且上面的代碼沒有給我們留下一個停止循環的方法,除非是整個tab頁面被關閉或者刷新。第一個問題,如果你想要主循環只是跑起來而不需要去操作它,你可以創建一個立即執行的函數表達式如下(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 () {
  function main() {
    window.requestAnimationFrame( main );
    
    // Your main loop contents.
  }
  
  main(); // Start the cycle
})();

當瀏覽器運行到這裡,它會定義你的主循環並且立即把它放到執行隊列中,等待下一幀的到來。這個函數不會被添加到任何對象裏,所以main這個名字或者說這個函數在下面的代碼裏就依然有效,可以被定義來幹其他事情。

提示:從實現上說,用一個if語句來判斷 requestAnimationFrame() 是否執行是一個更加常見的做法而不是調用 cancelAnimationFrame()。

至於第二個問題,想要停止主循環,你需要停止調用main()通過window.cancelAnimationFrame()這個方法。你需要給 cancelAnimationFrame() 函數傳遞調用 requestAnimationFrame() 時返回的句柄。我們假設你的遊戲邏輯是寫在一個叫 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 () {
  function main() {
    MyGame.stopMain = window.requestAnimationFrame( main );
    
    // Your main loop contents.
  }
  
  main(); // Start the cycle
})();

我們現在有一個定義在 MyGame 模塊內的變量 stopMain,這個變量存儲着在主循環裏調用 requestAnimationFrame() 返回的句柄。這樣在其他任何時候,我們想要停止主循環,只需要這樣調用一下就好:

window.cancelAnimationFrame( MyGame.stopMain );

在 javascript 裏寫一個主循環的關鍵就是決定你的遊戲行為是由哪些事件驅動起來的,並且注意不同的系統之間的相互影響。

創建一個更加優化的主循環

最終,在 javascript 裏,瀏覽器跑它自己的邏輯循環,你的代碼也跑在它的循環裏。上面的章節講解的寫循環的方式是為了避免和瀏覽器的邏輯循環區分開。這些需要瀏覽器在每一幀調用的 main 方法,被添加到 window.requestAnimationFrame() 裏。至於怎麼把這些請求放進瀏覽器逻辑循环中取決於瀏覽器自身。W3C spec for requestAnimationFrame W3C對 requestAnimationFrame 的定義並沒有明確規定瀏覽器需要在何時去執行 requestAnimationFrame 的回調函數。這其實也有利於瀏覽器來做一些針對性的優化,在瀏覽器合適的時候去調用。

現代版本的 Firefox 和 Chrome 瀏覽器(也許其他的也是這樣)嘗試在他們邏輯循環一幀時間線的最開始地方去調用 requestAnimationFrame 的回調函數。瀏覽器的主線程就像下面這樣執行:

  1. 開始一個新幀(當前一幀已經變現完)。
  2. 檢查所有調用 requestAnimationFrame 的地方並執行。 
  3. requestAnimationFrame 的回调函数失去主線程的控制後,執行垃圾回收以及其他的一些需要在邏輯幀之前執行的任務。
  4. 休眠(直到被事件喚醒)直到顯示器準備垂直同步。

如果擁有足夠的時間來運行所有的邏輯,你可以考慮一下開發一個實施應用。上面所有的步驟會在16.5 毫秒內被執行,也就是 60Hz 的刷新速率。瀏覽器會盡可能早的調用你的代碼讓它獲得最大的運行時間。你的邏輯代碼可能會啟動甚至不在主線程中的任務(比如WebGL裏的光柵化和著色器程序)。當瀏覽器主線程在處理垃圾回收、其他任務或者處理異步事件時,負載高的計算可以同時運行在Web Worker或者GPU中。

當我們在討論運行時間時,許多瀏覽器提供一個叫做 High Resolution Time 的工具。Date 對象不再是一個可以被信賴的處理時間事件的方法了,因為它非常的不精確並且可能會被操作系統的時鐘修改。High Resolution Time 這個工具會計算從 navigationStart(當前面的document已經卸載了) 開始的毫秒數,這個值是一個精確到千分之一毫秒的小數,它就是 DOMHighResTimeStamp,無論為了用它幹什麼,請記得它是一個浮點數。

提示:系統(硬件或者軟件)如果不提供毫秒級精度也允許提供毫秒精度當作最小值。如果它能夠提供,那麼它應該提供 0.001 ms 精度。

This value is not too useful alone, since it is relative to a fairly uninteresting event, but it can be subtracted from another timestamp to accurately and precisely determine how much time elapsed between those two points. To acquire one of these timestamps, you can call window.performance.now() and store the result as a variable.

var tNow = window.performance.now();

Back to the topic of the main loop. You will often want to know when your main function was invoked. Because this is common, window.requestAnimationFrame() always provides a DOMHighResTimeStamp to callbacks as an argument when they are executed. This leads to another enhancement to our previous main loops.

/*
* 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 () {
  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
})();

Several other optimizations are possible and it really depends on what your game attempts to accomplish. Your game genre will obviously make a difference but it could even be more subtle than that. You could draw every pixel individually on a canvas or you could layer DOM elements (including multiple WebGL canvases with transparent backgrounds if you want) into a complex hierarchy. Each of these paths will lead to different opportunities and constraints.

It is decision... time

You will need to make hard decisions about your main loop: how to simulate the accurate progress of time. If you demand per-frame control then you will need to determine how frequently your game will update and draw. You might even want update and draw to occur at different rates. You will also need to consider how gracefully your game will fail if the user's system cannot keep up with the workload. Let us start by assuming that you will handle user input and update the game state every time you draw. We will branch out later.

Note: Changing how your main loop deals with time is a debugging nightmare, everywhere. Think about your needs, carefully, before working on your main loop.

What most browser games should look like

If your game can hit the maximum refresh rate of any hardware you support then your job is fairly easy. You can simply update, render, and then do nothing until 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 () {
  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
})();

If the maximum refresh rate cannot be reached, quality settings could be adjusted to stay under your time budget. The most famous example of this concept is the game from id Software, RAGE. This game removed control from the user in order to keep its calculation time at roughly 16ms (or roughly 60fps). If computation took too long then rendered resolution would decrease, textures and other assets would fail to load or draw, and so forth. This (non-web) case study made a few assumptions and tradeoffs:

  • Each frame of animation accounts for user input.
  • No frame needs to be extrapolated (guessed) because each draw has its own update.
  • Simulation systems can basically assume that each full update is ~16ms apart.
  • Giving the user control over quality settings would be a nightmare.
  • Different monitors input at different rates: 30 FPS, 75 FPS, 100 FPS, 120 FPS, 144 FPS, etc.
  • Systems that are unable to keep up with 60 FPS lose visual quality to keep the game running at optimal speed (eventually it outright fails, if quality becomes too low.)

Other ways to handle variable refresh rate needs

Other methods of tackling the problem exist.

One common technique is to update the simulation at a constant frequency and then draw as much (or as little) of the actual frames as possible. The update method can continue looping without care about what the user sees. The draw method can view the last update and when it happened. Since draw knows when it represents, and the simulation time for the last update, it can predict a plausible frame to draw for the user. It does not matter whether this is more frequent than the official update loop (or even less frequent). The update method sets checkpoints and, as frequently as the system allows, the render method draws instants of time around them. There are many ways to separate the update method in web standards:

  • Draw on requestAnimationFrame and update on a window.setInterval or window.setTimeout.
    • This uses processor time even when unfocused or minimized, hogs the main thread, and is probably an artifact of traditional game loops (but it is simple.)
  • Draw on requestAnimationFrame and update on a setInterval or setTimeout in a Web Worker.
    • This is the same as above, except update does not hog the main thread (nor does the main thread hog it). This is a more complex solution, and might be too much overhead for simple updates.
  • Draw on requestAnimationFrame and use it to poke a Web Worker containing the update method with the number of ticks to compute, if any.
    • This sleeps until requestAnimationFrame is called and does not pollute the main thread, plus you are not relying on old fashioned methods. Again, this is a bit more complex than the previous two options, and starting each update will be blocked until the browser decides to fire rAF callbacks.

Each of these methods have similar tradeoffs:

  • Users can skip rendering frames or interpolate extra ones depending on their performance.
  • You can count on all users updating non-cosmetic variables at the same constant frequency minus hiccups.
  • Much more complicated to program than the basic loops we saw earlier.
  • User input is completely ignored until the next update (even if the user has a fast device).
  • The mandatory interpolation has a performance penalty.

A separate update and draw method could look like the following example. For the sake of demonstration, the example is based on the third bullet point, just without using Web Workers for readability (and, let's be honest, writeability).

Note: This example, specifically, is in need of technical review.

/*
* 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 mainloop must run.
*                   It is just a generic example function that you might have added.
*/

;(function () {
  function main( tFrame ) {
    MyGame.stopMain = window.requestAnimationFrame( main );
    var nextTick = MyGame.lastTick + MyGame.tickLength;
    var 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) {
      var timeSinceTick = tFrame - MyGame.lastTick;
      numTicks = Math.floor( timeSinceTick / MyGame.tickLength );
    }

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

  function queueUpdates( numTicks ) {
    for(var i=0; i < numTicks; i++) {
      MyGame.lastTick = 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
})();

Another alternative is to simply do certain things less often. If a portion of your update loop is difficult to compute but insensitive to time, you might consider scaling back its frequency and, ideally, spreading it out into chunks throughout that lengthened period. An implicit example of this is found over at The Artillery Blog for Artillery Games, where they adjust their rate of garbage generation to optimize garbage collection. Obviously, cleaning up resources is not time sensitive (especially if tidying is more disruptive than the garbage itself).

This may also apply to some of your own tasks. Those are good candidates to throttle when available resources become a concern.

Summary

I want to be clear that any of the above, or none of them, could be best for your game. The correct decision entirely depends on the trade-offs that you are willing (and unwilling) to make. The concern is mostly with switching to another option. Fortunately, I do not have any experience with this but I have heard it is an excruciating game of Whack-a-Mole.

An important thing to remember for managed platforms, like the web, is that your loop may stop execution for significant periods of time. This could occur when the user unselects your tab and the browser sleeps (or slows) its requestAnimationFrame callback interval. You have many ways to deal with this situation and this could depend on whether your game is single player or multiplayer. Some choices are:

  • Consider the gap "a pause" and skip the time.
    • You can probably see how this is problematic for most multiplayer games.
  • You can simulate the gap to catch up.
    • This can be a problem for long drops and/or complex updates.
  • You can recover the game state from a peer or the server.
    • This is ineffective if your peers or server are out-of-date too, or they don't exist because the game is single player and doesn't have a server.

Once your main loop has been developed and you have decided on a set of assumptions and tradeoffs which suit your game, it is now just a matter of using your decisions to calculate any applicable physics, AI, sounds, network synchronization, and whatever else your game may require.

文件標籤與貢獻者

 此頁面的貢獻者: rangercyh, jackblackevo, RishYang, MashKao
 最近更新: rangercyh,