C++到底適不適合做遊戲開發?漫談C++遊戲引擎開發

前言


1、C是一個結構化語言,它的重點在於算法和數據結構。C程序的設計首要考慮的是如何通過一個過程,對輸入(或環境條件)進行運算處理得到輸出(或實現過程(事務)控制)。

2、C++,首要考慮的是如何構造一個對象模型,讓這個模型能夠契合與之對應的問題域,這樣就可以通過獲取對象的狀態信息得到輸出或實現過程(事務)控制。 所以C與C++的最大區別在於它們的用於解決問題的思想方法不一樣。之所以說C++比C更先進,是因為“ 設計這個概念已經被融入到C++之中 ”。

如果你喜歡我的文章,請務必關注一下,都是滿滿的乾貨~如果你想獲取C/C++/windows/liunx更多行業內的第一手新鮮資料,請關注我 私信回覆“01”即可領取,新手小白到企業級項目實戰資料!


最近我用 C++ 寫了一個遊戲引擎,並用該引擎開發了一個名為 Hop Out 的小型手遊。先來看看實際運行效果:

Hop Out 是一款類似復古街機遊戲,但擁有 3D 卡通外觀的遊戲。闖關方式為改變所有墊子的顏色,這一點和 Q*Bert 遊戲很相似。

Hop Out 仍在開發當中,不過遊戲引擎部分基本完工了,所以我想在這裡分享關於遊戲引擎開發的一些技巧。

在我看來,開發遊戲引擎比較尷尬的一個情況就是你可能不知不覺地就造就出一個龐然大物,然後你一看到它就頭皮發麻,所以我的主張是保持事物的可控性,具體將從以下三個方面進行闡述:

  • 採用迭代方法
  • 先三思而後合併
  • 認識到序列化是個很大的主題

採用迭代方法


我的第一條建議是先快速地讓程序運行起來,然後迭代地進行開發。

如果條件允許的話,找個樣例程序,然後以此為基礎開始。以我為例,先下載 SDL 再打開 Xcode-iOS/Test/TestiPhoneOS.xcodeproj ,然後在 iPhone 上運行 testgles2 樣例程序。立刻我就得到了一個很可愛的旋轉立方體,如下圖。

C++到底適不適合做遊戲開發?漫談C++遊戲引擎開發


然後我下載一個別人做好的馬里奧 3D 模型。隨後編寫了一個文件格式不太複雜的 OBJ 文件加載程序,接著修改樣例程序,讓馬里奧取代立方體,如下圖。還有,我集成了SDL_image 來幫助加載紋理。

C++到底適不適合做遊戲開發?漫談C++遊戲引擎開發


再然後,我實現了雙搖桿控制來移動馬里奧,如下圖。

C++到底適不適合做遊戲開發?漫談C++遊戲引擎開發


接下來我想著研究一下骨骼動畫,所以我打開 Blender 製作了一個觸手模型,並通過一段可以前後擺動的有兩根骨頭的骨架來操控它。

C++到底適不適合做遊戲開發?漫談C++遊戲引擎開發


不過這裡我放棄了使用 OBJ 文件格式,轉而編寫了一個將數據從 Blender 導出到自定義 JSON 文件的 Python 腳本,這些 JSON 文件存儲了皮膚網格、骨骼、動畫等數據。在 C++ JSON library 的幫助下我將這些文件加載到了遊戲中。

C++到底適不適合做遊戲開發?漫談C++遊戲引擎開發


上述過程成功後,我接著使用 Blender 製作更加精緻的人物。下圖展示了我製作出的第一個可操控的 3D 人物。

C++到底適不適合做遊戲開發?漫談C++遊戲引擎開發


後來我又做了一大堆的工作,不過這裡我想強調的重點是,我沒有在動手編程之前先規劃好引擎架構。事實上,每當要添加一個新特性時,我只著眼於用最簡單的代碼將其實現,然後觀察這些代碼,看看它們自然而然呈現出的是一種什麼架構。這裡所講的引擎架構,指的是組成遊戲引擎的模塊集、模塊之間的依賴關係,以及模塊之間交互所使用的 API 。

C++到底適不適合做遊戲開發?漫談C++遊戲引擎開發


這是一種迭代開發的方法,這種方法在編寫遊戲引擎時非常有用,其優點在於不管開發工作進行到哪個階段,你始終都有一個可運行的程序。如果在後續提取代碼模塊時出現問題,你可以通過與上一次可正常運行的代碼對比以快速地找出錯誤。顯然,這裡我假設你使用了某種源代碼控制軟件。

也許你認為這種開發方法會浪費大量的時間,因為中間過程會產生許多後續需要清理的垃圾代碼。但是,大部分的清理工作無非就是將代碼從一個 .cpp 文件移動到另一個 .cpp 文件、將函數聲明提取到 .h 文件、或者一些其他簡單的操作。決定代碼的歸屬其實是一件相當困難的工作,但是顯然,當代碼呈現在你面前時,這個工作就會簡單許多。

