前言:
游戲循環(huán)(Game Loop)是做游戲時繞不開的一個話題。網(wǎng)上已經(jīng)有很多文章講解了如何在網(wǎng)頁中使用 JavaScript 實現(xiàn)游戲循環(huán),但基本上都只提到requestAnimationFrame就完事了。這只能用來做個 demo ,對一個完整的游戲來說是遠(yuǎn)遠(yuǎn)不夠的。正好之前畢設(shè)時查閱了相關(guān)資料,對比之下這篇文章講的最為全面。因此我翻譯了這篇文章,希望能給大家?guī)韼椭?br> 原文:A Detailed Explanation of JavaScript Game Loops and Timing
原文鏈接:http://isaacsukin.com/news/2015/01/detailed-explanation-javascript-game-loops-and-timing
在任何狀態(tài)隨時間改變的應(yīng)用中,主循環(huán)都是核心的部分。在游戲中,主循環(huán)一般被稱為游戲循環(huán),既要負(fù)責(zé)計算物理與 AI,也要把計算出來的畫面渲染在屏幕上。很不幸,在網(wǎng)上能找到的大多數(shù)主循環(huán)的實現(xiàn) —— 尤其是以 JavaScript 來編寫的 —— 都存在著一些計時上的問題。不瞞你說,我自己也寫過不少錯誤的實現(xiàn)。這篇文章旨在告訴你為什么這么多主循環(huán)需要被修正,以及如何實現(xiàn)一個正確的主循環(huán)。
如果你不想看講解,而只是想直接拿正確的代碼來用,可以使用我的開源庫 MainLoop.js 。
初次嘗試
我們即將來編寫一個“游戲”。簡單起見,只是來畫一個會左右來回移動的方塊。
<div id="box"></div>
讓它展示出來:
#box {
background-color: red;
height: 50px;
left: 150px;
position: absolute;
top: 10px;
width: 50px;
}
看上去還不錯。接下來我們來搭建 JavaScript 應(yīng)用的腳手架。首先,我們的方塊需要一些屬性來控制它的位置和速度。我們用一個 draw() 函數(shù)來渲染它的位置。
var box = document.getElementById('box'), // 方塊
boxPos = 10, // 方塊的位置
boxVelocity = 2, // 方塊的速度
limit = 300; // 方塊跑多遠(yuǎn)以后調(diào)頭
function draw() {
box.style.left = boxPos + 'px';
}
然后是游戲的邏輯。我們希望方塊來回移動,所以要給它加個速度。你很快就會發(fā)現(xiàn),從這里就慢慢開始出錯了。
function update() {
boxPos += boxVelocity;
// 如果跑過頭了,就讓方塊調(diào)頭
if (boxPos >= limit || boxPos <= 0) boxVelocity = -boxVelocity;
}
現(xiàn)在讓我們把游戲跑起來。為了跑這個游戲,我們需要一個循環(huán),不停地調(diào)用 update() 函數(shù)讓方塊移動,然后調(diào)用 draw() 函數(shù)把移動后的位置渲染在屏幕上。我們要怎么做到這一點呢?
如果你用其他語言寫過游戲,你或許會想到使用 while 循環(huán):
while (true) {
update();
draw();
}
然而,JavaScript 是單線程的,這意味著如果你這么寫,那么瀏覽器在這個頁面里就做不了其他任何事了。幾秒鐘后瀏覽器會卡住,然后告訴用戶出錯了,問是否要終止程序。你肯定不想你的游戲只能跑幾秒鐘,所以這么搞肯定不行。我們需要一種方法,讓游戲循環(huán)把控制權(quán)移交給瀏覽器,直到瀏覽器準(zhǔn)備好再次執(zhí)行我們的工作。
如果你熟悉 JavaScript,你也許會想到 setTimeout() 或是 setInterval()。這兩個方法允許你在指定時間之后繼續(xù)執(zhí)行代碼。這看上去行得通,不過在瀏覽器中我們有更好的做法:requestAnimationFrame()。這是個較新的函數(shù)但已經(jīng)得到了良好的瀏覽器支持(在本文寫作時的 2015 年,除 IE9 及以下的瀏覽器都支持它)。你向這個函數(shù)傳遞一個回調(diào),它會在瀏覽器下次準(zhǔn)備執(zhí)行渲染時執(zhí)行這個回調(diào)。在一臺 60Hz 顯示器上,一個優(yōu)化良好的應(yīng)用每秒可以更新畫面(繪制幀)60 次。所以,為了達(dá)到最佳的 60 幀幀率,你的主循環(huán)有 1 / 60 = 16.667 毫秒的時間做完一次循環(huán)的工作。在后文中我們會對 node.js/io.js 和低版本瀏覽器做兼容。
記住大多數(shù)顯示器每秒不能展示大于 60 幀(FPS)。人類是否能夠區(qū)分出高分辨率的區(qū)別要取決于應(yīng)用類型,不過可以作為參考的是,電影一般是 24FPS,其他視頻 30FPS,大多數(shù)游戲 30FPS 以上都可以接受,虛擬現(xiàn)實也許需要 75FPS 才能感覺自然。有些游戲顯示器最高能到 144FPS。
好了,讓我們用 requestAnimationFrame() 來實現(xiàn)主循環(huán)!
function mainLoop() {
update();
draw();
requestAnimationFrame(mainLoop);
}
// 入口點
requestAnimationFrame(mainLoop);

注意 draw() 方法是在 update() 方法之后調(diào)用的,這是因為我們希望能盡可能渲染出應(yīng)用最新的狀態(tài)。(注:有些基于 canvas 的應(yīng)用需要在首幀,即任何更新都沒有發(fā)生之前,渲染出應(yīng)用的的初始狀態(tài)。我們會在后文中探討一種實現(xiàn)方式。)網(wǎng)上有些文章猜測在 requestAnimationFrame 的回調(diào)中先渲染再做邏輯會使屏幕繪制更快,但實際上并非如此。即使是,那也只是在繪制當(dāng)前幀更快和下一幀更快之間做一個權(quán)衡。當(dāng) requestAnimationFrame 下一次調(diào)用時就無所謂了,畢竟一次只能更新一幀,但我覺得這樣在邏輯上是更順暢的。
計時問題
目前為止,我們的 update() 方法有個問題,便是它依賴于幀率。換句話說,如果你的游戲跑的慢(即每秒內(nèi)能夠執(zhí)行的幀數(shù)較少),那么方塊移動的也越慢;而如果你的游戲跑的快(每秒內(nèi)能夠執(zhí)行的幀數(shù)較多),那么方塊移動的也越快。我們不希望出現(xiàn)如此不可預(yù)測的行為,尤其是在多人游戲中。沒人會希望他們的游戲角色行動遲緩,只因他們電腦的配置沒那么好。即使是在單機(jī)游戲中,游戲速度也會顯著影響難度。在考驗反應(yīng)的游戲里,游戲速度越低就會更簡單,而速度越高就會難,甚至沒法玩下去。
針對這個問題,我們來加入一個控制 FPS 的能力。我們可以利用 requestAnimationFrame() 函數(shù)的能力,為回調(diào)提供一個時間戳。每次循環(huán)執(zhí)行時,我們確認(rèn)下是否達(dá)到了一段最小時間。如果是,我們就渲染這一幀,如果不是,我們就跳過這次循環(huán),等待下一幀。
var lastFrameTimeMs = 0, // 上一輪循環(huán)運行的時間
maxFPS = 10; // 我們想要限制的最大 FPS
function mainLoop(timestamp) {
// 控制幀率
if (timestamp < lastFrameTimeMs + (1000 / maxFPS)) {
requestAnimationFrame(mainLoop);
return;
}
lastFrameTimeMs = timestamp;
// ...
}

我們可以做的更好。這個問題在于我們的應(yīng)用并不和現(xiàn)實時間掛鉤,該怎么去修正呢?
首先讓我們嘗試用速度乘以兩幀之間的時間差(delta)。我們把 boxPos += boxVelocity 替換成 boxPos += boxVelocity * delta。修改 update 方法,從主循環(huán)中接收 delta 這個參數(shù):
// 調(diào)整速度,使它不與 FPS 掛鉤
var boxVelocity = 0.08,
delta = 0;
function update(delta) { // 增加 delta 參數(shù)
boxPos += boxVelocity * delta; // 速度現(xiàn)在與時間相關(guān)
// ...
}
function mainLoop(timestamp) {
// ...
delta = timestamp - lastFrameTimeMs; // 獲取當(dāng)前時間與上一幀的時間差 delta
lastFrameTimeMs = timestamp;
update(delta); // 傳入 delta 參數(shù)
// ...
}
結(jié)果相當(dāng)不錯!現(xiàn)在我們的方塊看上去不受幀率影響,隨時間移動恒定的距離。

如果它的表現(xiàn)與你預(yù)期不符……好吧,接著看。
物理問題
嘗試把速度值上調(diào),例如 0.8 。你會注意到有幾秒種這個方塊運動得很不平穩(wěn),也許會從可視范圍里跑出去。這是不應(yīng)該出現(xiàn)的。是哪里有問題呢?
問題在于,之前方塊每幀運動相同的距離,而現(xiàn)在我們加入了 delta 以后,每幀運動的距離都有所不同,而這些距離有時會相當(dāng)大。在游戲中,這一現(xiàn)象可能會讓玩家穿墻,或是阻礙他們跳過障礙物。
另一個問題是,因為方塊每幀移動的距離不同,在計算過程中一些小的四舍五入的誤差,會隨著時間推移而累積,造成方塊位置的漂移。沒有兩個人可以玩到相同的游戲,因為他們的幀率不同,造成的誤差也不同。這聽起來挺微不足道的,但是實踐中,哪怕是在正常幀率下,也只要幾秒鐘就能讓誤差變得可被玩家感知。這不僅對玩家不好,也對測試不好,因為我們希望給定相同的輸入時,程序也能給出相同的輸出。換句話說,我們希望程序是具有確定性 的。
一種解決方案
解決這一物理問題的關(guān)鍵點在于我們想要兩全其美:既要在每次執(zhí)行 update 方法時模擬相等的游戲時間,又要模擬兩幀之間并不每次都相等的現(xiàn)實時間。事實證明我們可以做到,只需在兩幀之間以固定大小的 delta 值多次運行 update 方法,直到我們模擬完整段現(xiàn)實時間。讓我們稍稍調(diào)整下主循環(huán):
// 每次調(diào)用 update 時只模擬 1000 ms / 60 FPS = 16.667 ms 的時間
var timestep = 1000 / 60;
function mainLoop(timestamp) {
// ...
// 計算我們累積下來的還沒有被模擬過的時間
delta += timestamp - lastFrameTimeMs; // 注意這里是 +=
lastFrameTimeMs = timestamp;
// 以固定大小的步長模擬整段 delta 時間
while (delta >= timestep) {
update(timestep);
delta -= timestep;
}
draw();
requestAnimationFrame(mainLoop);
}
我們把兩幀之間的現(xiàn)實時間分隔成了小段,多次傳遞給 update() 方法。update() 方法本身無需修改,只要改變傳遞給它的參數(shù),這樣每次 update() 時都會模擬相等的游戲時間。update() 方法會在一幀之內(nèi)調(diào)用多次以模擬完這一幀距離上一幀經(jīng)過的真實時間。(如果這一幀距離上一幀經(jīng)過的時間比單次 update 模擬的時間還要小,我們在這一幀就不調(diào)用 update() 方法了。如果有剩余未被模擬的時間,我們就把它累積起來放到下一幀去模擬。這就是我們需要通過 += 計算 delta,而不是直接賦值給它的原因,因為我們需要記錄上一幀剩余下來沒被模擬的時間。)這種方式避免了四舍五入不一致導(dǎo)致的誤差錯誤,也保證了兩幀之間不會出現(xiàn)大到能穿墻的巨大跨越。

