跳一跳是我想玩的遊戲類型:3D卡通外觀的復古街機遊戲。目標是改變每一個填充塊的顏色,就像Q * Bert同樣。程序員
Hop Out仍在開發中,但引擎的功能已經很完善了,因此我想在這裏分享一些關於引擎開發的技巧。編程
你爲何想要寫一個遊戲引擎?可能有不少緣由:json
你是個修理工,喜歡從頭開始創建系統,直到系統完成。xcode
關於遊戲開發你想了解更多。你在遊戲行業工做了多年,如今仍然在不停的琢磨。你甚至不肯定本身是否能夠從頭開始編寫一個引擎,由於它與大型工做室的編程工做的平常職責大不相同。你想知道答案。架構
你喜歡控制。對徹底按照你想要的方式組織代碼,知道一切都在哪裏,感到滿意。app
你能夠從AGI(1984),id Tech 1(1993),Build(1995)等經典遊戲引擎以及Unity和Unreal等行業巨頭那裏得到靈感。框架
你相信咱們這個遊戲產業應該試着去揭開引擎發展的序幕。咱們並無掌握製做遊戲的藝術。還離得很遠!咱們對這個過程的研究越多,改進的機會就越大。函數
2017年的遊戲平臺 – 手機,遊戲機和電腦 – 很是強大,並且在不少方面都很是類似。遊戲引擎的開發並非像過去同樣,在脆弱和怪異的硬件上掙扎。在我看來,更可能是關於本身製造出來的複雜性的鬥爭。創造一個怪物很容易!這就是爲何本文建議圍繞着保持事情可控的緣由。我把它分紅三部分:工具
這個建議適用於任何類型的遊戲引擎。我不會告訴你如何編寫着色器,八叉樹是什麼,或者如何添加物體。這些事兒,都是我假設你已經知道並且應該知道 – 這很大程度上取決於你想要製做的遊戲類型。佈局
相反,我故意選擇了一些彷佛沒有被普遍認可或說起的觀點 – 這些是我在試圖揭開一個主題神祕面紗時最感興趣的一些觀點。
個人第一條建議是使一些東西(任何東西),快速運行起來,而後迭代。
若是可能的話,從一個示例應用程序開始,初始化設備並在屏幕上繪製一些東西。就我而言,我下載了SDL,打開了Xcode-iOS / Test / TestiPhoneOS.xcodeproj,而後在個人iPhone上運行了testgles2示例。
瞧!我使用OpenGL ES 2.0,生成了一個可愛的旋轉立方體。
下一步,是下載一個其餘人制做的馬里奧3D 模型。我寫了一個快速和粗糙的OBJ文件加載器 – 文件格式並不太複雜 – 而且修改了例程,來呈現Mario,而不是一個立方體。我還集成了SDL_Image來幫助加載紋理。
而後我實現了一個雙搖桿控制器用來操控馬里奧(我原本想要建立的是一個雙搖桿設計遊戲,並非馬里奧。)
接下來,我想探索骨骼動畫,因此我打開了Blender,作了一個觸手模型,而且用一個先後擺動的雙骨架來操縱它。
此時,我放棄了OBJ文件格式,編寫了一個Python腳原本從Blender導出自定義的JSON文件。這些JSON文件描述了皮膚網格,骨架和動畫數據。在C ++ JSON庫的幫助下將這些文件加載到遊戲中。
一旦這個完成,我回到了Blender,並作了更詳細的角色設計。 (這是我創造的第一個被操縱的3D人,我爲他感到驕傲。)
在接下來的幾個月裏,我採起了如下幾個步驟:
重點是:在開始編程以前,我沒有對引擎架構進行設計。這是一個通過深思熟慮的選擇。相反,我只是寫了實現下一個特性的最簡單的代碼,而後我會查看代碼,看看會出現什麼天然生成的架構。我說的「引擎架構」是指組成遊戲引擎的模塊集,這些模塊之間的依賴關係,以及用於與每一個模塊交互的 API。
這是一個迭代的方法,由於它關注於較小的可交付成果。它在編寫遊戲引擎時效果很是好,由於在每一個步驟中,你都有一個正在運行的程序。若是在將代碼合成到新模塊中時出現問題,能夠隨時將作的更改與之前工做的代碼進行比較。顯然,我假設你在使用某種源代碼管理工具。
你可能會認爲這種方法浪費了不少時間,由於老是在編寫糟糕的代碼,以後須要清理。可是大部分的清理操做都是將代碼從一個.cpp文件移動到另外一個,將函數聲明提取到.h文件中,或者直接進行簡單的修改。決定事情應該去哪是難點,可是這在已經有代碼的時候會更容易決定。
我認爲用相反的方法:試圖設計出一個可以提早完成全部需求的架構,會浪費更多的時間。我最喜歡的兩篇關於系統過分設計風險的文章是 Tomasz Dąbrowski 的《泛化的惡性循環》和 Joel Spolsky 的《不要讓架構太空人嚇到你》。
我並非說在用代碼處理問題以前,不該該在紙上進行設計。我也不是說你不該該事先決定你想要的功能。好比,我從一開始就知道我想讓個人引擎在後臺線程中加載全部資源。我只是沒有嘗試設計或實現該功能,直到個人引擎首先加載一些資源。
迭代的方法給了我一個比我之前盯着一張白紙左思右想更優雅的架構。個人引擎的iOS版本如今是 100% 原始代碼,包括自定義數學庫,容器模板,反射/序列化系統,渲染框架,物理模塊和音頻混合器。我能夠編寫每個模塊,可是你可能沒有必要本身寫全部這些東西。你可能會發現適合本身引擎的許多優秀的開源代碼庫。 GLM、Bullet Physics 和 STB 頭文件只是一些有趣的例子。
做爲程序員,咱們儘可能避免代碼重複,喜歡代碼遵循統一的風格。不過,我認爲不要讓這些本能凌駕於每個決定之上。
偶爾要抵制一下 DRY 原則 舉個例子,個人引擎包含了幾個「智能指針」模板類,與 std :: shared_ptr 相似。每個指針做爲一個原始指針的包裝,有助於防止內存泄漏。
這樣可能看起來像其中一些類複製了其它的功能,違反 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 ++)類。能夠經過在文件頭中存儲版本號來實現向後兼容,而後將這個數字傳遞給每一個加載函數。這是可行的,儘管這樣代碼可能維護起來比較繁瑣。
void load(InStream& in, u32 fileVersion) { // 加載預期的成員變量 in >> m_position; in >> m_direction; // 僅當正在加載的文件版本是2或更大時才加載新的變量 if (fileVersion >= 2) { in >> m_velocity; } } void load(InStream& in, u32 fileVersion) { // 加載預期的成員變量 in >> m_position; in >> m_direction; // 僅當正在加載的文件版本是2或更大時才加載新的變量 if (fileVersion >= 2) { in >> m_velocity; } }
經過反射(特別是經過建立描述(C ++)類型佈局的運行時數據),能夠編寫更靈活,不容易出錯的序列化代碼。想要快速瞭解反射如何進行序列化,請看一下開源項目Blender是如何實現的。
從源代碼構建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協議緩衝區是序列化遊戲資產的理想選擇,可是值得研究。
編寫一個遊戲引擎,即便是一個小遊戲引擎,也是一個很大的任務。關於這個我能夠說的還有不少,可是對於這個長度的帖子來講,這真的是我認爲最有用的建議:迭代地工做,抵制統一代碼的衝動,而且知道序列化是一個大問題,你須要選擇一個合適的策略。根據個人經驗,若是忽視這些事情,每一件事情均可能成爲一個絆腳石。
滿滿的自豪感,真的很想知道你們的想法,還請持續關注更新,更多幹貨和資料請直接聯繫我,也能夠加羣710520381,邀請碼:柳貓,歡迎你們共同討論