況且在我看來,先絞盡腦汁地想出一個你認為能滿足未來所有需求的架構,然後再著手編程,會比迭代開發浪費更多的時間。這裡推薦一下我最喜歡的關於介紹過度工程危害的兩篇文章,一篇是 Tomasz Dąbrowski 的 The Vicious Circle of Generalization ,另一篇是 Joel Spolsky 的 Don’t Let Architecture Astronauts Scare You 。

但是請注意,我並沒有說你永遠都不應該先在紙面上解決問題,然後編程實現它。我也並沒有說你不應該提前規劃好你想要的功能。就我而言,我從一開始就想要遊戲引擎能夠在後臺線程中加載所有 assets 文件,但是我一開始並沒有去設計如何實現這個功能,而且一開始也確實沒有實現這個功能,實際上我一開始只實現了加載部分 assets 文件的功能。

先三思而後合併


作為程序員,我們似乎會本能地避免代碼重複、統一代碼風格以讓源代碼看起來美觀、優雅。然而,我的第二條建議是不要盲目地遵循這種本能。

給 DRY 原則放個假


為了給你一個示例,我的引擎包含了幾個 smart pointer 模板類,類似於 std::shared_ptr 。通過作為一個 raw pointer 的包裝器,它們個個都能防止內存洩漏。

  • Owned<> 用於被單個對象擁有的動態分配的對象。
  • Reference<> 使用引用計數來以便一個對象被多個對象擁有。
  • audio::AppOwned<> 被音頻混頻器外的代碼使用。它允許遊戲系統擁有音頻混頻器使用的對象,比如當前正在播放的聲音。
  • audio::AudioHandle<> 使用一個引用計數系統內部的音頻混頻器。


看起來似乎這些類的功能有重複的地方,違背了 DRY(Don't Repeat Yourself) 原則。事實確實如此,在開發早期,我曾想方設法地儘可能多地重用現有的 Reference<> 類。但是後來我發現音頻對象的生命週期受一些特殊的規則控制:如果音頻對象已經完成了播放,並且遊戲也沒有一個指向該音頻對象的指針,那麼該音頻對象就可以立即排隊等待刪除了。如果遊戲有一個指向該音頻對象的指針,那麼該音頻對象就不該被刪除。如果遊戲有一個指向該音頻對象的指針,但是該指針的擁有者在聲音沒有播放完成之前被破壞掉了,那麼該聲音就該被取消。我認為,與其增加Reference<>的複雜度,還不如引入單獨的模板類,況且後者顯然更實用一點。

95%的情況下,重用已有代碼是沒毛病的。然而,當你感覺到重用代碼變了味、或者你正在把簡單的東西變得複雜的時候,你就該仔細想想要不要堅持重用代碼。

大膽地使用不同的調用約定


Java 有一點我很不喜歡,那就是每個函數都必須定義在類中。在我看來,這根本就是胡來,這樣做也許使你的代碼看起來更整齊一點,但其實它變相地鼓勵了過度工程(over-engineering),而且也不能很好地支持我先前所提到地迭代開發方法。

在我的 C++ 引擎中,有些函數屬於類,有些函數不屬於類。例如,遊戲中的每個敵人都是一個類,敵人的大多數行為都是在類中實現,但是球體滾動這個行為是通過調用函數 sphereCast() 實現的,該函數屬於 physics 命名空間,但是函數 sphereCast() 並不屬於任何類——它就是 physics 模塊的一部分。

我通過一個構建系統組織代碼,該構建系統用於管理模塊之間的依賴關係。將這個函數強行塞進一個類中對於改進代碼組織來講沒多大意義。

再來談談多態(polymorphism))中的動態調度(dynamic dispatch)。我們經常需要在不知道對象確切類型的情況下調用函數獲取對象。大多數 C++ 程序員的第一反應是使用虛函數定義抽象基類,然後在派生類中重載這些函數。

這的確是一種行之有效的方法,但這只是實現該功能的眾多方法中的一種罷了。還有一些可以不引入多餘的代碼,或者帶有其他好處的動態調度技術:

  • C++11 引入了 std::function ,這是一種很方便的存儲回調函數的方法。你還可以編寫一個 std::function 個人版本,這樣在調試器中單步執行時或許就沒那麼痛苦了。
  • 許多回調函數可以用一對指針來實現: 一個函數指針和一個 opaque 參數,只需要在回調函數內部進行顯式轉換即可。純 C 庫中有很多這種例子。
  • 有時侯, 底層類型實際上在編譯時是已知的, 因此你可以綁定函數調用而無需額外的運行時開銷。Turf ,是我在遊戲引擎中使用的一個庫, 就大量使用了這種技術。感興趣的可以看看 turf::Mutex 。
  • 不過有時侯最直接的方法莫過於自己構建和維護一個原始函數指針表。我在音頻混頻器和序列化系統中使用了這種方法。正如下文將要提到的,Python 解釋器也大量使用了此技術。
  • 甚至你可以將函數指針存儲在哈希表中, 將函數名作為鍵。我使用此技術調度輸入事件, 如多點觸摸事件。這是一個記錄遊戲輸入並使用回放系統重新播放策略的一部分。


