企業級30k大佬教你如何編寫 C++ 遊戲引擎,下一個拳頭公司就是你

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

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

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

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

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

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

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

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

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

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

使用迭代方法

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

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

企業級30k大佬教你如何編寫 C++ 遊戲引擎,下一個拳頭公司就是你

C語言/C++/編程開發496926338

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

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

企業級30k大佬教你如何編寫 C++ 遊戲引擎,下一個拳頭公司就是你

C語言/C++/編程開發496926338

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

企業級30k大佬教你如何編寫 C++ 遊戲引擎,下一個拳頭公司就是你

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

企業級30k大佬教你如何編寫 C++ 遊戲引擎,下一個拳頭公司就是你

此時,我放棄了OBJ文件格式,編寫了一個Python腳本來從Blender導出自定義的JSON文件。這些JSON文件描述了皮膚網格,骨架和動畫數據。在C ++ JSON庫的幫助下將這些文件加載到遊戲中。

企業級30k大佬教你如何編寫 C++ 遊戲引擎,下一個拳頭公司就是你

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

企業級30k大佬教你如何編寫 C++ 遊戲引擎,下一個拳頭公司就是你

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

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

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

企業級30k大佬教你如何編寫 C++ 遊戲引擎,下一個拳頭公司就是你

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

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

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

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

迭代的方法給了我一個比我以前盯著一張白紙冥思苦想更優雅的架構。我的引擎的iOS版本現在是 100% 原始代碼,包括自定義數學庫,容器模板,反射/序列化系統,渲染框架,物理模塊和音頻混合器。我可以編寫每一個模塊,但是你可能沒有必要自己寫所有這些東西。你可能會發現適合自己引擎的許多優秀的開源代碼庫。 GLM、Bullet 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或專有格式,然後最終轉換為特定於平臺的可以快速加載到引擎的遊戲格式。流水線中的最後一個應用通常被稱為“炊具”。炊具可能被集成到另一個工具,甚至分佈在幾臺機器上。通常,炊具和一些工具是與遊戲引擎本身一起開發和維護的。

企業級30k大佬教你如何編寫 C++ 遊戲引擎,下一個拳頭公司就是你

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

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

void load(InStream& in, u32 fileVersion) {

// 加載預期的成員變量

in >> m_position;

in >> m_direction;

// 僅當正在加載的文件版本是2或更大時才加載新的變量

if

(fileVersion >= 2) {

in >> m_velocity;

}

}

通過反射(特別是通過創建描述C ++類型佈局的運行時數據),可以編寫更靈活,不容易出錯的序列化代碼。想要快速瞭解反射如何進行序列化,請看一下開源項目Blender是如何實現的。

企業級30k大佬教你如何編寫 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協議緩衝區是序列化遊戲資產的理想選擇,但是值得研究。

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

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


分享到:


相關文章: