前言:
游戲循環(huán)(Game Loop)是做游戲時繞不開的一個話題玫膀。網(wǎng)上已經(jīng)有很多文章講解了如何在網(wǎng)頁中使用 JavaScript 實(shí)現(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é)計(jì)算物理與 AI,也要把計(jì)算出來的畫面渲染在屏幕上赞庶。很不幸训挡,在網(wǎng)上能找到的大多數(shù)主循環(huán)的實(shí)現(xiàn) —— 尤其是以 JavaScript 來編寫的 —— 都存在著一些計(jì)時上的問題。不瞞你說歧强,我自己也寫過不少錯誤的實(shí)現(xiàn)澜薄。這篇文章旨在告訴你為什么這么多主循環(huán)需要被修正,以及如何實(shí)現(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ù)把移動后的位置渲染在屏幕上傲须。我們要怎么做到這一點(diǎn)呢?
如果你用其他語言寫過游戲趟脂,你或許會想到使用 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)實(shí)也許需要 75FPS 才能感覺自然力九。有些游戲顯示器最高能到 144FPS耍铜。
好了,讓我們用 requestAnimationFrame()
來實(shí)現(xiàn)主循環(huán)跌前!
function mainLoop() {
update();
draw();
requestAnimationFrame(mainLoop);
}
// 入口點(diǎn)
requestAnimationFrame(mainLoop);
注意 draw()
方法是在 update()
方法之后調(diào)用的,這是因?yàn)槲覀兿M鼙M可能渲染出應(yīng)用最新的狀態(tài)抵乓。(注:有些基于 canvas 的應(yīng)用需要在首幀伴挚,即任何更新都沒有發(fā)生之前,渲染出應(yīng)用的的初始狀態(tài)臂寝。我們會在后文中探討一種實(shí)現(xiàn)方式章鲤。)網(wǎng)上有些文章猜測在 requestAnimationFrame
的回調(diào)中先渲染再做邏輯會使屏幕繪制更快,但實(shí)際上并非如此咆贬。即使是败徊,那也只是在繪制當(dāng)前幀更快和下一幀更快之間做一個權(quán)衡。當(dāng) requestAnimationFrame
下一次調(diào)用時就無所謂了掏缎,畢竟一次只能更新一幀皱蹦,但我覺得這樣在邏輯上是更順暢的。
計(jì)時問題
目前為止眷蜈,我們的 update()
方法有個問題沪哺,便是它依賴于幀率。換句話說酌儒,如果你的游戲跑的慢(即每秒內(nèi)能夠執(zhí)行的幀數(shù)較少)辜妓,那么方塊移動的也越慢;而如果你的游戲跑的快(每秒內(nèi)能夠執(zhí)行的幀數(shù)較多)忌怎,那么方塊移動的也越快籍滴。我們不希望出現(xiàn)如此不可預(yù)測的行為,尤其是在多人游戲中榴啸。沒人會希望他們的游戲角色行動遲緩孽惰,只因他們電腦的配置沒那么好。即使是在單機(jī)游戲中鸥印,游戲速度也會顯著影響難度勋功。在考驗(yàn)反應(yīng)的游戲里坦报,游戲速度越低就會更簡單,而速度越高就會難狂鞋,甚至沒法玩下去片择。
針對這個問題,我們來加入一個控制 FPS 的能力要销。我們可以利用 requestAnimationFrame()
函數(shù)的能力构回,為回調(diào)提供一個時間戳夏块。每次循環(huán)執(zhí)行時疏咐,我們確認(rèn)下是否達(dá)到了一段最小時間。如果是脐供,我們就渲染這一幀浑塞,如果不是,我們就跳過這次循環(huán)政己,等待下一幀酌壕。
var lastFrameTimeMs = 0, // 上一輪循環(huán)運(yùn)行的時間
maxFPS = 10; // 我們想要限制的最大 FPS
function mainLoop(timestamp) {
// 控制幀率
if (timestamp < lastFrameTimeMs + (1000 / maxFPS)) {
requestAnimationFrame(mainLoop);
return;
}
lastFrameTimeMs = timestamp;
// ...
}
我們可以做的更好。這個問題在于我們的應(yīng)用并不和現(xiàn)實(shí)時間掛鉤歇由,該怎么去修正呢卵牍?
首先讓我們嘗試用速度乘以兩幀之間的時間差(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 千劈。你會注意到有幾秒種這個方塊運(yùn)動得很不平穩(wěn)祭刚,也許會從可視范圍里跑出去。這是不應(yīng)該出現(xiàn)的墙牌。是哪里有問題呢涡驮?
問題在于,之前方塊每幀運(yùn)動相同的距離憔古,而現(xiàn)在我們加入了 delta 以后遮怜,每幀運(yùn)動的距離都有所不同,而這些距離有時會相當(dāng)大鸿市。在游戲中锯梁,這一現(xiàn)象可能會讓玩家穿墻即碗,或是阻礙他們跳過障礙物。
另一個問題是陌凳,因?yàn)榉綁K每幀移動的距離不同剥懒,在計(jì)算過程中一些小的四舍五入的誤差,會隨著時間推移而累積合敦,造成方塊位置的漂移初橘。沒有兩個人可以玩到相同的游戲,因?yàn)樗麄兊膸什煌涞海斐傻恼`差也不同蹄咖。這聽起來挺微不足道的,但是實(shí)踐中歧杏,哪怕是在正常幀率下逻澳,也只要幾秒鐘就能讓誤差變得可被玩家感知。這不僅對玩家不好蒜魄,也對測試不好扔亥,因?yàn)槲覀兿Mo定相同的輸入時,程序也能給出相同的輸出谈为。換句話說旅挤,我們希望程序是具有確定性 的。
一種解決方案
解決這一物理問題的關(guān)鍵點(diǎn)在于我們想要兩全其美:既要在每次執(zhí)行 update
方法時模擬相等的游戲時間伞鲫,又要模擬兩幀之間并不每次都相等的現(xiàn)實(shí)時間粘茄。事實(shí)證明我們可以做到,只需在兩幀之間以固定大小的 delta 值多次運(yùn)行 update
方法榔昔,直到我們模擬完整段現(xiàn)實(shí)時間驹闰。讓我們稍稍調(diào)整下主循環(huán):
// 每次調(diào)用 update 時只模擬 1000 ms / 60 FPS = 16.667 ms 的時間
var timestep = 1000 / 60;
function mainLoop(timestamp) {
// ...
// 計(jì)算我們累積下來的還沒有被模擬過的時間
delta += timestamp - lastFrameTimeMs; // 注意這里是 +=
lastFrameTimeMs = timestamp;
// 以固定大小的步長模擬整段 delta 時間
while (delta >= timestep) {
update(timestep);
delta -= timestep;
}
draw();
requestAnimationFrame(mainLoop);
}
我們把兩幀之間的現(xiàn)實(shí)時間分隔成了小段,多次傳遞給 update()
方法撒会。update()
方法本身無需修改嘹朗,只要改變傳遞給它的參數(shù),這樣每次 update()
時都會模擬相等的游戲時間诵肛。update()
方法會在一幀之內(nèi)調(diào)用多次以模擬完這一幀距離上一幀經(jīng)過的真實(shí)時間屹培。(如果這一幀距離上一幀經(jīng)過的時間比單次 update
模擬的時間還要小,我們在這一幀就不調(diào)用 update()
方法了怔檩。如果有剩余未被模擬的時間褪秀,我們就把它累積起來放到下一幀去模擬。這就是我們需要通過 +=
計(jì)算 delta
薛训,而不是直接賦值給它的原因媒吗,因?yàn)槲覀冃枰涗浬弦粠S嘞聛頉]被模擬的時間。)這種方式避免了四舍五入不一致導(dǎo)致的誤差錯誤乙埃,也保證了兩幀之間不會出現(xiàn)大到能穿墻的巨大跨越闸英。
如果你調(diào)整 boxVelocity
的值锯岖,你會看到方塊會正確地呆在它應(yīng)該在的地方。不錯甫何!
注意: timestep 值的選擇并不是任意的出吹。分母有效地限制了用戶能夠感知的每秒幀數(shù)(除非繪制是插值的,如下所述)辙喂。當(dāng)實(shí)際 FPS 低于最大值時捶牢,降低 timestep 會增加可感知到的最大 FPS,但代價是每一幀執(zhí)行 update()
的次數(shù)會更多巍耗。由于執(zhí)行 update()
越多秋麸,消耗的時間也越多,這就又會降低幀率芍锦。如果幀率降的太多竹勉,就可能會陷入一種死亡螺旋。
死亡螺旋
遺憾的是我們又引入了一個新問題娄琉。在我們的初次嘗試中,如果一幀花費(fèi)了比較長的時間執(zhí)行更新和渲染吓歇,幀率就會自然地降低孽水,直到每一幀花費(fèi)的時間足夠更新和渲染完成。然而城看,現(xiàn)在我們的更新依賴于時間女气。如果某一幀花費(fèi)了較長時間來模擬,那么下一幀會需要模擬更長的時間测柠。這意味著我們需要更多次執(zhí)行 update()
方法炼鞠,而這進(jìn)一步意味著這新的一幀需要花費(fèi)更多的時間來模擬,如此往復(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)行游戲每篷。這意味著所有玩家體驗(yàn)到的都是最慢的那個人的速度。如果一個玩家實(shí)在落后太多端圈,他就會掉線焦读,這樣就不會拖慢其他玩家。因此舱权,在鎖步的游戲中矗晃,這個玩家就掉線了。
在一個沒有使用鎖步的多人游戲宴倍,比如第一人稱射擊游戲中张症,一般都會有一個服務(wù)器維持著游戲的“權(quán)威狀態(tài)”。也就是說鸵贬,服務(wù)器會接收所有玩家的輸入俗他,并計(jì)算出游戲世界應(yīng)有的樣子,以避免玩家作弊阔逼。如果一個玩家的本地游戲距離權(quán)威狀態(tài)太遠(yuǎn)兆衅,即 panic 的狀態(tài),那么這個玩家需要被拉回權(quán)威狀態(tài)嗜浮。(在實(shí)踐中羡亩,如果直接把玩家拉回去會令人很迷惑,所以一般會有一個替代的 update
方法危融,讓客戶端能夠更加平滑地回到服務(wù)端的權(quán)威狀態(tài)畏铆。)一旦我們把用戶拉回了最新的狀態(tài),我們就不需要再去本地模擬這一堆剩下的時間了专挪,因?yàn)槲覀円呀?jīng)把這些時間高效快進(jìn)掉了及志。因此我們可以把它們丟棄掉:
function panic() {
delta = 0; // 丟棄未模擬的時間
// ... 把玩家同步到權(quán)威狀態(tài)
}
如果在服務(wù)端這么干,會引起不確定性的行為寨腔,不過只有服務(wù)端需要保證游戲運(yùn)行的確定性速侈,所以在多人游戲的客戶端這么做是完全可以的。
在單機(jī)游戲中迫卢,我們可以讓游戲繼續(xù)運(yùn)行一會看看游戲運(yùn)行速度能不能趕上來倚搬。但當(dāng)游戲在中間狀態(tài)過渡時,游戲會看上去在幾幀之內(nèi)運(yùn)行得飛快乾蛤。另一種可以接受的方法是每界,直接丟棄未模擬的時間捅僵,就像我們前面在非鎖步的多人游戲中做的那樣。這會引入不確定性的行為眨层,不過你可能覺得這是個極端情況庙楚,可以另當(dāng)別論。
無論如何趴樱,如果游戲持續(xù)出現(xiàn) panic 的情況馒闷,而且也不是因?yàn)闃?biāo)簽頁在后臺引起的,這可能提示了游戲的主循環(huán)運(yùn)行得實(shí)在太慢了叁征。也許你需要增大 timestep 的值纳账。
FPS 控制
另一種可以避免死亡螺旋(總的來說,避免低幀率)的方法是監(jiān)控游戲的運(yùn)行幀率捺疼,并在幀率過低時疏虫,調(diào)整主循環(huán)中的行為。往往在 panic 狀態(tài)發(fā)生前啤呼,我們就能檢測到掉幀的情況卧秘,可以由此來預(yù)防 panic 。
有許多監(jiān)測幀率的方式媳友,其一是在一段時間內(nèi)(比如最近 10 秒)持續(xù)監(jiān)測每秒渲染的幀數(shù)斯议,并取平均值。然而這稍微有點(diǎn)吃性能醇锚,而且我們希望最近幾秒鐘的幀數(shù)能獲得更高的權(quán)重。一種簡單做法是坯临,使用加權(quán)平均來計(jì)算權(quán)重:
var fps = 60,
framesThisSecond = 0,
lastFpsUpdate = 0;
function mainLoop(timestamp) {
// ...
if (timestamp > lastFpsUpdate + 1000) { // 每秒更新一次
fps = 0.25 * framesThisSecond + (1 - 0.25) * fps; // 計(jì)算新 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粥喜。(注意這是最不得已的情況凸主,因?yàn)檫@會導(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í)行長時間運(yùn)行的操作衍腥,那么在主循環(huán)中分塊處理這些操作磺樱,而不是在事件處理程序中一次處理這些操作,可以避免幀的延遲紧阔。而 end()
方法則可以增量地執(zhí)行不受時間影響的坊罢、需要較長運(yùn)行時間的更新,以及根據(jù) FPS 的變化作調(diào)整擅耽。
選擇 timestep
一般來說活孩,1000/60 在大多數(shù)情況是個好選擇,因?yàn)榇蠖鄶?shù)顯示器以 60Hz 運(yùn)行乖仇,如果你發(fā)現(xiàn)你的程序十分吃性能憾儒,也許你可以將其設(shè)為 1000/30。這有效地限制了你的可感知幀率為 30FPS(除非使用了插值乃沙,如后文所述)起趾。注意幀率會根據(jù)你的顯示器和顯卡驅(qū)動進(jìn)行調(diào)節(jié),因此你設(shè)置的最大值可能與實(shí)際運(yùn)行時測得的值不相同警儒。如果你的游戲運(yùn)行流暢训裆,且你希望模擬得更加精確,你可以考慮使用高端游戲顯示屏的 FPS蜀铲,如 75边琉、90、120 和 144记劝。再高的話最終運(yùn)行速度可能反而就會變慢了变姨。
性能考量
如果程序的性能并不盡如人意,你可以使用插值繪制和 Web Worker 這兩種重構(gòu)方式來獲得實(shí)實(shí)在在的性能提升厌丑。
插值繪制
在每次 update
結(jié)束之后定欧,在 delta
中經(jīng)常會有一段小于一整個 timestep 的剩余時間。將這段尚未被模擬的剩余時間所占 timestep 的百分比傳入 draw()
方法就可以在兩幀之間做一個插值怒竿。即使在高幀率下砍鸠,這種視覺上的平滑效果也有助于降低畫面的卡頓。
卡頓之所以會出現(xiàn)愧口,是因?yàn)?update()
方法模擬的時間與兩個 draw()
方法經(jīng)過的時間往往是不同的睦番。進(jìn)一步說,假如 update()
發(fā)生在下面第一行的每條豎線所代表的時間點(diǎn),而 draw()
發(fā)生在下面第二行的每條豎線所代表的時間點(diǎn)托嚣,那么在 draw()
方法發(fā)生渲染的時間點(diǎn)巩检,總會有一些剩余時間還沒有被 update()
方法所模擬:
update() timesteps: | | | | | | | | |
draw() calls: | | | | | | |
為了使 draw()
對方塊移動做插值以進(jìn)行渲染,必須保留上次 update()
之后對象的狀態(tài)示启,并將其用于計(jì)算中間狀態(tài)兢哭。注意這意味著渲染最多落后一次 update()
。這仍然比外推(推測對象在下一次 update()
之后的狀態(tài))要好夫嗓,因?yàn)楹笳呖赡軙a(chǎn)生奇怪的結(jié)果迟螺。要注意存儲多個狀態(tài)實(shí)現(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()
方法花費(fèi)的時間足夠長攻柠,以至于幀率低于預(yù)期球订,那么我們可以將 update()
方法中無需在每幀之間執(zhí)行的部分放入 Web Worker 中。(網(wǎng)上的很多地方有時會建議使用 setTimeout()
或 setInterval()
來進(jìn)行調(diào)度瑰钮。這些方法只需對現(xiàn)有的代碼進(jìn)行較小的改動冒滩,但由于 JavaScript 是單線程的,這些改動仍然會阻止渲染并降低幀率浪谴。使用 Web Worker 需要做更大的改動开睡,但它們在單獨(dú)的線程中執(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)建造游戲中計(jì)算市民的幸福指數(shù))官卡、不影響場景的物理效果(比如風(fēng)中飄動的旗幟)和任何被遮擋或是離場景很遠(yuǎn)的事物。 - 如果
draw()
方法需要基于 Web Worker 中的行為對物理做插值醋虏,那么 Web Worker 需要把插值結(jié)果傳回主線程寻咒,使其在draw()
方法中可用。 - Web Worker 不能訪問主線程中的狀態(tài)颈嚼,因此它們不能直接修改你場景中的物體毛秘。與 Web Worker 之間傳遞數(shù)據(jù)是一個痛點(diǎn)。最簡單的辦法是使用 Transferable Objects:你可以傳遞一個 ArrayBuffer 給 Web Worker,并銷毀原始引用叫挟。
你可以在 HTML5 Rocks 中了解更多有關(guān)于 Web Worker 和 Transferable Objects 的信息艰匙。
啟動與停止
目前,一旦我們的游戲啟動了主循環(huán)抹恳,我們是沒有任何方法停止的员凝。讓我們引入 start()
和 stop()
函數(shù)來管理游戲的運(yùn)行。首先我們要找到停止 requestAnimationFrame
的方法奋献。一種方式是維持一個 running
的布爾變量來控制主循環(huán)下一次是否要繼續(xù)調(diào)用 requestAnimationFrame
健霹。一般來說沒啥問題,不過如果游戲開始后立馬停止瓶蚂,那么無論如何都有一幀會被運(yùn)行糖埋,無法取消。因此我們需要一個能夠真正取消渲染循環(huán)的方法窃这。幸運(yùn)的是我們有 cancelAnimationFrame()
方法瞳别。當(dāng)調(diào)用 requestAnimationFrame
時會返回一個 frame ID,可以傳遞給cancelAnimationFrame()
方法钦听。
frameID = requestAnimationFrame(mainLoop);
切記如果你做了 FPS 控制洒试,別忘了在 FPS 控制的節(jié)流條件中也做下改動,記錄 frame ID朴上。
現(xiàn)在我們來實(shí)現(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ù))痪宰。這通常不難叼架,因?yàn)樗鼈冎灰獧z查下 running
變量就可以決定自身是否要執(zhí)行了。另一個需要注意的點(diǎn)是衣撬,在多人游戲中暫停會導(dǎo)致該玩家的客戶端失去同步乖订,因此一般要讓他們退出游戲,或是在主循環(huán)再次啟動時把玩家拉到最新的位置(在確認(rèn)玩家是否真的想要暫停之后)具练。
開始游戲循環(huán)會更為棘手一些乍构。我們需要關(guān)注以下四點(diǎn)。首先扛点,如果主循環(huán)已經(jīng)在運(yùn)行哥遮,我們不能允許它再次運(yùn)行,否則會導(dǎo)致同時請求多幀渲染陵究,降低運(yùn)行速度眠饮。其次,我們需要確蓖剩快速地切換游戲的開始和停止不會造成錯誤仪召。再次寨蹋,我們需要在游戲尚未發(fā)生任何更新時渲染出游戲的初始狀態(tài),因?yàn)槲覀冎餮h(huán)的 draw
是在 update
之后調(diào)用的扔茅。最后已旧,當(dāng)游戲暫停時,我們需要重置一些變量的值咖摹,以防暫停時我們記錄的需要模擬的時間仍然在流逝评姨。另外,在游戲重新啟動后萤晴,事件處理和后臺任務(wù)也要恢復(fù)運(yùn)行吐句。這是我們的代碼:
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)用運(yùn)行。
總結(jié)
我們需要考慮的東西很多彼棍。如果你想簡單地實(shí)現(xiàn)游戲循環(huán)灭忠,只要用我的 MainLoop.js 開源庫就可以了。這樣你就不用擔(dān)心上面這么多的問題座硕。
如果你自己動手弛作,那么還可以做一些通用性的代碼優(yōu)化。例如华匾,可以把小方塊封裝在自己單獨(dú)的類中映琳。整個腳本應(yīng)該被包裹在一個 IIFE(立即執(zhí)行函數(shù)) 中,僅暴露接口給外部蜘拉,以防止污染瀏覽器的全局命名空間刊头,或是把代碼打包成 CommonJS 或 AMD 的模塊。MainLoop.js 已經(jīng)把這些都做好了(甚至做得更好)诸尽,不過總而言之我們已經(jīng)做得相當(dāng)不錯了。
最后印颤,我要感謝 Glenn Fiedler 編寫了經(jīng)典的 Fix your timestep! 一文您机,它是本文中如此多工作的起點(diǎn)。也感謝 Ian Langworth 為我審閱了我在關(guān)于制作 3D 頁游的書中包含的本文的簡略版本,并提出了 Web Worker 相關(guān)的一些建議际看。最后的最后咸产,如果你仍然想尋找關(guān)于這個話題的更多資源,也許可以考慮看下 Game Programming Patterns 這本書仲闽,或者 MDN 上一篇相對簡略的文章脑溢。