你是否正在考慮構建一個遊戲引擎呢?你對如何構建一個遊戲引擎是否已經有了一個明確的計劃呢?你是否已經對如何組織遊戲引擎各個模組之間的關係有了一個通盤的考慮?如果沒有,那麼本文將對你建立一個良好的遊戲架構提出一些有益的方案,如果你已經對上面的問題有了一個明確的答案,那麼本文不是你需要閱讀的內容。本文的目的是給那些沒有任何建立完整遊戲引擎經驗的人提供一些入門性的知識,使他們初步瞭解一下如何來構建一個遊戲引擎,構建遊戲引擎應該注意哪些方面的問題,並提供了一些成熟的設計模版並指出這些設計模版使用的範圍,我希望這些內容對那些中級編程人員也有一個良好的參考作用。本文的內容來源於一些流行的編程書籍,具體書目請見本文最後的部分,由於本文是介紹性質的文章,因此如果你對哪方面的內容非常感興趣請參考相應的書籍,本文或許有很多錯誤的地方,如果你有什麼看法的話可以通過Email和我進行討論,我的地址為dreams_wu@sina.com。
這裡必須再次提醒你,本文介紹的是一些通用的遊戲編程技巧,雖然是通用但是可能並不是非常全面,可能存在這樣或那樣的缺陷,因此如果你希望它發揮最大的效用必須恰當的使用它,而不是不分場合的濫用。切記切記,一個初學者最容易犯的錯誤就是任意使用一些設計模版而不顧它的使用範圍。
在開始構建一個遊戲引擎時你需要先考慮哪些方面的問題呢?這是你必須認真考慮的問題,我的答案是首先必須考慮代碼的可讀性,尤其是在多人進行開發時更必須高度重視,如果你寫的代碼其他人需要花費非常大的精力進行閱讀,那麼根本談不上提高工作效率,下面是提高代碼可讀性的一些良好建議:
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引擎中只有部分模組使用了這個方法,可能是這個介面引入時間太晚的緣故。