如果你調(diào)整 boxVelocity 的值,你會看到方塊會正確地呆在它應(yīng)該在的地方。不錯!
注意: timestep 值的選擇并不是任意的。分母有效地限制了用戶能夠感知的每秒幀數(shù)(除非繪制是插值的,如下所述)。當(dāng)實際 FPS 低于最大值時,降低 timestep 會增加可感知到的最大 FPS,但代價是每一幀執(zhí)行 update() 的次數(shù)會更多。由于執(zhí)行 update() 越多,消耗的時間也越多,這就又會降低幀率。如果幀率降的太多,就可能會陷入一種死亡螺旋。
死亡螺旋
遺憾的是我們又引入了一個新問題。在我們的初次嘗試中,如果一幀花費了比較長的時間執(zhí)行更新和渲染,幀率就會自然地降低,直到每一幀花費的時間足夠更新和渲染完成。然而,現(xiàn)在我們的更新依賴于時間。如果某一幀花費了較長時間來模擬,那么下一幀會需要模擬更長的時間。這意味著我們需要更多次執(zhí)行 update() 方法,而這進(jìn)一步意味著這新的一幀需要花費更多的時間來模擬,如此往復(fù)……直到整個應(yīng)用無法響應(yīng)然后掛掉。這就是死亡螺旋。
一般來說,只要我們把 timestep 的值設(shè)的足夠高,每次執(zhí)行 update 的開銷都比它模擬的時間要短,這樣就不會有問題。但執(zhí)行開銷在不同的硬件和負(fù)載上都是不一樣的。而且我們討論的是 JavaScript 環(huán)境,意味著我們對執(zhí)行環(huán)境只有很微小的控制權(quán)。如果用戶切換到另一標(biāo)簽頁,瀏覽器就會停止當(dāng)前標(biāo)簽頁的渲染,當(dāng)用戶再切回來時,我們就累積了很長一段時間要去做模擬。如果這消耗了太多時間,瀏覽器就會掛起。
合理性檢查
我們需要一個逃生通道。讓我們在 update 的循環(huán)中加入一個合理性檢查:
var numUpdateSteps = 0;
while (delta >= timestep) {
update(timestep);
delta -= timestep;
// Sanity check
if (++numUpdateSteps >= 240) {
panic(); // 出現(xiàn)了異常情況,需要做修復(fù)
break; // 跳出循環(huán)
}
}
在我們的 panic 方法中要做些什么呢?這得看情況。
回合制多人游戲用了一種叫“鎖步”的網(wǎng)絡(luò)技術(shù),它可以保證所有的玩家以相同步調(diào)進(jìn)行游戲。這意味著所有玩家體驗到的都是最慢的那個人的速度。如果一個玩家實在落后太多,他就會掉線,這樣就不會拖慢其他玩家。因此,在鎖步的游戲中,這個玩家就掉線了。
在一個沒有使用鎖步的多人游戲,比如第一人稱射擊游戲中,一般都會有一個服務(wù)器維持著游戲的“權(quán)威狀態(tài)”。也就是說,服務(wù)器會接收所有玩家的輸入,并計算出游戲世界應(yīng)有的樣子,以避免玩家作弊。如果一個玩家的本地游戲距離權(quán)威狀態(tài)太遠(yuǎn),即 panic 的狀態(tài),那么這個玩家需要被拉回權(quán)威狀態(tài)。(在實踐中,如果直接把玩家拉回去會令人很迷惑,所以一般會有一個替代的 update 方法,讓客戶端能夠更加平滑地回到服務(wù)端的權(quán)威狀態(tài)。)一旦我們把用戶拉回了最新的狀態(tài),我們就不需要再去本地模擬這一堆剩下的時間了,因為我們已經(jīng)把這些時間高效快進(jìn)掉了。因此我們可以把它們丟棄掉:
function panic() {
delta = 0; // 丟棄未模擬的時間
// ... 把玩家同步到權(quán)威狀態(tài)
}
如果在服務(wù)端這么干,會引起不確定性的行為,不過只有服務(wù)端需要保證游戲運行的確定性,所以在多人游戲的客戶端這么做是完全可以的。
在單機(jī)游戲中,我們可以讓游戲繼續(xù)運行一會看看游戲運行速度能不能趕上來。但當(dāng)游戲在中間狀態(tài)過渡時,游戲會看上去在幾幀之內(nèi)運行得飛快。另一種可以接受的方法是,直接丟棄未模擬的時間,就像我們前面在非鎖步的多人游戲中做的那樣。這會引入不確定性的行為,不過你可能覺得這是個極端情況,可以另當(dāng)別論。
無論如何,如果游戲持續(xù)出現(xiàn) panic 的情況,而且也不是因為標(biāo)簽頁在后臺引起的,這可能提示了游戲的主循環(huán)運行得實在太慢了。也許你需要增大 timestep 的值。
FPS 控制
另一種可以避免死亡螺旋(總的來說,避免低幀率)的方法是監(jiān)控游戲的運行幀率,并在幀率過低時,調(diào)整主循環(huán)中的行為。往往在 panic 狀態(tài)發(fā)生前,我們就能檢測到掉幀的情況,可以由此來預(yù)防 panic 。
有許多監(jiān)測幀率的方式,其一是在一段時間內(nèi)(比如最近 10 秒)持續(xù)監(jiān)測每秒渲染的幀數(shù),并取平均值。然而這稍微有點吃性能,而且我們希望最近幾秒鐘的幀數(shù)能獲得更高的權(quán)重。一種簡單做法是,使用加權(quán)平均來計算權(quán)重:
var fps = 60,
framesThisSecond = 0,
lastFpsUpdate = 0;
function mainLoop(timestamp) {
// ...
if (timestamp > lastFpsUpdate + 1000) { // 每秒更新一次
fps = 0.25 * framesThisSecond + (1 - 0.25) * fps; // 計算新 FPS
lastFpsUpdate = timestamp;
framesThisSecond = 0;
}
framesThisSecond++;
// ...
}
這里的 0.25 是衰減系數(shù) - 這本質(zhì)上體現(xiàn)了最近幾秒鐘的權(quán)重有多大。
fps 變量保存了我們估算出來的 FPS。我們該拿它做什么用呢?首先可以想到的是把它顯示出來:
// 假設(shè)我們在 HTML 中增加了 <div id="fpsDisplay"></div> 這個元素
var fpsDisplay = document.getElementById('fpsDisplay');
function draw() {
box.style.left = boxPos + 'px';
fpsDisplay.textContent = Math.round(fps) + ' FPS'; // 展示 FPS
}

我們可以把 FPS 派上更多的用場。如果 FPS 太低了,我們可以退出游戲,降低畫質(zhì),停止或減少主循環(huán)之外的行為,例如事件處理器、音頻播放;降低非關(guān)鍵更新的頻率,或增加 timestep。(注意這是最不得已的情況,因為這會導(dǎo)致每次 update() 調(diào)用模擬更多的時間,進(jìn)而使程序有更多的不確定性,因此應(yīng)謹(jǐn)慎使用。)如果 FPS 回升了,就恢復(fù)這些行為。
開始與結(jié)束
在主循環(huán)開始和結(jié)束時分別調(diào)用一個回調(diào)方法(讓我們叫它們 begin() 和 end() 方法)來做初始化和清理工作是很有用的。一般來說,begin() 可以用于在 update 執(zhí)行前處理輸入(例如當(dāng)玩家按下開火鍵時刷出子彈)。如果需要對用戶輸入執(zhí)行長時間運行的操作,那么在主循環(huán)中分塊處理這些操作,而不是在事件處理程序中一次處理這些操作,可以避免幀的延遲。而 end() 方法則可以增量地執(zhí)行不受時間影響的、需要較長運行時間的更新,以及根據(jù) FPS 的變化作調(diào)整。
選擇 timestep
一般來說,1000/60 在大多數(shù)情況是個好選擇,因為大多數(shù)顯示器以 60Hz 運行,如果你發(fā)現(xiàn)你的程序十分吃性能,也許你可以將其設(shè)為 1000/30。這有效地限制了你的可感知幀率為 30FPS(除非使用了插值,如后文所述)。注意幀率會根據(jù)你的顯示器和顯卡驅(qū)動進(jìn)行調(diào)節(jié),因此你設(shè)置的最大值可能與實際運行時測得的值不相同。如果你的游戲運行流暢,且你希望模擬得更加精確,你可以考慮使用高端游戲顯示屏的 FPS,如 75、90、120 和 144。再高的話最終運行速度可能反而就會變慢了。
性能考量
如果程序的性能并不盡如人意,你可以使用插值繪制和 Web Worker 這兩種重構(gòu)方式來獲得實實在在的性能提升。
插值繪制
在每次 update 結(jié)束之后,在 delta 中經(jīng)常會有一段小于一整個 timestep 的剩余時間。將這段尚未被模擬的剩余時間所占 timestep 的百分比傳入 draw() 方法就可以在兩幀之間做一個插值。即使在高幀率下,這種視覺上的平滑效果也有助于降低畫面的卡頓。
卡頓之所以會出現(xiàn),是因為 update() 方法模擬的時間與兩個 draw() 方法經(jīng)過的時間往往是不同的。進(jìn)一步說,假如 update() 發(fā)生在下面第一行的每條豎線所代表的時間點,而 draw() 發(fā)生在下面第二行的每條豎線所代表的時間點,那么在 draw() 方法發(fā)生渲染的時間點,總會有一些剩余時間還沒有被 update() 方法所模擬:
update() timesteps: | | | | | | | | |
draw() calls: | | | | | | |
為了使 draw() 對方塊移動做插值以進(jìn)行渲染,必須保留上次 update() 之后對象的狀態(tài),并將其用于計算中間狀態(tài)。注意這意味著渲染最多落后一次 update() 。這仍然比外推(推測對象在下一次 update() 之后的狀態(tài))要好,因為后者可能會產(chǎn)生奇怪的結(jié)果。要注意存儲多個狀態(tài)實現(xiàn)起來比較困難,而且這個過程也是耗時操作,可能會導(dǎo)致幀率下降。因此除非你觀察到了卡頓現(xiàn)象,否則這么做很可能是不值得的。
我們可以這樣對我們的方塊進(jìn)行插值:
var boxLastPos = 10;
function update(delta) {
boxLastPos = boxPos; // 保存上一次 update 時方塊的位置
boxPos += boxVelocity * delta;
// ...
}
function draw(interp) {
box.style.left = (boxLastPos + (boxPos - boxLastPos) * interp) + 'px'; // 進(jìn)行插值
// ...
}
function mainLoop(timestamp) {
// ...
draw(delta / timestep); // 傳入插值的百分比

使用 Web Worker 來更新
與主循環(huán)中的任何事物一樣,update() 方法的執(zhí)行時間直接影響了幀率。如果 update() 方法花費的時間足夠長,以至于幀率低于預(yù)期,那么我們可以將 update() 方法中無需在每幀之間執(zhí)行的部分放入 Web Worker 中。(網(wǎng)上的很多地方有時會建議使用 setTimeout() 或 setInterval() 來進(jìn)行調(diào)度。這些方法只需對現(xiàn)有的代碼進(jìn)行較小的改動,但由于 JavaScript 是單線程的,這些改動仍然會阻止渲染并降低幀率。使用 Web Worker 需要做更大的改動,但它們在單獨的線程中執(zhí)行,故可以為主循環(huán)釋放出更多時間。)
這里列舉了部分在使用 Web Worker 時需考慮的內(nèi)容:
- 在遷移到 Web Worker 前分析你的代碼。也許渲染過程才是瓶頸,此時你首先應(yīng)當(dāng)考慮的是降低場景的視覺復(fù)雜度。
- 將
update()中的所有內(nèi)容都遷移到 Web Worker 中是不可取的,除非你的draw()方法能夠像我們之前討論的那樣進(jìn)行插值。最容易移出update()的是后臺更新(比如在城鎮(zhèn)建造游戲中計算市民的幸福指數(shù))、不影響場景的物理效果(比如風(fēng)中飄動的旗幟)和任何被遮擋或是離場景很遠(yuǎn)的事物。 - 如果
draw()方法需要基于 Web Worker 中的行為對物理做插值,那么 Web Worker 需要把插值結(jié)果傳回主線程,使其在draw()方法中可用。 - Web Worker 不能訪問主線程中的狀態(tài),因此它們不能直接修改你場景中的物體。與 Web Worker 之間傳遞數(shù)據(jù)是一個痛點。最簡單的辦法是使用 Transferable Objects:你可以傳遞一個 ArrayBuffer 給 Web Worker,并銷毀原始引用。
你可以在 HTML5 Rocks 中了解更多有關(guān)于 Web Worker 和 Transferable Objects 的信息。
啟動與停止
目前,一旦我們的游戲啟動了主循環(huán),我們是沒有任何方法停止的。讓我們引入 start() 和 stop() 函數(shù)來管理游戲的運行。首先我們要找到停止 requestAnimationFrame 的方法。一種方式是維持一個 running 的布爾變量來控制主循環(huán)下一次是否要繼續(xù)調(diào)用 requestAnimationFrame。一般來說沒啥問題,不過如果游戲開始后立馬停止,那么無論如何都有一幀會被運行,無法取消。因此我們需要一個能夠真正取消渲染循環(huán)的方法。幸運的是我們有 cancelAnimationFrame() 方法。當(dāng)調(diào)用 requestAnimationFrame 時會返回一個 frame ID,可以傳遞給cancelAnimationFrame() 方法。
frameID = requestAnimationFrame(mainLoop);
切記如果你做了 FPS 控制,別忘了在 FPS 控制的節(jié)流條件中也做下改動,記錄 frame ID。
現(xiàn)在我們來實現(xiàn) stop() 方法:
var running = false,
started = false;
function stop() {
running = false;
started = false;
cancelAnimationFrame(frameID);
}
此外,當(dāng)主循環(huán)暫停時,也要暫停事件處理和其他的后臺任務(wù)(比如通過 setInterval() 執(zhí)行的任務(wù)或是 Web Worker 中的任務(wù))。這通常不難,因為它們只要檢查下 running 變量就可以決定自身是否要執(zhí)行了。另一個需要注意的點是,在多人游戲中暫停會導(dǎo)致該玩家的客戶端失去同步,因此一般要讓他們退出游戲,或是在主循環(huán)再次啟動時把玩家拉到最新的位置(在確認(rèn)玩家是否真的想要暫停之后)。
開始游戲循環(huán)會更為棘手一些。我們需要關(guān)注以下四點。首先,如果主循環(huán)已經(jīng)在運行,我們不能允許它再次運行,否則會導(dǎo)致同時請求多幀渲染,降低運行速度。其次,我們需要確??焖俚厍袚Q游戲的開始和停止不會造成錯誤。再次,我們需要在游戲尚未發(fā)生任何更新時渲染出游戲的初始狀態(tài),因為我們主循環(huán)的 draw 是在 update 之后調(diào)用的。最后,當(dāng)游戲暫停時,我們需要重置一些變量的值,以防暫停時我們記錄的需要模擬的時間仍然在流逝。另外,在游戲重新啟動后,事件處理和后臺任務(wù)也要恢復(fù)運行。這是我們的代碼:
function start() {
if (!started) { // 防止多次啟動
started = true;
// 第一幀來獲取時間戳,并繪制初始畫面.
// 記錄 frame ID 用于停止
frameID = requestAnimationFrame(function(timestamp) {
draw(1); // 首次渲染
running = true;
// 重置一些記錄時間相關(guān)的變量
lastFrameTimeMs = timestamp;
lastFpsUpdate = timestamp;
framesThisSecond = 0;
// 真正啟動主循環(huán)
frameID = requestAnimationFrame(mainLoop);
});
}
}

Node.js/IO.js 與 IE9 支持
現(xiàn)在我們代碼的主要問題,是 requestAnimationFrame() 和 cancelAnimationFrame() 缺乏對 node.js/IO.js 環(huán)境,和 IE9 以及更早的瀏覽器的兼容(如果你還關(guān)心那些瀏覽器的話)。我們可以利用 timer 做一個 polyfill:
// 代碼來自于 MIT 協(xié)議的 https://github.com/underscorediscovery/realtime-multiplayer-in-html5
var requestAnimationFrame = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (function() {
var lastTimestamp = Date.now(),
now,
timeout;
return function(callback) {
now = Date.now();
timeout = Math.max(0, timestep - (now - lastTimestamp));
lastTimestamp = now + timeout;
return setTimeout(function() {
callback(now + timeout);
}, timeout);
};
})(),
cancelAnimationFrame = typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame : clearTimeout;
我們使用了 Date.now() 以減少兼容性問題,但這在 IE8 中是不支持的。如果你真的需要支持 IE8,可以使用 +new Date() 代替,但這會產(chǎn)生一堆臨時對象,增加垃圾回收的負(fù)載,進(jìn)而導(dǎo)致你的游戲卡頓。此外 IE8 本身也很慢,很難支持有較多 JavaScript 邏輯的應(yīng)用運行。
總結(jié)
我們需要考慮的東西很多。如果你想簡單地實現(xiàn)游戲循環(huán),只要用我的 MainLoop.js 開源庫就可以了。這樣你就不用擔(dān)心上面這么多的問題。
如果你自己動手,那么還可以做一些通用性的代碼優(yōu)化。例如,可以把小方塊封裝在自己單獨的類中。整個腳本應(yīng)該被包裹在一個 IIFE(立即執(zhí)行函數(shù)) 中,僅暴露接口給外部,以防止污染瀏覽器的全局命名空間,或是把代碼打包成 CommonJS 或 AMD 的模塊。MainLoop.js 已經(jīng)把這些都做好了(甚至做得更好),不過總而言之我們已經(jīng)做得相當(dāng)不錯了。
最后,我要感謝 Glenn Fiedler 編寫了經(jīng)典的 Fix your timestep! 一文,它是本文中如此多工作的起點。也感謝 Ian Langworth 為我審閱了我在關(guān)于制作 3D 頁游的書中包含的本文的簡略版本,并提出了 Web Worker 相關(guān)的一些建議。最后的最后,如果你仍然想尋找關(guān)于這個話題的更多資源,也許可以考慮看下 Game Programming Patterns 這本書,或者 MDN 上一篇相對簡略的文章。