1. 程式人生 > >iOS遊戲引擎剖析

iOS遊戲引擎剖析

               

譯自<<iPhone Game Development>>  O’Reilly   第2章

為了解決“如何在IPHONE上建立一個遊戲”這個大問題,我們需要首先解決諸如“如何顯示影象”與“如何播放聲音”等一系列小問題。這些問題關係到建立部分遊戲引擎。就像人類的身體一樣,遊戲引擎的每個部分雖然不同,但是卻都不可或缺。因此,首先從遊戲引擎剖析開始本章。我們將會討論一個遊戲引擎的所有主要部分,包括應用程式框架、狀態機、影象引擎、物理引擎、聲音引擎、玩家輸入和遊戲邏輯。

    寫一個好玩的遊戲是一項牽扯到很多程式碼的大任務。非常有必要從一開始就對專案進行良好的,有組織的設計,而不是隨著進度的進行而到處雜亂新增程式碼。就像建造房屋一樣,建築師為整幢房屋勾畫藍圖,建築工人以此來建造。但是,許多對遊戲程式設計不熟悉的程式設計人員會從根據導讀建造出房屋的一部分,並隨著學習的進行為其新增房間,這無疑將會導致不好的結果。

 圖2-1 遊戲引擎的功能結構

    圖2-1顯示了一個適用於大部分遊戲的遊戲引擎結構。為了理解一個遊戲引擎的所有部分和它們是如何工作在一起的,我們可以先為整個遊戲做設計,然後再建立我們的應用程式。在以下的幾個小節中,我們的講解內容將會涵蓋圖2-1的每個部分。

  • 應用程式框架
  • 遊戲狀態管理器
  • 影象引擎

    應用程式框架

     應用程式框架包含使應用程式工作的必須程式碼,包括建立一個應用程式例項和初始化其他子系統。當應用程式執行時,會首先建立一個框架類,並接管建立和銷燬狀態機、影象引擎和聲音引擎。如果我們的遊戲足夠複雜以至於它需要一個物理引擎,框架也會管理它。

    框架必須適應於我們所選擇的平臺的獨特性,包括相應任何的系統事件(如關機與睡眠),以及管理載入與載出資源以使其他的程式碼只需要集中與遊戲。

   主迴圈

    框架會提供主迴圈,它是一切互動程式後的驅動力量。在迴圈中的每一次迭代過程中,程式會檢查和處理接受到的事件,運行遊戲邏輯中的更新並在必要時將內容描畫到螢幕上。(參見圖2-2)

  圖2-2 主迴圈序列

    主迴圈如何實現依賴於你使用的系統。對於一個基本的控制檯程式,它可能是一個簡單的while迴圈中呼叫各個函式:

  1. while( !finished )
  2. {  
  3.     handle_events();  
  4.     update();  
  5.     render();  
  6.     sleep(20);  
  7. }  

    注意到這裡的sleep函式。它使得程式碼休眠一小段時間不致於佔用全部的CPU。

    有些系統完全不想讓使用者程式碼那些寫,它們使用了回撥系統以強制程式設計師常規的釋放CPU。這樣,當應用程式執行後,程式設計師註冊一些函式給系統在每次迴圈中回撥:

  1. void main(void
  2. {  
  3.     OS_register_event_handler( myEventHandler );  
  4.     OS_register_update_function( myUpdate );  
  5.     OS_register_render_function( myRender );  
  6. }  

    一旦程式執行後,根據必要情況,那些函式會間隔性的被呼叫。IPHONE是最接近後面這個例子。你可以在下一章和IPHONE SDK中看到它。

   遊戲狀態管理器

    一個好的視訊遊戲不僅有一組動作來維持遊戲:它會提供一個主選單允許玩家來設定選項和開始一個新遊戲或者繼續上次的遊戲;製作群屏將會顯示所有辛勤製作這款遊戲的人員的名字;而且如果你的遊戲沒有使用者指南,應該一個幫助區域會給使用者一些提示告訴他們應該做什麼。

    以上任何一種場合都是一種遊戲狀態,並且代表中一段獨立的應用程式程式碼片段。例如,使用者在主選單呼叫的函式與導航與使用者在製作群屏呼叫的是完全不同的,所以程式邏輯也是不同的。特別的是,在主選單,你可能會放一張圖片和一些選單,並且等待使用者選擇哪個選項,而在製作群屏,你將會把遊戲製作人員的名字描繪在螢幕上,並且等待使用者輸入,將遊戲狀態從製作群屏改為主選單。最後,在遊戲中狀態,將會渲染實際的遊戲並等待使用者的輸入以與遊戲邏輯進行互動。

    以上的所有遊戲狀態都負責相應使用者輸入、將內容渲染到螢幕、併為該遊戲狀態提供相對應的應用程式邏輯的任務。你可能注意到了這些任務都來自於之前討論的主迴圈中,這是因為它們就是同樣的任務。但是,每個狀態都會以它們自己的方式來實現這些任務,這也就是為什麼要保持他們獨立。你不必在主選單程式碼中尋找處理遊戲中的事件的程式碼。

   狀態機

    狀態管理器是一個狀態機,這意味著它跟蹤著現在的遊戲狀態。當應用程式執行後,狀態機會建立基本的狀態資訊。它接著建立各種狀態需要的資訊,並在離開每種狀態時銷燬暫時儲存的資訊。

    狀態機維護著大量不同物件的狀態。一個明顯的狀態是使用者所在螢幕的狀態(主選單、遊戲中等)。但是如果你有一個有著人工智慧的物件在螢幕上時,狀態機也可以用來管理它的“睡眠”、“攻擊”、“死亡”狀態。

    什麼是正確的遊戲狀態管理器結構?讓我們看看一些狀態機並決定哪種最適合我們。

    有許多實現狀態機的方式,最基本的是一個簡單的switch語句: 

  1. class StateManager 
  2. {  
  3.     void main_loop() 
  4.     {  
  5.         switch(myState) 
  6.         {  
  7.         case STATE_01:  
  8.             state01_handle_event();  
  9.             state01_update();  
  10.             state01_render;  
  11.             break;  
  12.         case STATE_02:  
  13.             state02_handle_event();  
  14.             state02_update();  
  15.             state02_render;  
  16.             break;  
  17.         case STATE_03:  
  18.             state03_handle_event();  
  19.             state03_update();  
  20.             state03_render;  
  21.             break;  
  22.         }  
  23.     }  
  24. };  

    改變狀態時所有需要做的事情就是改變myState變數的值並返回到迴圈的開始處。但是,正如你看到的,當我們加入越來越多的狀態時,程式碼塊會變得越來越大。而且更糟的是,為了使程式按我們預期的執行,我們需要在程式進入或離開某個狀態時執行整個任務塊,初始化該狀態特定的變數,載入新的資源(比如圖片)和釋放前一個狀態載入的資源。在這個簡單的switch語句中,我們需要加入更多的程式塊並保證不會漏掉任何一個。

    以上是一些簡單重複的勞動,但是我們的狀態管理器需要更好的解決方案。下面一種更好的實現方式是使用函式指標:

  1. class StateManager 
  2. {  
  3.     //the function pointer:
  4.     void (*m_stateHandleEventFPTR) (void);  
  5.     void (*m_stateUpdateFPTR)(void);  
  6.     void (*m_stateRenderFPTR)(void);
  7.     void main_loop() 
  8.     {  
  9.         stateHandleEventFPTR();  
  10.         m_stateUpdateFPTR();  
  11.         m_stateRenderFPTR();  
  12.     } 
  13.     void change_state(  void (*newHandleEventFPTR)(void),  
  14.                     void (*newUpdateFPTR)(void),  
  15.                     void (*newRenderFPTR)(void)  
  16.     ) 
  17.     {  
  18.         m_stateHandleEventFPTR = newHandleEventFPTR;  
  19.         m_stateUpdateFPTR = newUpdateFPTR;  
  20.         m_stateRenderFPTR = newRenderFPTR  
  21.     }  
  22. };  

    現在,即使我們處理再多狀態,主迴圈也足夠小而且簡單。但是,這種解決方案依然不能幫助我們很好的解決初始化與釋放狀態。因為每種遊戲狀態不僅包含程式碼,還有各自的資源,所以更恰當的做法是將遊戲狀態作為物件的屬性來考慮。因此,接下來,我們將會看看面向物件(OOP)的實現。

    我們首先建立一個表示遊戲狀態的類:

  1. class GameState  
  2. {  
  3.     GameState();        //constructor
  4.     virtual ~GameState();    //destructor
  5.     virtualvoid Handle_Event();  
  6.     virtualvoid Update();  
  7.     virtualvoid Render();  
  8. };  

    接著,我們改變我們的狀態管理器以使用這個類:

  1. class StateManager
  2. {  
  3.     GameState* m_state; 
  4.     void main_loop()
  5.     {  
  6.         m_state->Handle_Event();  
  7.         m_state->Update();  
  8.         m_state->Render();  
  9.     } 
  10.     void change_state( GameState* newState )
  11.     {  
  12.         delete m_state;  
  13.         m_state = newState;  
  14.     }  
  15. };  

    最後,我們建立一個指定具體遊戲狀態的類:

  1. class State_MainMenu : public GameState  
  2. {  
  3.     int m_currMenuOption;  
  4.     State_MainMenu();  
  5.     ~State_MainMenu();  
  6.     void Handle_Event();  
  7.     void Update();  
  8.     void Render();  
  9. };  

    當遊戲狀態以類來表示時,每個遊戲狀態都可以儲存它特有的變數在該類中。該類也可以它的建構函式中載入任何資源並在解構函式中釋放這些資源。

    而且,這個系統保持著我們的程式碼有很好的組織結構,因為我們需要將遊戲狀態程式碼分別放在各個檔案中。如果你在查詢主選單程式碼,你只需要開啟State_MainMenu類。而且OOP解決方案使得程式碼更容易重用。

    這個看起來是最適合我們需要的,所以我們決定使用它來作為我們的狀態管理器。

   影象引擎

    影象引擎負責視覺輸出,包括使用者藉以互動的圖形使用者介面(GUI)物件,2D精靈動畫或3D模型動畫,並渲染的背景與特效。

    雖然渲染2D與3D圖片的技術不盡相同,但他們都完成相同的一組圖形任務,包括紋理和動畫,它們的複雜度是遞增的。

    紋理

    對於顯示圖片,紋理是中心。2D時,一個平面圖片是以畫素為單位顯示在螢幕上,而在3D時,一組三角行(或者被稱為網格)在數學魔法作用下產生平面圖片並顯示在螢幕上。這以後,一切都變得複雜。

    畫素、紋理與圖片

    當進行螢幕描繪時,基本單位是畫素。每個畫素都可以被分解為紅、綠、藍色值和我們馬上要討論的Alpha值。

    紋理是一組關於渲染一組畫素的資料。它包含每個畫素的顏色資料。

    圖片是一個更高層的概念,它並非與一組特殊的畫素與紋理相關聯。當一個人看到一組畫素,他的大腦會將它們組合成一幅圖片,例如,如果畫素以正確的順序表示,他可能會看到一幅長頸鹿的畫像。

    保持以上這些概念獨立是非常必要的。紋理可能包含構成長頸鹿圖片的畫素:它可能包含足夠的畫素來構成一隻長頸鹿的多幅圖片,或者僅包含構成一幅長頸鹿圖片的畫素。紋理本身只是一組畫素的集合,它並不固有的知道它包含的是一幅圖片。

    透明度

    在任一刻,你的遊戲會有幾個或者多個物體渲染在螢幕上,其中一些會與另外一個重疊。問題是,如何知道哪個物體的哪個畫素應該被渲染出來呢?

    如果在最上層的紋理(在其他紋理之後被描畫)是完全不透明的,它的畫素將會被顯示。但是,對於遊戲物體,可能是非矩形圖形和部分透明物體,結果會導致兩種紋理的結合。

    2D圖片中最常用的混合方式是完全透明。假如我們想畫一幅考拉(圖2-3)在爬在桉樹頂上(圖2-4)的的圖片。考拉的圖片以矩形紋理的方式儲存在記憶體中,但是我們不想畫出整個矩形,我們只想畫出考拉身體的畫素。我們必須決定紋理中的每個畫素是否應該顯示。

    圖2-3 考拉紋理

 

圖2-4 桉樹紋理

    有些圖片系統通過新增一層遮罩來達到目的。想象我們在記憶體中有一幅與考拉紋理大小一樣的另外一份紋理,它只包含白色和黑色的畫素。遮罩中每個白色的畫素代表考拉應該被描畫出來的一個畫素,遮罩中的黑色畫素則代表不應該被描畫的畫素。如果當我們將考拉描畫到螢幕上時,有這樣的一個遮罩層,我們就可以檢查考拉對應的畫素並僅將需要描畫的畫素表示出來。如果每個畫素有允許有一組範圍值而不是二進位制黑/白值,那麼它還可以支援部分透明(參見圖2-5)。

 圖2-5 考拉遮罩紋理

    紋理混合

    為紋理而準備的儲存容量大到足以支援每個畫素都有一個範圍值。典型的是,一個Alpha值佔一個位元組,即允許0-255之間的值。通過合併兩個畫素可以表現出有趣的視覺效果。這種效果通常用於部分透明化,例如部分或完全看透物體(圖2-6)。

圖2-6 部分透明的綠色矩形

    我們可以為每個畫素來設定Alpha以決定它們如何被混合。如果一個畫素允許的範圍值為0-255,Alpha的範圍值也同樣應當為0-255。儘管紅色值為0表示當描畫時不應該使用紅色,但Alpha值為0則表示該畫素根本不應該被描畫。同樣,128的紅色值表示描畫時應該使用最大紅色值的一半,128的Alpha值表示當與另外一個畫素混合時,應該使用該畫素的一半顏色值。

    當混合物體時,正確的排列物體順序是非常重要的。因為每個混合渲染動作都只會渲染源物體與目標物體,首先被描畫的物體不會與後描畫的物體發生混合。儘管這在2D圖片中很容易控制,但是在3D圖片中變得非常複雜。

    旋轉
   

    在2D圖片中,大部分的紋理都會被直接渲染到目標上而不需要旋轉。這是因為標準硬體不具備旋轉功能,所以旋轉必須在軟體中計算完成。這是一個很慢的過程,而且容易產商低質量的圖片。

    通常,遊戲開發人員會通過預先渲染好物體在各個方向的圖片,並當物體某個方向上時,為其在螢幕上描畫正確的圖片來避免以上問題的發生。

    在3D圖片中,旋轉的計算方式與照明相同,是硬體渲染處理過程中的一部分。

    剪貼

    由於某些在後面章節解釋的原因,紋理的另外一個重要方面是剪貼。儘管我們目前的例子都是將源紋理直接描畫到目標紋理上,但是經常會出現的情況是,需要將部分源紋理描畫到目標紋理的有限的一部分上。

    例如,如果你的源紋理是在一個檔案中含有多幅圖片,裁剪允許你僅將希望描畫的部分渲染出來。

    剪貼同樣允許你進行描畫限制到目標紋理的一小部分上。它可以幫你通過紋理對映以3D方式渲染物體,將紋理鋪蓋到三角形組成的任意形狀的網格上。例如,一個紋理可以表示衣服或動物的毛皮,而且當3D角色穿著它移動的死後可能產生褶皺。這時候的紋理通常被稱作面板。

    動畫

    通過渲染連續的圖片,我們可以確保玩家看到一個移動的物體,儘管他所做的僅僅是在同樣的畫素上,但這些畫素在快速的改變顏色。這就是動畫的基本概念。2D動畫很簡單,但3D動畫通常牽扯到更多的物體與動作,因此更復雜。

    除了討論動畫技巧,這一節還會討論主要的優化型別可以使得我們的影象引擎有效的和可靠的完成複雜的不可能以原始方式來完成的圖形任務。一些主要的優化技巧包括淘汰、紋理排序、使用智慧紋理檔案、資源管理和細節級別渲染。

    2維動畫:精靈

    在2D影象中,如果我們要渲染馬兒賓士的完整場景,我們可以先創建出馬兒的賓士各個姿態的圖片。這種圖片成為一幀。當一幀接一幀的渲染到螢幕上時,馬兒動起來了(見圖2-7)。這和電影建立動畫的方式非常相似,電影也是通過展示連續的幀來達到移動效果。

    圖2-7 斯坦福德的馬的動作

    為了將這些幀儲存在一起,我們將它們放在同一個紋理中,稱為精靈。通過前面章節我們描述的裁剪方法,將只包含當前幀內容的部分渲染到螢幕上。

    你可以將每一幀渲染多次直到渲染該序列的下一幀。這取決於你希望你的動畫播放的多快,以及提供了多少幀圖片。事實上,通過渲染的幀速和順序,你可以創造出多種特效。

    3維動畫:模型

    與2D動畫中每次重畫時都維護一幅用來渲染的圖片--精靈不同,3D動畫是通過實際的計算的計算運動的幾何效果。正如我們之前描述的,所有的3D物體都由包含一個或多個三角形構成,被稱作網格。有多種可以使網格動起來的方法,這些技術與遊戲發展與圖形硬體有關。這些技術後的基本概念都是:關鍵幀

    關鍵幀與我們之前討論的2D動畫中的幀有些許不同。2維動畫的美術人員畫出每一幀並儲存在紋理中。但是在3D中,只要我們儲存了最特殊的幾幀,我們就可以通過數學計算得到其他幀。

    最開始的使用網格動畫的遊戲實際上儲存了網格的多個拷貝,每一個拷貝都是都在不同的關鍵幀方向上。例如,如果我們在3D中渲染馬兒,我們應該為上面精靈的每一個關鍵幀都建立網格。在time(1),第一幀被描畫出來,在time(2),第二針被描述出來。

    在主要關鍵幀之間,使用一種叫做“插值”的技術方法。因為我們知道time(1)的關鍵幀和time(2)的關鍵幀有著相同數量的三角形,但是方向稍有區別,我們可以建立當前時間點的臨時的,融合了前面兩個網格的網格。所以在時間time(1.5),臨時網格看起來正好介於time(1)與time(2)之間,而在time(1.8),看起來更偏向於time(2)。

    以上技術效率低下的原因是很明顯的。它僅在只有少量的三角形和少量的關鍵幀時才是可接受的,但是現代影象要求有高解析度與生動細節的動畫。幸運的是,有更好的儲存關鍵幀資料的方法。

    這就技術叫做“骨骼動畫”(skeletal animation, or bone rigging)。還是以馬兒為例,你可能注意到了大多數的三角形都是成組的移動,比如頭部組、尾部組和四肢組。如果你將它們都看成是骨頭關聯的,那麼將這些骨頭組合起來就形成了骨骼。

    骨骼是由一組可以適用於網格的骨頭組成的。當一組骨骼在不同方向連續的表示出來的時候,就形成了動畫。每一幀動畫都使用的是相同的網格,但是都會有骨頭從前一方位移動到下一個方位的細小的動作變化。

    通過僅儲存在某一個方位的網格,然後在每一關鍵幀時都利用它,我們可以建立一個臨時的網格並將其渲染到螢幕上。通過在兩個關鍵幀之間插值,我們可以以更小的成本來建立相同的動畫。

    動畫控制器

    動畫控制器物件在抽象低層次的任務非常有用,如選擇哪一幀來渲染,渲染多長時間,決定下一幀代替前一幀等。它也起到連線遊戲邏輯與影象引擎等動畫相關部分的作用。

   在頂層,遊戲邏輯只關心將設某些東西,如播放跑動的動畫,和設定它的速度為可能應該每秒跑動數個單位距離。控制器物件知道哪個幀序列對應的跑動動畫以及這些幀播放的速度,所以,遊戲邏輯不必知道這些。

    粒子系統

    另外一個與動畫控制器相似的有用物件是粒子系統管理器。當需要描畫高度支離破碎的元素,如火焰、雲朵粒子、火苗尾巴等時可以使用粒子系統。雖然粒子系統中的每個物件都有有限的細節與動畫,它們組合起來卻能形成富有娛樂性的視覺效果。

    淘汰

    最好的增加每秒鐘描畫到螢幕上的次數的方法是在每次迭代中都減少描畫在螢幕上的數目的總量。你的場景可能同時擁有成百上千的物體,但是如果你只需要描述其中的一小部分,你仍然可以將螢幕渲染得很快。

    淘汰是從描畫路徑上移除不必要的物體。你可以在多層次上同時進行淘汰。例如,在一個高層次,一個使用者在一間關閉了門的房間裡面是看不到隔壁房間的物體的,所以你不必描畫出隔壁其他物體。

    在一個低層次,3D影象引擎會經常移除部分你讓它們描畫的網格。例如,在任意合適的給定時間點,半數的網格幾何體在攝影機背面,你從攝像機中看不到這些網格,看到的只是攝影機前方的網格,因此,當網格被渲染時,所有的在攝影機背後的網格都會被忽略。這叫做背面淘汰。

    紋理排序