漫谈C++游戏引擎开发

论坛 期权论坛 期权     
看雪学院   2019-7-8 05:49   2906   0


前言
最近我用 C++ 写了一个游戏引擎,并用该引擎开发了一个名为 Hop Out 的小型手游。先来看看实际运行效果:

(译者注 这里本来有个小视频,放到附件里了,感兴趣的朋友请下载观看,文件不到4MB。)

Hop Out 是一款类似复古街机游戏,但拥有 3D 卡通外观的游戏。闯关方式为改变所有垫子的颜色,这一点和 Q*Bert 游戏很相似。

Hop Out 仍在开发当中,不过游戏引擎部分基本完工了,所以我想在这里分享关于游戏引擎开发的一些技巧。

在我看来,开发游戏引擎比较尴尬的一个情况就是你可能不知不觉地就造就出一个庞然大物,然后你一看到它就头皮发麻,所以我的主张是保持事物的可控性,具体将从以下三个方面进行阐述:
  • 采用迭代方法
  • 先三思而后合并
  • 认识到序列化是个很大的主题

采用迭代方法
我的第一条建议是先快速地让程序运行起来,然后迭代地进行开发。

如果条件允许的话,找个样例程序,然后以此为基础开始。以我为例,先下载 SDL 再打开 Xcode-iOS/Test/TestiPhoneOS.xcodeproj ,然后在 iPhone 上运行 testgles2 样例程序。立刻我就得到了一个很可爱的旋转立方体,如下图。




然后我下载一个别人做好的马里奥 3D 模型。随后编写了一个文件格式不太复杂的 OBJ 文件加载程序,接着修改样例程序,让马里奥取代立方体,如下图。还有,我集成了 SDL_image 来帮助加载纹理。




再然后,我实现了双摇杆控制来移动马里奥,如下图。




接下来我想着研究一下骨骼动画,所以我打开 Blender 制作了一个触手模型,并通过一段可以前后摆动的有两根骨头的骨架来操控它。




不过这里我放弃了使用 OBJ 文件格式,转而编写了一个将数据从 Blender 导出到自定义 JSON 文件的 Python 脚本,这些 JSON 文件存储了皮肤网格、骨骼、动画等数据。在 C++ JSON library 的帮助下我将这些文件加载到了游戏中。




上述过程成功后,我接着使用 Blender 制作更加精致的人物。下图展示了我制作出的第一个可操控的 3D 人物。




后来我又做了一大堆的工作,不过这里我想强调的重点是,我没有在动手编程之前先规划好引擎架构。事实上,每当要添加一个新特性时,我只着眼于用最简单的代码将其实现,然后观察这些代码,看看它们自然而然呈现出的是一种什么架构。这里所讲的引擎架构,指的是组成游戏引擎的模块集、模块之间的依赖关系,以及模块之间交互所使用的 API 。




这是一种迭代开发的方法,这种方法在编写游戏引擎时非常有用,其优点在于不管开发工作进行到哪个阶段,你始终都有一个可运行的程序。如果在后续提取代码模块时出现问题,你可以通过与上一次可正常运行的代码对比以快速地找出错误。显然,这里我假设你使用了某种源代码控制软件。

也许你认为这种开发方法会浪费大量的时间,因为中间过程会产生许多后续需要清理的垃圾代码。但是,大部分的清理工作无非就是将代码从一个 .cpp 文件移动到另一个 .cpp 文件、将函数声明提取到 .h 文件、或者一些其他简单的操作。决定代码的归属其实是一件相当困难的工作,但是显然,当代码呈现在你面前时,这个工作就会简单许多。

况且在我看来,先绞尽脑汁地想出一个你认为能满足未来所有需求的架构,然后再着手编程,会比迭代开发浪费更多的时间。这里推荐一下我最喜欢的关于介绍过度工程危害的两篇文章,一篇是 Tomasz Dbrowski 的 The Vicious Circle of Generalization ,另一篇是 Joel Spolsky 的 Don’t Let Architecture Astronauts Scare You 。

但是请注意,我并没有说你永远都不应该先在纸面上解决问题,然后编程实现它。我也并没有说你不应该提前规划好你想要的功能。就我而言,我从一开始就想要游戏引擎能够在后台线程中加载所有 assets 文件,但是我一开始并没有去设计如何实现这个功能,而且一开始也确实没有实现这个功能,实际上我一开始只实现了加载部分 assets 文件的功能。


先三思而后合并
作为程序员,我们似乎会本能地避免代码重复、统一代码风格以让源代码看起来美观、优雅。然而,我的第二条建议是不要盲目地遵循这种本能。


[h1]给 DRY 原则放个假[/h1]
为了给你一个示例,我的引擎包含了几个 smart pointer 模板类,类似于 std::shared_ptr 。通过作为一个 raw pointer 的包装器,它们个个都能防止内存泄漏。

  • Owned 用于被单个对象拥有的动态分配的对象。
  • Reference 使用引用计数来以便一个对象被多个对象拥有。
  • audio::AppOwned 被音频混频器外的代码使用。它允许游戏系统拥有音频混频器使用的对象,比如当前正在播放的声音。
  • audio::AudioHandle 使用一个引用计数系统内部的音频混频器。

看起来似乎这些类的功能有重复的地方,违背了 DRY(Don't Repeat Yourself) 原则。事实确实如此,在开发早期,我曾想方设法地尽可能多地重用现有的 Reference
类。但是后来我发现音频对象的生命周期受一些特殊的规则控制:如果音频对象已经完成了播放,并且游戏也没有一个指向该音频对象的指针,那么该音频对象就可以立即排队等待删除了。如果游戏有一个指向该音频对象的指针,那么该音频对象就不该被删除。如果游戏有一个指向该音频对象的指针,但是该指针的拥有者在声音没有播放完成之前被破坏掉了,那么该声音就该被取消。我认为,与其增加Reference的复杂度,还不如引入单独的模板类,况且后者显然更实用一点。

95%的情况下,重用已有代码是没毛病的。然而,当你感觉到重用代码变了味、或者你正在把简单的东西变得复杂的时候,你就该仔细想想要不要坚持重用代码。


[h1]大胆地使用不同的调用约定[/h1]
Java 有一点我很不喜欢,那就是每个函数都必须定义在类中。在我看来,这根本就是胡来,这样做也许使你的代码看起来更整齐一点,但其实它变相地鼓励了过度工程(over-engineering),而且也不能很好地支持我先前所提到地迭代开发方法。

在我的 C++ 引擎中,有些函数属于类,有些函数不属于类。例如,游戏中的每个敌人都是一个类,敌人的大多数行为都是在类中实现,但是球体滚动这个行为是通过调用函数 sphereCast() 实现的,该函数属于 physics 命名空间,但是函数 sphereCast() 并不属于任何类——它就是 physics 模块的一部分。

我通过一个构建系统组织代码,该构建系统用于管理模块之间的依赖关系。将这个函数强行塞进一个类中对于改进代码组织来讲没多大意义。

再来谈谈多态(polymorphism))中的动态调度(dynamic dispatch)。我们经常需要在不知道对象确切类型的情况下调用函数获取对象。大多数
C++
程序员的第一反应是使用虚函数定义抽象基类,然后在派生类中重载这些函数。

这的确是一种行之有效的方法,但这只是实现该功能的众多方法中的一种罢了。还有一些可以不引入多余的代码,或者带有其他好处的动态调度技术:

  • C++11 引入了 std::function ,这是一种很方便的存储回调函数的方法。你还可以编写一个 std::function 个人版本,这样在调试器中单步执行时或许就没那么痛苦了。
  • 许多回调函数可以用一对指针来实现: 一个函数指针和一个 opaque 参数,只需要在回调函数内部进行显式转换即可。纯 C 库中有很多这种例子。
  • 有时侯, 底层类型实际上在编译时是已知的, 因此你可以绑定函数调用而无需额外的运行时开销。Turf ,是我在游戏引擎中使用的一个库, 就大量使用了这种技术。感兴趣的可以看看 turf::Mutex 。
  • 不过有时侯最直接的方法莫过于自己构建和维护一个原始函数指针表。我在音频混频器和序列化系统中使用了这种方法。正如下文将要提到的,Python 解释器也大量使用了此技术。
  • 甚至你可以将函数指针存储在哈希表中, 将函数名作为键。我使用此技术调度输入事件, 如多点触摸事件。这是一个记录游戏输入并使用回放系统重新播放策略的一部分。

动态调度是一个很大的课题,我只是随便举些例子罢了,实际上还有很多方法都可以实现。随着编写的可扩展底层代码(在开发游戏引擎中很常见)越来越多,你会探索出越来越多的方法。

如果你不习惯这种编程方式,那么
Python 解释器或许对你来是是一个非常好的学习资源。它使用 C 编写,实现了一个强大的对象模型:每个 PyObject 都指向了一个 PyTypeObject ,而每个 PyTypeObject 都包含了一个用于动态调度的函数指针表。如果你感兴趣的话,可以从阅读文档 Defining New Types 开始。


认识到序列化是一个很大的主题
序列化(Serialization)指的是将运行时对象转化为字节序列,换句话讲,就是保存和加载数据。

对于许多游戏引擎来讲,游戏内容是以各种可编辑格式创建的,如 .png 、 .json 、 .blend 或者一些专有格式等,最终再将其转化为游戏引擎可以快速加载的平台特定的游戏格式。这个管道中的最后一个应用程序通常被称为 cooker 。cooker 也许会被集成到其他工具中,甚至分布在多台机器上。通常上,cooker 和许多工具是随游戏引擎本身一起开发和维护的。




在建立这样一个管道时,其中每个阶段的文件格式都由你设定。你也许会自己定义一些文件格式,这些文件格式可能会随着引擎功能的不断添加演变。随着它们的演变,有一天你或许会发现必须使某些程序与以前保存的文件格式保持兼容。但是,无论何种格式,你最终都得用
C++ 进行序列化。

C++ 实现序列化的方法数不胜数,一个比较容易想到的方法是在你想要序列化的 C++ 类中添加 load 函数和 save 函数。在文件头部中存储版本号,然后将版本号传递到每个 load 函数中,你就可以实现向后兼容性。这种办法可行,不过可能导致代码非常冗杂而难以维护。

void load(InStream& in, u32 fileVersion) {
        // Load expected member variables
        in >> m_position;
        in >> m_direction;
        // Load a newer variable only if the file version being loaded is 2 or greater

        if (fileVersion >= 2) {
            in >> m_velocity;
        }
    }
不过我们可以写出更灵活、更不容易出错的序列化代码,这里用到了反射(reflection)),具体来讲是创建描述 C++ 类型布局的运行时数据。如果想要快速了解一下如何在序列化时使用反射,可以看看开源项目 Blender 。




当你从源代码构建 Blender 时,会发生许多事情。首先,一个名为 makesdna 的程序会被编译并运行。这个程序会解析 Blender 源树中的一组 C 头文件,然后输出一个包含了被称为 SDNA 的自定义格式的文件,该文件中存放了这些头文件内部定义的所有 C 类型的紧凑摘要,这些 SDNA 数据就是反射数据(reflection data)。

然后 这些 SDNA 数据被链接到 Blender ,并和 Blender 所写的每个 .blend 文件一起保存。从此以后,每加载一个 .blend 文件,Blender 就会比较该 .blend 文件的 SDNA 数据与运行时链接到当前版本的 SDNA 数据,并使用通用序列化代码来处理差异。

这种策略使得 Blender 的向前和向后兼容性非常强大。你可以在最新版中加载 1.0 版的文件,也可以在旧版本中加载新版本的 .blend 文件。


Blender 类似,许多游戏引擎和与之相关的工具都会生成并使用自己的反射数据。有很多方法做到这一点:你可以像 Blender 那样解析自己的
C/C++ 源代码来提取类型信息。你也可以创建一门独立的数据描述语言,并编写一个工具来生成此语言的 C++
类型定义和反射数据。你还可以使用预处理器宏和 C++
模板来生成运行时反射数据。一旦有了可用的反射数据,有无数种方法基于它编写一个通用序列化程序。

显然,我在此省略了许多细节。我只想说明确实有很多种方法来序列化数据,其中有一些方法是相当复杂的。程序员们通常并不会像讨论其他引擎系统那样讨论序列化,虽然事实上大部分其他的引擎系统都依赖序列化。

例如,GDC 2017 上的96个编程会谈中,我统计了下,31个是关于图形学的,11个关于在线的,10个关于工具的,4个关于AI的,3个关于物理的,2个关于音频的,但是只有1个直接涉及到了序列化。
总结
开发游戏引擎,哪怕规模很小,也是一项艰巨的任务。关于此我还有很多东西可说,但是考虑到博客长度,老实来讲,这就是我能想到的最实用的建议了:迭代开发、稍微控制一下统一代码的冲动、认识到序列化是一个很大的课题,你也许就能根据此确定出一个比较合适的策略了。根据我的经验,如果忽略了这些东西,它们很可能就会成为你的绊脚石。







本文由看雪翻译小组hesir 编译,来源Jeff  preshing
转载请注明来自看雪社区

热门阅读


点击阅读原文/read,
更多干货等着你~


分享到 :
0 人收藏
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

积分:55
帖子:11
精华:0
期权论坛 期权论坛
发布
内容

下载期权论坛手机APP