動態調度是一個很大的課題,我只是隨便舉些例子罷了,實際上還有很多方法都可以實現。隨著編寫的可擴展底層代碼(在開發遊戲引擎中很常見)越來越多,你會探索出越來越多的方法。

如果你不習慣這種編程方式,那麼 Python 解釋器或許對你來是是一個非常好的學習資源。它使用 C 編寫,實現了一個強大的對象模型:每個 PyObject 都指向了一個 PyTypeObject ,而每個 PyTypeObject 都包含了一個用於動態調度的函數指針表。如果你感興趣的話,可以從閱讀文檔 Defining New Types 開始。

認識到序列化是一個很大的主題


序列化(Serialization)指的是將運行時對象轉化為字節序列,換句話講,就是保存和加載數據。

對於許多遊戲引擎來講,遊戲內容是以各種可編輯格式創建的,如 .png 、 .json 、 .blend 或者一些專有格式等,最終再將其轉化為遊戲引擎可以快速加載的平臺特定的遊戲格式。這個管道中的最後一個應用程序通常被稱為 cooker 。cooker 也許會被集成到其他工具中,甚至分佈在多臺機器上。通常上,cooker 和許多工具是隨遊戲引擎本身一起開發和維護的。

C++到底適不適合做遊戲開發?漫談C++遊戲引擎開發


在建立這樣一個管道時,其中每個階段的文件格式都由你設定。你也許會自己定義一些文件格式,這些文件格式可能會隨著引擎功能的不斷添加演變。隨著它們的演變,有一天你或許會發現必須使某些程序與以前保存的文件格式保持兼容。但是,無論何種格式,你最終都得用 C++ 進行序列化。

C++ 實現序列化的方法數不勝數,一個比較容易想到的方法是在你想要序列化的 C++ 類中添加 load 函數和 save 函數。在文件頭部中存儲版本號,然後將版本號傳遞到每個 load 函數中,你就可以實現向後兼容性。這種辦法可行,不過可能導致代碼非常冗雜而難以維護。

void load(InStream& in, u32 fileVersion) { // Load expected member variables in >> m_position; in >> m_direction; // Load a newer variable only if the file version being loaded is 2 or greater if (fileVersion >= 2) { in >> m_velocity; } }


不過我們可以寫出更靈活、更不容易出錯的序列化代碼,這裡用到了反射(reflection)),具體來講是創建描述 C++ 類型佈局的運行時數據。如果想要快速瞭解一下如何在序列化時使用反射,可以看看開源項目 Blender 。

C++到底適不適合做遊戲開發?漫談C++遊戲引擎開發


當你從源代碼構建 Blender 時,會發生許多事情。首先,一個名為 makesdna 的程序會被編譯並運行。這個程序會解析 Blender 源樹中的一組 C 頭文件,然後輸出一個包含了被稱為 SDNA 的自定義格式的文件,該文件中存放了這些頭文件內部定義的所有 C 類型的緊湊摘要,這些 SDNA 數據就是反射數據(reflection data)。

然後 這些 SDNA 數據被鏈接到 Blender ,並和 Blender 所寫的每個 .blend 文件一起保存。從此以後,每加載一個 .blend 文件,Blender 就會比較該 .blend 文件的 SDNA 數據與運行時鏈接到當前版本的 SDNA 數據,並使用通用序列化代碼來處理差異。

這種策略使得 Blender 的向前和向後兼容性非常強大。你可以在最新版中加載 1.0 版的文件,也可以在舊版本中加載新版本的 .blend 文件。

和 Blender 類似,許多遊戲引擎和與之相關的工具都會生成並使用自己的反射數據。有很多方法做到這一點:你可以像 Blender 那樣解析自己的 C/C++ 源代碼來提取類型信息。你也可以創建一門獨立的數據描述語言,並編寫一個工具來生成此語言的 C++ 類型定義和反射數據。你還可以使用預處理器宏和 C++ 模板來生成運行時反射數據。一旦有了可用的反射數據,有無數種方法基於它編寫一個通用序列化程序。

顯然,我在此省略了許多細節。我只想說明確實有很多種方法來序列化數據,其中有一些方法是相當複雜的。程序員們通常並不會像討論其他引擎系統那樣討論序列化,雖然事實上大部分其他的引擎系統都依賴序列化。

例如,GDC 2017 上的96個編程會談中,我統計了下,31個是關於圖形學的,11個關於在線的,10個關於工具的,4個關於AI的,3個關於物理的,2個關於音頻的,但是隻有1個直接涉及到了序列化。

如果你喜歡我的文章,請務必關注一下,都是滿滿的乾貨~如果你想獲取C/C++/windows/liunx更多行業內的第一手新鮮資料,請關注我 私信回覆“01”即可領取,新手小白到企業級項目實戰資料!


總結


開發遊戲引擎,哪怕規模很小,也是一項艱鉅的任務。關於此我還有很多東西可說,但是考慮到博客長度,老實來講,這就是我能想到的最實用的建議了:迭代開發、稍微控制一下統一代碼的衝動、認識到序列化是一個很大的課題,你也許就能根據此確定出一個比較合適的策略了。根據我的經驗,如果忽略了這些東西,它們很可能就會成為你的絆腳石。


分享到:


相關文章: