从新建文件夹开始构建ShadowPlay Engine游戏引擎(4)
本篇序言
这次博客更新距离上次的时间间隔变短了好多,因为最近硬是抽出了一大部分时间来进行引擎的开发。而且运气很好的是在写链表这种很“敏感”的的数据结构的时候并没有出现那种灾难性的后果(恐怕是前一段时间在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 国际许可协议进行过许可