速通自研游戏引擎 - 深入浅出 Boidmachine
Boidmachine 是个什么东西?
Boidmachine 最初的构想是作为一套后端无关的游戏开发框架,用于教学目的和小游戏制作,最早实践于《提瓦特幸存者 - 双人对战版》。随着2024年CiGA GameJam的到来,一个十分大胆的想法冒了出来,我决定利用半个月的空闲时间,将这个尚未开发完善的框架魔改为游戏引擎内核,并配套前端可视化编辑器,作为这次限时开发挑战赛的生产力。
从工程角度讲,这确实是一个十分冒险的行为,在前前后后一周左右的有效工作时间内,从头开始搭建全套游戏开发工具链,这意味着我几乎没有时间试错并且完全覆盖地测试,但没有什么比这种限时开发更刺激了!事实证明我做到了,一个十分保守的最小实现游戏引擎套件赶在赛题公布当天的清晨完工,并在此基础上完成了我们的作品《Unlimited Cage》。
本文以此为主题分享Boidmachine的设计思想和实现细节,诚然,游戏引擎是一个非常庞大且有深度的话题,我无法确保这种极限开发过程中的解决方案是最佳思路,但是这至少是相对稳妥且“够用”的;注意,为了便于理解和讲述,本文中的代码只摘取部分核心片段简化后展示,或是无法直接复制使用的伪代码,但还是希望本文可以对正在学习和实践引擎开发的同学们起到抛砖引玉的作用。
技术栈总览
从设计角度讲,Boidmachine Engine(后称BmEngine)的方方面面有很多成熟引擎的影子,这归功于我强大的抄袭 (借鉴) 能力,但是程序员的事那能叫“偷”嘛,但要说相似度最高的引擎,那可能就是非Godot莫属了,万物皆节点的思想不仅有着和ECS有着同样强大的灵活性来处理解耦,在实现上也更容易避开性能陷阱。
在渲染、输入和音频的处理上,依然使用了喜闻乐见的SDL全家桶,但经由我天马行空的操作,在渲染上可以同时支持SDLRenderer和OpenGL的片段着色器;在脚本上,我选择了个人更熟悉的Lua,并且借助EtherEngine项目阶段的遗产Emake,完成了脚本的加密和固化;在物理碰撞和区域触发上,考虑到不会有2D游戏不会有太复杂的碰撞,且为了减少和编辑器接入的工作量,继续保持手写的AABB碰撞,其实即便是复杂外形的碰撞这也同样适用,如果一个AABB不够用,那就多放几个AABB!在最终发布资源包的制作上,BmEngine使用了一套简单的虚拟资源目录设计,来支持引擎自动加载资源和脚本手动加载资源。
下表将列出引擎套件开发过程中所依赖的全部三方内容:
Thirdparty | Description |
---|---|
SDL | 窗口、输入 |
SDL_image | 图片解码 |
SDL_mixer | 音频解码 |
SDL_ttf | TrueType字体支持 |
SDL2_gfx | 图元绘制 |
SDL_net | TCP/UDP封装 |
cJSON | JSON序列化/反序列化 |
cpp-httplib | HTTP跨进程调试 |
imgui | 引擎编辑器GUI |
implot | 性能分析图表 |
ImGuiColorTextEdit | GLSL着色器代码编辑 |
imgui_markdown | Markdown渲染支持 |
gl3w | OpenGL核心模式支持 |
glm | 向量、矩阵数学运算 |
Lua | 脚本 |
nativefiledialog | 文件/目录选取 |
Remix Icon | 编辑器图标 |
引擎 Editor
为什么需要编辑器?
诚然,只有Runtime内核是可以完全可以制作游戏作品的,但是对于复杂场景搭建和界面元素定位,没有所见即所得的可视化编辑制作起来还是十分抽象且耗时的。而从更进一步的设计哲学上讲,仅使用代码描述游戏功能的方方面面,对开发者而言是“视野受限”的,在IDE中编写代码时,我们在同一时间能够关注到的只有单一的模块和场景片段,而借助可视化编辑器立体的展现,我们可以高效地速览场景内容,同时可以对抽象数据借助界面信息具象化表达,避免直接修改造成的人为失误。
在某种程度上,Editor可以当做一个“配置文件生成器”的概念进行独立设计,它只需要提供可视化的编辑,最终导出各类配置和场景文件,PIE调试或发布后,只需要让Runtime加载这些内容进行处理就可以了,如何“执行”他们已经不是Editor需要关注的事情了。
资源
编辑器程序需要加载的资源无非是两部分,其一是编辑器自身所需的图标纹理和字体,其二是指定工程目录下的游戏资源;前者没有什么好说的,这部分内容较少且固定,同步加载也不会有太明显的性能问题;而后者情况就很多了,我们需要校验资源的完整性(如工程文件、场景文件等),检查指定文件类型资源的合法性(如用户错把*.mp3
文件命名为了.jpg
文件),这些加载的结果最好都能够实时地反馈给开发者,在告知引擎使用者当前项目加载进度的同时,也是在告诉引擎开发者你写的编辑器没有在启动时卡死
所以,这就不得不使用多线程进行异步的资源加载了。主线程(也就是ImGui所在的视频线程)保持画面更新的同时,开辟一条新的线程执行递归地扫描资源目录并加载资源的过程,最终资源会被分类到不同的池中(如纹理池、音频池等),这些资源池都使用ResID字符串映射到加载目标的关系,而ResID在引擎中统一被设计为位于resources目录下且相对于项目工程根目录的路径,如一张图片在纹理池中的映射关系可能为u8R"(resources\img\menu.png) -> SDL_Texture*"
,BmEngine支持的资源扩展名和类型映射如下所示:
类型 | 扩展名 | 加载目标 |
---|---|---|
纹理 | .jpg ,.jpeg ,.png ,.bmp ,.tiff ,.webp |
SDL_Texture* |
音频 | .mp3 ,.wav ,.ogg ,.flac |
Mix_Music* |
视频 | .avi ,.mp4 ,.mov ,.wmv ,.flv ,.mpeg ,.webm |
- |
字体 | .ttf ,.otf ,.ttc |
Bm::Font* (TTF_Font* 扩展) |
脚本 | .lua ,.luac ,.dll |
Bm::Script* |
场景 | .scene |
Bm::Scene* |
配置 | .ini ,.json |
- |
着色器 | .glsl |
- |
对纹理资源进行异步加载的时候要谨慎一些,因为SDL_Renderer
并不支持多线程,所以我们在生成SDL_Texture
时要加锁来避免竞争,比较推荐先生成SDL_Surface
后再进行纹理的生成,因为前者耗时相对更长且不需要SDL_Renderer
,这样可以让锁的粒度更细一些,而不是使用IMG_LoadTexture
一步到位生成纹理。
其中对于音频和字体的处理相对特殊一点:
- 于音频而言,SDL_mixer库提供了两种方式来播放,一种是将音频加载为
Mix_Music*
,作为音乐播放,还有一种便是加载为Mix_Chunk*
,作为音效在指定的Channel上播放,比较通用的方案是可以根据文件的扩展名来处理,如.mp3
格式加载为音乐,而.wav
格式加载为音效,也可以是根据音频文件的大小,因为音乐音频通常体积较大,播放时是流式加载解码的,而音效则可能需要低延迟、高频次地播放,所以需要提前在内存中展开,但是在编辑器环境下,我们只需要在选取资源后进行试听预览,这无需使用多个混音通道,所以加载为Mix_Music*
是更合适的; - 于字体而言,SDL_ttf库在加载字体时需要指定字号,我们希望在编辑器层面,字体资源是“族”的概念,开发者只需要关注使用哪个字体文件,而字号和各类加粗斜体样式是其下的子类属性,所以,在BmEngine中,字体资源对象
Bm::Font*
是一个容器,包含多个TTF_Font*
字体对象,并且采用懒加载的处理,只有当开发者使用了这个字号的指定字体后,才会加载对应的TTF_Font*
。bm_font.h >folded 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33namespace Bm
{
class Font
{
public:
Font() = default;
~Font()
{
for (auto& pair : font_pool)
TTF_CloseFont(pair.second);
}
void set_load_path(const std::string& path)
{
this->file_path = path;
}
TTF_Font* get_font(int size)
{
if (!font_pool[size])
font_pool[size] = TTF_OpenFont(file_path.c_str(), size);
return font_pool[size];
}
private:
std::string file_path;
std::unordered_map<int, TTF_Font*> font_pool;
};
}
除此之外,对于脚本的处理,在编辑器阶段如果不需要可视化地处理export属性,完全可以跳过加载,这正是现阶段BmEngine的思路,而如果需要处理编辑器的Inspector中设置脚本属性,则首先应该在脚本对象中定义_export
字段的函数对象,在编辑器进行资源加载时调用此函数收集需要暴露的信息,并将其翻译为对应的ImGui组件,在随后的场景保存阶段时,带有export属性的节点需要额外存储自己的编辑后信息,而在运行时,在对应节点实例化后,这些暴露出的字段会在脚本的_new
方法后且在_on_enter
方法前被注入到实例化的节点对象中,此过程中的细节在后续的细分章节中再进行详细讨论。
场景
先说一个在特定语义下的暴论:游戏运行时不需要有“场景”的概念!
场景及相关功能是BmEngine的核心,在开始这部分内容之前,我们需要首先明确几个概念:
- 节点:Node是游戏场景树的最小组成部分,也就是说Node几乎等价于的GameObject,但是如果和Godot相比较,BmEngine中的Node更像是在
Node2D
基础上的扩展,它不仅拥有Transform属性,还拥有CanvasItem
的渲染控制等诸多功能;在Editor中,继承自Node的每一个子类节点都会重写自己的load
和dump
方法,来处理加载和保存逻辑;
场景:Scene是一个编辑器概念,我们将节点树存储到不同的场景中,来实现“组合”的思想,同时将其模块化来方便复用,相较于Unity,BmEngine中的场景既有Scene本身作为游戏运行阶段的功能,还拥有Prefab预制体的功能,如一个战斗场景的Scene中,可以有Player的Scene和敌人的Scene。场景的核心在于其内部的节点树,对外提供
load
和dump
方法,在加载和存储时分别递归地调用场景中每一个节点的对应方法;场景模板:SceneTemplate是一个运行时概念,编辑器导出的
.scene
场景文件,在运行时会被首先加载为场景模板,也就是说在Runtime眼中,场景ResID映射的对象类型为SceneTemplate*
;场景模板在加载时其实已经基本完成了对其内部场景树的构建,只不过场景树中的“场景”节点,只保存了ResID的引用,而不是在此时进行实例化,这种延迟展开主要是考虑到场景资源在加载时依赖顺序并不明确,以及持有引用的方式比单独拷贝一份内存占用更优;场景实例:SceneInstance是一个运行时的临时性概念,它本质是已经实例化的场景树,在大多数情况下,它都会立即被挂载到世界树上,参与更新和渲染。SceneInstance由SceneTemplate通过
instantiate
方法得到,在实例化方法中,场景模板会遍历自身场景树的每一个节点,调用其重写的copy
方法,这与拷贝构造函数的功能相似;除此之外,当前实例化的场景模板所拥有的场景树中,如果持有对其他场景模板的引用,那么会在此时调用该场景子节点的instantiate
方法,递归地实例化,直到场景树中的所有节点都是基础节点类型。
所以,理清了这几个概念,在本小节开头处的暴论就很容易理解了,在游戏运行的过程中,所有的场景都被实例化为了有限的节点类型,追根溯源他们最上层的父节点都是世界树节点(Node基类实例)。
不过,在Editor和Runtime两个阶段中,场景的更新是有区别的,在编辑器阶段,场景树中引用其他场景的节点,应该始终持有ResID引用而不是将其完全实例化,因为我们希望在其他Scene中的修改可以实时地同步到所有引用它的地方,这显然不应该拷贝;而在运行时环境下,SceneTemplate作为资源类型,是不需要考虑修改变更的,所以彻底地拷贝和实例化是最佳选择。
那在这种设计之下,我们该如何处理游戏不同阶段的场景跳转呢?在《Unlimited Cage》中,所有的场景都游戏启动时都被挂载到了scene_root.scene
根场景下,但是除去入口子场景外,其他场景都处于禁用更新和渲染的状态(如下图所示,灰色文本的节点禁用了渲染,红色文本的节点禁用了更新),我们只需要在游戏运行的过程中不断地切换Sequence
节点下启用的子节点即可。由于场景引用的资源是全局且单例的,场景树本身是十分轻量的结构,所以不会有严重的内存问题;对于子场景内有大量节点的情况,则可以将这些节点运行时动态添加,这本质无非是选择空间还是时间的问题了。
调试
要把对调试的支持作为工具设计的第一步,永远不要自信于自己不会犯错
如果能够实时地将Runtime中的场景树同步到Editor中(就像Godot一样),那自然是极好的,但是这种功能的开发显然是成本高昂的;其实如果不是追求十分极端的调试效率,这种小体量作品只需要控制台输出和Lua报错信息就已经足够可以调试了,因为再底层的错误,Lua也不会像C++一样一声不响地崩溃掉,这就是脚本带给我的自信!
即便如此,在BmEngine中,我还是做了双向调试的设计:
- 在开始场景调试后,编辑器会通过启动参数告知Runtime进程需要连接到的调试端口,随后Runtime会使用HTTP长连接接入Editor的调试服务器,随后的所有日志输出都将被重定向到编辑器界面的控制台;
- 在编辑器的控制台输入指令并发送后,指令字符串会缓存到指令队列中,在下一次Runtime轮询时将对应的节点送往场景中所有的
DebugCmdReceiver
节点,并将指令传递给对应的回调函数,这样运行时程序也可以很及时地处理来自编辑器的调试信息了,这在跳转游戏章节和流程处理时十分有用。
引擎 Runtime
正如前文所述,Editor只负责导出数据文件,而Runtime只需要关心如何根据这些数据文件构造正确的场景就好了。由于Editor同样需要编辑和预览,所以在很多引擎的设计中,Editor是引用Runtime的,但这种设计对项目的架构和解耦要求较高,需要仔细地拆分功能细节,在开发过程中受限较大;所以在BmEngine这种速效项目中,Editor与Runtime是两套不同的代码实现,二者在场景加载和节点更新渲染的逻辑几乎相同,但是在场景实例、脚本回调和摄像机等其他方面区别较大,下面将根据不同主题剖析Runtime实现细节。
实例
在Boidmachine中,万物都是节点,场景中所有的子类节点均继承自Node
基类,那么如何根据序列化后的场景文件构造具体的节点子类示例,便成了反序列化阶段很关键的一步了,一个场景文件内容可能如下所示:
1 | { |
我们可以很清晰地看出,每一个节点对应到了一个JSON的Object结构,具体的子类节点除去基类Node所拥有的字段之外,还扩展了自己的数据和逻辑;在这些字段之中,即为关键的一个便是type
属性,在反序列化过程中,引擎会在TypeName -> FactoryFunc
的映射池中查找对应的节点构造工厂方法,实例化对应子类型的节点后,调用其重写的load
方法;同样地,在处理child_list
字段时,引擎也会递归地构造完成子节点列表后,再将当前节点实例返回给上层。
当然,在实际执行的过程中,由于Node内部的Type枚举字段在运行时同样有用,所以在代码实现中,JSON中存储的字符串类型的
TypeName
会首先使用std::unordered_map<std::string, NodeType>
映射为NodeType
枚举,再在std::function<Node* ()> [(int)NodeType::INVALID]
工厂方法数组中检索对应索引的工厂方法进行构造。
1 | std::unordered_map<std::string, NodeType> Node::name_type_pool = |
对于NodeType::Scene
类型的场景,我们只需要解析其引用场景的ResID即可,因为反序列化的过程是在构造SceneTemplate的阶段,正如前面所述,具体的引用场景实例化方法在父场景实例化时延迟调用。
渲染
对于渲染这种高频次执行的任务,全部使用脚本来控制是不高效的,我们希望脚本只负责命令式地发送指令和设置数据,具体的渲染逻辑应该由引擎层面完成,其实对于2D游戏引擎来说,渲染的内容就很简单了,无非是图片纹理和文本,而文本的渲染也是优先被生成为纹理再进行渲染的,所以归根结底我们只需要处理好纹理的渲染逻辑基本上就可以万事大吉了。
那么既然要将一切具体的渲染任务交付给引擎层面来处理,那么脚本或是配置层面就需要有足够的接口来对渲染任务进行配置,其中很重要的一点便是渲染次序的问题。在默认情况下,BmEngine会根据场景间节点的顺序从前往后绘制所有可被渲染的节点内容,而如果我们想要指定渲染次序,则可以通过设置节点的z_index
属性来对其进行排序,z_index
较小的节点会被优先渲染,在画面上处于更底层;而对于某些俯视角2D游戏,我们希望根据物体在场景中竖直方向的关系模拟前后遮挡关系,所以就可以开启父节点的y_sort
属性,引擎会在渲染时对其子节点进行排序,再进行渲染。
这也就意味着,我们需要先真正执行渲染操作前,首先遍历世界树,收集所有可渲染的节点,再依次执行渲染操作,这与现代引擎的渲染指令队列的思路很像,只不过我们可以单纯收集启用了渲染的节点,再依次调用它们重写的on_render
方法。
1 | // node.cpp |
在决定好了渲染的顺序后,那么一个Node具体应该渲染到窗口的什么位置上呢?这不仅需要考虑自身的位置,还需要考虑当前摄像机视口的位置和尺寸,以及窗口当前的尺寸,这些内容杂糅成一团,所以为了理清思路,我们逐层设置了一些概念:
相对坐标、世界坐标和渲染坐标
相对坐标是当前节点相对于其父节点的坐标,而世界坐标则是当前节点相对于世界树根节点(也就是世界原点)位置的坐标,而渲染坐标是当前节点在世界坐标的基础上,相对于游戏摄像机的坐标,这三层坐标关系是层层递进的,在节点更新时的计算也是逐步求解的;注意,虽然我们这里是以位置坐标进行了举例和说明,但是实际上所有Transform属性(如旋转、缩放等),以及可被继承的渲染和更新属性(如不透明度、更新时间缩放等),也是同样的道理,我们需要根据父子关系对其进行依次求解。
正如上图所示,当父节点的rotation
属性发生变更时,子节点的rotation
和position
属性可能同时发生变更;以Editor中的节点更新代码为例(Runtime中的变换过程不需要考虑摄像机的缩放),演示MVP(Model-View-Projection)变换过程:
1 | void Node::on_update(const EditorCamera* camera) |
摄像机纹理
摄像机除去描述视口的位置外,还充当着渲染目标的角色,在场景渲染时,所有节点先被渲染到摄像机纹理上,而在随后的过程中再将摄像机纹理根据显示模式贴附到窗口上,这样便解决了因为窗口尺寸不同游戏画面的适配问题。在BmEngine中,一共有4种不同的适配模式,以满足不同类型的显示需求,但是大部分情况下,Fit
模式是足够通用的:
既然渲染上考虑到了摄像机纹理的拉伸,那么对应到玩家的鼠标输入上,也是需要根据当前的纹理缩放进行重新映射的,如根据鼠标移动消息更新鼠标当前的屏幕坐标和世界坐标位置时,则需要进行这样的计算:
1 | switch (event->type) |
到现在这一步,我们的渲染位置和窗口适配的问题基本上就可以全部解决了,但是,如果我们希望给摄像机纹理做一些炫酷的特效和后处理呢?又或者更通用一点,我的图片素材纹理也希望可以在运行时进行一些特效的处理,全部使用软渲染怕是性能堪忧的,那么有没有什么方法在现有的渲染框架基础上,增加对OpenGL着色器的支持呢?
SDL_Renderer + OpenGL
众所周知,SDL是支持OpenGL的,但是SDL_Renderer是不兼容OpenGL的,因为SDL_Renderer是一套后端无关的顶层渲染接口,即便是使用SDL_WINDOW_OPENGL
标志初始化了窗口,强制使用SDL_Renderer进行渲染的行为也会十分奇怪。
但是,天无绝人之路,要是想牺牲一点性能剑走偏锋一下,那还是可以做得到的,OpenGL有glReadPixels
函数可以回读像素数据,而SDL“恰好”有SDL_TEXTUREACCESS_STREAMING
标志可以创建频繁更新的纹理(有一种“伊织有一双脚而我刚好有一张嘴”的美)。那么我们就可以将SDL_Surface中的像素数据提交到OpenGL中,在使用着色器完成绘制后,再回读到SDL_Texture中,这样便可以继续使用SDL_Renderer进行渲染绘图了,同时也可以支持使用OpenGL的片段着色器相对高效地实现炫酷的效果。
在Windows下,我们只需要拿到窗口句柄就可以初始化OpenGL上下文了,这样就可以避开使用SDL_WINDOW_OPENGL
初始化SDL窗口,从而确保使用SDL_Renderer渲染大部分内容;而glReadPixels
从显存回读像素数据显然不是性能良好的策略,但是这种性能损耗与大量渲染任务存在时使用软渲染相比,反而还是有优势的,也算是为了兼容SDL_Renderer和OpenGL的代价了。
1 | HGLRC MakeOpenGLContext(HWND hwnd) |
音频
在SDL_mixer的加持下,音频播控的实现其实已经足够简明和轻松了,引擎更多地可能是关注如何控制资源的动态加载。考虑到最终包体资源文件不会独立存在,且音乐的播放采用流式解码,所以BmEngine中使用从内存中加载音乐对象的接口,并始终持有未解码前音乐资源的内存;音效的加载同理,二者均使用ResID为键的资源池进行懒加载。
相较于音乐节点,音效节点提供了一个额外的选项,支持将音量的设置应用到资源,这样便可以调整该音效资源在全局环境下的播放音量,而不需要在每一个使用该音效的位置都设置其音量大小;此外,在SDL_mixer中,音乐的播放只会占用一个通道,相当于采用单例的方式播控,但是音效的播控是基于Channel的,所以最理想的状态是每一个音效节点在执行播放时都占用独立的通道,这样我们便可以相对精确地控制其状态,但如果游戏中同时有大量音效在播放,那么默认开启的8个通道可能就会被重复占用覆盖,所以可以在引擎初始化时通过Mix_AllocateChannels
尝试设置开启更多的通道,当然,太大的数字也是没有意义的,游戏在同一时刻不应该有大量音效同时播放,这对混音表现也是灾难性的。
输入
最理想的输入系统是实现动作映射的,也就是说开发者可以预先将按键操作定义为含义明确的动作,脚本层面可以仅检查动作的触发,而无需关注更底层的按键操作。但是比较可惜的是,考虑到紧迫的开发时间,在现有的BmEngine中,输入映射并没有完整地实现;可即便如此,我们还是提供了一些对按键状态的封装来方便脚本层面检查当前帧的触发逻辑。
1 | std::string InputManager::input_text; |
这样我们在当前帧开始时调用InputManager::new_frame
来清空状态,在事件循环中对按键状态进行更新并累加对应的值(如鼠标滚轮向量和输入文本内容),而在随后的场景更新中,我们便可以在脚本中通过Input.is_key_just_pressed(Input.KEY_A)
检查对应的按键是否在当前帧被按下,在引擎层面对消息进行记录一方面可以减少脚本层面记录帧间按键状态的麻烦,另一方面可以减少事件循环中对脚本频繁调用造成的性能消耗。
当然,不是所有的SDL_Event都会记录并传递给脚本层,如上述代码中的SDL_WINDOWEVENT
触发且窗口消息类型为SDL_WINDOWEVENT_SIZE_CHANGED
时,我们便需要在引擎层面重新生成摄像机渲染目标纹理并计算其适配属性。
物理
正如前文所述,BmEngine还没有接入Box2D,所以在物理系统的实现中,更多的是对Area区域触发的实现,碰撞和限制移动的功能需要开发者在脚本层面控制。在碰撞的实现上,CollisionBox2D节点拥有两个独立的属性来描述自己所处的碰撞层和可以发生碰撞的目标层,与渲染命令列表相似,在世界树更新时,物理系统会收集场景中所有可以发生碰撞的外形,在检测其碰撞条件是否匹配后再进行几何层面的相交性检测,在成功发生碰撞后根据回调路径调用对应节点所绑定脚本的指定名称函数,并将彼此节点对象作为参数传递进去。
网络
网络同样是处于Roadmap中但是没有实现完全的部分,其实自己一开始还是幻想在GameJam上做点大家可以一起玩的多人游戏的,但最终还是说服自己把这个功能实现的优先级往后安排了,毕竟精力总是有限的,48小时的开发还是将游戏内容安排得简单一点为好。但是在已有的程序框架下,实现游戏世界的同步并不困难,简单粗暴一点,我们只需要dump权威服务器上场景树的每一个节点,然后分发给每一个客户端就好;优化一点,那便是记录帧间的节点变更,添加脏标志,但是这样就不太容易实现无状态的服务器了。
相较于dump世界树,更关键的是如何封装网络通信协议,如果我们使用TCP作为可靠的传输,那便只需要考虑如何封包就好了:
在上图所示的数据包体结构中,客户端和服务端约定所有数据都由固定长度的头部和可变长度的有效载荷构成,固定长度的头部为描述此数据包载荷长度的十六进制字符串,客户端和服务端的数据包解析器都是拥有两个状态的状态机:解析头部状态和解析载荷状态,这样当我们从TCP缓冲区拉取数据后,便可以根据是否到达当前解析目标的长度决定跳转到另外一个状态,同时,当解析器从解析载荷状态跳转到解析头部状态时,就意味着当前数据包已经被完整接收,此时便可以将其推送进入SDL的事件队列,作为引擎层的UserEvent进行解析和处理。
脚本
BmEngine使用Lua作为开发脚本,本章节可能需要读者具备Lua或其他脚本语言绑定的经验。
脚本与引擎交互总是需要有个主从关系的,我们暂且把主循环所在的部分称之为“主”,被调用的部分称之为“从”。上古项目EtherEngine中,引擎更像是类似PyGame程序库的角色,提供对窗口、绘图、输入和网络等内容的封装,将接口暴露给Lua环境,从而让脚本可以根据自己的需求实现主循环并为所欲为地调用,这种模式的优点在于灵活性高,引擎只需要考虑封装接口就好了,具体的程序架构由脚本实现;但是奈何开发任务总量是固定的,在C层节省的工作,就必须在Lua层补齐,过度依赖脚本层不仅会导致脚本的功能划分不明确,需要兼顾引擎层和GamePlay层的内容,还会导致脚本层模块化不清晰,难以复用;所以,在BmEngine的设计中,还是遵从了现有的大多数游戏引擎的设计思路,C层控制游戏的主循环,那么脚本层只需要考虑编写不同阶段的回调函数就可以了。
那么,下一步需要考虑的就是脚本内容的设计了。在BmEngine中,有两类脚本,一种是伴随着引擎启动时加载并运行,这类脚本一般用于开发者自定义全局的管理器和初始化脚本环境(如定义自己的消息总线管理器),这类脚本只需要在引擎脚本环境初始化完成后且主循环开始前全部执行一遍即可,没有太多讨论的必要;另一类脚本是可以挂载到场景节点上的脚本,这类脚本模拟了类的实现,在执行后返回一个table,并在其中实现了引擎约定的函数实现,从而可以在不同的阶段进行调用。
1 | local _module = {} |
在上面的脚本示例中,我们可以注意到,每一个方法第一个参数都是self
,这等价于C++类的this
指针,在实际运行的过程中,引擎会把节点本身作为full-userdata参数传递进来,我们可以直接通过调用已经注册的metatable中的方法修改或获取节点数据。当该脚本挂载到Text节点上时,运行效果如下图所示,下面我们一起来以小见大分析一下引擎脚本的设计。
_new
方法用于在节点构造时被调用,它在对应的C层节点对象构造后被尝试调用,脚本中所有的调用都是“尝试性”的,也就是说如果开发者没有在该模块中定义该名称的方法,引擎便不会执行任何操作,但是如果开发者定义了_new
字段的值,但该值不是function类型,那么引擎会报错并终止运行,_new
方法可以对外返回一个值,该值会被存储为payload,也就是跟随该节点实例的Lua层负载,我们用继承的思想来看待的话,我们可以把这种行为看做是脚本对C层节点类型的继承,并扩展了属于自己的字段;而如果我们想要实现类内静态变量,则可以仿照上面脚本开始处的static_val
变量的定义方式,将它放在模块的局部命名空间下。
_on_enter
方法会在节点被挂载到世界树上时被调用,这时其父子关系已经明确,可以使用get_parent
和find_child_by_name
等方法自由访问世界树。
_on_update
方法会在节点帧更新时被调用,除去self
参数外,还会额外传入一个delta
帧更新时间参数,delta
的值会受到tick_scale
属性的影响,所以这就很容易实现“砸瓦鲁多”或子弹时间的效果,我们只需要控制父节点的更新时间缩放,那么在其下的所有子节点更新时间都会进行加速或减速。在上述脚本代码中,我们通过payload
方法获取到了在_new
中创建的负载table,对其值进行了修改并根据已经过去的时间修改了文本节点的字号属性和旋转属性。
_on_render
其实并非需要在其中填充渲染逻辑,正如前文所述,渲染具体行为是引擎根据节点当前数据自行完成的,不需要脚本实现方方面面的细节,所以_on_render
方法实则为对渲染结果的补充,如果我们想要绘制调试线框或额外的信息,则可以在方法中通过get_render_position
和get_render_size
等方法获取当前节点应该渲染的视口位置,绘制自定义的元素信息。
这里又提到了“渲染坐标”这个概念,正如前文所述,相对坐标、世界坐标和渲染坐标是层层推进计算得到的,而在开发者层面,当所有内容都已经挂载到世界树上,所以只需要关注节点的世界坐标即可,但是一定不要忘记在修改世界坐标后,同步修改节点的相对坐标;对于渲染坐标,由于在含义上其值是由其余两个值推导得到的,所以仅提供获取方法就可以了。
1 | template <const char* Metaname> |
_on_exit
方法会在节点退出世界树时被调用,这种行为一般是因为自身或父节点调用了脚本层的delete
方法导致的;注意,这时便又引出了一个新的话题,那便是节点的生命周期问题,我们该在什么时刻真正的释放掉节点所占用的内存?
节点所占用的内存空间我们可以分为两部分思考,一个是Lua层,一个是C层;Lua层又由两部分构成,一个是full-userdata本身的内存占用,另一个是节点的payload属性在注册表中的内存占用,full-userdata作为lua虚拟机的元素,本身(注意,这里说的是本身,而不是它指向的C层对象)是受垃圾回收器控制的,我们无需过多关注,而payload属性作为C层节点属性的字段,是可以在C层节点的析构函数中进行释放的,所以,Lua层本身并没有太大问题需要考虑,内存管理的核心便是关注C层节点对象何时释放;而在世界树中,当一个节点在更新时被设置为了invalid
状态,它不应该立即执行删除逻辑,而是应该在当前帧完全更新完毕后,再将无效的节点从世界树上剥离出去,但是剥离之后的节点此时不应该立即通过delete释放内存,因为此时Lua层面可能还有对指向此节点的userdata,过早释放会导致节点调用成员方法时访问到无效内存地址,所以我们这里可以使用智能指针或手动引用计数,并重写metatable的__gc
字段,当userdata被释放时,C层节点的引用计数-1,而当节点无效且引用计数为0时,再真正把C层的节点delete掉,一定要注意的事,节点无效是前置条件,因为当我们没有使用脚本访问该节点时,userdata在Lua环境中的引用可能始终为0,我们不能盲目地在更新时将所有引用为0的节点释放掉。
发布
BmEngine在发布时支持两种资源包形式,散装的形式和打包的形式,散装的形式没有太多可以深究的地方,在这种模式下,Runtime会在启动时加载meta.json
配置文件读取入口场景等信息,这与开发环境下的加载行为几乎完全相同;而在打包发布的模式下,Runtime会解析与自身同名的.pkg
扩展名的资产包,既然所有的场景和资源都使用ResID进行标记,那么资产包的结构就可以剔除目录结构实现扁平化,一种可行的资产包结构如下所示:
但是这种形式的包裹是“不安全”的,因为脚本作为资源的一种形式,也会被打入资源包,如果和其他资源一样采取默认形式,那么只要使用文本工具打开资产包,便可以任意地查看和修改脚本代码(呱,这简直和裸奔一样让人羞耻啊),所以在BmEngine中,借助Emake自动化工具,首先将Lua脚本生成为C代码,再使用模板进行编译期加密屏蔽代码中的明文字符串,最后再编译为dll,一起打入资源包中,虽然仍然可以通过逆向Lua虚拟机运行时的字节码来还原代码,但是这已经比直接打包Lua代码文本安全太多了,矛和盾的问题讨论下去恐怕永无止境了。
特别感谢
- 我自己,疑似有点太冒险了,但是还是基本上圆满完成了这次挑战,点赞!
- 张爱丽同学,为BmEngine设计了Editor和Runtime图标,并提供了高质量的测试素材!
- CiGA GameJam 2024 的队友和场外援助的朋友们,你们包容了引擎的诸多不完善并让这次挑战以一部完美的作品收尾!
速通自研游戏引擎 - 深入浅出 Boidmachine