遊戲引擎中的通用編程技術(一)

你是否正在考慮構建一個遊戲引擎呢?你對如何構建一個遊戲引擎是否已經有了一個明確的
計劃呢?你是否已經對如何組織遊戲引擎各個模塊之間的關係有了一個通盤的考慮?如果沒有,
那麼本文將對你建立一個良好的遊戲架構提出一些有益的方案,如果你已經對上面的問題有了一
個明確的答案,那麼本文不是你需要閱讀的內容。本文的目的是給那些沒有任何建立完整遊戲引
擎經驗的人提供一些入門性的知識,使他們初步瞭解一下如何來構建一個遊戲引擎,構建遊戲引
擎應該注意哪些方面的問題,並提供了一些成熟的設計模版並指出這些設計模版使用的範圍,我
希望這些內容對那些中級編程人員也有一個良好的參考作用。本文的內容來源於一些流行的編程
書籍,具體書目請見本文最後的部分,由於本文是介紹性質的文章,因此如果你對哪方面的內容
非常感興趣請參考相應的書籍,本文或許有很多錯誤的地方,如果你有什麼看法的話可以通
過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

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