翻譯不完整。請協助 翻譯此英文文件。
本文是站在技術觀點,透過主迴圈的執行方式解析常見遊戲的作業流程。進而協助剛入門的遊戲開發者了解遊戲撰寫的必要條件,並說明如 JavaScript 等的 Web 標準是如何成為有效工具。即使是剛要開始接觸 Web 的遊戲開發老手也能獲得不錯的資訊。
呈現、接受、編譯、計算、重複
所有遊戲的最終目標,就是要為玩家呈現某一情境、接受玩家輸入、將輸入訊號編譯為動作、由這些動作計算新的情境。遊戲其實就是這些流程不斷重複的迴圈,直到發生某些終止條件 (如勝\敗或睡覺時間到了關機)。可別驚訝,這種形式與遊戲引擎的設計方式息息相關。
某些特殊條件又根據遊戲而有所不同。
某些遊戲是依照玩家的輸入訊號來進行此循環。試想你正開發「找出 2 張相似圖片的不同之處」類型的遊戲。這種遊戲就是為玩家呈現 2 張圖片、接受玩家點擊 (或觸碰)、編譯輸入訊號為成功\失敗\暫停\選單互動等、計算更新過的圖片呈現本次輸入的結果。遊戲迴圈可因玩家輸入進行更多動作,或直接休眠等玩家後續輸入;不同於「回合制」的遊戲迴圈。回合制只有在玩家反應時才會更新畫面,而不會持續更新各個幀像。
其他遊戲就會控制每一段儘可能最短的獨立時間片段 (Timeslice),並套用上述的相同原則。些微的差異在於:動畫的每一幅幀像均將帶動整個週期,且任何玩家輸入訊號中的變動,都將由第一個可用的回合抓取。這種「各幅幀像用一次」的模式,即建置於所謂的「主迴圈 (Main loop)」之中。如果你的遊戲迴圈是以時間為基礎,則 this 將是其權限,且你的模擬作業也將遵循此一原則。
但主迴圈可能不需「各幅幀像」的控制方式。你的遊戲迴圈可能類似上述「找出不同點」的範例,即以輸入事件為基礎。這種主迴圈就可能同時需要輸入與模擬時間,甚至是完全依賴其他事件的迴圈。
還好,現今的 JavaScript (即如下一段所敘述) 可輕鬆開發有效率、各幅幀像執行一次的主迴圈。當然,你的遊戲只有等你自己完成最佳化。如果某樣東西看起來應該附掛於更罕見的事件上,則最好將之置於主迴圈之外 (並非一定如此)。
於 JavaScript 中建構主迴圈
JavaScript 能與「事件 (Event)」以及「回呼 (Callback)」函式達到最佳效果。新款瀏覽器會立刻呼叫所需的函式,並趁機在間隔之間閒置 (或執行其他作業)。找到合適時機附加自己的程式碼是不錯的想法。試想,你可能真的必須依照嚴格的時間間隔、各幅幀像,或在特定事件發生之後,才能呼叫自己的函式。若搭配瀏覽器的限制就更嚴格了,而且呼叫函式的時機可讓瀏覽器進行最佳化。同樣的,如此可能讓你能更輕鬆作業。
某些程式碼必須順著各幅幀像執行,所以這類函式為何需要附掛至瀏覽器 redraw schedule 以外的地方呢?在 Web 上,
就可以作為大部分 well-programmed per-frame 主迴圈的基礎。一旦呼叫 it,就必須將回呼函式送入其內。而在下次重新繪製之前,會在合適的時間回呼此函式。下列為簡易主迴圈的範例:window.requestAnimationFrame()
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
There are two obvious issues with our previous main loop: main()
pollutes the
object (where all global variables are stored) and the example code did not leave us with a way to stop the loop unless the whole tab is closed or refreshed. For the first issue, if you want the main loop to just run and you do not need easy (direct) access to it, you could create it as an Immediately-Invoked Function Expression (IIFE).window
/* * 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 })();
When the browser comes across this IIFE, it will define your main loop and immediately queue it for the next frame. It will not be attached to any object and main
(or main()
for methods) will be a valid unused name in the rest of the application, free to be defined as something else.
Note: In practice, it is more common to prevent the next requestAnimationFrame() with an if-statement, rather than calling cancelAnimationFrame().
For the second issue, stopping the main loop, you will need to cancel the call to main()
with
. You will need to pass window.cancelAnimationFrame()
cancelAnimationFrame()
the ID token given by requestAnimationFrame()
when it was last called. Let us assume that your game's functions and variables are built on a namespace that you called MyGame
. Expanding our last example, the main loop would now look like:
/* * 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 })();
We now have a variable declared in our MyGame
namespace, which we call stopMain
, that contains the ID returned from our main loop's most recent call to requestAnimationFrame()
. At any point, we can stop the main loop by telling the browser to cancel the request that corresponds to our token.
window.cancelAnimationFrame( MyGame.stopMain );
The key to programming a main loop, in JavaScript, is to it attach to whatever event should be driving your action and pay attention to how the different systems involved interplay. You may have multiple components driven by multiple different types of events. This feels like unnecessary complexity but it might just be good optimization (not necessarily, of course). The problem is that you are not programming a typical main loop. In Javascript, you are using the browser's main loop and you are trying to do so effectively.
Building a more optimized main loop in JavaScript
Ultimately, in JavaScript, the browser is running its own main loop and your code exists in some of its stages. The above sections describe main loops which try not to wrestle away control from the browser. These main methods attach themselves to window.requestAnimationFrame()
, which asks the browser for control over the upcoming frame. It is up to the browser how to relate these requests to their main loop. The W3C spec for requestAnimationFrame does not really define exactly when the browsers must perform the requestAnimationFrame callbacks. This can be a benefit because it leaves browser vendors free to experiment with the solutions that they feel are best and tweak it over time.
Modern versions of Firefox and Google Chrome (and probably others) attempt to connect requestAnimationFrame
callbacks to their main thread at the very beginning of a frame's timeslice. The browser's main thread thus tries to look like the following:
- Start a new frame (while the previous frame is handled by the display).
- Go through the list of
requestAnimationFrame
callbacks and invoke them. - Perform garbage collection and other per-frame tasks when the above callbacks stop controlling the main thread.
- Sleep (unless an event interrupts the browser's nap) until the monitor is ready for your image (VSync) and repeat.
You can think about developing realtime applications as having a budget of time to do work. All of the above steps must take place every 16-and-a-half milliseconds to keep up with a 60 Hz display. Browsers invoke your code as early as possible to give it maximum computation time. Your main thread will often start workloads that are not even on the main thread (such as rasterization or shaders in WebGL). Long calculations can be performed on a Web Worker or a GPU at the same time as the browser uses its main thread to manage garbage collection, its other tasks, or handle asynchronous events.
While we are on the topic of budgeting time, many web browsers have a tool called High Resolution Time. The Date
object is no longer the recognised method for timing events because it is very imprecise and can be modified by the system clock. High Resolution Time, on the other hand, counts the number of milliseconds since navigationStart
(when the previous document is unloaded). This value is returned as a decimal number accurate to a thousandth of a millisecond. It is known as a
but, for all intents and purposes, consider it a floating point number.DOMHighResTimeStamp
Note: Systems (hardware or software) that are not capable of microsecond accuracy are allowed to provide millisecond accuracy as a minimum. They should provide 0.001ms accuracy if they are capable of it, however.
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 awindow.setInterval
orwindow.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 asetInterval
orsetTimeout
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.
- This sleeps until
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.