天天看點

遊戲引擎中的通用程式設計技術

你是否正在考慮建構一個遊戲引擎呢?你對如何建構一個遊戲引擎是否已經有了一個明确的

計劃呢?你是否已經對如何組織遊戲引擎各個子產品之間的關系有了一個通盤的考慮?如果沒有,

那麼本文将對你建立一個良好的遊戲架構提出一些有益的方案,如果你已經對上面的問題有了一

個明确的答案,那麼本文不是你需要閱讀的内容。本文的目的是給那些沒有任何建立完整遊戲引

擎經驗的人提供一些入門性的知識,使他們初步了解一下如何來建構一個遊戲引擎,建構遊戲引

擎應該注意哪些方面的問題,并提供了一些成熟的設計模版并指出這些設計模版使用的範圍,我

希望這些内容對那些中級程式設計人員也有一個良好的參考作用。本文的内容來源于一些流行的程式設計

書籍,具體書目請見本文最後的部分,由于本文是介紹性質的文章,是以如果你對哪方面的内容

非常感興趣請參考相應的書籍,本文或許有很多錯誤的地方,如果你有什麼看法的話可以通

過Email和我進行讨論,我的位址為[email protected]。

    這裡必須再次提醒你,本文介紹的是一些通用的遊戲程式設計技巧,雖然是通用但是可能并不是

非常全面,可能存在這樣或那樣的缺陷,是以如果你希望它發揮最大的效用必須恰當的使用它,

而不是不分場合的濫用。切記切記,一個初學者最容易犯的錯誤就是任意使用一些設計模版而不

顧它的使用範圍。

    在開始建構一個遊戲引擎時你需要先考慮哪些方面的問題呢?這是你必須認真考慮的問題,

我的答案是首先必須考慮代碼的可讀性,尤其是在多人進行開發時更必須高度重視,如果你寫的

代碼其他人需要花費非常大的精力進行閱讀,那麼根本談不上提高工作效率,下面是提高代碼可

讀性的一些良好建議:

1、建立一份簡單明了的命名規則。一份良好的命名規則可以大幅提高代碼的可讀性,規則必須

簡單明了,通常隻需要兩三分鐘的閱讀應該可以讓其他人掌握,例如在代碼中直接使用匈牙利

命名法這種大家熟知的規則,使用字母I作為接口類的首字母,使用C開頭作為實作類的首字母,

使用g_開頭的變量名作為全局變量,s_開頭作為靜态變量名,m_開頭作為内部變量名,使用_開

頭作為類内部使用的函數名等等,通過名字就可以使你大概了解對象的使用範圍和基本功能。

2、不要讨厭寫注釋。一個程式設計者易犯的錯誤就是不寫注釋,認為它會增加自己的工作量,但是

他沒有考慮到相應的工作量已經轉移到代碼閱讀者的身上,可能看代碼的人會花費比寫注釋時間

兩倍或者三倍的時間來閱讀代碼,這是一種非常不負責任的行為,通過一段簡短的注釋可以使閱

讀者迅速的了解代碼的功能,進而把時間更多的用到功能的擴充上。下面是一些良好的建議:盡

量對每一個變量标明它的功能。對每一個函數聲明的地方标明它的功能,對于複雜的函數還應當

寫清參數和傳回值的作用,注意是在聲明函數的頭檔案中。在關鍵的代碼處寫清它的作用,尤其

是在進行複雜的運算時更應如此。在每一個類聲明的地方簡要的介紹它的功能。

3、減少類的繼承層次。通常對于遊戲程式設計來說每一個類的繼承層次最好不要超過4層,因為過多

的繼承不僅會減少代碼的可讀性,同時使類表指針變長,代碼體積增大,減低類的執行效率。還

要注意要減少多重繼承,因為不小心它會形成程式設計者非常讨厭的“鑽石”形狀。同時還要注意如

果能使用類的組合的話那麼就盡量減少使用類的繼承,當然這是設計技巧的問題。

4、減少每行代碼的長度。盡量不要在一行代碼中完成一個複雜的運算,這樣做會增加閱讀難度,

同時不符合現代CPU的執行,由于CPU現在都使用了超長流水線的設計,它非常适合執行那些每行

代碼非常短而行數非常多的代碼,例如對一個複雜的數學運算,寫成一行不如每一步驟寫一行。

    以上建議是我的一些粗略看法,如果你還有什麼好的看法可以給我指出來,同時上面的建議

并不是絕對的,例如類的繼承并不是絕對不能超過4層,如果你需要的話可以使用更多的繼承,前

提是這樣帶來的好處大于代碼執行效率的損失。

    接着看看要考慮什麼,在Game Programming Gems3的《一個基于對象組合的遊戲架構》一文

指出了幾個值得考慮的問題,首先是平台相關性與獨立性和遊戲相關性與獨立性的問題,也就是

說應當作到引擎的架構與平台和遊戲都無關。為什麼要做到與平台無關性呢?這是因為你必須在

開始架構引擎考慮它的可移植性,如果在開始你沒有注意到這個問題,那麼一旦在遊戲完成後需

要移植到其他的遊戲平台上,你會發現麻煩大了,你需要修改的地方實在是太多了,所有與平台

相關的API調用都需要修改,所有使用了平台特定功能的子產品也需要修改,這是一個非常耗費精力

的事情,可能需要花費和開發一個遊戲一樣的時間,而如果你在開始的時候就考慮到這個問題,

那麼非常簡單,隻需要寫一個相應平台的子產品替換掉原來的子產品即可,這樣精力就可以放在如何

充分的利用特定平台的能力來提高遊戲的表現力上,而不是代碼修改上。下面簡單的談一下如何

使引擎作到與平台無關。

1、注意作業系統的差異。現在主流的作業系統主要是Windows和Linux兩種,當然還有Unix和Mac

,在程式設計時你必須注意這一點,當你需要包含Windows的頭檔案時,你必須将它包含在宏_WIN32

中,下面是一個簡單的例子:

#ifdef _WIN32

#include "windows.h"

#endif

而你使用Windows平台特定的API時也應當如此,這樣在其他平台上編譯時可以保證Windows平台

相應的代碼不會被編譯進去。對于其他平台也應當如此。

2、注意編譯器的差異。現在通用的編譯器主要有VC,BC和gcc幾種,在進行Windows平台程式設計時,

你通常會使用VC或BC,而對Linux平台程式設計時通常使用gcc,使用VC編譯器你不可能編譯出用于Linux

平台的代碼,是以在程式設計時也需要注意,你可以使用上面的方法通過特定的宏來将不同的編譯器

分離開。舉一個簡單的例子:

#ifdef _WIN32

#ifdef _MSC_VER

typedef signed __int64  int64;

#endif

#elif defined _LINUX

typedef long long  int64;

#endif

在不同的編譯器中對64位變量的命名是不同的,因為它并不是C++标準的一部分,而是編譯器的擴

展部分。另外一個例子是編譯器使用的内聯彙編代碼,在VC中你可以使用_asm來指明,而對于

Linux平台的編譯器你需要使用它的專用關鍵字了。

3、注意CPU的差異。對于不同平台來說它通常會使用不同的CPU,不過幸好Windows和Linux都支援

X86的CPU,這也是PC遊戲的主流CPU平台,而XBOX使用的也是X86的CPU,除非你需要移植到PS2平台

,否則這将大大減輕你的程式設計負擔,在X86平台上提供了一個cpuid的指令可以非常友善的檢查CPU

的特性,如是否支援MMX,SSE,SSE2,3DNow!技術等,通過它你可以使用特定的CPU特性來加速你

的代碼執行速度。

4、注意圖形API的差異。現在圖形API主要存在兩種主流的平台DirectX和OpenGL,DirectX隻能用于

Windows平台,而OpenGL幾乎被所有的平台所支援。是以你需要為不同的圖形API進行封裝,将它做

成不同的子產品,在需要的時候進行切換。完成這個工作最好的方法是使用後面介紹的類廠模式。

5、注意顯示卡的差異。現在顯示卡有兩大主流ATI和NV,雖然顯示卡可以被主流的作業系統所支援,但是

必須注意在不同的遊戲平台上還是使用不同的GPU,而在GPU之間也相應有自己的功能擴充,是以在

使用特定的擴充功能時必須檢查一下是否被顯示卡所支援。

6、注意shader語言的差異。可程式設計圖形語言的出現是最重要的一項發明,現在幾乎每一個遊戲都

在使用這項技術,而正由于它的重要性現在出現了多個标準,HLSL隻能用于DX中,而OpenGL由于

标準的開放性更加混亂,每一個顯示卡廠商都根據自己的産品推出相應的擴充指令來實作shader,

而NV更推出了GC可以同時适用于DirectX和OpenGL,這是一個非常好的想法,不過由于這不是一個

開放的标準是以沒有得到其他廠商的支援,在ATI顯示卡上運作GC代碼你會發現比在NV顯示卡慢了幾個

數量級,由于上面的情況你需要根據不同的平台相應進行封裝,方法和第4條一樣。下面的建議值得

你去考慮,當你使用DirectX平台時應當使用HLSL,而對于OpenGL可以封裝為兩個子產品,根據顯示卡

的不同進行切換,也可以使用GC特别為NV的顯示卡封裝一個子產品來對它進行優化。

    這裡需要補充一點,如果可以的話盡量和OGRE一樣為不同的作業系統進行封裝,這樣友善在

不同的系統之間進行切換。

    接着看看如何實作遊戲無關性,通常遊戲引擎如果要實作遊戲的無關性是非常困難的,這也就

是說要求你的引擎适合所有的遊戲類型,這太難了,考慮一下一個RPG遊戲引擎如果用來做一個RTS

遊戲那簡直是不可能,類似的你不可能拿Q3引擎來做RTS遊戲,但是如果引擎設計的非常良好的話

還是可以實作部分的遊戲無關性。也就是說你可以将引擎的一部分子產品設計成通用的子產品,這樣在

開發其他類型的遊戲時可以重用這部分的代碼,這部分代碼包括底層顯示,聲音,網絡,輸入等

部分,在設計它們時你必須保證它們具有良好的通用性。

    在這些問題之後你應當考慮程式的國際化問題。這也是非常重要的方面,因為你的遊戲可能在

其它國家發行,這主要是注意語言方面的問題,尤其是字元串的處理,在C++的标準庫中提供了一個

String容器,它提供了對國際化的良好支援,是以在引擎中你需要從頭到尾的使用它。

    接下來我們看看本文最重要的内容,如何組織一個引擎的架構。這是引擎最重要的部分,為什麼

重要呢?如果我們把引擎看作一間房子的話,那麼架構可以看作是房子的架構,當你完成這個架構

後就可以向架構内添磚加瓦蓋房子了。下面讓我們來看看如何建構這個架構,通常一個大型的軟體

工程是按照子產品化的方式來建構的,程式設計之前要進行必要的需求分析,将軟體工程根據不同的功能

劃分為幾個較大的功能子產品,對比較複雜的子產品你可能還需要将它分為幾個子子產品,并需要給出各

個子產品之間的邏輯關系。當你編寫一個引擎時也需要進行相應的功能分析,讓我們看看如何來劃分

引擎的功能子產品,如果按照上面的遊戲無關性和相關性進行分析的話我們可以發現它可以分為遊戲

相關層和無關層兩層,遊戲相關層由于包含了遊戲的邏輯性代碼也被稱為邏輯層。邏輯層應該位于

引擎的最頂層,如果你在開發一個區域網路或線上遊戲的話,按照網絡程式的C/S開發模式,這一層

應該分為兩個子產品,伺服器和用戶端子產品,它包含了和特定遊戲相關的所有功能,如AI,遊戲角色,

遊戲事件管理,網絡管理等等。在它下面就是遊戲無關層了,包括了引擎核心子產品,GUI子產品,檔案

系統管理子產品等等,其中引擎的核心子產品是最重要的部分,邏輯層主要通過它來和底層的子產品打交

道,它應該包含場景管理,特效管理,控制台管理,圖形處理等等内容。在向下就是一些底層子產品

了,如圖形渲染子產品,輸入裝置子產品,聲音子產品,網絡子產品,實體子產品,角色模型子產品等等,所有

的這些底層子產品必須通過核心子產品來和邏輯層進行互動,是以核心子產品是整個引擎的樞紐,所有的

子產品都通過它來進行互動。

    下面看看應該如何來進行子產品的設計,這裡有一些通用的規則是你應當遵守的:

1、減少子產品之間的關系複雜度。我們知道通常每一個子產品内部都存在大量的對象需要在各個子產品

之間進行互相的調用,如果我們假設每一個子產品内部對象的數量為N的話,那麼每兩個子產品之間的

關系複雜度為N*N,這樣的複雜度是不可接受的,為什麼呢?首先是它非常不利于管理,由于各個

子產品都存在大量的全局對象,并存在互相依存的關系,并且各自建立的時間各不相同,這就存在

初始化順序的沖突,考慮這種情況,一個子產品中存在一個對象需要另外一個子產品中的對象才能進行

初始化,當這個對象進行初始化時而另外的對象在之前并沒有初始化就會引發程式的崩潰。其次,

不利于多人進行同時的開發,由于各個子產品存在互相依存的關系,當複雜度非常高時就會出現子產品

與子產品的高度依存,也就是說一個子產品沒有完成下一個子產品就無法完成,是以就需要一個子產品一個

子產品按照它的依存關系進行程式設計,而無法同步進行。是以在設計子產品時的第一件事情

是減少子產品之間的複雜度,為此你在設計子產品時必須為子產品設計一個互動接口,并約定所有子產品之

間的互動必須通過這個接口來進行,這樣子產品之間的關系複雜度就降低為1*1了,非常友善管理,同

時這非常利于多人之間進行開發,假如每個人負責一個子產品的開發的話,那麼你隻需要先完成這個

接口類,其他人就可以利用這個接口進行其他子產品的開發,而不必等到你完成所有的類再進行,這

樣所有的子產品都是同步進行,可以節省大量寶貴的開發時間。

2、對類的抽象接口而不是類的實作程式設計。這是《Design Patten》一書作者對所有軟體程式設計者的建議

,它也對遊戲程式設計有很大的指導意義。對子產品中所有被其它子產品使用的類都要建立一個抽象接口,

其它子產品要使用這個抽象接口進行程式設計,這樣其它子產品就可以在不需要知道類是如何實作的情況下

進行程式設計。這樣做的好處是在接口不改變的情況下任意對類的實作進行改變而不必通知其它人,這

對多人開發非常有用。

3、根據調用對象的不同對類進行分層。實際上本條還是對第2條的補充,分層還是為了更好隐藏底

層的實作。通常一個類不僅被其它子產品使用還要被自身子產品所調用,而且它們需要的功能也不同,

是以我們可以讓一個類對外部顯現一個接口而對内部也顯現一個接口,這樣做的好處和上面一樣,

因為一個複雜的子產品也是多人在進行程式設計的。

4、通過讓一個類對外顯現多個接口來減少類的數量。減少關系複雜度的一個方法是減少類的數量,

是以我們可以把完成不同功能的類合并成一個類,并讓它對外表現為多個接口,也就是一個類的

實作可以繼承多個接口。

上面的建議隻是起到參考作用,具體實作時你應該根據情況靈活使用,而不是任意亂用。

    下面的内容涉及到具體的程式設計技巧,

    對于引擎中的全局對象你可以使用Singleton,如果你不了解它是什麼可以閱讀《Design Patten

》,裡面有對它的詳細介紹,具體的使用可以通過OGRE引擎獲得。

    調用子產品内的對象可以通過類廠來實作。COM可以看作是一種典型的類廠,DX就是使用它來進行

設計的,而著名的開源引擎Crystle Space也是通過建立一個類似的COM物體來實作的,但是我并不

對它很認可,首先建構一個類似COM的類廠非常複雜,開銷有點大,其次COM的一個優點是可以對程

序實作向下相容,這也是DX使用它的重要原因,而一個遊戲引擎并不需要。OGRE中也實作了一個類廠

結構,這是一個比較通用的類廠,但是使用起來還是需要寫一段代碼。我比較欣賞VALVE的做法,它

通過使用一個宏就解決了這個問題,非常高效,使用起來也非常友善。這個做法很簡單,它把每個

子產品中需要對外暴露的接口都連接配接到一個内部維護的連結清單上,每一個接口都和一個接口名相連,這

樣外部子產品可以通過傳入一個接口名給CreateInterface函數就可以獲得這個接口的指針了,非常簡

單。下面看看它的具體實作。它内部儲存的連結清單結構如下:

class InterfaceReg

{

public:

InterfaceReg( InstantiateInterfaceFn fn , const char *pName );

public:

InstantiateInterfaceFn m_CreateFn;

const char *m_pName;

InterfaceReg *m_pNext;

static InterfaceReg *s_pInterfaceRegs;

};

并定義了兩個函數指針和一個函數

#define CREATEINTERFACE_PROCNAME "CreateInterface"

typedef void *(CreateInterfaceFn)( const char *pName , int *pReturnCode );

typedef void *(InstantiateInterfaceFn)( void );

DLL_EXPORT void *CreateInterface( const char *pName , int *pReturnCode );

下面看看它如何通過宏來建立連結清單

#define EXPOSE_INTERFACE( className , interfaceName , versionName ) /

    static void *__Create##className##_Interface() { return (interfaceName*) new className; } /

    static InterfaceReg  __g_Create##interfaceName##_Reg( __Create_##className##_Interface , versionName );

如果你有一個類CPlayer它想對外暴露接口IPlayer,那麼很簡單,可以這麼做

#define PLAYER_VERSION_NAME   "IPlayer001"

EXPOSE_INTERFACE( CPlayer , IPlayer , PALYER_VERSION_NAME );

如果在其他子產品内你需要獲得這個接口,可以這麼做

CreateInterfaceFn factory = reinterpret_cast<CreateInterfaceFn> (GetProcAddress( hDLL , CREATEINTERFACE_PROCNAME ));

IPlayer player = factory( PLAYER_VERSION_NAME , 0 );

其中hDLL為子產品的句柄。這裡函數指針factory實際指向子產品内部的CreateInterface函數,這個

函數通過比較傳入的接口名從連結清單找到指定類指針。

    解決了類廠問題,下面讓我們看看如何建立子產品對外的接口,在Game Programming Gems3的

《一個基于對象組合的遊戲架構》一文提出了一種架構,Half Life2引擎中對這種架構進行了有效

的擴充,你可以讓所有的對外暴露的接口都使用這個架構,前提是子產品隻有一個接口對外暴露。

class IAppSystem

{

public:

// Here's where the app systems get to learn about each other 

virtual bool Connect( CreateInterfaceFn factory ) = 0;

virtual void Disconnect() = 0;

// Here's where systems can access other interfaces implemented by this object

// Returns NULL if it doesn't implement the requested interface

virtual void *QueryInterface( const char *pInterfaceName ) = 0;

// Init, shutdown

virtual InitReturnVal_t Init() = 0;

virtual void Shutdown() = 0;

};

通過Connect方法你可以将兩個子產品建立一個連接配接關系,通過QueryInterface方法你可以檢索到其他

需要暴露接口,這種方法很好的為所有的子產品建立一個标準的對外接口,極大的減輕了程式設計的複雜

性,遺憾的是在HL2引擎中隻有部分子產品使用了這個方法,可能是這個接口引入時間太晚的緣故。

(待續)

原文Blog: http://blog.gameres.com/show.asp?blogID=118&column=0

繼續閱讀