本篇序言
這次部落格更新距離上次的時間間隔變短了好多,因為最近硬是抽出了一大部分時間來進行引擎的開發。而且運氣很好的是在寫連結串列這種很“敏感”的的資料結構的時候並沒有出現那種災難性的後果(恐怕是前一段時間在leetcode刷資料結構的原因吧)。於是本人才能在上篇博文釋出後不久完成了基本渲染物件,渲染鏈,場景鏈這三個系統的實現。能這麼順利,運氣其實佔了很大的因素(笑)。
雖然由於這次更新的速度快的離譜,但還請各位放心,至少不會像法國土豆的年貨遊戲那樣遭(育碧:你禮貌麼?)。因為本次的內容會觸及本引擎渲染系統最核心的一些部分,雖然不能說最複雜,但至少在某些方面也奠定了本引擎的未來開發基調。所以內容可能會比較長,還請各位耐心觀看。
好的,正文開始,好戲開場!
1. 渲染框架(第二部分):OpenGL抽象
在上一篇博文的末尾,我提到了我們的引擎目前還存在的一個問題,那就是依舊含有較高的平臺依賴,準確的說是對OpenGL的依賴,在我們的RenderFrame
類的實現裡面還存在著大量gl打頭的函式呼叫。以及許多函式的引數列表裡還有著OpenGL的上下文型別,這明顯是一個比較致命的問題,比如我們太膨脹想要將本引擎移植到PS5或者是XBOX Series S|X上呢。雖然在VULKAN這種抽象層級低的API大行其道的當代使用一個API就可在幾乎所有平臺上流暢執行,但由於概念構型與OpenGL這種老一代的圖形API不同以及本人技術力太過生草(現在還不會用VULKAN畫三角形),所以目前我們只能為我們的引擎做好可能會移植到DX11平臺甚至是新的VULKAN的準備(當然也有可能一直賴在OpenGL不走了),為了降低引擎與圖形API的耦合度,我們必須將OpenGL抽象出我們的引擎。
這裡普及一下關於OpenGL與VULKAN,雖然兩者都是由Khronos Group負責維護的API標準,但兩者在基礎概念上有很大不同,OpenGL採用單執行緒狀態機,而VULKAN是完全支援多執行緒。舉個例子,各位經常會發現同一個遊戲在不同版本的顯示卡驅動中或者同代不同品牌顯示卡中會有不同的幀率表現,這就是由於OpenGL的抽象層級太高以及只支援單執行緒管線處理所導致的,由於Khronos Group給OpenGL設定的介面太過“自然”化(可以理解為高階程式語言相對應於組合語言的語言表達高度自然化),而具體實現方法由各個顯示卡廠商開發的驅動去完成,所以得到的結果參差不齊,同一個處理紋理的OpenGL函式可能在ATI的顯示卡上甚至是某個版本的顯示卡驅動上執行效率極高,在英偉達的顯示卡甚至某個版本的驅動上效率次一些。而VULKAN不同,它與顯示卡之間只有一層“薄顯示卡驅動”,VULKAN給的API更加貼合顯示卡的工作原理,將一切的優化工作交給軟體開發者,也便使得它比起老前輩OpenGL更跨平臺以及更有效率。
回到我們的引擎中。說了那麼多,也只是為了讓大家意識到引擎與圖形API之間抽象的重要性,而並不是將OpenGL貶到蠻荒之地去,相反,OpenGL對開發者是最友好的API沒有之一。好的,接下來具體到我們的引擎實現中來。
值得欣慰的是,由於目前我們很及時地意識到圖形API抽象的重要性。所以在情況並未一團糟的情況下,我們可以很方便的進行圖形API抽象。既然要抽象,那就抽象地徹底一些,我們在引擎解決方案裡新建一個VS靜態庫專案,專門存放與OpenGL底層API互動的邏輯實現。到時我們的引擎只要呼叫由這個靜態庫抽象出的方法即可。
由於我們需要在這個抽象模組中實現OpenGL的方法,那麼我們首先就得為專案建立依賴,即GLFW以及GLAD的附加包含目錄以及附加依賴項。而且我們還想將ImGui的初始化與上下文也獨立出去,所以也請包含ImGuiSharedLib專案。
在以上所有工作做好後,我們開始程式碼工作,首先新建一個定義用標頭檔案SPOpenGLRenderer.h
,即和VS專案名一致即可。在本檔案中鍵入如下程式碼:
// 包含OpenGL抽象方法類
#include "SPOpenGLRenderAPI.h"
// 包含抽象出的上下文類
#include "SPRendererCtx.h"
#include <imgui_impl_glfw.h>
#include <imgui_impl_opengl3.h>
namespace Shadow
{
// 既然要抽象,那就抽象地徹底一些,把名稱上的依賴也給抽象掉
typedef SPOpenGLRenderAPI SHADOW_RENDER_API;
typedef GLFWwindow* SHADOW_RENDER_API_CTX;
typedef ImGuiContext* SHADOW_IMGUI_CTX;
}
接下來在此VS專案裡新建類,名為SPOpenGLRenderAPI
。從構建日誌系統得來的經驗告訴我們:我們可以將這個類構建成一個靜態類,這樣可以不建立額外物件佔用空間以及不會產生全域性變數重定義等問題,那麼將如下程式碼實現鍵入類中:
// Declare.
class SPOpenGLRenderAPI
{
public:
// RenderFrame.
// 這個函式就是將我們在渲染框架建構函式中執行的相關方法。
static void RendererInitialize(int _iScrWeight, int _iScrHeight,
std::string _sWindowTitle, bool& _bIsWithEditor);
// 相對應為渲染框架中解構函式中執行的相關方法。
static void RendererTerminator();
// 關於這裡我為什麼會寫loopstart以及loopend兩個函式
// 這也就是狀態機系統的一大弊端,任何流程都是嚴格線性的,渲染中迴圈也是一樣
// 比如渲染一個三角形的渲染程式碼必須要在glClear以後並在glSwapBuffers以前一樣
static void RendererLoopStart();
static void RendererLoopEnd();
// 由於查詢方法內部實現還是用到了平臺相關程式碼,所以我又將它抽象了一層
static bool WindowStatusQuery() noexcept;
// 由於我們將ImGui初始化以及繪製等相關過程也交給了抽象方法類,所以編輯器的相關開關也要被移到這裡
// 其實還有一個解決方案,這也是我在寫這篇博文時才想到的,可以將ImGui的初始化獨立出另外的方法,
// 這樣也比較符合單一職責原則一些。大家也可以試一試。
static bool GetEditorSwitch() noexcept;
// 這就是我們將上下文獨立後的產物。
static SPRendererCtx* GetContext() noexcept;
// 返回出API的上下文
static GLFWwindow* GetAPICtx() noexcept;
// 返回出ImGui的上下文
static ImGuiContext* GetImGuiCtx() noexcept;
private:
static bool b_isWithEditor;
static SPRendererCtx* rc_Ctx;
};
在編寫完以後,我們就可以將我們上次編寫的上下文抽象也加進來了,這樣,一個較為完整的圖形API抽象就完成了,其實還有許多方法在我們開發後期還會加進去,不過目前這些方法足夠了。將本抽象靜態庫編譯後接下來將所有引用OpenGL的引擎模組更換OpenGL依賴為我們寫的本抽象靜態庫。按下F5後我們會發現執行成功。正如我們所預期的那樣。
2. 渲染框架(第三部分):渲染核心的設計
接下來開始進行渲染核心的設計,這也是本文這次要著重講的地方。在當初引擎的應用程式架構剛搭建好時,我們就發現我們的應用程式若要想成功在入口點內執行,只能通過C++的執行時動態型別判斷以及一大堆的回撥函式。我們的渲染物件也是如此,就比如渲染場景時引擎框架是完全不知道我們的場景中有多少個物體,多少個光源等,有可能是一個,也有可能是114514個(這麼臭的場景真是屑),引擎是無法預測的。我們總不可能將待渲染元件全部寫死在渲染框架裡,這樣就失去遊戲引擎的靈活性了。所以在研究了許多現行成熟的引擎,以及結合了本人極度生草的技術力後,本人為此引擎設計了一套鏈式渲染核心,從小到大分別是基礎渲染物件,渲染鏈,場景鏈。接下來我會對每一個概念進行說明。
在對每個概念進行說明之前,我會結合一點例子來說明我這套渲染核心的工作原理,希望大家在看完本文後會對這款引擎渲染核心的設計思路有所瞭解,在以後開發自己的引擎中提供思路和幫助。
由於我們這套引擎在3D和2D場景下皆可適用,所以我們必須要折中找到3D和2D場景中的共同點,那麼,首先讓我們來看看3D場景中的特性。
相信各位之中有許多曾經體驗過虛幻引擎或者Unity引擎開發遊戲的開發人員。不知在各位的開發過程中是否發現過我們使用的Actor或者是某些模型檔案真正在3dsmax或者是maya以及blender之中是由多個模型零件組成的模型組?以及在我們的場景開發中我們會發現我們的場景其實是由一系列的模型物件組成,比如一個庫房的場景就由一大堆的貨箱以及昏暗的電燈組成。真實世界的組成也是這樣由一大堆的元素組成,用哲學中唯物辯證法關於聯絡的觀點的一句話說就是:“事物內部不同組成部分的聯絡體現了事物具有內部結構性”。以及在後來我們引擎中需要使用的assimp模型載入庫裡,也是將3D模型拆分成多個模型零件匯入到記憶體中的。
接下來咱們聊一聊2D場景,以我最喜歡的PSP遊戲之一《超級彈丸論破2》來說,在遊戲裡面有這麼一個系統,如下圖示:
當玩家在海島外景上漫遊時,可能是由於PSP機能限制,Spike將漫遊從一代的3D漫遊變成了2D卷軸場景,但相信各位看到後都會說:這多簡單,一幅畫加一個動圖就實現了,是麼?真有這麼簡單那可就省了不少事。其實剔除玩家操縱的創妹以及人物立繪,這樣一個2D卷軸場景至少用到了多達六個的圖層(尤其是在未來旅館門口那裡用到的圖層是最多的),這是由於要體現近大遠小以及近快遠慢的場景透視特性。一個圖層就可看做一個場景元件。當然,更復雜的還在後面,玩家操控的創妹可不僅僅是一張簡單的動圖精靈,由於本人貼的截圖是來自於模擬器版,所以畫面精細了許多,有些細節不容易看出來,但要是各位有條件的話可以仔細觀察PSP版本的畫面,創妹的腿部以及手臂的關節處是有細小的縫隙的,也就是說2D卷軸中的創妹是由一堆面片通過2D骨骼拼接出來(聽起來雖然有些毛骨悚然,但真實情況就是如此),使得2D人物的運動相比gif動圖更加真實自然(各位也不用對著2D骨骼技術望洋興嘆,本引擎在後期也會加入2D骨骼系統,這也是本人構建2D系統的終極目標。小高,你們的2D骨骼不錯麼,拿來吧你!)。
所以在總結了以上兩個普遍場景來說,我們會發現一個共同點,那就是遊戲中的場景是由一大堆的元件所組成,而元件又可分化為子元件,而這些子元件便是不可再分的基礎渲染單元(注意,這裡的基礎渲染單元與OpenGL的基礎渲染單元不是一個概念)。所以這也便帶出了本引擎的渲染核心:基礎渲染物件,渲染鏈,場景鏈。
首先放出三者之間的聯絡:
本引擎中內建兩條場景鏈,一條是專用於編輯器視窗渲染。一條專用於單個場景中所有組建的渲染,場景鏈的每個節點內都含有一條渲染鏈,一條渲染鏈就代表一個場景元件,也就是一個模型組或者一個創妹,而一條渲染鏈中可以有多個基礎渲染物件結點,而每個基礎渲染物件節點就是引擎最小的渲染單元,也就是一個零件模型或創妹的一個面片,OpenGL的繪製順序也便是由大到小,即從場景鏈開始檢索每個場景鏈結點,而進入了場景鏈結點的繪製函式後,場景鏈結點會將OpenGL導引到場景鏈下每個基礎渲染物件的渲染函式的裡面進行相關繪製,渲染完一個節點後跳到下一個繼續,直到渲染完所有的結點為止。當然繪製的型別根據傳入的上下文自動選擇。
由於採用的是連結串列的資料結構,所以完全不用擔心一個場景中不能擁有任意數目的元件以及一個元件中不能包含多個元素,只要你的電腦夠強勁,元件隨便加(笑)。當然,本渲染核心也是有一些缺點的,比如記憶體分配方面,以及連結串列遍歷消耗的時間和算力都是比較高的,而且在面對開放世界場景需要多個場景塊載入的情況時(比如虛幻5的演示Demo,我真的好酸)就會力不從心了。本引擎也無法應對大型遊戲開發的效能需求。
不過目前在中小體量遊戲開發中,本人還是很有自信地認為本人設計的架構可以勝任(歡迎有遊戲引擎開發經驗的大佬光速來打我臉)。在說明了大致架構設計後,我們便可以開始進行相關實現了。
2.1 基礎渲染物件SPRenderObj
這是本引擎最基礎的渲染單元,所以其中要實現的功能是最多的,不過目前我們不用新增太多屬性和方法,目前注重於資料結構的實現。由於為了讓引擎在不知道的情況下可以執行我們設定的各個不同的基礎渲染物件(比如光源或者是猶他茶壺等),所以這個基礎渲染物件類是作為一個虛基類而存在的,在真正執行的時候,引擎通過C++的RTTI機制來呼叫真正物件裡面的繪製方法。所以接下來讓我們建立基礎渲染物件的類宣告與類定義。
首先我們可以知道的是我們的基礎渲染物件需要有的功能是繪製以及判斷是否可繪製的方法。但是由於編輯器視窗也是一種基礎渲染物件,所以我們需要建立一種方法的兩種不同過載來應對不同的繪製上下文。所以我們可以這樣去寫:
// 這兩個函式都是虛擬函式,方便派生類可以直接在裡面寫邏輯
// 其實後期還會在這裡加入攝相機變換矩陣的引數
// 不過這都是數學庫建好之後的事情了,現在先不著急
void SPRenderObj :: Render(GLFWwindow*)
{
EngineLog :: ErrorLog(SHADOW_ENGINE_LOG, "Wrong context import in.");
// 元件渲染程式碼(請在派生類裡面實現)
}
void SPRenderObj :: Render(ImGuiContext*)
{
EngineLog :: ErrorLog(SHADOW_ENGINE_LOG, "Wrong context import in.");
// 視窗渲染程式碼(請在派生類裡面實現)
}
到時我們只要寫好對應物件的渲染函式即可,當由於某些原因不小心寫錯上下文時也不至於程式崩潰,頂多就是不進行繪製並報出錯誤資訊而已。
然後接下來是其中的判斷是否可繪製方法,關於這個最直觀的體現便是遊戲場景模型被破壞後留下的缺口,舉一個大家都知道的例子:《俠盜獵車手:聖安地列斯》中劇情最後一幕反派警察駕駛的消防車從葛洛夫大街的橋上衝了出去,在劇情結束後,我們會發現橋上那個被撞開的缺口會一直存在,其實橋樑在建模的時候本身就是有一個那樣的豁口,只是在遊戲事件觸發前,欄杆以及它所對應的碰撞盒是被允許繪製的,但事件發生後,引擎取消了那一段欄杆模型與碰撞盒的繪製許可,所以在接下來的渲染迴圈中不再被繪製。這種判定其實只要一個私有布林成員以及它的相關Get與Set方法即可解決,這裡不再過多贅述。然後就是注意在派生類的渲染函式的繪製中記得使用相關判定即可。
2.2 渲染鏈SPRenderList以及渲染物件代理SPRenderListNode
我們先從渲染物件代理講起,由於我們的基礎渲染物件只負責基礎渲染功能,它並不知道其他基礎渲染物件的存在,但由於我們最終要把基礎渲染物件置入渲染鏈中,所以我們必須要讓引擎可以找到下一個基礎渲染物件,當然我們也可以給基礎渲染物件裡面加指向下一個基礎渲染物件的指標,從而讓它“知道”下一個基礎渲染物件的位置,不過這就容易造成一定的耦合性了,即不符合單一職責原則,並且更要命的是指標操作一旦出現問題則容易造成程式的完全崩盤,我們更希望有一個單獨的類來幫我們的基礎渲染物件去幹這些事情,而不是讓我們的基礎渲染物件去當“多面冠軍”。
所以此時我們就需要渲染代理類SPRenderListNode
(我當然知道代理的英文是Surrogate,只是為了讓它表達渲染鏈結點的意思)來負責這一功能,它可以接管原先需要基礎渲染物件所做的節點相關操作,而且避免了在日後可能因為更換API導致基礎渲染物件類宣告重寫帶來的的麻煩。接下來讓我們進行宣告:
class SPRenderListNode
{
public:
// 預設建構函式,許多人會問這裡為什麼會需要預設建構函式
// 不要著急,稍後我會講到
SPRenderListNode();
// 原則上這個函式是不會呼叫的,即使呼叫了,繪製的結果也只是將一個物體
// 在同一個狀態和位置下繪製兩次罷了。
SPRenderListNode(SPRenderListNode*);
// 為結點指定相應的需要被代理的基礎渲染物件
SPRenderListNode(SPRenderObj*);
// 解構函式
~SPRenderListNode();
// 我們將設定結點下一個指標指向的操作獨立在結點的類內。
bool SetNextNode(SPRenderListNode*);
// 返回指向下一個節點的指標。
SPRenderListNode* ReturnNextNode() const noexcept;
// 返回被代理的渲染物件
SPRenderObj* GetObj() const noexcept;
private:
SPRenderListNode* sprlNode_next;
SPRenderObj* sprObj_nodeCtn;
};
很簡單,一個渲染鏈結點(基礎渲染物件代理)只要做這麼多就可以了,它只起到連結一系列渲染物件的作用。由於我們的渲染物件與代理結點之間使用指標連結,所以我們必須要考慮到重複賦值所帶來的一些問題,如圖:
上圖表示的是我們引擎中的其中一條渲染鏈,在某些特殊情況下這條渲染鏈中的結點A和結點B均指向了同一個基礎渲染物件,這看起來沒什麼,就像我說的頂多是繪製兩次罷了,但實際上可沒有這麼簡單,假如說此時這條渲染鏈出於某些原因被釋放出記憶體,當A先於B釋放時,A會直接呼叫delete關鍵字釋放了本基礎渲染物件的記憶體,而這段邏輯記憶體對映的真實實體記憶體中沒人知道誰還在裡面儲存了什麼,甚至有可能是系統級程序(這個就與作業系統自身記憶體排程相關了),那麼當輪到B的時候,B如果再次呼叫delete進行釋放的話,那便會因為訪問未知記憶體內容造成整個程式的崩潰,最嚴重的情況甚至有可能導致整個作業系統的崩潰(著名的“《彩虹6號》PS4版宕機問題“大部分就是由於糟糕的記憶體管理的鍋)。所以我們需要有一個元件或者同等類別的機制來確保我們的渲染鏈安全釋放記憶體。所以這時我們就可以為每個基礎渲染物件設定一個計數器,而這個計數器的作用就是為了統計同時連線到本基礎渲染單元的代理結點數。當代理結點數大於1時,代理結點釋放時就不必釋放掉基礎渲染物件,只有當代理結點數等於1時,代理結點才會釋放掉連結的基礎渲染物件。通過設定這種釋放規則來保證記憶體安全。
由於C++的一個特點便是OOP,也就是說我們可以將計數器單獨抽象出一個類,儘量降低耦合,確保單一職責。不過這種計數器的結構比較簡單,本人不在這裡展示它的程式碼,我會說明其中的邏輯,大家可以嘗試著自己實現:既然計數器是單獨抽象出來的類,那我們為了儘量降低耦合性以及一個基礎渲染物件對應一個計數器的情況,我們可以用前向宣告以及指標去讓代理結點知道計數器的存在,在複製構造的時候我們會同時獲取另一個代理所指向的計數器,並實現加1操作。在釋放資源的解構函式中,我們會先讓解構函式去到指向的計數器裡來判斷此時同時指向本渲染物件的代理數目,若唯一,則同時釋放掉渲染物件,若不唯一,則將指向計數器以及渲染物件的指標置為空(nullptr)即可。
在完成了渲染代理結點後,我們便可以開始渲染鏈的宣告,既然我們要尊重單一職責原則,那麼我們只要在這個類裡實現連結串列相關操作(增,刪,查就夠了,插入的操作沒有任何必要,由於OpenGL是通過深度來確定繪製的層次關係,而不是Java Awt中的先後順序)即可。類的宣告如下:
class SHADOW_STAGE_API SPRenderList
{
public:
// 這裡是預設建構函式
SPRenderList();
// 解構函式,由於有了渲染物件的計數器,我們需要在解構函式裡做的工作會輕鬆很多
~SPRenderList();
// 新增渲染代理結點
bool AddNode();
// 為渲染代理結點新增渲染物件
bool AddNode(SPRenderObj*);
// 剔除代理結點(頭插法逆過程)
bool SubNode();
// 剔除符合相關"ID"條件的結點
bool SubNode(SHADOW_RENDER_OBJ_ID);
// 渲染結點的兩個過載函式
void NodeRender(SHADOW_RENDER_API_CTX);
void NodeRender(SHADOW_IMGUI_CTX);
// 查詢符合相應ID的結點的位置
SPRenderListNode* SPRLSearchNode(SHADOW_RENDER_OBJ_ID);
// 設定渲染鏈的ID
void SetId(SHADOW_RENDER_LIST_ID);
// 得到渲染鏈的ID
SHADOW_RENDER_LIST_ID GetId();
// 設定以及獲取渲染連的渲染許可
void SetDrawSwitch(bool _bIsDraw) noexcept;
bool GetDrawSwitch() noexcept;
private:
bool NodeIsExist(SHADOW_RENDER_OBJ_ID);
SHADOW_RENDER_LIST_ID s_Id;
// 指向連結串列的指標,結合上面的預設建構函式以及無參的AddNode方法大家可以看出
// 這裡也就是我為什麼需要在渲染代理結點裡設定預設建構函式的原因:
// 即單個指標不可能進行相關設定操作,也就是說單個指標在未指向實際的物件的記憶體時,
// 我們無權通過指標操作類中的函式,如果非要這麼做,沒人知道會發生什麼事情。
SPRenderListNode* sprl_list;
bool b_isListDraw;
};
這樣,我們便構建了一條較為完整的渲染鏈,我們可以在渲染框架中試驗一下:我們首先在引擎編輯器模組中建立一個渲染物件的派生類AppEditorDemo類,在其視窗的渲染函式中隨便寫一點視窗內容,我們可以用這個類建立幾個渲染物件(記得把視窗名稱名稱換一下)。然後在渲染框架中建立一條渲染鏈,依次將我們建立的渲染物件加入進去,最後由程式繪製,Application類裡建構函式的原始碼如下:
// 建立三個基礎渲染物件
appDemoAlfa = new AppEditorDemo("LATempleA");
appDemoAlfa->SetDrawSwitch(true);
appDemoBeta = new AppEditorDemo("LATempleB");
appDemoBeta->SetDrawSwitch(true);
appDemoGamma = new AppEditorDemo("LATempleG");
appDemoGamma->SetDrawSwitch(true);
// 建立渲染鏈(這一段程式碼是在渲染框架裡)
SPRenderList* sprlA = new SPRenderList();
sprlA->SetDrawSwitch(true);
// 將我們建立渲染物件加入進渲染鏈中
sprlA->AddNode(appDemoAlfa);
sprlA->AddNode(appDemoBeta);
sprlA->AddNode(appDemoGamma);
執行結果如下所示:
看起來很不錯,不過如果各位是第一次執行的話會發現貌似只繪製了一個視窗,沒有關係,我們可以試著把第一個視窗移開,就會發現其實三個視窗在同一個地方繪製的,這是ImGui在第一次繪製時並不會產生相關視窗屬性的配置檔案,不過我們後期可以在程式中寫死視窗的相關屬性,畢竟編輯器只有一套。
在成功建立了渲染鏈後我們就可以建立場景鏈了,場景鏈與渲染鏈之間只是改了資料型別而已,其內部實現邏輯是一致的,所以具體實現不做過多說明,Application中的檢驗程式碼如下:
// 渲染鏈A中的渲染物件
appDemoAlfa = new AppEditorDemo("LATempleA");
appDemoAlfa->SetDrawSwitch(true);
appDemoBeta = new AppEditorDemo("LATempleB");
appDemoBeta->SetDrawSwitch(true);
appDemoGamma = new AppEditorDemo("LATempleG");
appDemoGamma->SetDrawSwitch(true);
// 渲染鏈B中的渲染物件
appDemoAlpha = new AppEditorDemo("LBTempleA");
appDemoAlpha->SetDrawSwitch(true);
appDemoBravo = new AppEditorDemo("LBTempleB");
appDemoBravo->SetDrawSwitch(true);
appDemoCharlie = new AppEditorDemo("LBTempleC");
appDemoCharlie->SetDrawSwitch(true);
// 共同建立兩條渲染連
SPRenderList* sprlA = new SPRenderList();
sprlA->SetDrawSwitch(true);
SPRenderList* sprlB = new SPRenderList();
sprlB->SetDrawSwitch(true);
// 為第一條渲染鏈新增結點
sprlA->AddNode(appDemoAlfa);
sprlA->AddNode(appDemoBeta);
sprlA->AddNode(appDemoGamma);
// 為第二條渲染鏈新增結點
sprlB->AddNode(appDemoAlpha);
sprlB->AddNode(appDemoBravo);
sprlB->AddNode(appDemoCharlie);
// 將兩條渲染鏈新增入渲染框架內的場景鏈中
this->ReturnRFInstance()->spsl_demo.AddNode(sprlA);
this->ReturnRFInstance()->spsl_demo.AddNode(sprlB);
最後的執行結果如下:
當我們取消掉渲染鏈A的繪製許可即設定不可繪製時,結果如下:
成功了,我們引擎的渲染核心成功執行,在程式結束後,程式也成功釋放資源並退出。說明我們構建的渲染核心的確是按照我們的構想成功執行。
其實這裡還有一個問題,我們在有玩遊戲時會經常發現,有時我們需要在兩個或者多個場景之間來回切換,像上述檢驗程式碼中的這種步驟如果在每一次切換場景中都執行一遍那顯然很低效,過長的載入時間會消耗玩家的熱情,所以我們還需要在引擎中設定一個場景緩衝區,但當然這個緩衝區是一個定長指標陣列,當我們在遊玩這個場景時,引擎會開闢另一個執行緒並在這個新建立的執行緒內自動讀取並建立與我們遊玩場景相關聯的其他場景並載入進這個緩衝區中,以至於我們需要在切換場景時不會打斷我們的遊戲體驗,不過這都是後話,至少是在我們引擎執行緒庫建立之後的內容了。
本篇結語
在本文中,我們成功抽象出了圖形API以及設計併成功實現了引擎的渲染核心系統。看起來的確是有點遊戲引擎(或者說是渲染引擎)的樣子了。不過還是有一些問題存在,不知各位有沒有發現,我們的引擎從開始搭建到現在一直都在進行一個特別危險的行為:直接使用new以及delete關鍵字去進行相關記憶體的分配與釋放操作,這種操作在小型程式中並不會產生多大的問題,但是會在尤其是遊戲引擎這種對於效能要求極高的大型軟體專案中會不可避免的會產生野指標,空指標訪問等一系列的致命問題。雖然new與delete關鍵字比起C語言的malloc以及free安全得多,但僅僅是對於小專案來說。一個好的記憶體管理是整個引擎良好執行的基礎,所以這也便遷出本人下一次將會和各位探討的內容——記憶體管理模組,這會是一個較大的系統模組,所以我計劃著用一整篇博文去進行討論,所以,敬請期待。好的,下次見~
本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行過許可