游戲編程模式--游戲循環


 游戲循環

  游戲循環可以說是游戲編程模式中的精髓,幾乎所有的游戲都包含它,相比而言,那些非游戲的程序卻很難見它的蹤影。這是為什么了?原因在於交互。

  解釋之前我們先回想一下早期的程序員的工作方式:他們先寫好程序,然后把代碼丟給計算機,然后計算機執行幾個小時再回來查看執行結果。想想如果執行出錯,排查錯誤的工作會怎么樣?程序員意識到這個問題,於是交互式編程就誕生了。它有點像這個樣子:

  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);
}

  當然,現代的計算機程序都是按事件的方式來處理的,所以用事件的概念來寫就是:

while(true)
{
    Event* event = waitForEvent();
    dispatchEvent(event);
}

  這種改寫本質上與之前的並沒有什么區別。但我們發現一個問題,就是用戶沒有輸入的時候,程序就會阻塞在waitForEvent上,但現實中我們的游戲並不會這樣,它會一直運行,畫面也會不斷的更新。所以真實的游戲循環的第一個特點就是:它處理用戶輸入,但並不等待用戶輸入,游戲循環始終在執行:

while(true)
{
    processInput();
    update();
    render();
}

  上述代碼就是游戲循環最基本的結構了。processInput處理兩幀之間的所有用戶輸入,upate讓游戲(數據)模擬迭代一步,它執行游戲AI和物理計算(這是常見順序);最后render對游戲進行渲染以將游戲內容展現給玩家。

  很明顯,上述程序會以盡可能快的速度運行。兩個因素決定了幀率:

  1.循環每一幀要處理的信息量,信息量越大需要的時間就越多;

  2.底層平台的速度。速度越快的芯片在相同的時間內能夠處理更多的代碼。多核、多CPU、專用聲卡以及操作系統的調度器都影響着你在一幀中所能處理的代碼量。

  早期的游戲每幀被精心設計的剛好能在一幀的時間內完成代碼的運行,以便它能夠在開發者期望的速度下運行。但假如你在一個稍快或稍慢的機器上運行,則游戲會發生加速或減速的想象。而今,很少有開發者對他們的游戲所運行的硬件平台有精確的了解,取而代之的是他們必須讓游戲智能的適配多種硬件機型。而這就是循環模式的另一個特點:這一模式讓游戲在一個與硬件無關的速度常量下運行。

  綜上,我們可以對游戲循環模式做一個定義:一個游戲循環會在游戲過程中持續的運轉。每循環一次,它非阻塞的處理用戶的輸入,更新游戲狀態,並渲染游戲。它跟蹤流逝的時間並控制游戲的速率。

 示例代碼

  接下來讓我們一步一步的來完善游戲循環的實現方式,首先,先寫一個最簡單的游戲循環。

while(true)
{
    processInput();
    update();
    render();
}

  這就是之前我們的那個寫法,很明顯,這個是最簡單的實現方法。但它的問題也很明顯——你無法控制游戲運轉的快慢,它受底層硬件的影響很大。我們可以對它做一些小改動,假設你希望讓游戲以60幀每秒的速度運行,即一幀時間大概是16毫秒。假如你能在這16毫秒內進行所有的游戲更新和渲染,那么你的游戲就可以這個穩定的幀率來執行,而你所需要的就是處理這一幀,等待下一幀的到來,代碼如下:

while(true)
{
    double start = getCurrentTime();
    processInput();
    update();
    render();
    
    sleep(start+MS_PER_FRAME-getCurrentTime());
}

  這里的一個改進就是當程序不需要一幀的時間就處理完了所有工作,就sleep剩余的時間,保證游戲不會過快的運行。但如果游戲過慢時,這個改進將毫無幫助。我們還可以嘗試更復雜的辦法,現在我們的問題是:

  1.每次更新游戲花去一個固定的時間值;

  2.需要花些實際時間來進行更新。

  加入第二步的時間長於第一步,那么游戲會變慢。例如當需要16毫秒以上的時間來更新幀速為16毫秒每幀的游戲時,就可能無法維持運行速度。但假如我們能在單獨一幀這樣紅進行超過16毫秒的游戲狀態更新,那么我們可以不那么頻繁的更新游戲並且能夠趕上游戲的行進速度。具體的想法就是計算這一幀距離上一幀的實際時間間隔作為更新步長。幀處理的實際時間越長,這個步長也就越長。這個辦法使得游戲總會越來越接近實際時間。他們稱此為變值時間步長(或者浮動時間步長),代碼如下:

double lastTime = getCurrentTime();
while(true)
{
    double current = getCurrentTime();
    double elapsed = current - lastTime;
    processInput();
    update(elapsed);
    render();
    
    lastTime = getCurrentTime();
}

   這個方法非常的巧妙,比如子彈飛行的速度,若在之前的循環中,不同機器上子彈表現的速率是不同的,但使用這種浮動步長則可以很好的處理這種情況,在慢的機器上,處理一幀的時間較長,所以步長也長,而在較快的機器上則較短,處理的時候使用(速度*步長)計算子彈在這一幀中行進的距離,這樣,不同機器上子彈一秒移動的距離就表現的相同了。看樣子好像達到了我們的目的,但這種方式也隱藏這不少的問題。首先就是浮點數計算的問題,我們知道計算做浮點數運算是會有誤差的,幀率較高的機器更新的次數多,所以執行的浮點數計算也多,相應的累計誤差也更大,這樣在積累了足夠的誤差后,不同的機器上,子彈的位置將不一樣。另一個就是會影響物理引擎,游戲的物理引擎通常會做實際物理規則的近似。為了防止這近似計算失控,系統通常都會進行減幅運算。這個減幅運算被小心的安排成以某個固定時長進行,而采用浮動步長將會導致物理引擎也變得不穩定。

   那么讓我們再觀察一下現在的實現,會發現render這一部分是不受步長影響的一部分,通常,游戲引擎中渲染也不會受步長影響。因為渲染引擎表現的是游戲時間中的一瞬間,所以它並不關心距離上次渲染過去了多長時間,它只是把當前游戲狀態渲染出來而已。我么可以利用這一點,還是使用固定步長更新,因為這會使物理引擎和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();
}

   這段代碼的邏輯就是追趕的部分不再是更新一次就立即追上玩家時間,而是分多次固定步長來更新。MS_PER_UPDATE這個常量只是我們更新游戲的時間間隔,不再是視覺上的幀率。這一間隔越短,追趕實際時間花費的處理次數就越多。間隔越大,跳幀就越明顯。理論上,你希望它足夠短,通常快於60FPS,以使游戲維持高保真度。但要注意,別讓它過短。你必須保證這個時間步長大於每次update()函數的處理時間,即使再最慢的機器上也如此,否則,你的游戲便跟不上現實時間。

   很幸運,我們贏得了一些喘息的時間。我們通過把渲染拉出更新循環之外來實現這一點。這一方法釋放了大量的CPU時間。最后的結果是游戲通過國定時間步長更新,實現了在多硬件平台上以恆定的速率進行游戲模擬。只不過在地段機器上玩家會看到游戲窗口出現跳幀的情況。

  不過,這里還有一個問題,就是殘留的延遲。我們的更新是固定步長更新,但渲染時隨機的時間點。這意味着從玩家的角度來看,游戲常會在兩次更新之間渲染出完全相同的畫面。還是那子彈的飛行來分析,因為渲染時間點並不總是在更新完成時,也就是說完全可能在兩次更新之間渲染,所以就有這樣的情況出現,前一幀子彈在屏幕左側,后一幀子彈在屏幕的右側,如果渲染在兩幀之間進行,那么玩家希望看到的是子彈在屏幕的中間,但按照上面的實現方法,子彈依然會在屏幕的左側。這意味着動作看起來會顯得卡頓而不流暢。那如果處理這種情況了?上面的代碼有個問題,就是我們不是在lag等於0的時候退出循環,而是在lag小於MS_PER_UPDATE的時候,那這個時候的lag表示什么了?其實這時候的lag表示的是進入下一幀(渲染)的時間間隔,也就是實際渲染的畫面應該是當前再往前更新lag時間的畫面。所以一個優化就是把這個時間傳入render函數,我們假設當前的變化趨勢不變,利用這個時間進行一個預測,比如子彈的速度是400像素每秒,那我們傳入0.5秒的時候,那它出現的位置我們就預測為當前位置+200像素。當然這個推斷不一定准確,比如子彈在lag時間內碰到牆反彈了,那這個時候計算的位置就不對了。這個我們是沒有辦法的,除非物理引擎更新完成。但這個瑕不掩瑜,比不預測造成的卡頓情況要好太多。

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);    //歸一化后無需擔心幀率
}

 設計決策

  雖然我們已經做了很多的優化了,但依然留下了很多的問題。一旦你考慮諸如與顯示器刷新速率的同步、多線程、GPU等因素,實際的游戲循環將會變得復雜許多。在這樣的高級層面,你可能需要考慮一下這些問題:

  誰來控制游戲循環

  這是你或多或少要面臨的問題。假如你的游戲嵌入瀏覽器里,那么你往往無法自己編寫經典的游戲循環。瀏覽器自帶基於事件的機制已經預先包含了這一循環。類似的,假如你使用了現成的游戲引擎,你也將以來於它的游戲循環而不是你自己來控制。每個方法都有其優缺點:

  •   使用平台的事件循環

  優點是相對簡單,你無須擔心游戲核心循環的代碼和優化的問題;它也會於平台協作的很好,但缺點就是你失去了時間的控制,平台會在其認為合適的時候調用你的代碼,而且更糟的是許多應用程序的事件循環的概念並不同於游戲——它們通常很慢且斷續。

  •   使用游戲引擎的游戲循環

  這也是大多數游戲采用的做法,因為編寫一個好的游戲循環需要很多的技巧,而游戲引擎的通常是由業內非常有經驗的人來編寫的,其性能和安全性都有保證,唯一的缺點就是當出現一些於引擎循環不那么合拍的需求時,你無法獲得循環的控制權。

  •   自己編寫游戲循環

  優點很明顯,一切都在你的掌控中,但隨之而來的是你不得不處理游戲之后的一些事,比如操作系統的事件處理。

  能量損耗

  這里的選擇有兩個:第一就是盡可能快的跑,這會提供最好的游戲體驗但會消耗更多的能量,通常建議在pc上這么干;第二就是限制幀率,在移動平台上,電池電量是有限了,所以我們要節省電量的使用,限制幀率后,在本幀處理完后的空余時間,游戲將會休眠。

  如何控制游戲速度

  上面我們給出了四個版本的游戲循環實現方法,分別是

  •   非同步的固定時間步長

  它是最簡單的方式,但游戲速度受硬件平台和游戲復雜度的影響,

  •   同步的固定時長

  它是我們的第二種實現方式,這個依然很簡單,它省電而且不會運行過快,但它可能過慢,除非外置幀渲染並同步。

  •   變時步長

  優點是能適應過快或過慢的硬件平台。但采用這種方式,游戲將變得很不穩定,尤其是物理模塊和網絡模塊。

  •   定時更新、變時渲染

  這個是我們實現中最復雜的方式,能適應過快或過慢的硬件平台。但缺點就是復雜,要真正使用還有很多的工作要做。

  

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM