前言
小編是一個有著6年工作經(jīng)驗的工程師闺魏,關于C++,編程俯画,自己有做材料的整合析桥,一個完整的C++編程學習路線,學習資料和工具艰垂,能夠進我的群7253泡仗,-91790收取,免費送給大家猜憎,希望你也能憑著自己的努力娩怎,成為下一個優(yōu)秀的程序員
最近我用 C++ 寫了一個游戲引擎,并用該引擎開發(fā)了一個名為 Hop Out 的小型手游胰柑。先來看看實際運行效果:
(譯者注 這里本來有個小視頻截亦,放到附件里了,感興趣的朋友請下載觀看柬讨,文件不到4MB崩瓤。)
Hop Out 是一款類似復古街機游戲,但擁有 3D 卡通外觀的游戲姐浮。闖關方式為改變所有墊子的顏色谷遂,這一點和 Q*Bert 游戲很相似。
Hop Out 仍在開發(fā)當中卖鲤,不過游戲引擎部分基本完工了肾扰,所以我想在這里分享關于游戲引擎開發(fā)的一些技巧畴嘶。
在我看來,開發(fā)游戲引擎比較尷尬的一個情況就是你可能不知不覺地就造就出一個龐然大物集晚,然后你一看到它就頭皮發(fā)麻窗悯,所以我的主張是保持事物的可控性,具體將從以下三個方面進行闡述:
采用迭代方法
先三思而后合并
認識到序列化是個很大的主題
采用迭代方法
我的第一條建議是先快速地讓程序運行起來偷拔,然后迭代地進行開發(fā)蒋院。
如果條件允許的話,找個樣例程序莲绰,然后以此為基礎開始欺旧。以我為例,先下載 SDL 再打開 Xcode-iOS/Test/TestiPhoneOS.xcodeproj 蛤签,然后在 iPhone 上運行 testgles2 樣例程序辞友。立刻我就得到了一個很可愛的旋轉立方體,如下圖震肮。
然后我下載一個別人做好的馬里奧 3D 模型称龙。隨后編寫了一個文件格式不太復雜的 OBJ 文件加載程序,接著修改樣例程序戳晌,讓馬里奧取代立方體鲫尊,如下圖。還有沦偎,我集成了SDL_image 來幫助加載紋理疫向。
再然后,我實現(xiàn)了雙搖桿控制來移動馬里奧扛施,如下圖鸿捧。
接下來我想著研究一下骨骼動畫,所以我打開 Blender 制作了一個觸手模型疙渣,并通過一段可以前后擺動的有兩根骨頭的骨架來操控它。
不過這里我放棄了使用 OBJ 文件格式堆巧,轉而編寫了一個將數(shù)據(jù)從 Blender 導出到自定義 JSON 文件的 Python 腳本妄荔,這些 JSON 文件存儲了皮膚網(wǎng)格、骨骼谍肤、動畫等數(shù)據(jù)啦租。在 C++ JSON library 的幫助下我將這些文件加載到了游戲中。
上述過程成功后荒揣,我接著使用 Blender 制作更加精致的人物篷角。下圖展示了我制作出的第一個可操控的 3D 人物。
后來我又做了一大堆的工作系任,不過這里我想強調(diào)的重點是恳蹲,我沒有在動手編程之前先規(guī)劃好引擎架構虐块。事實上,每當要添加一個新特性時嘉蕾,我只著眼于用最簡單的代碼將其實現(xiàn)贺奠,然后觀察這些代碼,看看它們自然而然呈現(xiàn)出的是一種什么架構错忱。這里所講的引擎架構儡率,指的是組成游戲引擎的模塊集、模塊之間的依賴關系以清,以及模塊之間交互所使用的 API 儿普。
這是一種迭代開發(fā)的方法,這種方法在編寫游戲引擎時非常有用掷倔,其優(yōu)點在于不管開發(fā)工作進行到哪個階段箕肃,你始終都有一個可運行的程序。如果在后續(xù)提取代碼模塊時出現(xiàn)問題今魔,你可以通過與上一次可正常運行的代碼對比以快速地找出錯誤勺像。顯然,這里我假設你使用了某種源代碼控制軟件错森。
也許你認為這種開發(fā)方法會浪費大量的時間吟宦,因為中間過程會產(chǎn)生許多后續(xù)需要清理的垃圾代碼。但是涩维,大部分的清理工作無非就是將代碼從一個 .cpp 文件移動到另一個 .cpp 文件殃姓、將函數(shù)聲明提取到 .h 文件、或者一些其他簡單的操作瓦阐。決定代碼的歸屬其實是一件相當困難的工作蜗侈,但是顯然,當代碼呈現(xiàn)在你面前時睡蟋,這個工作就會簡單許多踏幻。
況且在我看來,先絞盡腦汁地想出一個你認為能滿足未來所有需求的架構戳杀,然后再著手編程该面,會比迭代開發(fā)浪費更多的時間。這里推薦一下我最喜歡的關于介紹過度工程危害的兩篇文章信卡,一篇是 Tomasz D?browski 的 The Vicious Circle of Generalization 隔缀,另一篇是 Joel Spolsky 的 Don’t Let Architecture Astronauts Scare You 。
但是請注意傍菇,我并沒有說你永遠都不應該先在紙面上解決問題猾瘸,然后編程實現(xiàn)它。我也并沒有說你不應該提前規(guī)劃好你想要的功能。就我而言牵触,我從一開始就想要游戲引擎能夠在后臺線程中加載所有 assets 文件淮悼,但是我一開始并沒有去設計如何實現(xiàn)這個功能,而且一開始也確實沒有實現(xiàn)這個功能荒吏,實際上我一開始只實現(xiàn)了加載部分 assets 文件的功能敛惊。
先三思而后合并
作為程序員,我們似乎會本能地避免代碼重復绰更、統(tǒng)一代碼風格以讓源代碼看起來美觀瞧挤、優(yōu)雅。然而儡湾,我的第二條建議是不要盲目地遵循這種本能特恬。
給 DRY 原則放個假
為了給你一個示例,我的引擎包含了幾個 smart pointer 模板類徐钠,類似于 std::shared_ptr 癌刽。通過作為一個 raw pointer 的包裝器,它們個個都能防止內(nèi)存泄漏尝丐。
Owned<> 用于被單個對象擁有的動態(tài)分配的對象显拜。
Reference<> 使用引用計數(shù)來以便一個對象被多個對象擁有。
audio::AppOwned<> 被音頻混頻器外的代碼使用爹袁。它允許游戲系統(tǒng)擁有音頻混頻器使用的對象远荠,比如當前正在播放的聲音。
audio::AudioHandle<> 使用一個引用計數(shù)系統(tǒng)內(nèi)部的音頻混頻器失息。
看起來似乎這些類的功能有重復的地方譬淳,違背了 DRY(Don't Repeat Yourself) 原則。事實確實如此盹兢,在開發(fā)早期邻梆,我曾想方設法地盡可能多地重用現(xiàn)有的 Reference<> 類。但是后來我發(fā)現(xiàn)音頻對象的生命周期受一些特殊的規(guī)則控制:如果音頻對象已經(jīng)完成了播放绎秒,并且游戲也沒有一個指向該音頻對象的指針浦妄,那么該音頻對象就可以立即排隊等待刪除了。如果游戲有一個指向該音頻對象的指針替裆,那么該音頻對象就不該被刪除校辩。如果游戲有一個指向該音頻對象的指針,但是該指針的擁有者在聲音沒有播放完成之前被破壞掉了辆童,那么該聲音就該被取消。我認為惠赫,與其增加Reference<>的復雜度把鉴,還不如引入單獨的模板類,況且后者顯然更實用一點。
95%的情況下庭砍,重用已有代碼是沒毛病的场晶。然而,當你感覺到重用代碼變了味怠缸、或者你正在把簡單的東西變得復雜的時候诗轻,你就該仔細想想要不要堅持重用代碼。
大膽地使用不同的調(diào)用約定
Java 有一點我很不喜歡揭北,那就是每個函數(shù)都必須定義在類中扳炬。在我看來,這根本就是胡來搔体,這樣做也許使你的代碼看起來更整齊一點恨樟,但其實它變相地鼓勵了過度工程(over-engineering),而且也不能很好地支持我先前所提到地迭代開發(fā)方法疚俱。
在我的 C++ 引擎中劝术,有些函數(shù)屬于類,有些函數(shù)不屬于類呆奕。例如养晋,游戲中的每個敵人都是一個類,敵人的大多數(shù)行為都是在類中實現(xiàn)梁钾,但是球體滾動這個行為是通過調(diào)用函數(shù) sphereCast() 實現(xiàn)的绳泉,該函數(shù)屬于 physics 命名空間,但是函數(shù) sphereCast() 并不屬于任何類——它就是 physics 模塊的一部分陈轿。
我通過一個構建系統(tǒng)組織代碼圈纺,該構建系統(tǒng)用于管理模塊之間的依賴關系。將這個函數(shù)強行塞進一個類中對于改進代碼組織來講沒多大意義麦射。
再來談談多態(tài)(polymorphism))中的動態(tài)調(diào)度(dynamic dispatch)蛾娶。我們經(jīng)常需要在不知道對象確切類型的情況下調(diào)用函數(shù)獲取對象。大多數(shù) C++ 程序員的第一反應是使用虛函數(shù)定義抽象基類潜秋,然后在派生類中重載這些函數(shù)蛔琅。
這的確是一種行之有效的方法,但這只是實現(xiàn)該功能的眾多方法中的一種罷了峻呛。還有一些可以不引入多余的代碼罗售,或者帶有其他好處的動態(tài)調(diào)度技術:
C++11 引入了 std::function ,這是一種很方便的存儲回調(diào)函數(shù)的方法钩述。你還可以編寫一個 std::function 個人版本寨躁,這樣在調(diào)試器中單步執(zhí)行時或許就沒那么痛苦了。
許多回調(diào)函數(shù)可以用一對指針來實現(xiàn): 一個函數(shù)指針和一個 opaque 參數(shù)牙勘,只需要在回調(diào)函數(shù)內(nèi)部進行顯式轉換即可职恳。純 C 庫中有很多這種例子所禀。
有時侯, 底層類型實際上在編譯時是已知的, 因此你可以綁定函數(shù)調(diào)用而無需額外的運行時開銷。Turf 放钦,是我在游戲引擎中使用的一個庫, 就大量使用了這種技術色徘。感興趣的可以看看 turf::Mutex 。
不過有時侯最直接的方法莫過于自己構建和維護一個原始函數(shù)指針表操禀。我在音頻混頻器和序列化系統(tǒng)中使用了這種方法褂策。正如下文將要提到的,Python 解釋器也大量使用了此技術颓屑。
甚至你可以將函數(shù)指針存儲在哈希表中, 將函數(shù)名作為鍵斤寂。我使用此技術調(diào)度輸入事件, 如多點觸摸事件。這是一個記錄游戲輸入并使用回放系統(tǒng)重新播放策略的一部分邢锯。
動態(tài)調(diào)度是一個很大的課題扬蕊,我只是隨便舉些例子罷了,實際上還有很多方法都可以實現(xiàn)丹擎。隨著編寫的可擴展底層代碼(在開發(fā)游戲引擎中很常見)越來越多尾抑,你會探索出越來越多的方法。
如果你不習慣這種編程方式蒂培,那么 Python 解釋器或許對你來是是一個非常好的學習資源再愈。它使用 C 編寫,實現(xiàn)了一個強大的對象模型:每個 PyObject 都指向了一個 PyTypeObject 护戳,而每個 PyTypeObject 都包含了一個用于動態(tài)調(diào)度的函數(shù)指針表翎冲。如果你感興趣的話,可以從閱讀文檔 Defining New Types 開始媳荒。
認識到序列化是一個很大的主題
序列化(Serialization)指的是將運行時對象轉化為字節(jié)序列抗悍,換句話講,就是保存和加載數(shù)據(jù)钳枕。
對于許多游戲引擎來講缴渊,游戲內(nèi)容是以各種可編輯格式創(chuàng)建的,如 .png 鱼炒、 .json 衔沼、 .blend 或者一些專有格式等,最終再將其轉化為游戲引擎可以快速加載的平臺特定的游戲格式昔瞧。這個管道中的最后一個應用程序通常被稱為 cooker 指蚁。cooker 也許會被集成到其他工具中,甚至分布在多臺機器上自晰。通常上凝化,cooker 和許多工具是隨游戲引擎本身一起開發(fā)和維護的。
在建立這樣一個管道時酬荞,其中每個階段的文件格式都由你設定缘圈。你也許會自己定義一些文件格式劣光,這些文件格式可能會隨著引擎功能的不斷添加演變袜蚕。隨著它們的演變糟把,有一天你或許會發(fā)現(xiàn)必須使某些程序與以前保存的文件格式保持兼容。但是牲剃,無論何種格式遣疯,你最終都得用 C++ 進行序列化。
C++ 實現(xiàn)序列化的方法數(shù)不勝數(shù)凿傅,一個比較容易想到的方法是在你想要序列化的 C++ 類中添加 load 函數(shù)和 save 函數(shù)缠犀。在文件頭部中存儲版本號,然后將版本號傳遞到每個 load 函數(shù)中聪舒,你就可以實現(xiàn)向后兼容性辨液。這種辦法可行,不過可能導致代碼非常冗雜而難以維護箱残。
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))被辑,具體來講是創(chuàng)建描述 C++ 類型布局的運行時數(shù)據(jù)燎悍。如果想要快速了解一下如何在序列化時使用反射,可以看看開源項目 Blender 盼理。
當你從源代碼構建 Blender 時谈山,會發(fā)生許多事情。首先宏怔,一個名為 makesdna 的程序會被編譯并運行奏路。這個程序會解析 Blender 源樹中的一組 C 頭文件,然后輸出一個包含了被稱為 SDNA 的自定義格式的文件臊诊,該文件中存放了這些頭文件內(nèi)部定義的所有 C 類型的緊湊摘要鸽粉,這些 SDNA 數(shù)據(jù)就是反射數(shù)據(jù)(reflection data)。
然后 這些 SDNA 數(shù)據(jù)被鏈接到 Blender 妨猩,并和 Blender 所寫的每個 .blend 文件一起保存幢炸。從此以后茁裙,每加載一個 .blend 文件,Blender 就會比較該 .blend 文件的 SDNA 數(shù)據(jù)與運行時鏈接到當前版本的 SDNA 數(shù)據(jù),并使用通用序列化代碼來處理差異票顾。
這種策略使得 Blender 的向前和向后兼容性非常強大。你可以在最新版中加載 1.0 版的文件抡四,也可以在舊版本中加載新版本的 .blend 文件央串。
和 Blender 類似,許多游戲引擎和與之相關的工具都會生成并使用自己的反射數(shù)據(jù)约谈。有很多方法做到這一點:你可以像 Blender 那樣解析自己的 C/C++ 源代碼來提取類型信息笔宿。你也可以創(chuàng)建一門獨立的數(shù)據(jù)描述語言犁钟,并編寫一個工具來生成此語言的 C++ 類型定義和反射數(shù)據(jù)。你還可以使用預處理器宏和 C++ 模板來生成運行時反射數(shù)據(jù)泼橘。一旦有了可用的反射數(shù)據(jù)涝动,有無數(shù)種方法基于它編寫一個通用序列化程序。
顯然炬灭,我在此省略了許多細節(jié)醋粟。我只想說明確實有很多種方法來序列化數(shù)據(jù),其中有一些方法是相當復雜的重归。程序員們通常并不會像討論其他引擎系統(tǒng)那樣討論序列化米愿,雖然事實上大部分其他的引擎系統(tǒng)都依賴序列化。
例如鼻吮,GDC 2017 上的96個編程會談中育苟,我統(tǒng)計了下,31個是關于圖形學的椎木,11個關于在線的违柏,10個關于工具的,4個關于AI的拓哺,3個關于物理的勇垛,2個關于音頻的,但是只有1個直接涉及到了序列化士鸥。
總結
開發(fā)游戲引擎闲孤,哪怕規(guī)模很小,也是一項艱巨的任務烤礁。關于此我還有很多東西可說讼积,但是考慮到博客長度,老實來講脚仔,這就是我能想到的最實用的建議了:迭代開發(fā)勤众、稍微控制一下統(tǒng)一代碼的沖動、認識到序列化是一個很大的課題鲤脏,你也許就能根據(jù)此確定出一個比較合適的策略了们颜。根據(jù)我的經(jīng)驗,如果忽略了這些東西猎醇,它們很可能就會成為你的絆腳石窥突。