【譯】JavaScript 游戲循環(huán)詳解

前言:
游戲循環(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;
 
    // ...
}

你可以看到現(xiàn)在它的移動如此之慢:

我們可以做的更好。這個問題在于我們的應(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)槲覀兿Mo定相同的輸入時,程序也能給出相同的輸出谈为。換句話說旅挤,我們希望程序是具有確定性 的。

一種解決方案

解決這一物理問題的關(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)大到能穿墻的巨大跨越闸英。

讓我們實(shí)際看一看:

如果你調(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 上一篇相對簡略的文章脑溢。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市赖欣,隨后出現(xiàn)的幾起案子屑彻,更是在濱河造成了極大的恐慌,老刑警劉巖顶吮,帶你破解...
    沈念sama閱讀 206,482評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件社牲,死亡現(xiàn)場離奇詭異,居然都是意外死亡悴了,警方通過查閱死者的電腦和手機(jī)搏恤,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,377評論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來湃交,“玉大人熟空,你說我怎么就攤上這事「爿海” “怎么了息罗?”我有些...
    開封第一講書人閱讀 152,762評論 0 342
  • 文/不壞的土叔 我叫張陵,是天一觀的道長腮敌。 經(jīng)常有香客問我阱当,道長,這世上最難降的妖魔是什么糜工? 我笑而不...
    開封第一講書人閱讀 55,273評論 1 279
  • 正文 為了忘掉前任弊添,我火速辦了婚禮,結(jié)果婚禮上捌木,老公的妹妹穿的比我還像新娘油坝。我一直安慰自己,他們只是感情好刨裆,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,289評論 5 373
  • 文/花漫 我一把揭開白布澈圈。 她就那樣靜靜地躺著,像睡著了一般帆啃。 火紅的嫁衣襯著肌膚如雪瞬女。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,046評論 1 285
  • 那天努潘,我揣著相機(jī)與錄音诽偷,去河邊找鬼坤学。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播元扔,決...
    沈念sama閱讀 38,351評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼飞苇!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起蜗顽,我...
    開封第一講書人閱讀 36,988評論 0 259
  • 序言:老撾萬榮一對情侶失蹤布卡,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后诫舅,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體羽利,經(jīng)...
    沈念sama閱讀 43,476評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,948評論 2 324
  • 正文 我和宋清朗相戀三年刊懈,在試婚紗的時候發(fā)現(xiàn)自己被綠了这弧。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,064評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡虚汛,死狀恐怖匾浪,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情卷哩,我是刑警寧澤蛋辈,帶...
    沈念sama閱讀 33,712評論 4 323
  • 正文 年R本政府宣布,位于F島的核電站将谊,受9級特大地震影響冷溶,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜尊浓,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,261評論 3 307
  • 文/蒙蒙 一逞频、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧栋齿,春花似錦苗胀、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,264評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至菇用,卻和暖如春澜驮,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背惋鸥。 一陣腳步聲響...
    開封第一講書人閱讀 31,486評論 1 262
  • 我被黑心中介騙來泰國打工泉唁, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留鹅龄,地道東北人。 一個月前我還...
    沈念sama閱讀 45,511評論 2 354
  • 正文 我出身青樓亭畜,卻偏偏與公主長得像,于是被迫代替她去往敵國和親迎卤。 傳聞我的和親對象是個殘疾皇子拴鸵,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,802評論 2 345