遊戲主循環(Game Loop)的詳細解析

譯自:https://dewitters.com/dewitters-gameloop/

一. 引言

  遊戲主循環是每個遊戲的心跳,輸送着整個遊戲需要的養分。不幸的是沒有任何一篇好的文章來指導一個菜鳥遊戲程序員如何爲自己的程序供養。不過不用擔心,因爲你剛好不小心看到了這篇,也是唯一一篇給予這個話題足夠重視的文章。

  由於我身爲遊戲程序員,我見過許許多多的手機小遊戲的代碼。這些代碼給我展示了五彩繽紛的遊戲主循環實現方法。你可能要問:“這麼簡單的一個小玩意還能做到千奇百怪?” 事實就是這樣,我就會在此文中討論一些主流實現的優缺點,並且給你介紹在我看來最好的輸送養分的解決方案。

  1. 最基本的遊戲主循環

  每一個遊戲都是由獲得用戶輸入,更新遊戲狀態,處理AI,播放音樂和音效,還有畫面顯示這些行爲組成。遊戲主循環就是用來處理這個行爲序列。如我在引言中所說,遊戲主循環是每一個遊戲的心跳。在此文中我不會深入講解上面提到的任何一個行爲,而只詳細介紹遊戲主循環。所以我把這些行爲簡化爲了兩個函數:

update_game();  //更新遊戲狀態 (後文可能翻譯爲邏輯幀)
display_game(); //更新顯示 (顯示幀)

  下面是最簡單的遊戲主循環:

bool game_is_running = true;
while( game_is_running ) {
    update_game();
    display_game();
}

  這個簡單循環的主要問題是它忽略了時間,遊戲會盡情的飛奔。在小霸王機器上運行會使玩家有極強的挫敗感,在牛逼的機器上運行則會要求玩家有超人的判斷力和 APM(原意爲慢的機器上運行慢,快的機器上運行快)。在遠古時代,硬件的速度已知的情況下,這不算什麼,但是目前有如此多的硬件平臺使得我們不得不去處理時間這個重要因素。對於時間的處理有很多的方法,接下來我會一一奉上。

  首先我會解釋兩個貫穿全文的術語:

  每秒幀數(後簡稱FPS)

  FPS是Frames Per Second的縮寫。在此文的上下文中它意味着display_game()每秒被調用的次數。

  遊戲速度

  遊戲速度是每秒更新遊戲狀態的速度,換言之,即update_game()每秒被調用的次數。

  2. FPS依賴於恆定的遊戲速度

  實現

  一個讓遊戲每秒穩定運行在25幀的解決方案如下:

const int FRAMES_PER_SECOND = 25;
const int SKIP_TICKS = 1000 / FRAMES_PER_SECOND;
DWORD next_game_tick = GetTickCount();
// GetTickCount() returns the current number of milliseconds
// that have elapsed since the system was started
int sleep_time = 0;
bool game_is_running = true;
while (game_is_running) {

	update_game();
	display_game();

	next_game_tick += SKIP_TICKS;
	sleep_time = next_game_tick - GetTickCount();
	if (sleep_time >= 0) {
		Sleep(sleep_time);
	}else {
		//Shit, we are running behind!
	}
}

