展开引擎代码样式

2020-06-01 05:37:15

因为我们向贡献者开放了我们的源代码,所以我们也不时地收到一些关于我们选择编码约定的问题。我们最近更新了我们的代码指南,使其更加具体。但我们也觉得,为我们如何确定我们的特定方法增加一些背景可能是有益的。

这些是我们多年来的一些经验,也是我们走上今天的发展道路的原因。

Defold已经有10多年的历史了,它源于创始人(Ragnar Svensson和Christian Murray)的想法,即必须有更好的方式来开发和维护游戏引擎(和游戏)。这些想法中的许多都是他们在斯德哥尔摩的游戏工作室雪崩工作室(Avalanche Studios)合作时产生的,开发引擎和游戏的功能。

我和拉格纳和克里斯蒂安在同一个工作室工作,但我比他们早一点开始工作,那是在2004年。那时候,我们正在开发的引擎在很大程度上是与今天人们想象的“现代”编程相当的。或者,一开始不是这样,但很快就变成这样了。(我当然是在“现代C++”乐队的马车上)。

这是随之而来的斗争的一大部分。代码库变得非常大,也许更重要的是,很难对其进行更改和迭代。当我们接近发货日期的时候,要把游戏放到DVD上真的是一件很困难的事情。同时,表演也不是很好。

我认为这有助于理解15年前“现代”发动机会是什么样子。当然,这不是在废话发动机或我们所做的工作,而是为了了解我们所走过的旅程。很多才华横溢的开发人员都在开发这个引擎,我们用它发布了很棒的游戏。但回过头来看,我们可以用不同的方式做某些事情,这就是Defold背后激发了很多想法的原因。

当我2004年开始在雪崩工作室工作时,“现代C++”的工作方式已经非常强大了,而且不仅仅是在我们公司。

我们在左边和右边使用C++模式。团队中的每个人都必须阅读“有效的C++”。我们感受到了一股“这太棒了”的新鲜气息,并迅速向引擎、容器和智能指针等添加了越来越多的代码。也许你还记得单例吧?

场景图由一个虚拟的GameObject基类组成,并且它有一个子游戏对象列表。每个游戏对象类型继承自基类,并且经常实现虚拟基类函数的一些变体。继承树很深,有时您从两个基类继承。游戏对象类型很多,很难跟踪实现添加的所有变化和细微差别。

数据流问题也越来越突出。对象的变换是在什么时候实际更新的,并且可以安全使用?

添加所有这些“最佳C++实践”的效果困扰了引擎很多年,过了很长一段时间,它们才被更适合这些任务的东西所取代。

这个时候,STL还很新。这是令人兴奋的,我们非常渴望学习和使用它。简单地避免再次“重新发明轮子”的承诺非常吸引人。

可以理解的是,我们到处都用了集装箱。毕竟,这就是他们在那里的目的,对吧?所有这一切的成本随着时间的推移而积累,在几年的时间里,技术债务大幅增长。

这款游戏的表现也受到了很大影响。stl容器所做的所有这些微小的分配,就像被一千根针刺死了一样。我记得Christian曾经分析过std::string的用法,在游戏的菜单屏幕显示之前,它已经进行了100万次分配。不太理想。即使在今天,分配也是要花费的。

而聪明的指针也是一个大问题。这不仅是因为每个弱ptr.lock()都非常昂贵,而且它也是数据所有权不明确的症状。

流API是性能问题的另一个重要来源(例如<;iostream>;、<;stringstream>;)。

另一个问题是,所有编译器实现stl的方式也略有不同,只是有一些细微的变化。要么是因为规范允许这样做,要么是因为有错误。

一开始,我们并没有真正想到编译时间,但它们确实增长了。等待汽车制造商的变化花了很长时间。队伍已经很大了,所以等待的费用很高!

代码大小也随之增长。当我们接近我们的第一个发货日期时,我们真的不得不努力将引擎安装到DVD上,它超过了惊人的20+MB!(是27个吗?)。

因此,正如许多开发人员所做的那样,我们努力工作了很长时间才能满足我们的要求。事实上,我们最终推出了这款游戏,它取得了成功,使我们能够继续开发引擎。

看到某些斗争随着时间的推移而重复出现,在引擎和游戏开发中,导致了克里斯蒂安和拉格纳之间的无数讨论。在意识到他们都在国内开发自己的发动机后,他们决定联手。这个创造就是后来的德福尔德。

在这一点上,克里斯蒂安已经在公司担任了几年的技术主管,我们已经开始改变旧的做事方式。做出的许多技术选择与Defold今天仍在使用的非常相似。

几年前,也就是2006年,发表了一篇关于战神的文章,读到这样的游戏可以放入1.5MB的可执行代码中,真的很鼓舞人心。作为一个很大的好处,这些小代码似乎也产生了比平时低得多的错误计数。经过多年的AAA引擎开发,这些想法将影响克里斯蒂安和拉格纳在创建Defold时所做的很多选择。

展开引擎总是努力做到尽可能小和快速。这既适用于开发,也适用于最终产品,因为它对开发人员和最终玩家都有帮助。

为了实现这个目标,我们用了一个简单的规则:“如果我们不需要它,我们就不用它”。我们将这个想法应用于设计和实现阶段,无论是大的还是小的。

例如,我们不会添加用户没有要求的功能,也不会试图解决手头问题之外的更多问题。

这方面的一个例子是我们的GUI系统,它非常初级。这是因为不同的用户通常对GUI系统应该支持的内容有非常不同的看法。因此,我们提供了基本的构建块,并为我们的用户提供了一种在此基础上构建他们自己的GUI系统的方法。

而且,在我们将第三方库添加到引擎之前,我们首先进行尽职调查,看看我们有什么选择。一种选择是,我们自己实现该功能。

虽然从技术上讲,我们确实使用C++(我们不能仅使用C编译器进行编译),但我们不会在C++的所有特性出现后立即使用它们。事实上,我们只使用了很少的功能,比如名称空间、RAII和几个模板。

我认为这种风格更像是C,而不是全C++。术语C-like C++非常常见,并且很好地描述了我们的引擎。

我们的大多数内部库都有一些公共头文件,供引擎中的其他系统使用。这些库通常具有创建/销毁上下文的功能。然后可以将此上下文传递给库的其余函数:

这里有关于C++的简化使用主题的另一个很好的资源:C++(它还列出了更多的阅读材料)

DeFold使用基于组件的方式向游戏对象添加功能。这分离了所有者(游戏对象)和数据本身(例如,子画面)之间的依赖关系。这允许我们拥有更清晰的依赖链,其中每个组件类型都会一个接一个地更新。而且,它还消除了对基本游戏对象类的需要。

而且,因为我们不使用类或继承,所以不需要RTTI。

除了我们拥有的一些容器类之外,我们实现类类型的唯一情况是我们必须与第三方交互(例如Box2D)。

在游戏中,您通常在使用数据之前就知道要使用的所有数据。您已经通过数据管道对其进行了预处理,并将其从源数据转换为编译数据,以供引擎使用。在此过程中,您可以采取任何必要步骤来确保捕获任何数据错误。这是有益的,因为您越早发现问题(最好是在创作过程中),成本就越低。

它还有另一个好处;因为数据管道已经验证了所有数据都是正确的,所以您不需要在引擎中出现C++异常。通过假设数据总是正确的,您可以摆脱大量的防御性编程。

任何库的使用都是一种选择,应该像工作流程中的其他任何东西一样通过尽职调查。在我们的例子中,出于性能原因(编译时、代码大小、运行时、内存),我们选择不使用它。

我们拥有的另一个特性是我们的扩展系统(插件),允许用户连接到引擎。如果我们要将stl作为依赖项添加,则由于stl的ABI,我们必须将相同版本的编译器强制应用于所有开发人员。这在过去已经是一个问题了,我们只是不想再陷入这种境地。

我们确实有一些例外(暂时),它是std::Sort(),我们还没有找到一个很好的替代规则(到目前为止)。在未来,我希望我们可以完全消除这种依赖。

在雪崩,我们最终用占地面积小、缓存友好的集装箱取代了我们的集装箱。事实上,dmArray和dmHashTable与Christian第一次在雪崩引擎中编写它们时的外观相似。

这些容器是为POD(普通老式数据)类型编写的,因为这就是我们使用它们的目的。

您经常听到人们说,编写自己的容器会引入bug,您应该坚持使用stl。我觉得这有问题,因为这来自于应该非常了解如何编写数组/哈希表容器的工程师。是的,您可能会遇到一些初始问题,但这些问题通常是在设计问题的过程中出现的(因为您可能允许自己为任务实现您喜欢的API)。但是,既然您编写了代码,您就可以很好地修复这些问题。

我们的array.h是219行代码。真的没有那么多代码需要担心。就编译时和运行时而言,它既短又快,并且完美地服务于我们的用例。

我们的hashtable.h有321行代码,而且非常快。也不是很复杂,请看一下。

有人曾经告诉我,指针是危险的,坦率地说,我不理解这一点。指针本身不做任何事情,它只是数据。应该由开发人员决定如何处理这些数据,也许更重要的是,何时使用这些数据。

最主要的恐惧之一似乎是“空指针”,但根据我的经验,它们是最容易捕获和修复的。它们很少发生(通常在开发新功能时),并且很快就会被修复。

一个更合理的恐惧是一个摇摆的指针,指针不再指向它最初指向的东西。这些确实在极少数情况下发生,但大多数情况下也相当容易修复。

一种流行的模式是使用const data&;来“确保安全”,但它们实际上也只是一个指针,容易受到相同问题的影响。

智能指针是许多人表示想要使用的另一个工具。成本之一当然是添加所需的if-语句,但最重要的是lock()调用的所有延迟,以及它添加的代码大小。我记不起以前的确切数字了,但锁需要很长时间。我不需要测量它就知道它不仅仅是访问原始指针。尤其是从不同的线索。

唯一指针是另一种模式,实际上,它更符合我们有时使用的RAII。但是,我们只需要在较小的范围内使用它们,因此手动释放指针同样容易。手动释放也有助于提高可读性。

继续讨论指针跟踪,需要记住的最重要的一点是,指针不会随机传递到不同的系统。我们知道哪个系统拥有某个指针,我们知道哪些系统可能可以访问该指针,我们还知道该数据的生命周期。

每个系统都知道自己的资源,并被称为数据的“所有者”。这种所有权可能会传递给另一个系统,但这是通过API清楚地传达的。拥有明确的数据所有者是很重要的,因为它允许您对数据做出重要的假设,例如“当我到达此代码时,数据将始终处于活动状态”。

这意味着您不必一直使用“if(data!=0)”(防御性编程)。

这意味着您不必保护任何智能指针中的数据。

大约在10-15年前,我对C++11必须提供的功能非常感兴趣,那时跟上并评估新特性是可以管理的。

每一项新功能都是一把锤子,我急切地在寻找钉子来使用它。然后我(和其他人一起)意识到,我们只是简单地再次跳旧舞,我们首先进入新功能,而没有考虑我们实际上需要或想要从我们的代码中得到什么。

如今,越来越多的特性被注入到C++语言中,一个诚实的问题是:“它是为谁准备的?”我还没有看到任何可以使这个引擎(或我在过去10年中编写的任何代码)显著改进的新特性。也许很方便,但在编译时间、代码大小、运行时或可读性方面并不是更好。因此,我们将坚持我们过去11年来一直走的经过验证的道路。

对于引擎代码,我们仍然使用-std=c++98的特性集,虽然我想在编译器标志中强制使用它,但有些平台要求我们使用-std=c++11,因此将编译器标志保留为默认值会更容易,只需避开该语言的最新特性即可。如果有一种方法可以将标准设置为“类似C的C++”,那就太好了。

Auto是我亲眼看到的一个完全接管代码库的例子。最后,它变得不可读,基本上是无类型的。我们从不着急,不能多打几个字。

我希望这能让我们深入了解为什么我们的代码看起来是这样的,以及为什么我们不打算更改它。请记住,我们的使用情形可能不适合您的使用情形。

如果您想了解更多关于我们的投稿流程以及如何帮助您的信息,请访问我们的投稿页面。

如果您想讨论源代码,请加入Defold论坛的source代码类别中的讨论。