如何編寫 C++ 遊戲引擎

李大萌發表於2018-06-05

最近我在用 C++ 寫遊戲引擎,再用這個引擎做了一個移動端小遊戲跳一跳(Hop Out)。下面是截自我的 iPhone6 的一個小片段。

視訊地址:http://preshing.com/images/hopoutclip.mp4

跳一跳是我想玩的遊戲型別:3D卡通外觀的復古街機遊戲。目標是改變每個填充塊的顏色,就像Q * Bert一樣。

Hop Out仍在開發中,但引擎的功能已經很完善了,所以我想在這裡分享一些關於引擎開發的技巧。

你為什麼想要寫一個遊戲引擎?可能有很多原因:

你是個修理工,喜歡從頭開始建立系統,直到系統完成。

關於遊戲開發你想了解更多。我在遊戲行業工作了14年,現在我仍然在不停的琢磨。我甚至不確定我是否可以從頭開始編寫一個引擎,因為它與大型工作室的程式設計工作的日常職責大不相同。我想知道答案。

你喜歡控制。對完全按照你想要的方式組織程式碼,知道一切都在哪裡,感到滿意。

你可以從AGI(1984),id Tech 1(1993),Build(1995)等經典遊戲引擎以及Unity和Unreal等行業巨頭那裡獲得靈感。

你相信我們這個遊戲產業應該試著去揭開引擎發展的序幕。我們並沒有掌握製作遊戲的藝術。還離得很遠!我們對這個過程的研究越多,改進的機會就越大。

2017年的遊戲平臺 – 手機,遊戲機和電腦 – 非常強大,而且在很多方面都非常相似。遊戲引擎的開發並不是像過去一樣,在脆弱和怪異的硬體上掙扎。在我看來,更多是關於自己製造出來的複雜性的鬥爭。創造一個怪物很容易!這就是為什麼本文建議圍繞著保持事情可控的原因。我把它分成三部分:

  1. 使用迭代方法
  2. 在統一事物前要三思
  3. 請注意,序列化是一個很大的課題

這個建議適用於任何型別的遊戲引擎。我不會告訴你如何編寫著色器,八叉樹是什麼,或者如何新增物體。這些事兒,都是我假設你已經知道而且應該知道 – 這很大程度上取決於你想要製作的遊戲型別。相反,我故意選擇了一些似乎沒有被廣泛承認或提及的觀點 – 這些是我在試圖揭開一個主題神祕面紗時最感興趣的一些觀點。

使用迭代方法

我的第一條建議是使一些東西(任何東西),快速執行起來,然後迭代。

如果可能的話,從一個示例應用程式開始,初始化裝置並在螢幕上繪製一些東西。就我而言,我下載了SDL,開啟了Xcode-iOS / Test / TestiPhoneOS.xcodeproj,然後在我的iPhone上執行了testgles2示例。

如何編寫 C++ 遊戲引擎

瞧!我使用OpenGL ES 2.0,生成了一個可愛的旋轉立方體。

下一步,是下載一個其他人制作的馬里奧3D 模型。我寫了一個快速和粗糙的OBJ檔案載入器 – 檔案格式並不太複雜 – 並且修改了例程,來呈現Mario,而不是一個立方體。我還整合了SDL_Image來幫助載入紋理。

如何編寫 C++ 遊戲引擎

然後我實現了一個雙搖桿控制器用來操控馬里奧(我本來想要建立的是一個雙搖桿設計遊戲,並不是馬里奧。)

如何編寫 C++ 遊戲引擎

接下來,我想探索骨骼動畫,所以我開啟了Blender,做了一個觸手模型,並且用一個前後擺動的雙骨架來操縱它。

如何編寫 C++ 遊戲引擎

此時,我放棄了OBJ檔案格式,編寫了一個Python指令碼來從Blender匯出自定義的JSON檔案。這些JSON檔案描述了皮膚網格,骨架和動畫資料。在C ++ JSON庫的幫助下將這些檔案載入到遊戲中。

如何編寫 C++ 遊戲引擎

一旦這個完成,我回到了Blender,並做了更詳細的角色設計。 (這是我創造的第一個被操縱的3D人,我為他感到驕傲。)

如何編寫 C++ 遊戲引擎

在接下來的幾個月裡,我採取了以下幾個步驟:

  1. 開始將向量和矩陣函式分解成我自己的3D數學庫。
  2. 用CMake專案替換.xcodeproj。
  3. 在Windows和iOS上執行引擎,因為我喜歡在Visual Studio下工作。
  4. 開始將程式碼移動到單獨的“引擎”和“遊戲”庫中。隨著時間的推移,我把它們分成更細粒度的庫。
  5. 寫了一個單獨的應用程式將我的JSON檔案轉換為遊戲可以直接載入的二進位制資料。
  6. 最終從iOS版本中刪除所有SDL庫。 (Windows版本仍然使用SDL。)

重點是:在開始程式設計之前,我沒有對引擎架構進行設計。這是一個經過深思熟慮的選擇。相反,我只是寫了實現下一個特性的最簡單的程式碼,然後我會檢視程式碼,看看會出現什麼自然生成的架構。我說的“引擎架構”是指組成遊戲引擎的模組集,這些模組之間的依賴關係,以及用於與每個模組互動的 API

如何編寫 C++ 遊戲引擎

這是一個迭代的方法,因為它關注於較小的可交付成果。它在編寫遊戲引擎時效果非常好,因為在每個步驟中,你都有一個正在執行的程式。如果在將程式碼合成到新模組中時出現問題,可以隨時將做的更改與以前工作的程式碼進行比較。顯然,我假設你在使用某種原始碼管理工具

你可能會認為這種方法浪費了很多時間,因為總是在編寫糟糕的程式碼,之後需要清理。但是大部分的清理操作都是將程式碼從一個.cpp檔案移動到另一個,將函式宣告提取到.h檔案中,或者直接進行簡單的修改。決定事情應該去哪是難點,但是這在已經有程式碼的時候會更容易決定。

我認為用相反的方法:試圖設計出一個能夠提前完成所有需求的架構,會浪費更多的時間。我最喜歡的兩篇關於系統過度設計風險的文章是 Tomasz Dąbrowski 的《泛化的惡性迴圈》和 Joel Spolsky 的《不要讓架構太空人嚇到你》

我並不是說在用程式碼處理問題之前,不應該在紙上進行設計。我也不是說你不應該事先決定你想要的功能。比如,我從一開始就知道我想讓我的引擎在後臺執行緒中載入所有資源。我只是沒有嘗試設計或實現該功能,直到我的引擎首先載入一些資源。

迭代的方法給了我一個比我以前盯著一張白紙冥思苦想更優雅的架構。我的引擎的iOS版本現在是 100% 原始程式碼,包括自定義數學庫,容器模板,反射/序列化系統,渲染框架,物理模組和音訊混合器。我可以編寫每一個模組,但是你可能沒有必要自己寫所有這些東西。你可能會發現適合自己引擎的許多優秀的開原始碼庫。 GLMBullet Physics 和 STB 標頭檔案只是一些有趣的例子。

在整合事物太多之前要三思

作為程式設計師,我們儘量避免程式碼重複,喜歡程式碼遵循統一的風格。不過,我認為不要讓這些本能凌駕於每一個決定之上。

偶爾要抵制一下 DRY 原則

舉個例子,我的引擎包含了幾個“智慧指標”模板類,與 std :: shared_ptr 類似。每一個指標作為一個原始指標的包裝,有助於防止記憶體洩漏。

  • <> 是用於具有單個所有者的動態分配的物件。
  • Reference<> 使用引用計數來允許一個物件擁有多個所有者。
  • audio :: AppOwned <> 被音訊混音器以外的程式碼呼叫,允許遊戲系統擁有音訊混音器使用的物件,例如當前播放的語音。
  • audio :: AudioHandle <> 使用音訊混音器內部的引用計數系統。

這樣可能看起來像其中一些類複製了其它的功能,違反 DRY(不要重複自己)的原則。事實上,在開發早期,我儘可能地重用現有的Reference <>類。但是,我發現音訊物件的生命週期是由特殊規則來管理的:如果一個音訊語音已經完成了一個樣本的播放,並且遊戲沒有指向該語音的指標,那麼該語音會被立即到刪除排隊等待。如果遊戲持有指標,則不應刪除這個語音物件。如果遊戲持有一個指標,但指標的所有者在語音結束之前被銷燬,這段語音應該被取消,而不是增加Reference <>的複雜性,我決定引入單獨的模板類,這樣更為實用。

95% 的時間都在重用現有的程式碼。但是,如果你開始感到麻痺,或者發現自己增加了一件簡單的事情的複雜性,那就問自己,程式碼庫中的東西是否應該是兩件事。

可以使用不同的呼叫規則

我不喜歡Java的一件事是,它強迫你在一個類中定義每個函式。在我看來,這是無稽之談。這可能會使你的程式碼看起來更加一致,但是它也鼓勵過度工程,並且不適合我前面描述的迭代方法。

在我的 C++ 引擎中,一些函式屬於類,有些則不屬於類。例如,遊戲中的每個敵人都是一個類,可能就像你預料的那樣,大部分敵人的行為都是在這個類內部實現的。另一方面,在我的引擎中投射的球體是通過呼叫 sphereCast() 函式來執行的,這是物理名稱空間中的一個函式。 sphereCast() 不屬於任何類 – 它只是物理模組的一部分。我構建了一個系統來管理模組之間的依賴關係,這使得我的程式碼組織得很好。將這個函式包裝在一個任意的類中不會以任何有意義的方式改善程式碼的組織。

然後是動態排程,這是一種多型的形式。我們經常需要為一個物件呼叫一個函式,而不知道該物件的確切型別。 C ++程式設計師的第一本能是用虛擬函式定義抽象基類,然後在派生類中重寫這些函式。這是有效的,但這只是一種技術。還有其他動態排程技術,不會引入額外的程式碼,或帶來其他好處:

 

  • C ++ 11引入了std :: function,這是儲存回撥函式的一個簡便方法。也可以編寫自己的std :: function版本,這樣在除錯中不會那麼痛苦。
  • 許多回撥函式可以用一對指標來實現:一個函式指標和一個型別不確定的引數。它只需要在回撥函式中進行明確的轉換。你在純C語言庫中經常看到。
  • 有時候,底層型別實際上是在編譯時已知的,你可以繫結這個函式呼叫而不用額外的執行開銷。 Turf是我在遊戲引擎中使用的一個庫,它非常依賴這種技術。例如看到turf:: Mutex,這只是針對特定平臺類的定義。
  • 有時,最直接的方法是自己構建和維護一個原始函式指標表。我在我的音訊混音器和序列化系統中使用了這種方法。Python直譯器也大量使用這種技術,如下所述。
  • 你甚至可以將函式指標儲存在雜湊表中,使用函式名稱作為關鍵字。我使用這種技術來排程輸入事件,如多點觸控事件。這是記錄遊戲輸入並用重放系統回放的策略的一部分。

動態排程是一個很大的課題。我只是想表明,有很多方法來實現它。你編寫的可擴充套件底層程式碼越多(這在遊戲引擎中很常見),越會發現替代方法越多。如果你不習慣這種程式設計,C語言編寫的Python直譯器是一個很好的學習資源。它實現了一個強大的物件模型:每個PyObject都指向一個PyTypeObject,每個PyTypeObject都包含一個用於動態分配的函式指標表。如果你想直接跳轉到其中的話,定義新型別的文件是一個很好的起點。

注意序列化是一個大問題

序列化是將執行時物件轉換為位元組序列的操作。換句話說,就是儲存和載入資料。

對於許多遊戲引擎來說,遊戲內容以各種可編輯的格式建立,例如.png,.json,.blend或專有格式,然後最終轉換為特定於平臺的可以快速載入到引擎的遊戲格式。流水線中的最後一個應用通常被稱為“炊具”。炊具可能被整合到另一個工具,甚至分佈在幾臺機器上。通常,炊具和一些工具是與遊戲引擎本身一起開發和維護的。

如何編寫 C++ 遊戲引擎

在建立這樣的流水線時,每個階段的檔案格式的選擇取決於你。你可以定義自己的一些檔案格式,這些格式可能會隨著新增引擎功能而變化。漸漸地可能會發現有必要保持某些程式與以前儲存的檔案相容。不管什麼格式,你最終都需要用C++來序列化它。

用C ++實現序列化有無數種方法。一個相當明顯的方式是將載入和儲存函式新增到要序列化的C ++類。可以通過在檔案頭中儲存版本號來實現向後相容,然後將這個數字傳遞給每個載入函式。這是可行的,儘管這樣程式碼可能維護起來比較繁瑣。

通過反射(特別是通過建立描述C ++型別佈局的執行時資料),可以編寫更靈活,不容易出錯的序列化程式碼。想要快速瞭解反射如何進行序列化,請看一下開源專案Blender是如何實現的。

如何編寫 C++ 遊戲引擎

 

從原始碼構建Blender時,有許多步驟。首先,編譯並執行一個名為makesdna的自定義實用程式。該實用程式解析Blender原始碼樹中的一組C語言標頭檔案,然後以SDNA的自定義格式輸出所有C定義型別的彙總。這個SDNA資料作為反射資料,連結到Blender本身,並儲存在Blender寫入的每個.blend檔案中。從這一刻開始,每當Blender載入一個.blend檔案,就會將.blend檔案的SDNA與連結到當前版本的SDNA進行比較,並使用通用序列化程式碼來處理差異。這個策略使Blender具有令人印象深刻的向前和向後相容性。你仍然可以在最新版本的Blender中載入1.0版本的檔案,也可以在舊版本中載入新的.blend檔案。

像Blender一樣,許多遊戲引擎及其相關工具都會生成並使用自己的反射資料。有很多方法可以做到這一點:可以像Blender一樣解析自己的C / C ++原始碼來提取型別資訊。你可以建立一個單獨的資料描述語言,並編寫一個工具來從該語言生成C ++型別定義和反射資料。可以使用前處理器巨集和C ++模板在執行時生成反射資料。一旦你有反射資料可用,有無數的方法來編寫一個通用的序列化器。

顯然,我省略了很多細節。在這篇文章中,我只想表明有很多不同的方法來序列化資料,其中一些非常複雜。程式設計師不會像其他引擎系統那樣討論序列化,儘管大多數其他系統依賴於它。例如,在GDC 2017給出的96個程式設計講座中,我數了一下,共有31次關於圖形,11次關於線上,10次關於工具,4次關於AI,3關於物理模組,2關於音訊的 – 但只有一個直接涉及到序列化

至少,試著想一想你的需求會有多複雜。如果你正在製作一個像Flappy Bird這樣的小遊戲,只有少數資源.,那麼你可能不需要想太多的序列化。你可以直接從PNG載入紋理,這樣很好處理。如果你需要一個向後相容的緊湊的二進位制格式,但不想自己開發,可以看看第三方庫,比如Cereal或者Boost.Serialization。我不認為Google協議緩衝區是序列化遊戲資產的理想選擇,但是值得研究。

編寫一個遊戲引擎,即使是一個小遊戲引擎,也是一個很大的任務。關於這個我可以說的還有很多,但是對於這個長度的帖子來說,這真的是我認為最有用的建議:迭代地工作,抵制統一程式碼的衝動,並且知道序列化是一個大問題,你需要選擇一個合適的策略。根據我的經驗,如果忽視這些事情,每一件事情都可能成為一個絆腳石。

我喜歡比較這些東西,真的很想聽到其他開發人員的意見。如果你已經寫了一個引擎,你的經驗是否讓你有什麼相同的結論嗎?如果你沒有寫,或者只是在構思,我也對你的想法也很感興趣。你認為什麼是好的學習資源?哪些部分對你來說看起來很神祕?你可以在下面評論或在Twitter上給我留言!

相關文章