注:
兩個重要變量來控制恆定幀數:
next_game_tick:這一幀完成的時間點
sleep_time:若大於0,則目前時間沒到完成這一幀的時間點。啓用Sleep等待時間點的到達;若小於0,則該幀的工作沒完成。
恆定幀數的好處:防止整個遊戲因爲跳幀而引起畫面的撕裂。性能較低的硬件會顯得更慢;而性能高的硬件則浪費了硬件資源。

  這個方案有一個非常大的優點:簡單!因爲你知道update_game()每秒被調用25次,那麼你的遊戲的邏輯部分代碼編寫將非常直白。比如說在這種主循環實現的遊戲中實現一個 重放函數將非常簡單(譯者注:因爲每幀的間隔時間已知,只需要記錄每一幀遊戲的狀態,回放時按照恆定的速度播放即可。就像電影膠片一樣)。如果在遊戲中沒有受到隨機值的影響,只需要記錄玩家的輸入就可以實現重放。

  在你實現這個循環的硬件上你可以按需要調整FRAMES_PER_SECOND到一個理想的值,但是這個遊戲主循環實現會在各種硬件上表現得怎麼樣呢?

  小霸王機

  如果硬件可以應付指定的FPS,那麼不會有什麼事情發生。但是小霸王通常是應付不了的,遊戲就會卡。在極端情況下就會卡得掉渣,或者一步十卡、一卡十步(原意爲某些情況下游戲速度很慢,有一些情況下又比較正常)。這樣的問題會毀掉你的遊戲,使得玩家及其挫敗。

  牛逼的機器

  在牛逼的機器上似乎不會有任何問題,但是這樣的遊戲主循環浪費大量的時鐘循環!牛逼的機器運行這個遊戲可以輕鬆的跑到300幀,卻每秒只運行了25或者30 幀~ 那麼這個主循環實現會讓擁有牛逼硬件的玩家無法盡情發揮其硬件效果產生極大的挫敗感(原意爲這樣的實現會讓你的視覺效果受到影響,尤其是高速移動物體)。

  從另外一個角度來說,在移動設備上,這一點可能會是一個優點。遊戲持續的高速運行會很快地消耗電池。

  結論

  基於恆定遊戲速度的FPS的主循環實現方案簡單易學。但是存在一些問題,比如定義的FPS太高會使得老爺機不堪重負,定義的FPS太低則會使得高端硬件損失太多視覺效果。

  3. 基於可變FPS的遊戲速度

  實現

  另外一種遊戲實現可以讓遊戲儘可能的飛奔,並且讓依據FPS來決定遊戲速度。遊戲狀態會根據每一顯示幀消耗的時間來進行更新。

DWORD prev_frame_tick;
DWORD curr_frame_tick = GetTickCount();
bool game_is_running = true;
while (game_is_running) {
	prev_frame_tick = curr_frame_tick;
	curr_frame_tick = GetTickCount();
	update_game(curr_frame_tick - prev_frame_tick);
	display_game();
}

注:
prev_frame_tick:上一幀完成的時間點
curr_frame_tick:目前的時間點
curr_frame_tick和prev_frame_tick的差即爲一幀所需的時間,根據這一個變量,每一幀的時間是不一樣的,FPS也是可變的
緩慢的硬件有時會導致某些點的某些延遲,遊戲變得卡頓。
快速的硬件也會出現問題,幀數可變意味着在計算時不可避免地會有計算誤差。
這種遊戲循環一見鍾情似乎很好,但不要被愚弄。慢速和快速硬件都可能導致遊戲出現嚴重問題。此外,遊戲更新功能的實現比使用固定幀速率時更難,爲什麼要使用它?

  這個遊戲主循環的代碼比起之前稍微複雜一些,因爲我們必須去考慮兩次update_game()調用之間的時間差。不過,好在這並不算複雜。

  初窺這個實現的代碼好像是一個理想的實現方案。我已經見過許多聰明的遊戲程序員用這種方式來書寫遊戲主循環。但是我會給你展示這個實現方案在小霸王和牛逼的機器上的嚴重問題!是的,包括非常職業非常嫺熟非常牛逼的玩家的機器。

  小霸王

  小霸王會在某些運算複雜的地方出現卡的情況,尤其在3D遊戲中的複雜場景更是如此。幀率的降低會影響遊戲輸入的響應,同時降低玩家的反應速度。遊戲狀態更新也會因此突然受到影響。這樣的情況會使得玩家和AI的反應速度減慢,造成玩家挫敗感加劇。比如一個在正常幀率下可以輕鬆越過的障礙會在低幀率下無法逾越。更嚴重的問題是在小霸王上會經常發生一些違反物理規律的怪事,如果這些運算涉及到物理模擬的話。

  牛逼的機器

  你可能會好奇,爲什麼剛纔的遊戲循環在飛快的機器上會出現問題。不幸的是,這個方案的確如此,首先,讓我給你介紹一些計算機數學知識。

  浮點數類型佔用內存大小是有限的,那麼有一些數值就無法被呈現。比如0.1就不能用2進製表示,所以會被近似的存儲在一個浮點數中。我用python給你們展示一下。

  >>> 0.1 => 0.10000000000000001

  這個問題本身並不怎麼具有戲劇性,但是這樣的後果卻截然相反。比方說你的賽車的速度是0.001個單元每微秒。那麼正確的結果是在10秒後你的賽車會移動10個單位,那麼我們這樣來實現一下:

