游戏编程设计模式——Game Loop
意圖
將游戲時間的進度從玩家輸入和處理器速度中分離出來。
動機
如果讓我選一個本書最不能少的模式,那就是這個。游戲循環是游戲編程模式中最精髓的一個例子。幾乎所有的游戲都會有它,再也沒有第二個應用如此廣泛的。但是在游戲之外有很少用到。
要看它是如何起作用的,讓我們把記憶拉回到過去。在哪個編程跟洗碗工一樣的青蔥歲月。你需要把一堆代碼塞進機器,按下按鈕,等待,然后出結果。這就是批次模式的編程——一旦工作完成,程序就停止運行了。
你今天仍然能夠看到,雖然謝天謝地我們不要在卡片上寫代碼了。Shell腳本,命令行程序,甚至是那些本書中的簡化小Python腳本,都是批次模式的編程。
采訪CPU
最后,當程序員們發現必須先把一堆代碼放到計算辦公司,然后回來等幾個小時,才能拿到結果。這改起bug來就會非常慢。他們希望能夠得到及時的反饋。交互式編程誕生了,一些最早的交互式程序,就是游戲:
你就可以跟程序實時進行交流。它會等待你的輸入,然后響應你。你可以再回復,輪流進行就像你在幼兒園學到的那樣。輪到你的時候,它就靜靜地坐在那里,一動不動。就像這樣:
事件循環
當今的圖形化UI應用很像以前的冒險游戲,當你取下他們的皮膚就會發現這一點。你的word處理器通常也是直到你按鍵或者點擊時才開始做事。
不同的地方只有它不用文本命令行,而是用用戶的輸入事件——鼠標點擊或者按鍵。它仍然像文字冒險游戲那樣等待用戶輸入。
不像這些通用軟件,游戲在用戶不提供輸入的時候也要進行下去。如果你就坐在屏幕前發呆,游戲也不會停止。動畫依然會播放,特效依然會舞動。如果你不幸運,怪物有可能就開始咬你的英雄了。
這就是游戲循環的第一個關鍵點:它處理用戶輸入,但是并不等待輸入。循環照樣運行。
我們后面會繼續完善,但是基本的元素都有了。processInput()處理了從上一次調用以來的所有用戶輸入。然后,update()進行一步游戲模擬。運行AI和物理(通常按照這個順序)。最后,render()繪制一遍游戲,讓玩家能看到發生了什么。
延遲的世界
如果輸入操作不打斷游戲循環,那一個明顯的問題就會冒出來:循環運行的有多快?每一次循環都要處理游戲中大量的狀態。我們可以看到游戲世界中的居民,也可以看到他們鐘表的指針滴答向前。
與此同時,玩家的時鐘也在在跑。如果要衡量游戲循環的快慢,我們可以用“每秒幀數”這個概念。如果游戲循環快,FPS就會很高,游戲也會更流暢,更快速。如果游戲循環慢,游戲就會卡得像幻燈片。
在我們現有的簡陋循環中,我們讓它有多快,跑多快,兩個方面決定幀率。第一個是每幀它需要干多少工作。復雜的物理運算,大量的游戲對象,大量的圖形細節會讓你的CPU和GPU異常忙碌,因此,它會讓完成一幀的時間更長。
第二個因素是底層平臺的速度。更快的芯片可以在相同時間內運行更多的代碼。多核心,多GPU,專業音頻硬件,和操作系統的調度都決定了以在一個時間片中能做多少工作。
時間縮漲
在早期的視頻游戲中,游戲循環的時間間隔是固定的。如果你要寫一個NES或者Apple IIe的游戲,你會很清楚地知道你的游戲會運行在什么樣的CPU上。所有你所擔心的就只剩下每一個時間片要處理多少工作。
老游戲都很小心地編寫代碼,好讓計算機在每一幀處理正好足夠的運算,好讓游戲能夠以開發者希望的速度運行。但是如果這個游戲在更快或者更慢的機器上跑,那游戲就會加速或者減速。
現在,很少有開發者能夠清楚地知道自己的游戲將會運行在什么樣的硬件上。相反,我們的游戲必須適應千變萬化的設備。
這就是游戲循環的另外一個關鍵點:它讓游戲在不同的底層硬件上也能以相同的速度運行。
模式
游戲循環在整個游戲過程中在不停地運行著,每一次循環,它都不停頓地處理用戶輸入,更新游戲狀態,渲染游戲。它通過切分一小段時間,來控制游戲速度。
何時用
設計模式要是用錯了,還不如不用,所以這個部分有必要提一個醒。設計模式的目的不是讓你在代碼中強行插入。
但是這個模式有一點不同。我們在使用的時候可以非常自信。如果你在使用游戲引擎,即使不自己寫,它也會被用到。
你可能認為在回合制游戲中可以不用游戲循環。但是即使玩家不輸入的時候游戲不進行,但視覺和聽覺是不斷變化的。動畫和音樂在你等待回合的時候也是在運行的。
記住
我們在這里討論的是游戲中最重要的代碼。有人說程序90%的時間在跑10%的代碼。游戲循環一定在這10%里面。小心處理這些代碼,并時刻注意它們的效率。
你可能需要協調與平臺事件循環的關系
如果你要在一個具有內置圖形UI和事件循環的操作系統上開發游戲,就會有兩天循環運行在游戲中,你需要把它們用好。
有時,你可以只用你自己的循環。例如,如果你在用Windows API編寫游戲,你的main()函數可以創建一個游戲循環。在里面你,你可以調用PeekMessage()去處理和分發操作系統的事件。不像GetMessage(),PeekMessage()不能中會中斷等待用戶輸入,所以你的游戲循環可以持續運行下去。
有的平臺,你不能這么容易的操作事件循環。如果你的目標平臺是瀏覽器,瀏覽器事件循環的實現被深埋在瀏覽器的內核中。因此,事件循環就會顯式的運行,你就應該把它當做你自己的游戲循環使用了。你可以調用像requestAnimationFrame()這樣的函數,去驅動你的游戲運行。
簡單的代碼
經過這么長的介紹,游戲循環的代碼就已經很清楚了。我們來看幾種變化,并分析他們的優缺點。
游戲循環驅動了AI,渲染,和其他游戲系統,但是這些并不屬于這個模式,所以我們這里只寫一個簡單的函數調用。像render(),update()以及其他函數的實現,就當做讀者的自己的練習吧。
能跑多快跑多快
我們已經看到一個最簡單的游戲循環:
這樣做的問題在于,你不能控制游戲能跑多快。在快機器上,循環運行的飛快,以至于玩家都反應不過來發生了什么。在滿機器上,游戲就會卡頓。如果你有一些很重的內容,或者有AI和物理,游戲就會運行的很慢。
打一個小盹
第一個變化我們來看一個簡單的解決辦法。如果你希望游戲運行在60FPS。也就是給每一幀大約16毫秒的時間。只要你的游戲處理和渲染在小于這個時間的時間內完成,你可以得到一個穩定的幀率。你只需要處理這一幀,然后等待,直到開始下一幀,就像這樣:
?
代碼如下:
這里的sleep()保證了即使計算機處理的很快,也不會導致游戲運行的更快。但是如果游戲運行的太慢,就沒啥用了。如果你更新和渲染一幀用時超過16毫秒,你的sleep()函數就會幫倒忙。如果我們用的是穿越回來的電腦,一切問題都解決了,可惜事與愿違。
相反,如果游戲太慢了,我們需要減少每一幀要做的工作——砍掉一些圖形效果,減弱AI。但是這回影響所有玩家的游戲體驗,包括那些擁有高端機器的人。
一小步,一大步
讓我們試一下更復雜的辦法。這個問題我們可以簡化成:
1、每一次更新把游戲時間前進了一個固定的時間段。
2、處理游戲運算的時候,實際上使用了一段固定的真實時間。
當后者用時超過前者時,游戲就被放慢了。如果我們用多于16ms的時間去處理應該在16ms內完成的事情,那就不可能保持應有的游戲速度。但是如果我們讓游戲前進多于16ms的進度,那就可以保持游戲速度,只不過刷新頻率變慢了而已。
這個想法就要去確定一個時間段,它代表了從上一幀到現在經過了多少真實時間。這段時間越長,游戲進度的步子就越大。它總會與真實時間相匹配,因為它會一點點趨近于真實時間。我們把這種方法叫做可變的或者動態時間段。就像這樣:
每一幀,我們都會計算從上一幀開始我們耗費了多少真實時間。當我們更新游戲狀態的時候,我們把這個時間穿進去。游戲引擎會根據這個時間去調整游戲世界的進度。
比如,你有一顆穿過屏幕的子彈。在固定的時間段內,每一幀,你可以根據他的速度移動它。在一個變化的時間段內,你可以根據這個時間段去縮放他的速度。隨著時間段的變大,每一幀它走過的路程也就越長。這顆子彈將會在相同的真實時間內穿越屏幕。無論它在一個兩倍快的機器上,還是在四倍慢的機器上。這樣看起來已經勝利了:
在不同的機器上,游戲運行的進度相同。
機器更快的玩家,可以得到更流暢的游戲體驗。
但是,這里有一個潛在的問題:我們給游戲增加了不確定性和不穩定性,我這里有一個例子:
比如,我又一個雙人聯網對戰的游戲,弗雷德有一臺牛逼游戲計算機,但喬治用了一臺古老的PC。前面說的那顆子彈,在他們的屏幕上穿越。在弗雷德的機器上,游戲運行的很快,所以每一幀時間都很短。比如,子彈穿越屏幕用了50幀。但在可憐的喬治的機器上,可能只有5幀。
這就意味著,在弗雷德的機器上,物理引擎更新了子彈的位置50次,但在喬治的機器上只進行了5次。大多數游戲使用了浮點數,他們會出現化整誤差。每次你把兩個浮點數相加,得到的結果可能不同。弗雷德的機器多進行了十倍的運算,游戲賬號買賣所以他可能會有比喬治更大的誤差。所以同樣一顆子彈可能停在不同的地方。
這只是其中一個比較嚴重的問題,還有更多。為了能夠實時運行,物理引擎往往用逼近法去模擬力學原理。為了不讓逼近法失控,會加入一些阻尼。這些阻尼是按步起作用的。所以,這也會讓物理引擎變得不穩定。
這些例子很嚴重,鞭策我們繼續改進…
加把勁
引擎中的一個部分不會收幀率變化的影響,那就是渲染。因為渲染引擎只是繪制一瞬間的內容,它不關心與上一次繪制之間的時間有多長。它渲染的是當前的一切。
我們可以利用這一點進行改進。我們可以用固定的時間間隔去更新游戲,這樣會讓一切都更簡單更穩定,像物理和AI。而我們可以去調整渲染的速度,以節省一些處理時間。
就像這樣:從上一次更新開始,到現在所用的一個固定的時間段,我們把這段時間當成真實世界比我們“領先”的時間。我們用一連串的固定時間來追趕它,代碼如下:
在每一幀的開始,我們根據消耗的真實時間,更新lag。這代表了游戲時間比真實世界時間慢了多少。然后我們在內部起一個循環去更新游戲,每一步消耗一個固定時間段,直到它追上現實時間。一旦我們干上它,我們再開啟渲染。就像這樣:
?
注意這里的時間間隔不再是可見的幀率了。MS_PER_UPDATE只是我們用來更新游戲的時間粒度。間隔越短,追趕真實時間的處理次數就越多。間隔越長,游戲變化就月劇烈。你會希望這個時間足夠短,最好比60FPS還要快,這樣游戲就會在快機器上模擬的更準確。
但是要小心不能搞的太短,你需要確保這個時間段要比update()的時間長,即使是在最慢的機器上。否則,你的游戲就會跟不上真實時間。
幸運的是,我們為自己留了一些余地。我們把渲染強行從更新循環中分拆出來。這也節省了很大一部分CPU時間。渲染的結果就是游戲可以用固定的時間間隔和恒定的速度運行,不管在什么樣的機器上。只不過在慢機器上,玩家看到的變化稍微劇烈一些。
卡在中間
我們還遺留了一個問題。我們用固定的時間間隔更新游戲,但是我們在不確定的時間點渲染。這意味著在玩家看來,游戲經常在兩次更新之間進行顯示。
這是時間線:
?
你看到了,我們的更新操作很緊湊,間隔固定。然而我們的渲染卻不一樣,比更新頻率要低,并且不穩定。這樣也還好。令人不爽的是渲染并不一定跟更新在一起。看一下第三次渲染。它正好在兩次更新之間:
?
想像一下,一顆子彈要穿越屏幕。在第一次更新的時候,它在屏幕左邊。第二次更新的時候它移動到了屏幕右邊。游戲在兩次更新之間做了渲染,所以玩家期待的應該是子彈出現在屏幕中央。在我們現有的實現中,它仍然會出現在屏幕左側。這就意味著游戲看起來會磕磕絆絆。
不過,我們知道渲染的時間離兩次更新的時間有多久:它儲存在lag里面。當它小于更新間隔時,我們跳出更新循環,而不是為0的時候。這就是我們距離下一次更新的時間。
當我們渲染的時候,我們把它傳進去。
渲染器知道每一個游戲對象和它當前的速度。發現子彈離屏幕的左邊緣20像素,并且每幀向又移動400像素。如果我們在兩幀的正中間,我們會傳入0.5給render()。因此,它就會把子彈華仔中間,在220像素上。噠噠!平滑的運動。
當然,這也許會造成一個錯誤結果。當我們計算下一幀的時候,可能會發現子彈遇到了障礙,或者減速了,或者有別的變化。我們渲染的位置有可能跟它下一幀的位置沖突。但是如果不完全更新完物理和AI,我們也不知道。
所以,這種推演或多或少的是在猜測并且有時會出錯。幸運的是這種錯誤不容易被發現。至少它不會比卡頓更容易被感受到。
設計決策
限于篇幅,有更多的內容我們并沒有講。一旦攙和進了像垂直同步,多線程,多GPU這些東西,一個真正的游戲循環就會變得很恐怖。在更高的層次,有幾個問題你需要考慮:
是你控制游戲循環,還是平臺?
更多情況下,這不需要你選擇。如果你的游戲運行在瀏覽器上,你就不能寫你自己的傳統意義上的游戲循環。瀏覽器的基于事件的機制天然支持。與之相似,如果你用現成的游戲引擎,你就直接用它提供的游戲循環,而不需要自己造輪子。
使用平臺的消息循環:
1> 簡單,你不需要擔心寫和優化游戲的核心循環。
2> 它可以在平臺上很好的運行。你不需要關注是不是要給平臺時間去處理它自己的消息,緩存消息,或者處理輸入沖突。
3> 你會喪失對時間的控制。平臺會在他覺得合適的時間去調用你的代碼。如果調用頻率和流暢度你不滿意,那也沒招,更可惡的是,大多數應用消息循環在設計的時候壓根就沒考慮游戲,所以經常很慢,切不穩定。
使用游戲引擎的循環:
1>你真的沒必要自己寫。寫一個游戲循環是很蛋疼的事情。因為核心代碼每一幀都會調用,所以一些很小的bug和效率問題都會大大的影響你的游戲。一個可靠的游戲循環是選擇已有引擎的一個很重要的原因。
2> 你不去寫它,當然,帶來的問題就是,即使引擎不能完美的滿足你,你也束手無策。
自己寫:
1> 完全控制。你可以為所欲為。你可以設計成最適合你游戲的方式。
2> 你必須要面對平臺。應用框架或者操作系統通常都需要處理一些自己的消息。如果你接管了循環,它就得不到這些消息了。你需要手動控制這些消息,好讓框架不被卡死。
如何管理能量消耗?
這不像5年前那樣,游戲是運行在街機或者一些專用的手持設備上。現在出現在了智能手機,筆記本,和其他移動設備上。就出現了你需要關注的問題了。一個游戲運行的很漂亮,但是不到半小時就把玩家的手機搞的沒電了,這不會讓玩家開心。
現在,你不僅需要考慮你的游戲看起來很漂亮,還需要考慮盡可能的少用CPU。也就是當你在一幀中做完了該做的時間,就讓CPU去sleep。
盡可能的快跑:
這可能是PC游戲的做法(也可能運行在筆記本上)。你的游戲循環可能從不讓操作系統調用sleep,相反,空余的時鐘,會被用來提高FPS,或者圖形表現力。
這回給你帶來最好的游戲體驗,但是也會耗費更多的電。如果玩家用筆記本,他們就會得到一個很好的暖腿寶。
限制幀率:
移動端游戲通常更注重游戲性而不是圖形表現。很多游戲都會這只幀率上限(通常是30或者60FPS)。如果游戲循環在時間限制前完成工作,它會調用sleep歇一歇。
這給了玩家“足夠的”體驗,并且讓電池更抗用一些。
怎樣控制游戲速度?
一個游戲循環有兩個要點:不阻斷的用戶輸入和適應時間分割。輸入很直觀,關鍵在于你如何處理時間。有無數的平臺可以跑游戲,但一款游戲只能在少數幾個平臺上跑,如何適應變化是關鍵。
固定時間間隔并且不同步
這就是我們的第一個示例代碼。你的游戲循環有多快,跑多快。
1> 簡單,這是主要(呃,也是唯一的)好處。
2> 游戲速度完全依賴硬件和游戲復雜度。主要缺點就是只要有一點變化就會影響游戲速度。這正是游戲循環要解決的。
帶同步的固定時間間隔
進一步復雜一點的就是使用固定時間間隔但是加入驗車或者同步點,避免游戲運行的太快。
1> 也很簡單。它只比前面的多了一行代碼。在多數游戲循環中,不管怎樣,都要進行同步。至少你需要雙緩存你的圖形,并且同步翻轉緩沖區,去刷新顯示。
2> 對能量很友好。這對移動游戲顯得更重要。你不希望干掉玩家的電池。你只需要讓它sleep幾毫秒,而不是把更多的處理塞進去,就會節省能量。
3> 游戲不會跑的太快,這就消除了固定間隔的一半憂慮。
4> 游戲跑太慢。如果它用太長的時間去更新和渲染,游戲也會變慢。因為這種做飯并沒有把更新和渲染分離。它不僅會降低幀率,也會降低游戲速度。
可變時間間隔
我在這里把它當成一種可選方案,因為我認識的開發者,大多數都不贊成這種做法。最好記住為什么它是一個壞想法:
1> 它適應過快或者過慢。如果游戲不能追上真實時間,它就會增加時間間隔,直到追上。
2> 它會讓游戲變得不可控,不穩定。這才是真正的問題。在可變的時間間隔下,物理和網絡就會變得更難控制。
固定的更新時間,可變的渲染
我們最后展示的代碼是最復雜的,但也是適應性最強的。它用固定時間間隔更新,但是如果需要,可以丟掉渲染幀去追趕真實時間。
1> 它適應過快或過慢。只要游戲能夠及時更新,游戲時間就不會落后。如果玩家的機器夠好,那就會得到更流暢的游戲體驗。
2> 它更復雜。主要的缺點就是實現起來更復雜,你必須確定一個更新時間間隔,足夠小已適應高端機器,足夠大以適應低端機器。
總結
以上是生活随笔為你收集整理的游戏编程设计模式——Game Loop的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 那些年,我在游戏开发中改过的bug:靠不
- 下一篇: 如何解决游戏延迟,增强用户体验? 几种可