Game Engine 0 to 1 (02): Something on the Screen
[02] Something on the Screen
“Got this love, I can feel,
And I know, yes for sure, it is real.
And it feels as though I've seen your face a thousand times...”
— Michael Jackson (This is it)
你可以在 Game Engine 0 to 1 标签下浏览该系列的所有文章。
在本章中,主要介绍 DungineX 中图形设备的设计与实现,从如何创建窗口到实现各种绘制功能。不过,作为一个从零开始的项目,还有很多基础组件有待实现,例如日志、工具方法等,因此也会对这些主题进行穿插介绍。
由于一些别扭的翻译原因,Texture 可以翻译为纹理,也指图片。所以在本文中提到纹理和图片均指 Texture。
设备抽象
首先,我想有必要回顾一下设备(Device)的概念。
什么是设备?我们都知道,根据冯·诺依曼架构,计算机分为控制器、运算器、存储、输入和输出五个部分,而设备主要就指的是其中的输入和输出。对于游戏,通常的输出设备就是屏幕和扬声器,而输入设备就是鼠标和键盘。游戏需要能够显示画面,播放声音,接收玩家的操作,因此设备接口对于游戏编写十分重要。
为什么需要设备抽象?
这个问题的答案非常显然,当然是为了方便使用。设备通常与硬件相关,而不同的硬件设备有不同的接口,由用户直接调用这些接口十分不便。这里有一张图非常生动形象,虽然是用来描述操作系统的,但是在这里同样合适。设备抽象要做的,就是屏蔽底层硬件细节,为用户提供统一、易用的接口。

虽然现有的一些库,比如 SDL3、FMOD 等已经提供了必要的抽象,从而可以相对简单的实现对应功能, 但是这些接口毕竟不是我们自己实现的,如果不进行抽象,则很难与我们的项目整合在一起。此外,这些接口的设计往往考虑到通用性,因此在游戏开发场景下可能还需进一步的封装。
具体地,设备抽象能够在以下两点为我们提供便利:
- 屏蔽底层细节。底层的组件往往考虑到通用性,相对晦涩复杂,因此在设备抽象中,可以根据具体的使用场景提供简化的 API。
- 提高灵活性。部分情况下,同一个服务可以有不同的实现,比如对于音频设备,我们可以使用 SDL3,也可以使用 FMOD,甚至可以禁用音频设备。有了设备抽象的存在,用户将使用统一的接口访问底层的设备支持,而不用关注具体的实现。这是否让你想到了 Dependency Injection?
- 方便模块间通信。这个就很容易理解了,根据我们自定义的抽象,可以更方便地实现各个组件的相互调用。
图形设备抽象
当然,这次的主题还是图形设备。对于图形设备,其主要负责窗口的管理,以及图形绘制。
为什么窗口会和绘制耦合在一起,原因在于,由于绘制缓冲区的存在,绘制指令往往依赖于图形上下文,而这一上下文通常绑定在一个窗口实例上。例如,在 SDL 中,渲染器(Renderer)依赖于当前窗口。
对于图形绘制,可以进一步分为三类对象的绘制,或者说渲染(Render)。
- 原生(Primitive)图形,也就是像素点、线,矩形等。
- 纹理(Texture),其实也就是绘制图片,因此这里也会涉及图片的加载。
- 文本(Text),这个比较特别,相对更加 tricky,之后会具体介绍。
提供这些接口的抽象后,用户就可以实现最基础绘制,在屏幕上显示有趣的图像了。
Infrastructure
不过,在我们开始实现图形设备接口之前,还需要一些准备工作。对于一个较大规模的项目来说,通常需要一些“基础设施”的支持,比如 C/C++ 很喜欢的类型别名,日志输出等。
类型别名(Type Alias)
可能是 C/C++ 支持这一特性的缘故吧,几乎所有项目都会有这一操作,一方面是为了可移植性,另一方面也是一种偷懒的方式吧。这也是我喜欢 C,尤其是 C++ 的原因之一,因为其提供了其他语言无法媲美的灵活性。
内存漏洞是 C++ 常常被诟病的问题,然而,现代的 C++ 已经有很完善的机制避免相关问题了,也就是智能指针 std::shared_ptr
。通过将代码中的裸指针替换为智能指针,便能很大程度上避免,甚至消除内存相关问题。
1 |
|
这一类型定义方法来自 The Cherno 的 Hazel。
除了智能指针封装外,目前还没有遇到其他特别的类型,具体可以参考 Utils\Types.h。当然,我们可以为基本类型定义别名,比如 uint32
等,但其实头文件 cstdint
已经提供了相关定义了。
日志输出(Logging)
尽管是自己亲手编写的程序,你是否时常会苦恼于难以定位错误,或是追踪程序的执行状态?对于这一问题,为程序添加日志输出是一个非常好的解决方案。在 DungineX 中,我们选择 spdlog 作为日志库,一方面是因为其灵活性与高性能,另一方面是因为我对它相对更加熟悉。
本节中,我简单介绍 DungineX 中的日志系统,具体的日志系统声明可参考 Utils/Log.h 和 Utils/Log.cpp。
日志配置
日志输出通常有以下几个配置选项:
- 名称(Name),最好可以通过名称区分不同组件的日志。
- 等级(Level),用来区分不同重要程度的日志消息。
- 目标(Sink),日志输出到哪里,控制台还是文件,还是多个目标。
- 格式(Format),具体的日志消息是什么样子的。
因此,我们的日志配置如下,即 LoggerSpecification
。注意到这里使用 std::initializer_list
来避免显式传入 std::vector
。
1 | struct LoggerSinkSpecification |
或许你看到这里大写的成员名称有些惊讶,这一风格来自我编写 C# 代码的经验,来标识 public 成员。
哪里需要 DGEX_API
?
虽然有些突兀,但是我们在这里第一次遇到这一问题。在 LoggerSpecification
中,我们只给构造函数添加了 DGEX_API
,即 __declspec(dllexport)
,那为什么不给成员变量也添加呢,或是更激进一些,直接为整个结构体添加这一属性呢?
你可以自行尝试给整个结构体添加 DGEX_API
,看看会发生什么。
这里的原因在于,__declspec(dllexport)
类似于 DLL 编写者与使用者之前的一个“约定”,即被标记的声明应当保持兼容,也就是说,即使更新了 DLL,客户端仍然能够以相同的方式调用被标记的“东西”。
那么问题就来了,对于函数,本质就是一个固定大小的地址,那么我们只要保证提供这个地址就可以。而对于成员变量,就不只是地址了,还有大小。成员大小取决于类型, 因此为了遵守“约定”,对应的类型也需要被标记,于是你会遇到 __declspec(dllexport)
传播的问题。对于自定义的类型,比如 LogLevel
,可以直接标记,而对于像 std::string
这样的标准库类型,由于其并不提供 DLL 导出,所以我们也没办法实现导出。不过,由于成员最终会变成地址偏移量,而头文件是提供给客户端的,因此用户可以获得相应的类型的声明,只要偏移量和大小一致,便能正确实现运行时的访问。
因此,我们只需要标记需要导出的函数即可。当然,不一定所有函数都需要导出,因此可以有选择地隐藏部分接口。不过,如果用户选择静态链接,那么其实还是可以使用所有接口,最根本的隐藏接口的方法还是将其放在私有头文件目录下,但是会相对麻烦些。
目前,我们认为只有 DLL 暴露的接口为公开接口。
Logger
接下来,是日志系统。恰当的日志能够让我们高效地排查程序中的问题,并且观测程序的执行状态。DungineX 中的 Logger 声明如下。我们刚刚说过,通常不应该,也不能导出整个结构体或类,而是导出其中对外暴露的方法,所以这里只导出对应的日志输出方法。
1 | class Logger |
从 Logger 中,已经可以看到些许 RAII 的影子了,即在构造函数中完成资源的初始化。
我们不希望用户随便创建 Logger,所以没有用 DGEX_API
标记构造和析构函数,而是将 Logger 的注册和获取放在了静态类 Log
中。
1 | class Log |
最后,我们可以用宏实现简化的日志输出。
1 |
Assertion
对于 C/C++ 这样没有运行时检查的语言来说,有时需要我们手动进行一些边界或条件检查。标准库提供的 assert
功能过于简单,我们尤其希望能够将 assert 的结果输出到日志,因此我们的 assert 可以和日志系统相结合。具体的 assert 实现见 Utils/Assert.h。
Pit Fall
有了基础设施之后,我们就可以正式开始编码了。由于是第一次使用 SDL3,而且是一个比较特殊的场景(将 SDL3 打包进 DLL),遇到了一些问题,也花了很多功夫解决。在开始前,对相关问题进行说明。
上下文绑定
这一问题确实很奇怪,对于 DungineX,我们将 SDL3 打包进引擎,再以 DLL 的形式提供给用户,看似没什么问题,但是却会使 SDL 的上下文管理出问题。
这一问题具体表现为,如果客户端只通过 DungineX 的接口调用 SDL,那么一切正常。而一旦客户端直接调用了 SDL 中标记只能在主线程中调用的函数,那么程序就会挂起(Hang)。
按理说 DLL 和程序运行在同一线程,但是可能因为 DLL 的加载,SDL 的上下文初始化机制,以及大量使用锁进行同步,导致二者不能共享同一上下文吧,所以导致了死锁。
具体原因还有待进一步探究。
Entry Point
刚刚提到了上下文绑定的问题,客户端不能直接调用 SDL 的函数,所以也就不能使用 SDL 提供的生命周期回调了。因此需要我们自行实现程序的入口。
上一章中的入口实现还很简单,本章中进行了更新,会在之后具体介绍。
窗口创建
下面,我们正式开始图形设备的编写。首先,自然是创建一个窗口,具体代码见 Device/Graphics/Window.h 和 Device\Graphics\Window.cpp。
窗口属性
对于一个窗口,其主要的属性很简单,无非是标题、大小以及一些选项,比如是否可缩放、是否全屏等。对此,我们可以定义如下的窗口属性。类似的,我们只将构造函数标记为 DGEX_API
。
1 | using WindowFlags = unsigned char; |
当然,窗口的创建由 DungineX 完成,那么为了使用户能够自定义窗口属性,可以提供这样一个函数,在创建窗口前,由用户提供自定义的窗口属性。
1 | DGEX_API void SetWindowPropertiesHint(const WindowProperties& properties); |
窗口创建
一个程序可能不止一个窗口,不过对于游戏来说,一个窗口足矣。因此,我们可以通过这种方式访问具体的窗口实例,当然这个接口不对用户暴露。
1 | static SDL_Window* sNativeWindow = nullptr; |
为了实现对 SDL 接口彻底的封装,我们不应当向用户暴露任何 SDL 的函数和类型。
对于窗口的创建,SDL 已经封装得很好了,我们只需要将我们自定义的窗口选项转化为 SDL 支持的窗口选项,并调用 SDL_CreateWindow
即可。同时,这里也展示了日志的使用。
1 | dgex_error_t InitWindow() |
关于日志输出的格式,可以参考 spdlog 的官方文档。
现在,我们在调用 InitWindow
函数后,就可以得到一个窗口了。
目前,在得到图形窗口时,控制台窗口依然存在,这是因为我们最终链接构建的是控制台应用。想要去掉控制台,可以之后切换至 Windows 窗口应用,使用 WinMain
代替 main
。
绘图接口
此时,我们已经能够得到一个黑黑的窗口,接下来,就可以在其中实现图形的绘制了。对于游戏来说,我们通常需要实现以下几个绘图功能:
- 在屏幕上绘制点、线等图形,以及图片和文字。
- 支持图像的缩放、旋转等效果。
- 在指定目标实现绘制。
总结起来,你需要了解以下几点概念。
- 绘制目标:本质是一块缓冲区,可以是屏幕,也可以是图片(纹理)。
- 绘图属性:比如线条颜色、文字大小等。
- 图像绘制:在绘图设备上实现绘制,包括原生图形,图片以及文字。
这些概念看起来很简单,实际一点也不复杂,SDL 已经提供了相应的封装,因此我们的任务很轻松。具体的绘制接口声明见 Renderer/RenderApi.h。
创建渲染器
SDL 的所有绘制操作都由 SDL_Renderer
完成,其绑定在当前窗口上,从而实现该窗口的绘制和渲染任务。在我们创建好窗口后,就可以初始化渲染器了。具体的渲染器初始化如下,与窗口类似,使用 sNativeRenderer
维护渲染器实例,便于最终释放资源。为了更好地展示 SDL 的功能,在初始化渲染器时,列出了所有可用的 Renderer Driver,可以看到 SDL 支持 Direct3D,OpenGL,Vulkan 等多个渲染后端。当然,这里我们最终让 SDL 自行选择。由于按照字典序选择, 因此很可能会选择 Direct3D。
1 | dgex_error_t InitRenderer() |
很奇怪,在我本地 SDL 始终无法支持 VSync,即使更换渲染后端还是无果,不过由于已经使用了双缓冲,是否使用 VSync 其实并不重要。
绘制顺序
DungineX 是一个 2D 游戏引擎,虽然是 2D,但是在处理伪 3D 场景时仍存在遮挡问题。在 OpenGL 中,我们可以指定顶点的三维坐标,由 OpenGL 决定渲染的遮挡关系。而 SDL 只支持 2D 绘制,不存在 Z 轴来支持深度检测,因此只能用画家算法(Painter‘s Algorithm)实现正确的图像遮挡关系。
虽然画家算法很简单,但是需要时刻关注对象的位置关系,会为用户带来很多麻烦。因此,我们可以对渲染器进行封装,从而支持一个虚拟的 Z 轴。
绘制指令
既然 SDL 不支持 Z 轴,那么我们可以抽象出支持 Z 轴的绘制指令。通过 RenderCommand
将具体的绘制动作封装起来,就可以实现对 RenderCommand 的排序,进而支持绘制的排序,具体的声明见 Device/Graphics/RenderCommand.h。由于用户无需了解 RenderCommand,所以我们可以将其放在私有目录下。
1 | class RenderCommand |
具体地,我们可以进一步实现具体的绘制指令,具体的声明见 Renderer/RenderCommandImpl.h。
首先,是原生绘制指令的封装,将绘制操作打包成匿名函数,从而可以在排序后进行调用。
1 | class NativeRenderCommand final : public RenderCommand |
其实这一个封装就足够了,但是由于图像绘制参数实在是太多,不仅有 Texture
对象,还有位置、缩放、旋转、翻转、锚点等,所以也值得我们为它封装一个绘制指令。TextureRenderCommand
相对复杂,具体见 Renderer/RenderCommandImpl.h。
渲染器封装
有了绘制指令后,我们要做的就是让渲染器识别我们的虚拟 Z 轴。渲染器的封装如下,支持提交绘制指令,以及执行绘制。同理,这里我们不希望用户直接创建 Renderer
,或是提交绘制指令,因此只暴露 Render()
方法,详细声明见 Device/Graphics/Renderer.h。
1 | class Renderer |
当然,并不是所有场景都对绘制顺序有要求,如果绘制对象已经有序的话,排序反而会影响性能,因此我们可以实现 DirectRender
和 OrderedRenderer
两种封装,分别针对无序和有序的场景,具体声明见 Device/Graphics/RendererImpl.h。
最后,可以提供如下的方法供用户创建渲染器。
1 | struct RendererProperties |
由于可以选择使用不同的渲染器实现绘制,因此需要提供函数选择当前活跃的渲染器,以及获取当前的渲染器。当然,我们有默认的渲染器,用 nullptr
代表。
1 | DGEX_API void SetCurrentRenderer(const Ref<Renderer>& renderer = nullptr); |
更进一步,我们也可以借鉴 std::lock_guard
的方式实现作用域内渲染器的使用和恢复。
1 |
绘制目标
刚刚提到了,绘图设备是绘图接口中的重要概念,是我们绘制的目标,类似“画板”(Canvas)。在 SDL 中,绘图设备本质是一块缓冲区,当然,屏幕是一块特殊的缓冲区。一块缓冲区对应一个 SDL_Texture
对象,而屏幕由 nullptr
代表。对于图片,在加载时可以选择是否可作为绘制目标。在 DungineX 中,为了简化,默认所有加载的图片都可作为绘制目标。
缓冲区封装
我们的缓冲区封装如下,为了避免不必要的资源管理问题,我们禁止了 Texture
对象的拷贝和移动操作,从而所有 SDL_Texture
都有相应的 Texture
管理。而且,我们这里并不使用 RAII,因为 Texture
对象并不实际拥有 SDL_Texture
,所以在析构时也不进行释放,而是通过 Destroy
方法显式地释放。具体的声明见 Renderer/Texture.h。
1 | class Texture |
这里我们不使用 RAII 还有另一个原因,就是析构函数的调用时机。全局变量会在 main
函数结束后析构,因此相应的析构函数会在 SDL 以及日志系统卸载之后触发。Texture 的释放不是问题,因为 SDL 卸载时即会清理所有资源,不会有 dobule-free 的问题。主要是其他系统,比如日志系统的卸载会使得析构函数无法输出日志。
双缓冲
为了提高绘制效率,SDL 默认对屏幕使用双缓冲机制。也就是说,所有向屏幕的绘制操作都首先在 Back Buffer 完成,当每帧更新屏幕时,才将 Back Buffer 里的内容绘制到 Front Buffer,也就是屏幕。因此每帧都需要手动调用 SDL_RenderPresent
更新屏幕画面。
1 | DGEX_API void FlushDevice(); |
绘图属性
在绘制过程中,我们需要控制特定的属性,比如线条和填充颜色,字体大小等,因此也需要提供相应的函数。在 DungineX 中,我们提供五种绘制属性。
- 清除颜色
- 线条颜色
- 填充颜色
- 字体颜色
- 字体大小
其中主要涉及颜色,因此有必要单独进行说明。
颜色常量
颜色结构体的定义位于 Renderer/Color.h,这里采用整数存储颜色信息。用 uint8_t
存储各个颜色能够稍微节省一些内存,但可能计算的时候会有一定的溢出风险,需要注意扩展。
1 | struct Color |
对于颜色,我们希望能够预先定义一些色彩常量,于是会遇到另一个问题,就是如何在 DLL 中导出变量。对于这个问题,你可以先回顾一下之前提到的这篇讨论:[Why/when is __declspec( dllimport ) not needed?](Why/when is __declspec( dllimport ) not needed?)。
对于函数和变量的导出是有一定区别的,因此我们需要重新定义 DGEX_API
,并为变量导出定义新的 DGEX_DATA
,具体见 Defines.h。
1 |
在有定义 DGEX_EXPORT
时,DungineX 需要对函数和变量进行导出,而用户只需要导入变量,因此有必要区分 DGEX_API
和 DGEX_DATA
。最终,我们可以正常地定义静态常量。
1 | struct Color |
图像绘制
下面,我们终于来到了图形设备接口的定义,首先是图形的绘制。最基本的,我们需要实现点、线和矩形的绘制。
这时你可能注意到了,自始至终我都没有提到任何涉及曲线的绘制,比如圆形。因为对于渲染引擎,或是 GPU 来说,它们擅长线段或是三角形的绘制,因此曲线通常通过特定的算法绘制,比如使用多边形拟合。这并不是特别必要的功能,因此暂时不进行实现。
具体地,我们有这几个函数进行原生图形的绘制。之前也解释了,我们希望能够控制绘制顺序,因此接口中都有 z
来进行排序。此外,绘制时的颜色属性通过之前提到的绘图属性控制。
1 | DGEX_API void DrawPoint(int x, int y, int z = 0); |
如果你之前使用过 Windows GDI,你可能还会问,为什么没有控制线条粗细的选项?因为一般来说,游戏依赖于图片资源,或专门的 Shader,很少直接使用线条绘制,所以没有必要提供更改线条粗细的选项。如果确实需要,可以交给客户端实现。
图像绘制
图像在之前的绘制目标中已经介绍过了。在 DungineX 中,图片和绘制目标相同,都是 Texture
,其绘制接口如下,在指定位置绘制图片。
1 | DGEX_API void DrawTexture(const Ref<Texture>& texture, int x, int y, int z = 0); |
游戏中需要大量绘制图片,对绘制也有很多要求,包括图像的缩放、旋转、透明度等,让用户一个一个指定略显麻烦。因此,这里我们可以采用 Builder 的思想,并使用 Fluent API 实现复杂的图像绘制指令。
这里略有炫技倾向。
具体地,我们可以声明相应的 Fluent API 对象,实现对各个属性的修改。
1 | class DrawTextureClause |
图片加载
最后,既然要绘制图片,那么肯定得先实现加载。在 Renderer/Texture.h 中,我们声明 LoadTexture
函数,加载指定路径的图片,其中使用 SDL3_image 的 IMG_Load
方法实现图片加载。在 SDL 中,为了与渲染引擎解耦,纹理首先被加载为 SDL_Surface
,而后被处理为具体渲染引擎支持的 SDL_Texture
。转换后,SDL_Surface
不再被使用,因此可以被销毁。
1 |
|
文本绘制
最后,对于任何应用程序,都少不了文字。然而,文本绘制一直都是非常具有挑战性的。一方面,文字形状看起来就很难绘制,另一方面排版也不容易。在 SDL 中,文本最终也是转化为图片进行绘制,需要首先加载字体文件,然后将所需要的文本渲染为 Texture。
然而,绘制的文本往往经常发生变化,每一帧都从字体中加载 Texture 显然会十分影响性能。因此一个通用的做法,是首先将字体中所有字符都首先渲染成图片,然后在绘制时通过拼接单个字符的图像实现文本的绘制。这一方法的名称很有意思,为字体创建 Atlas,即一大张包含所有字符的图片,其中每个字符对应一个 Glyph,在绘制时从中找到对应字符的 Glyph 进行输出。例如,一个 Atlas 图片如下,不过一般会根据字符间距进行压缩,不会像这个例子一样有这么大的间距。
很可惜,SDL,以及 SDL_ttf 都不提供生成 Atlas 的功能,更不支持高效的字符绘制,因此需要我们手动实现。不过,已经有 grimfang4/SDL_FontCache 为我们实现了这一功能。然而,这个仓库已经“年久失修”,只支持 SDL 2,所以还是需要一定修改,具体的改动见 Lord-Turmoil/SDL_FontCache,使其支持 SDL 3。当然,我们需要在 Vendor 中引入 SDL_FontCache。
字体封装
SDL_FontCache 进一步对 SDL_ttf 中的 SDL_Font
进行封装,提供 FC_Font
。这里确实没有必要让用户真的看到这个 FC_Font
,也不希望用户间接引入 FC_FontCache.h
,所以这里采用了 pImpl 的方式,对 FC_Font
进行了隐藏。具体的字体封装见 Renderer/Font.h,接受 TTF_Font
,并在构造函数中使用 FC_LoadFontFromTTF
加载 FC_Font
,并作为 _impl
。
1 | class Font |
字体加载
由于字体封装中,我们还是接收 TTF_Font
,所以字体加载时使用 SDL_ttf 的加载方式即可。和图片加载不同的是,我们不希望相同的字体被多次加载,因此需要记录加载了哪些字体,以及我们需要有一个默认字体。
对于前者,我们可以通过 std::unordered_map
轻松实现,根据 TTF_GetFontFamilyName
得到的字体名称判断即可。对于后者,由于我们目前只针对 Windows 系统,因此可以在 C:/Windows/Fonts 下挑一个字体,这里选择的是 C:/Windows/Fonts/Arial.ttf。当然,在用户指定加载字体时,如果指定路径不存在,也可以 Fallback 到系统目录下找一找。
这时,细心的你可能会发现,我们没有考虑字体的变形,比如加粗、倾斜等,这些特性我们可以留到之后再实现。
文本绘制
最后,是文本绘制,这里主要是字体大小的处理。字体大小是通过缩放实现的,我们可以在加载时选择一个标准,比如 100pt,随后根据用户指定的大小进行缩放。
最终,我们提供两个绘制函数,一个在指定位置绘制单行文本,另一个在指定矩形内绘制多行文本。
1 | enum : unsigned char |
模块初始化接口
最后,我们可以为所有的绘图设备提供统一的初始化接口,具体见 Device/Graphics/Graphics.h。
1 | DGEX_API dgex_error_t InitGraphics(); |
用户只需要调用统一的接口,即可实现整个绘图模块的加载和卸载。
程序入口
接下来,我们需要思考如何向用户提供我们的功能,或者说,用户应该如何使用 DungineX。这里我们需要有两方面的考量与权衡,一方面,我们希望 DungineX 足够简单,用户可以尽可能少地调用接口;另一方面,我们还是想保持足够的灵活性,允许用户实现更多自定义的行为。
主函数封装
首先,依然是我们自定义的 main
函数。和上一章相比,没有什么变化,因为我们将所有操作都交给了用户。不过呢,我们还是默认进行了日志系统的加载。
1 | static void Preamble() |
或许有一些 Annoying,但是由于很多组件都依赖日志输出,所以我们不得不提前进行加载。之后可以实现默认的空日志,从而可以完全不输出日志。
如果采用这种方式,那么需要由用户手动管理组件的注册,以及游戏的循环。目前,我们只有图形设备,因此用户的 main
函数如下,需要由用户手动完成各个步骤,但是会更加方便。
1 |
|
由于之前提到的问题,因为我们暂时没有提供事件处理接口,因此暂时没有具体示例。
基于回调的程序入口
当然,对于游戏,很多时候我们只关注游戏逻辑,因此我们可以参考 SDL 的方式,提供回调,从而简化游戏的编写。具体地,我们可以有如下几个回调。
OnInit
:在 DungineX 各个组件(除了日志)加载之前调用。OnStart
:在 DungineX 各个组件加载后、游戏主循环开始前调用。OnUpdate
:每帧更新时调用。OnEvent
:接收到事件后调用。OnExit
:主循环结束后、DungineX 卸载前调用。
于是,我们最终的入口如下,具体见 Device/EntryPoint.h。
1 | int main(int argc, char* argv[]) |
在 DgeXDgeXMainImplWithCallbacks
中,我们的流程伪代码如下。
1 | { |
按照我们最初的设想,实际上希望用户以 Interface
的形式提供游戏的每一个界面,方便界面的切换和统一管理,从而更新和事件都由 Interface
对象处理,于是不再需要 OnUpdate
和 OnEvent
两个回调。在之后我们实现 Interface
和 Application
时,会进一步进行完善。
目前在 Demo/HelloThere 下有一个示例,可以进行参考,具体执行效果如下。

预告
在下一章中,我们将实现事件处理,并继续完善游戏主循环。有了事件之后,我们的游戏引擎便可以说是初具雏形了。ᓚᘏᗢ