def get_distance( fps ):
    skip_ticks = 1000 / fps
    total_ticks = 0
    distance = 0.0
    speed_per_tick = 0.001
    while total_ticks < 10000:
        distance += speed_per_tick * skip_ticks
        total_ticks += skip_ticks
    return distance

  現在我們來得到40幀每秒時運行10秒後的結果

  >>> get_distance( 40 ) 得到 10.000000000000075

  等等~怎麼不是10呢?發生了什麼?嗯,400次加法後的誤差就有這麼大,每秒運行100次加法後又會是怎麼一個樣子呢?

  >>> get_distance( 100 ) 得到 9.9999999999998312

  誤差越來越大了!那麼40幀每秒的結果和100幀每秒之間誤差差距是多大呢?

  >>> get_distance( 40 ) - get_distance( 100 ) 得到 2.4336088699783431e-13

  你可能會想這樣的誤差可以忽略。但是真正的問題出現在你使用這些錯誤的值去進行更多的運算。小的誤差會被擴大爲致命的錯誤!然後這些錯誤會在遊戲飛奔的同時毀掉它!這些問題發生的機率絕對大到足夠引起你的注意。我有見過因爲這個原因在高幀率出現問題得遊戲。之後那個遊戲程序員發現這些問題出現在遊戲的核心部分,只有重寫大部分代碼才能修復它。

  結論

  這樣的遊戲主循環看上起不錯,但是並不怎麼樣。不管運行它的硬件怎樣,都可能出現嚴重的問題。另外,遊戲實現的代碼相對於固定遊戲速度的主循環而言更加複雜,那你還有什麼使用它的理由呢?

  4. 最大FPS和恆定遊戲速度

  實現

  我們的第一個實現中,FPS依賴於恆定的遊戲速度,在低端的機器上會出現問題。遊戲速度和遊戲顯示都會出現掉幀。一個可行的解決方案是犧牲顯示幀率的來保持恆定的遊戲速度。下面就實現了這種方案:

const int TICKS_PER_SECOND = 50;
const int SKIP_TICKS = 1000 / TICKS_PER_SECOND;
const int MAX_FRAMESKIP = 10;
DWORD next_game_tick = GetTickCount();
int loops;
bool game_is_running = true;
while(game_is_running ) {
  loops = 0;
  while(GetTickCount() > next_game_tick && loops < MAX_FRAMESKIP) {
      update_game();
        next_game_tick += SKIP_TICKS;
      loops++;
  }
  display_game();
}

注:
MAX_FRAMESKIP : 幀數縮小的最低倍數
最高幀數爲50幀,當幀數過低時,渲染幀數將降低(display_game()),最低可至5幀(最高幀數縮小10倍),更新幀數保持不變(update_game())
在慢速硬件上,每秒幀數會下降,但遊戲本身有望以正常速度運行。
遊戲在快速硬件上沒有問題,但是像第一個解決方案一樣,你浪費了很多寶貴的時鐘週期,可以用於更高的幀速率。在快速更新速率和能夠在慢速硬件上運行之間找到平衡點至關重要。(最重要的原因是限制了幀數)
缺點與第二種方案相似。

  遊戲會以穩定的50(邏輯)幀每秒的速度更新,渲染速度也儘可能的快。需要注意的是,如果渲染速度超過了50幀每秒的話,有一些幀的畫面將會是完全相同的。所以顯示幀率實際上也等同於最快每秒50幀。在小霸王上運行的話,顯示幀率會在更新遊戲狀態循環達到MAX_FRAMESKIP時下降。從上面這個例子來說就是當渲染幀率下降到5(FRAMES_PER_SECOND / MAX_FRAMESKIP)以下時,遊戲速度會變慢。

  小霸王

  在小霸王上運行這樣的遊戲循環會出現掉幀,但是遊戲速度不受到影響。如果硬件還是沒有辦法處理這樣的循環,那麼遊戲速度和遊戲幀率都會受到影響。

  牛逼的機器

  在牛逼的機器上這個遊戲循環不會出現問題,但是如同第一個解決方案一樣,還是浪費了太多的時鐘週期。找到一個快速更新並且依然能夠在小霸王上運行遊戲的平衡點是至關重要的!

  結論

  使用上面的這個方案可以使遊戲的實現代碼比較簡單。但是仍然有一些問題:如果定義了一個過高的FPS會讓小霸王喫不消,如果過低則會讓牛逼的機器難以發揮性能。

  5. 獨立的可變顯示幀率和恆定的遊戲速度

  實現

  有沒有可能對之前的那種方案進行優化使得它在各種平臺上都有足夠好的表現呢?當然是有的!遊戲狀態本身並不需要每秒更新60次。玩家輸入,AI信息等都不需要如此高的幀率來進行更新,大約每秒25次就足夠了。所以,我們可以試着讓update_game()每秒不多不少的被調用25次。渲染則放任不管,讓其飛奔。但是不能讓小霸王的低渲染幀率影響到遊戲狀態更新的速度。下面就是這個方案的實現:

