標籤:

從零開始手敲次世代遊戲引擎(五)

總體思路確定了,我們進入編碼。首先搭個架子。(演示命令行為Linux。Windows大部分類似,小部分命令名字不同請自行置換)

確認我們目前所處的位置:

[tim@iZ625ivhudwZ GameEngineFromScratch]$ git branchn* article_1n mastern

新建一個branch用於保存本篇文章開發的內容:

[tim@iZ625ivhudwZ GameEngineFromScratch]$ git checkout -b article_5nSwitched to a new branch article_5n[tim@iZ625ivhudwZ GameEngineFromScratch]$ git branchn article_1n* article_5n mastern

新建一個目錄,用於存放框架。其中再建立一個Common子目錄,用於存放各平台通用代碼;建立一個Interface子目錄,用於存放模塊間介面代碼:

[tim@iZ625ivhudwZ GameEngineFromScratch]$ mkdir Frameworkn[tim@iZ625ivhudwZ GameEngineFromScratch]$ cd Frameworkn[tim@iZ625ivhudwZ Framework]$ mkdir Commonn[tim@iZ625ivhudwZ Framework]$ mkdir Interfacen[tim@iZ625ivhudwZ Framework]$ dirnCommon Interfacen

現在整個項目目錄的結構如下:

[tim@iZ625ivhudwZ GameEngineFromScratch]$ tree .n.n├── Frameworkn│ ├── Commonn│ └── Interfacen├── LICENSEn├── main.cn└── README.mdn

將main.c移動到Framework/Common之下。也就是說,我們準備在不同平台直接共用同一個程序入口:

[tim@iZ625ivhudwZ GameEngineFromScratch]$ mv main.c Framework/Common/n[tim@iZ625ivhudwZ GameEngineFromScratch]$ tree .n.n├── Frameworkn│ ├── Commonn│ │ └── main.cn│ └── Interfacen├── LICENSEn└── README.mdnn3 directories, 3 filesn

在Framework/Interface之下新建Interface.hpp

[tim@iZ625ivhudwZ GameEngineFromScratch]$ vi Framework/Interface/Interface.hppn

通過宏定義定義幾個alias,用以提高代碼的可讀性:

#pragma oncenn#define Interface classnn#define implements publicn

在Framework/Interface執行新建IRuntimeModule.hpp

vi Framework/Interface/IRuntimeModule.hppn

在其中定義每個Runtime Module都應該支持的一些方法:

#pragma oncenn#include "Interface.hpp"nnnamespace My {n Interface IRuntimeModule{npublic:n virtual ~IRuntimeModule() {};nn virtual int Initialize() = 0;n virtual void Finalize() = 0;nn virtual void Tick() = 0;n };nn}n

說明一下:

#pragma oncen

這是是聲明這個頭文件在編譯的時候只需要處理一次。項目大了,同一個頭文件可能會被多次包含(比如在包含的其它頭文件裡面又包含了同樣的頭文件)。由於編譯器處理包含文件的方式是將其展開在源文件當中,所以如果不加這個條件編譯指令,頭文件的內容會在同一個源文件裡面多次展開,那麼編譯器就會報錯,說同一個東西被多次定義。

這個條件編譯指令對於很老的C/C++編譯器來說是不認識的。如果遇到這種情況,就需要將其改成下面這種形式:

#ifndef __INTERFACE_H__n#define __INTERFACE_H__nn<代碼>nn#endif // __INTERFACE_H__n

效果是一樣的。就是啰嗦一些。

virtual ~IRuntimeModule() {};n

虛函數的析構函數。因為是空函數,這個在Visual Studio裡面不定義也是可以的。但是按照嚴格的C++標準,包括《C++ Primer》這本書裡面的推薦做法,對於有其他虛函數的類,建議把析構函數也聲明為virtual。這是因為如果不這麼做,那麼當使用基類指針釋放派生類的實例的時候,可能導致只調用了基類的析構函數,從而產生memory leak的情況。在某些平台上,比如PSV,如果不定義這個虛析構函數,編譯器會報Warning。

virtual int Initialize() = 0;n virtual void Finalize() = 0;nn virtual void Tick() = 0;n

純虛成員函數。定義為純虛函數的目的是強制派生類實現這些方法。可以有效避免遺漏。

然後再說一下這3個函數(介面)的作用:

  1. Initialize(), 這是用來初始化模塊的
  2. Finalize(),這是用來在模塊結束的時候打掃戰場的
  3. Tick(),這個是用來讓驅動模塊驅動該模塊執行的。每調用一次,模塊進行一個單位的處理

之所以要單獨定義模塊的初始化/反初始化函數,而不是在類的構造函數/析構函數裡面完成這些工作,主要是有以下一些考慮:

  1. 在C/C++當中,全局變數(包括static變數)的初始化順序是不可預知的(未定義的)。對於不同的平台,可能順序不同。類當中的成員變數的初始化順序也有類似的問題
  2. 有些模塊我們可能只是想預載入到內存,後面再初始化。或者有些模塊在流程當中可能出現臨時不用的情況,想要釋放相關的平台資源,但是想保留其狀態,並不想將其從內存卸載。

接下來定義Application介面。這個介面用於抽象化不同平台的Application(並將其模塊化),使得我們可以用同一個主入口(main.c)啟動程序(也意味著我們可以使用同一套啟動參數)

[tim@iZ625ivhudwZ GameEngineFromScratch]$ vi Framework/Interface/IApplication.hppn

內容如下:

#pragma oncen#include "Interface.hpp"n#include "IRuntimeModule.hpp"nnnamespace My {n Interface IApplication : implements IRuntimeModulen {n public:n virtual int Initialize() = 0;n virtual void Finalize() = 0;n // One cycle of the main loopn virtual void Tick() = 0;nn virtual bool IsQuit() = 0;n };n}n

可以看到它繼承了我們剛才定義的IRuntimeModule,重載了IRuntimeModule的3個介面,另外增加了一個公共介面:IsQuit(),用於查詢應用程序是否需要退出。這是因為,在很多平台上用戶關閉應用程序都是通過系統通知過來的。我們的程序自身並不會直接進行這方面的判斷。所以當我們收到這樣的關閉通知的時候,我們就通過這個介面告訴主循環,我們該結束了。

可以看到這仍然是一個純虛類。接下來我們可以直接從這個類派生出各個平台的Application類。但是實際上,各個平台的Application雖然有很多不同,共通點也是很多的。提高代碼可維護性的一個重要做法,就是要避免同樣的代碼分散在不同的文件當中。否則很容易出現只改了一處而沒有改其他的情況。

因此,我們在Framework/Common下面,新建兩個文件,用來提供各平台共通的Application實現:

[tim@iZ625ivhudwZ GameEngineFromScratch]$ touch Framework/Common/BaseApplication.{hpp,cpp}n

大括弧是Linux Bash情況下的一種Hack,就是小技巧,可以一次生成兩個文件。如果是Windows,請分別生成這兩個文件。

現在我們編輯這兩個文件:

BaseApplication.hpp

#pragma oncen#include "IApplication.hpp"nnnamespace My {n class BaseApplication : implements IApplicationn {n public:n virtual int Initialize();n virtual void Finalize();n // One cycle of the main loopn virtual void Tick();nn virtual bool IsQuit();nn protected:n // Flag if need quit the main loop of the applicationn bool m_bQuit;n };n}n

BaseApplication.cpp

#include "BaseApplication.hpp"nn// Parse command line, read configuration, initialize all sub modulesnint My::BaseApplication::Initialize()n{n m_bQuit = false;nn return 0;n}nnn// Finalize all sub modules and clean up all runtime temporary files.nvoid My::BaseApplication::Finalize()n{n}nnn// One cycle of the main loopnvoid My::BaseApplication::Tick()n{n}nnbool My::BaseApplication::IsQuit()n{n return m_bQuit;n}n

好了。這個類裡面有一個受保護的變數m_bQuit,用於記錄應用程序是否被通知退出。

最後讓我們來修改我們的main.c。首先把它重新命名為main.cpp,因為我們用到了C++的特性,類。然後改寫成下面這個樣子:

#include <stdio.h>n#include "IApplication.hpp"nnusing namespace My;nnnamespace My {n extern IApplication* g_pApp;n}nnint main(int argc, char** argv) {n int ret;nn if ((ret = g_pApp->Initialize()) != 0) {n printf("App Initialize failed, will exit now.");n return ret;n }nn while (!g_pApp->IsQuit()) {n g_pApp->Tick();n }nn g_pApp->Finalize();nn return 0;n}n

因為我們將不同平台的應用程序進行了抽象,所以我們的main函數不需要關心我們目前到底是工作在哪個平台。我們只需要通過IApplication介面提供的方法進行調用就可以了。

好了,一個基本的架子我們已經搭建好了。但是要讓它跑起來之前,我們還需要做一些事情。什麼事情?注意這一行:

namespace My {n extern IApplication* g_pApp;n}n

我們需要定義一個具體的Application實例。讓我們新建一個Empty目錄,代表一個特殊的平台(無平台),然後在裡面寫一個EmptyApplication.cpp, 來創建這個實例:

#include "BaseApplication.hpp"nnnamespace My {n BaseApplication g_App;n IApplication* g_pApp = &g_App;n}n

好了,現在我們的項目差不多是這個樣子:

[tim@iZ625ivhudwZ GameEngineFromScratch]$ treen.n├── Emptyn│ └── EmptyApplication.cppn├── Frameworkn│ ├── Commonn│ │ ├── BaseApplication.cppn│ │ ├── BaseApplication.hppn│ │ └── main.cppn│ └── Interfacen│ ├── IApplication.hppn│ ├── Interface.hppn│ └── IRuntimeModule.hppn├── LICENSEn└── README.mdnn4 directories, 9 filesn

為了編譯它,我們需要創建CMakeLists.txt。首先我們需要在項目根目錄創建一個如下:

cmake_minimum_required (VERSION 3.1)nset (CMAKE_C_STANDARD 11)nset (CMAKE_CXX_STANDARD 11)nproject (GameEngineFromScrath)ninclude_directories("${PROJECT_SOURCE_DIR}/Framework/Common")ninclude_directories("${PROJECT_SOURCE_DIR}/Framework/Interface")nadd_subdirectory(Framework)nadd_subdirectory(Empty)n

注意前三行是因為我們的引擎後面會用到一些C/C++ 11的特性。現在刪掉這三行也可以。

然後是Framework目錄裡面需要一個:

add_subdirectory(Common)n

這個只是用來完成一個CMake的遞歸搜索

Framework/Common下面需要一個,用來建立Framework庫:

add_library(CommonnBaseApplication.cppnmain.cppn)n

最後就是Empty目錄下面一個,用來建立Empty平台的最後的可執行文件:

add_executable(Empty EmptyApplication.cpp)ntarget_link_libraries(Empty Common)n

好了,可以編譯了。仍然是採用out of source tree的方式,退回到項目根目錄,創建一個build目錄,進入build目錄,執行

cmake ..nmaken

就可以了。生成文件為build/Empty/Empty(.exe)

如果是Linux,也可以採用docker的方式進行編譯。這也是我推薦的方式,可以有效避免在系統里安裝一大堆開發用的包和工具。同樣回到根目錄,執行

[tim@iZ625ivhudwZ GameEngineFromScratch]$ docker run -it --rm -v $(pwd):/usr/src tim03/clangnbash-4.4# cd buildnbash-4.4# cmake ../n-- The C compiler identification is GNU 6.3.0n-- The CXX compiler identification is GNU 6.3.0n-- Check for working C compiler: /usr/bin/gccn-- Check for working C compiler: /usr/bin/gcc -- worksn-- Detecting C compiler ABI infon-- Detecting C compiler ABI info - donen-- Detecting C compile featuresn-- Detecting C compile features - donen-- Check for working CXX compiler: /usr/bin/c++n-- Check for working CXX compiler: /usr/bin/c++ -- worksn-- Detecting CXX compiler ABI infon-- Detecting CXX compiler ABI info - donen-- Detecting CXX compile featuresn-- Detecting CXX compile features - donen-- Configuring donen-- Generating donen-- Build files have been written to: /usr/src/buildnnbash-4.4# makenScanning dependencies of target Commonn[ 20%] Building CXX object Framework/Common/CMakeFiles/Common.dir/BaseApplication.cpp.on[ 40%] Linking CXX static library libCommon.an[ 60%] Built target CommonnScanning dependencies of target Emptyn[ 80%] Building CXX object Empty/CMakeFiles/Empty.dir/EmptyApplication.cpp.on[100%] Linking CXX executable Emptyn[100%] Built target Emptynbash-4.4# Empty/Emptyn^Cn

注意我們這個引擎目前正常情況下不會有任何輸出,而且會死循環。按Ctrl+C退出。

(-- EOF --)

本作品採用知識共享署名 4.0 國際許可協議進行許可。


推薦閱讀:

卡通渲染-向罪惡裝備xrd前進!
全球首家爆款手游公司,縮水9倍上市卻是「最好」結果
由FMOD說開去----來自東拉西扯的康托
在《掠食》之前,它還有一個夭折的兄弟

TAG:游戏开发 |