遊戲循環 Game Loop
遊戲設計模式 Game Programming PatternsSequencing Patterns
意圖
將遊戲的進行和玩家的輸入解耦,和處理器速度解耦。
動機
如果本書中有一個模式不可或缺,那非這個模式莫屬了。 遊戲循環是“遊戲編程模式”的精髓。 幾乎每個遊戲都有,兩兩不同,而在非遊戲的程序幾乎沒有使用。
爲了看看它多有用,讓我們快速緬懷一遍往事。 在每個編寫計算機程序的人都留着鬍子的時代,程序像洗碗機一樣工作。 你輸入一堆代碼,按個按鈕,等待,然後獲得結果,完成。 程序全都是批處理模式的——一旦工作完成,程序就停止了。
你在今日仍然能看到這些程序,雖然感謝上天,我們不必在打孔紙上面編寫它們了。 終端腳本,命令行程序,甚至將Markdown翻譯成這本書的Python腳本都是批處理程序。
採訪CPU
最終,程序員意識到將批處理代碼留在計算辦公室,等幾個小時後拿到結果才能開始找程序漏洞的方式實在低效。 他們想要立即的反饋。交互式 程序誕生了。 第一批交互式程序中就有遊戲:
YOU ARE STANDING AT THE END OF A ROAD BEFORE A SMALL BRICK
BUILDING . AROUND YOU IS A FOREST. A SMALL
STREAM FLOWS OUT OF THE BUILDING AND DOWN A GULLY.
> GO IN
YOU ARE INSIDE A BUILDING, A WELL HOUSE FOR A LARGE SPRING.
你可以和這個程序進行實時交互。 它等待你的輸入,然後進行響應。 你再輸入,這樣一唱一和,就像相聲一樣。 當輪到你時,它停在那裏啥也不做。像這樣:
while (true)
{
char* command = readCommand();
handleCommand(command);
}
事件循環
如果你剝開現代的圖形UI的外皮,會驚訝地發現它們與老舊的冒險遊戲差不多。 文本處理器通常呆在那裏什麼也不做,直到你按了個鍵或者點了什麼東西:
while (true)
{
Event* event = waitForEvent();
dispatchEvent(event);
}
這與冒險遊戲主要的不同是,程序不是等待文本指令,而是等待用戶輸入事件——鼠標點擊、按鍵按下之類的。 其他部分還是和以前的老式文本冒險遊戲一樣,程序阻塞等待用戶的輸入,這是個問題。
不像其他大多數軟件,遊戲即使在沒有玩家輸入時也繼續運行。 如果你站在那裏看着屏幕,遊戲不會凍結。動畫繼續動着。視覺效果繼續閃爍。 如果運氣不好的話,怪物會繼續吞噬英雄。
這是真實遊戲循環的第一個關鍵部分:它處理用戶輸入,但是不等待它。循環總是繼續旋轉:
while (true)
{
processInput();
update();
render();
}
我們之後會改善它,但是基本的部分都在這裏了。
processInput()
處理上次調用到現在的任何輸入。
然後update()
讓遊戲模擬一步。
運行AI和物理(通常是這種順序)。
最終,render()
繪製遊戲,這樣玩家可以看到發生了什麼。
時間之外的世界
如果這個循環沒有因爲輸入而阻塞,這就帶來了明顯的問題,要運轉多快呢? 每次進行遊戲循環都會推動一定的遊戲狀態的發展。 在遊戲世界的居民看來,他們手上的表就會滴答一下。
同時,玩家的真實手錶也在滴答着。 如果我們用實際時間來測算遊戲循環運行的速度,就得到了遊戲的“幀率”(FPS)。 如果遊戲循環的更快,FPS就更高,遊戲運行得更流暢、更快。 如果循環得過慢,遊戲看上去就像是慢動作電影。
我們現在寫的這個循環是能轉多快轉多快,兩個因素決定了幀率。 一個是每幀要做多少工作。複雜的物理,衆多遊戲對象,圖形細節都讓CPU和GPU繁忙,這決定了需要多久能完成一幀。
另一個是底層平臺的速度。 更快的芯片可以在同樣的時間裏執行更多的代碼。 多核,GPU組,獨立聲卡,以及系統的調度都影響了在一次滴答中能夠做多少東西。
每秒的幀數
在早期的視頻遊戲中,第二個因素是固定的。 如果你爲NES或者Apple IIe寫遊戲,你明確知道遊戲運行在什麼CPU上。 你可以(也必須)爲它特製代碼。 你只需擔憂第一個因素:每次滴答要做多少工作。
早期的遊戲被仔細地編碼,一幀只做一定的工作,開發者可以讓遊戲以想要的速率運行。 但是如果你想要在快些或者慢些的機器上運行同一遊戲,遊戲本身就會加速或減速。
現在,很少有開發者可以奢侈地知道遊戲運行的硬件條件。遊戲必須自動適應多種設備。
這就是遊戲循環的另一個關鍵任務:不管潛在的硬件條件,以固定速度運行遊戲。
模式
一個遊戲循環在遊玩中不斷運行。 每一次循環,它無阻塞地處理玩家輸入,更新遊戲狀態,渲染遊戲。 它追蹤時間的消耗並控制遊戲的速度。
何時使用
使用錯誤的模式比不使用模式更糟,所以這節通常告誡你不要過於熱衷設計模式。 設計模式的目標不是往代碼庫裏儘可能的塞東西。
但是這個模式有所不同。我可以很自信的說你會使用這個模式。 如果你使用遊戲引擎,你不需要自己編寫,但是它還在那裏。
你可能認爲在做回合制遊戲時不需要它。 但是哪怕是那裏,就算遊戲狀態到玩家回合才改變,視覺和聽覺 狀態仍會改變。 哪怕遊戲在“等待”你進行你的回合,動畫和音樂也會繼續運行。
記住
我們這裏談到的循環是遊戲代碼中最重要的部分。 有人說程序會花費90%的時間在10%的代碼上。 遊戲循環代碼肯定在這10%中。 你必須小心謹慎,時時注意效率。
你也許需要與平臺的事件循環相協調
如果你在操作系統的頂層或者有圖形UI和內建事件循環的平臺上構建遊戲, 那你就有了兩個應用循環在同時運作。 它們需要很好地協調。
有時候,你可以進行控制,只運行你的遊戲循環。
舉個例子,如果捨棄了Windows的珍貴API,main()
可以只用遊戲循環。
其中你可以調用PeekMessage()
來處理和分發系統的事件。
不像GetMessage()
,PeekMessage()
不會阻塞等待用戶輸入,
因此你的遊戲循環會保持運作。
其他的平臺不會讓你這麼輕鬆地擺脫事件循環。
如果你使用網頁瀏覽器作爲平臺,事件循環已被內建在瀏覽器的執行模型深處。
這樣,你得用事件循環作爲遊戲循環。
你會調用requestAnimationFrame()
之類的函數,它會回調你的代碼,保持遊戲繼續運行。
示例代碼
在如此長的介紹之後,遊戲循環的代碼實際上很直觀。 我們會瀏覽一堆變種,比較它們的好處和壞處。
遊戲循環驅動了AI,渲染和其他遊戲系統,但這些不是模式的要點,
所以我們會調用虛構的方法。在實現了render()
,update()
之後,
剩下的作爲給讀者的練習(挑戰!)。
跑,能跑多快跑多快
我們已經見過了可能是最簡單的遊戲循環:
while (true)
{
processInput();
update();
render();
}
它的問題是你不能控制遊戲運行得有多快。 在快速機器上,循環會運行得太快,玩家看不清發生了什麼。 在慢速機器上,遊戲慢的跟在爬一樣。 如果遊戲的一部分有大量內容或者做了很多AI或物理運算,遊戲就會慢一些。
休息一下
我們看看增加一個簡單的小修正如何。 假設你想要你的遊戲以60FPS運行。這樣每幀大約16毫秒。 只要你用少於這個的時長進行遊戲所有的處理和渲染,就可以以穩定的幀率運行。 你需要做的就是處理這一幀然後等待,直到處理下一幀的時候,就像這樣:
代碼看上去像這樣:
while (true)
{
double start = getCurrentTime();
processInput();
update();
render();
sleep(start + MS_PER_FRAME - getCurrentTime());
}
如果它很快地處理完一幀,這裏的sleep()
保證了遊戲不會運行太快。
如果你的遊戲運行太慢,這無濟於事。
如果需要超過16ms來更新並渲染一幀,休眠的時間就變成了負的。
如果計算機能回退時間,很多事情就很容易了,但是它不能。
相反,遊戲變慢了。 可以通過每幀少做些工作來解決這個問題——減少物理效果和絢麗光影,或者把AI變笨。 但是這影響了那些有快速機器的玩家的遊玩體驗。
一小步,一大步
讓我們嘗試一些更加複雜的東西。我們擁有的問題基本上是:
- 每次更新將遊戲時間推動一個固定量。
- 這消耗一定量的真實時間來處理它。
如果第二步消耗的時間超過第一步,遊戲就變慢了。 如果它需要超過16ms來推動遊戲時間16ms,那它永遠也跟不上。 但是如果一步中推動遊戲時間超過16ms,那我們可以減少更新頻率,就可以跟得上了。
接着的思路是基於上幀到現在有多少真實時間流逝來選擇前進的時間。 這一幀花費的時間越長,遊戲的間隔越大。 它總能跟上真實時間,因爲它走的步子越來越大。 有人稱之爲變化的或者流動的時間間隔。它看上去像是:
double lastTime = getCurrentTime();
while (true)
{
double current = getCurrentTime();
double elapsed = current - lastTime;
processInput();
update(elapsed);
render();
lastTime = current;
}
每一幀,我們計算上次遊戲更新到現在有多少真實時間過去了(即變量elapsed
)。
當我們更新遊戲狀態時將其傳入。
然後遊戲引擎讓遊戲世界推進一定的時間量。
假設有一顆子彈跨過屏幕。 使用固定的時間間隔,在每一幀中,你根據它的速度移動它。 使用變化的時間間隔,你根據過去的時間拉伸速度。 隨着時間間隔增加,子彈在每幀間移動得更遠。 無論是二十個快的小間隔還是四個慢的大間隔,子彈在真實時間裏移動同樣多的距離。 這看上去成功了:
- 遊戲在不同的硬件上以固定的速度運行。
- 使用高端機器的玩家獲得了更流暢的遊戲體驗。
但悲劇的是,這裏有一個嚴重的問題: 遊戲不再是確定的了,也不再穩定。 這是我們給自己挖的一個坑:
假設我們有個雙人聯網遊戲,Fred的遊戲機是臺性能猛獸,而George正在使用他祖母的老爺機。 前面提到的子彈在他們的屏幕上飛行。 在Fred的機器上,遊戲跑得超級快,每個時間間隔都很小。 比如,我們塞了50幀在子彈穿過屏幕的那一秒。 可憐的George的機器只能塞進大約5幀。
這就意味着在Fred的機器上,物理引擎每秒更新50次位置,但是George的只更新5次。 大多數遊戲使用浮點數,它們有舍入誤差。 每次你將兩個浮點數加在一起,獲得的結果就會有點偏差。 Fred的機器做了10倍的操作,所以他的誤差要比George的更大。 同樣 的子彈最終在他們的機器上到了不同的位置。
這是使用變化時間可引起的問題之一,還有更多問題呢。 爲了實時運行,遊戲物理引擎做的是實際機制法則的近似。 爲了避免飛天遁地,物理引擎添加了阻尼。 這個阻尼運算被小心地安排成以固定的時間間隔運行。 改變了它,物理就不再穩定。
這種不穩定性太糟了,這個例子在這裏的唯一原因是作爲警示寓言,引領我們到更好的東西……
追逐時間
遊戲中渲染通常不會被動態時間間隔影響到。 由於渲染引擎表現的是時間上的一瞬間,它不會計算上次到現在過了多久。 它只是將當前事物渲染在所在的地方。
我們可以利用這點。 以固定的時間間隔更新遊戲,因爲這讓所有事情變得簡單,物理和AI也更加穩定。 但是我們允許靈活調整渲染的時刻,釋放一些處理器時間。
它像這樣運作:自上一次遊戲循環過去了一定量的真實時間。 需要爲遊戲的“當前時間”模擬推進相同長度的時間,以追上玩家的時間。 我們使用一系列的固定時間步長。 代碼大致如下:
double previous = getCurrentTime();
double lag = 0.0;
while (true)
{
double current = getCurrentTime();
double elapsed = current - previous;
previous = current;
lag += elapsed;
processInput();
while (lag >= MS_PER_UPDATE)
{
update();
lag -= MS_PER_UPDATE;
}
render();
}
這裏有幾個部分。
在每幀的開始,根據過去了多少真實的時間,更新lag
。
這個變量表明瞭遊戲世界時鐘比真實世界落後了多少,然後我們使用一個固定時間步長的內部循環進行追趕。
一旦我們追上真實時間,我們就渲染然後開始新一輪循環。
你可以將其畫成這樣:
注意這裏的時間步長不是視覺上的幀率了。
MS_PER_UPDATE
只是我們更新遊戲的間隔。
這個間隔越短,就需要越多的處理次數來追上真實時間。
它越長,遊戲抖動得越厲害。
理想上,你想要它足夠短,通常快過60FPS,這樣遊戲在高速機器上會有高效的表現。
但是小心不要把它整得太短了。
你需要保證即使在最慢的機器上,這個時間步長也超過處理一次update()
的時間。
否則,你的遊戲就跟不上現實時間了。
幸運的是,我們給自己了一些喘息的空間。 技巧在於我們將渲染拉出了更新循環。 這釋放了一大塊CPU時間。 最終結果是遊戲以固定時間步長模擬,該時間步長與硬件不相關。 只是使用低端硬件的玩家看到的內容會有抖動。
卡在中間
我們還剩一個問題,就是剩下的延遲。 以固定的時間步長更新遊戲,在任意時刻渲染。 這就意味着從玩家的角度看,遊戲經常在兩次更新之間時顯示。
這是時間線:
就像你看到的那樣,我們以緊湊固定的時間步長進行更新。 同時,我們在任何可能的時候渲染。 它比更新發生得要少,而且也不穩定。 兩者都沒問題。糟糕的是,我們不總能在正確的時間點渲染。 看看第三次渲染時間。它發生在兩次更新之間。
想象一顆子彈飛過屏幕。第一次更新時,它在左邊。 第二次更新將它移到了右邊。 這個遊戲在兩次更新之間的時間點渲染,所以玩家期望看到子彈在屏幕的中間。 而現在的實現中,它還在左邊。這意味着看上去移動發生了卡頓。
方便的是,我們實際知道渲染時距離兩次更新的時間:它被存儲在lag
中。
我們在lag
比更新時間間隔小時,而不是lag
是零時,跳出循環進行渲染。
lag
的剩餘量?那就是到下一幀的時間。
當我們要渲染時,我們將它傳入:
render(lag / MS_PER_UPDATE);
渲染器知道每個遊戲對象以及它當前的速度。
假設子彈在屏幕左邊20像素的地方,正在以400像素每幀的速度向右移動。
如果在兩幀正中渲染,我們會給render()
傳0.5。
它繪製了半幀之前的圖形,在220像素,啊哈,平滑的移動。
當然,也許這種推斷是錯誤的。 在我們計算下一幀時,也許會發現子彈碰撞到另一障礙,或者減速,又或者別的什麼。 我們只是在上一幀位置和我們認爲的下一幀位置之間插值。 但只有在完成物理和AI更新後,我們才能知道真正的位置。
所以推斷有猜測的成分,有時候結果是錯誤的。 但是,幸運地,這種修正通常不可感知。 最起碼,比你不使用推斷導致的卡頓更不明顯。
設計決策
雖然這章我講了很多,但是有更多的東西我沒講。 一旦你考慮顯示刷新頻率的同步,多線程,多GPU,真正的遊戲循環會變得更加複雜。 即使在高層,這裏還有一些問題需要你回答:
擁有遊戲循環的是你,還是平臺?
這個選擇通常是已經由平臺決定的。 如果你在做瀏覽器中的遊戲,很可能你不能編寫自己的經典遊戲循環。 瀏覽器本身的事件驅動機制阻礙了這一點。 類似地,如果你使用現存的遊戲引擎,你很可能依賴於它的遊戲循環而不是自己寫一個。
-
使用平臺的事件循環:
-
簡單。你不必擔心編寫和優化自己的遊戲核心循環。
-
平臺友好。 你不必明確地給平臺一段時間讓它處理它自己的事件,不必緩存事件,不必管理任何平臺輸入模型和你的不匹配之處。
-
你失去了對時間的控制。 平臺會在它方便時調用代碼。 如果這不如你想要的那樣平滑或者頻繁,太糟了。 更糟的是,大多數應用的事件循環並未爲遊戲設計,通常是又慢又卡頓。
-
-
使用遊戲引擎的循環:
-
不必自己編寫。 編寫遊戲循環非常需要技巧。 由於是每幀都要執行的核心代碼,小小的漏洞或者性能問題就對遊戲有巨大的影響。 穩固的遊戲循環是使用現有引擎的原因之一。
-
不必自己編寫。 當然,硬幣的另一面是,如果引擎無法滿足你真正的需求,你也沒法獲得控制權。
-
-
自己寫:
-
完全的控制。 你可以做任何想做的事情。你可以爲遊戲的需求訂製開發。
-
你需要與平臺交互。 應用框架和操作系統通常需要時間片去處理自己的事件和其他工作。 如果你擁有應用的核心循環,平臺就沒有這些時間片了。 你得顯式定期檢查,保證框架沒有掛起或者混亂。
-
如何管理能量消耗?
在五年前這還不是問題。 遊戲運行在插到插座上的機器上或者專用的手持設備上。 但是隨着智能手機,筆記本以及移動遊戲的發展,現在需要關注這個問題了。 畫面絢麗,但會耗幹三十分鐘前充的電,並將手機變成空間加熱器的遊戲,可不能讓人開心。
現在,你需要考慮的不僅僅是讓遊戲看上去很棒,同時也要儘可能少地使用CPU。 你需要設置一個性能的上限:完成一幀之內所需的工作後,讓CPU休眠。
-
儘可能快地運行:
這是PC遊戲的常態(即使越來越多的人在筆記本上運行遊戲)。 遊戲循環永遠不會顯式告訴系統休眠。相反,空閒的循環被劃在提升FPS或者圖像顯示效果上了。
這會給你最好的遊戲體驗。 但是,也會盡可能多地使用電量。如果玩家在筆記本電腦上游玩,他們就得到了一個很好的加熱器。
-
固定幀率
移動遊戲更加註意遊戲的體驗質量,而不是最大化圖像畫質。 很多這種遊戲都會設置最大幀率(通常是30或60FPS)。 如果遊戲循環在分配的時間片消耗完之前完成,剩餘的時間它會休眠。
這給了玩家“足夠好的”遊戲體驗,也讓電池輕鬆了一點。
你如何控制遊戲速度?
遊戲循環有兩個關鍵部分:不阻塞用戶輸入和自適應的幀時間步長。 輸入部分很直觀。關鍵在於你如何處理時間。 這裏有數不盡的遊戲可運行的平臺, 每個遊戲都需要在其中一些平臺上運行。 如何適應平臺的變化就是關鍵。
-
固定時間步長,沒有同步:
見我們第一個樣例中的代碼。你只需儘可能快地運行遊戲。
-
簡單。這是主要的(好吧,唯一的)好處。
-
遊戲速度直接受到硬件和遊戲複雜度影響。 主要的缺點是,如果有所變化,會直接影響遊戲速度。遊戲速度與遊戲循環緊密相關。
-
-
固定時間步長,有同步:
對複雜度控制的下一步是使用固定的時間間隔,但在循環的末尾增加同步點,保證遊戲不會運行得過快。
-
還是很簡單。 這比過於簡單以至於不可行的例子只多了一行代碼。 在多數遊戲循環中,你可能總需要做一些同步。 你可能需要雙緩衝圖形並將緩衝塊與更新顯示的頻率同步。
-
電量友好。 這對移動遊戲至關重要。你不想消耗不必要的電量。 通過簡單地休眠幾個毫秒而不是試圖每幀塞入更多的處理,你就節約了電量。
-
遊戲不會運行得太快。 這解決了固定循環速度的一半問題。
-
遊戲可能運行的太慢。 如果花了太多時間更新和渲染一幀,播放也會減緩。 因爲這種方案沒有分離更新和渲染,它比更高級的方案更容易遇到這點。 沒法扔掉渲染幀來追上真實時間,遊戲本身會變慢。
-
-
動態時間步長:
我把這個方案放在這裏作爲問題的解決辦法之一,附加警告:大多數我認識的遊戲開發者反對它。 不過記住爲什麼反對它是很有價值的。
-
能適應並調整,避免運行得太快或者太慢。 如果遊戲不能追上真實時間,它用越來越長的時間步長更新,直到追上。
-
讓遊戲不確定而且不穩定。 這是真正的問題,當然。在物理和網絡部分使用動態時間步長會遇見更多的困難。
-
-
固定更新時間步長,動態渲染:
在示例代碼中提到的最後一個選項是最複雜的,但是也是最有適應性的。 它以固定時間步長更新,但是如果需要趕上玩家的時間,可以扔掉一些渲染幀。
-
能適應並調整,避免運行得太快或者太慢。 只要能實時更新,遊戲狀態就不會落後於真實時間。如果玩家用高端的機器,它會回以更平滑的遊戲體驗。
-
更復雜。 主要負面問題是需要在實現中寫更多東西。 你需要將更新的時間步長調整得儘可能小來適應高端機,同時不至於在低端機上太慢。
-
參見
-
關於遊戲循環的經典文章是Glenn Fiedler的”Fix Your Timestep“。如果沒有這篇文章,這章就不會是這個樣子。
-
Witters關於game loops的文章也值得閱讀。