const int TICKS_PER_SECOND = 25;
const int SKIP_TICKS = 1000 / TICKS_PER_SECOND;
const int MAX_FRAMESKIP = 5;
DWORD next_game_tick = GetTickCount();
int loops;
float interpolation;
bool game_is_running = true;
while( game_is_running ) {
  loops = 0;
  while(GetTickCount() > next_game_tick && loops < MAX_FRAMESKIP) {
      update_game();
      next_game_tick += SKIP_TICKS;
      loops++;
  }
  interpolation = float(GetTickCount() + SKIP_TICKS - next_game_tick) / float(SKIP_TICKS);
  display_game(interpolation);
}

注:
渲染幀數是預測與插值實現的。 interpolation 計算等價的幀數

  使用這種方案的update_game()實現會比較簡單,相對而言,display_game()則會變得稍許複雜。你需要實現一個接收插值參數的預言函數,這並不是什麼難事,只是需要做一些額外的工作。我會接着解釋這個預言函數是如何工作的,不過首先讓我告訴你爲什麼需要這樣的一個函數。

  遊戲狀態每秒被更新25次,如果你渲染的時候不使用插值計算,渲染幀率就會被限定在25幀。需要注意的是,25幀並沒有人們想象中的糟糕,電影畫面在每秒 24幀的情況下依然流暢。所以25幀可以很好的展示遊戲畫面,不過對於高速移動的物體,更高的幀率會帶來更好的效果。所以我們要做的是,在顯示幀之間讓高速移動的物體平滑過度。這就是我們需要一個插值和預言函數的原因。

  插值和預言函數

  如我之前所說,遊戲狀態更新在一個恆定的幀率下運行着,當你渲染畫面的時刻,很有可能就在兩個邏輯幀之間。假設你已經第10次更新了你的遊戲狀態,現在你需要渲染你的場景。這次渲染就會出現在第10次和第11次邏輯幀之間。很有可能出現在第10.3幀的位置。那麼插值的值就是0.3。舉個例子說,我的一輛賽車以下面的方式計算位置。

position = position + speed;

  如果第10次邏輯幀後賽車的位置是500,速度是100,那麼第11幀的位置就會是600. 那麼在10.3幀的時候你會在什麼位置渲染你的賽車呢?顯而易見,應該像下面這樣:

view_position = position + (speed * interpolation)

  現在,賽車將會被正確地渲染在530這個位置。

  基本上,插值的值就是渲染髮生在前一幀和後一幀中的位置。你需要做的就是寫出預言函數來預計你的賽車/攝像機或者其他物件在渲染時刻的正確位置。你可以根據物件的速度來計算預計的位置。這些並不複雜。對於某些預計後的幀中出現的錯誤現象,如某個物體被渲染到了某個物體之中的情況的確會出現。由於遊戲速度恆定在每秒更新25次狀態,那麼這種錯誤停留在畫面上的時間極短,難以發現,並無大礙。

  小霸王

  大多數情況下,update_game()執行需要的時間比display_game()少得多。實際上,我們可以假設在小霸王上update_game()每秒還是能運行25次。所以遊戲的邏輯狀態不會受到太大的影響,即使FPS非常低。

  牛逼的機器

  在牛逼的硬件上,遊戲速度會保持每秒25次,屏幕更新卻可以非常快。插值的方案可以讓遊戲在高幀率中有更好的畫面表現。但實質上游戲的狀態每秒只更新了25次。

  結論

  使遊戲狀態的更新獨立於FPS的解決方案似乎是最好的遊戲主循環實現。不過,你必須實現一個插值計算函數。

  6. 整體總結

  遊戲主循環對遊戲的影響遠遠超乎你的想象。我們討論了4個可能的實現方法,其中有一個方案是要堅決避免的,那就是可變幀率來決定遊戲速度的方案(第3點)。

  一個恆定的幀率對移動設備而言可能是一個很好的實現,如果你想展示你的硬件全部的實力,那麼最好使用FPS獨立於遊戲速度的實現方案(第5點)。

  如果你不想麻煩的實現一個預言函數則可以使用最大幀率的實現方案(第4點),只是要找到一個幀率大小的平衡點。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章