现代C++游戏开发:思考与误区

2020-05-16 18:52:37

过去的几天很有趣。我的Twitter遭到了游戏开发社区的攻击,他们看不到现代C++的太大价值,更喜欢用非常低的抽象层来编写代码。我的Twitter遭到了游戏开发社区的攻击,他们看不到现代C++的太大价值,更喜欢用非常低的抽象层来编写代码。只不过这一次不是我发动的,不同于不久前……。

这篇文章(1)讲述了我的一条tweet引发的激烈讨论的故事,(2)分析了游戏开发人员的一些常见要求和误解,以及(3)提供了每个游戏开发人员都应该使用的现代C++功能的列表。

我正在为地震制作一个虚拟现实模型。实际上,称它为Mod&34;有点小巫见大巫:我不仅花了相当多的时间在我的QuakeSpasm引擎分叉上增加了VR支持,而且我还几乎改变了每个游戏机制,把经典游戏变成了一流的虚拟现实体验:

对我来说,Quake VR项目是一个很好的乐趣来源,也是一次奇妙的学习经历。我与Quake的代码库建立了非常亲密的关系(包括它的所有怪癖和怪异),习惯了OpenVR,并涉足了我以前从未做过的OpenGL图形编程(例如几何着色器)。

我还将利用这个项目作为尝试C++17特性的机会,并总体上享受使用现代C++的乐趣。在2017年的语言标准中,我最喜欢的新增功能之一是折叠表达式。折叠表达式基本上将一个参数包减少到单个结果中,但也可以用于为包中的每个元素任意生成代码。

为了添加纹理粒子并消除即时模式OpenGL的开销,我从头开始重写了Quake的粒子系统。这样做,我发现自己需要将一些图像缝合在一起来创建纹理图集,以避免不必要的绑定和解开纹理。在我的特定场景中,所有纹理文件路径都是硬编码的,因为我没有兴趣也不需要实现自定义粒子纹理。因此,我决定尝试使用折叠表达式:

我越多地使用#cpp包和折叠表达式,我就越希望它们在运行时可用。它们是表达某些操作的一种非常优雅和方便的方式。(@seanbax的想法是正确的!)。作为一个例子,这里的原始生成的纹理地图集的地震虚拟现实。pic.twitter.com/X7tKrvPq0j。

-维托里奥·罗密欧(@supahvee1234)2020年5月11日

正如您从我的tweet中看到的,我正在使用一个参数包将所有要缝合在一起的图像传递给stitchImages可变模板函数。这显然不是必需的,因为运行时容器也可以工作,但是令人惊讶的是,它会产生一些非常优雅的代码1:

模板类型名称(<;TypeName).。图像>;TextureAtlas缝合图像(常量图像&;.。图像){const auto width=(images.width+.);const auto Height=std::max({images.heat.});//.。

关注宽度和高度的定义:使用折叠表达式可以非常简洁和毫不含糊地表达(1)将所有图像的宽度相加,(2)找出所有图像之间的最大高度。此外,这种方法允许两个变量都是常量限定的,避免了意外突变,并减少了读者的认知开销。这要归功于这两个变量保证在它们的整个生命周期内不会改变它们的值,从而使读者可以将注意力集中在函数体的移动部分上,从而减少了认知开销。

TextureAtlas stitchImages(const std::Vector<;Image>;&;image){std::size_t width=0;for(const auto&;img:image){width+=img.width;}std::size_t maxHeight=0;for(const auto&;img:image){maxHeight=std::max(maxHeight,img.high);}//.。

老实说,我认为上面的解决方案很糟糕。首先,我们使用的是std::size_t,它不能保证与Image::Width的类型匹配。为了(垂直地)正确,应该使用decltype(std::declval<;const Image&;>;().width),它很详细。无论如何,代码仍然是不必要的冗长-大量的语法噪音让我怀疑当我查看它时代码是否正确,因为有更多的地方可能已经引入了缺陷。最后,我们失去了一致性,包括它的安全性和可读性的好处。

TextureAtlas stitchImages(const std::Vector<;Image>;&;image){const auto width=std::accumate(images.start(),images.end(),0[](const std::size_t acc,const Image&;img){return acc+img.width;});const auto Height=std::max_element(images.start(),images.end(),imgB.Height;})->;高度;//.。

真没意思。我们获得了常量,并避免了意外的高度类型不匹配,但对于一个非常简单的任务,存在着令人难以置信的噪音和样板。相反,考虑一下如何在其他语言(如Python)中执行此任务:

def stitchImages(Image):width=sum(图像中img的img.width)high=max(图像中img的img高度)#.

模板类型名称(<;TypeName).。图像>;TextureAtlas缝合图像(常量图像&;.。图像){const auto width=(images.width+.);const auto Height=std::max({images.heat.});//.。

我真的很喜欢上面的代码片段,我强烈建议人们在生产中编写这样的代码……。如果不是因为折叠表达式纯粹是编译时特性这一事实。这就引出了我推文的全部观点:

我越多地使用#cpp包和折叠表达式,我就越希望它们在运行时可用。它们是表达某些操作的一种非常优雅和方便的方式。(@seanbax的想法是正确的!)。

我试图引发的讨论是关于C++是否可以获得一种类似于在运行时也可以工作的折叠表达式的语法,因为我相信它是对语言的一个有价值的补充,可以同时提高可读性、简洁性和安全性。

我在最初的推文中标注了肖恩·巴克斯特,他创造了一种令人惊叹的语言,叫做Circle。简而言之,它是C++的扩展,添加了一个新的强大的元编程范例和许多新功能来提高生产力。绝对值得一查。

Circle支持列表理解、切片、范围、for表达式、函数折叠和展开表达式,甚至在运行时也是如此。事实上,您可以在Circle中编写stitchImages(使用运行时数据),其方式与可变模板版本非常接近:

TextureAtlas stitchImages(const std::Vector<;Image>;&;image){const auto width=(images[:].width+.);const auto Height=std::max({images[:].height.});//.。

[:]基本上是编译器魔术,它允许您将向量的元素视为参数包。它是您将使用循环执行的常规运行时操作的全部语法糖。我发现它非常有价值,我认为这是C++前进的正确方向-我希望看到一篇标准化的论文被提出。

当然,我在一个个人爱好游戏项目中对C++编译时和运行时特性之间差距的观察引起了Twitter游戏开发社区部分成员的注意,他们的第一反应自然是:(1)嘲弄地转发我的原始代码片段,向一个追随者展示那些C++程序员是多么愚蠢,或者(2)评论代码是多么迟钝。

虽然这已经很令人难过了,但我更失望的是,看到一些我真正钦佩其作品的人也有一些非常令人沮丧的事情要告诉我:

谢天谢地,我在生活中已经面对了足够多的困难,所以这种胡言乱语不会再让我(很大程度上)陷入困境。然而,想象一下,一个年轻的开发人员从他们的偶像中得到了这样的反应,对他们渴望分享的一个实验:那将是令人心碎的。

即使这条推文的作者只是泛泛而谈,但这种泛化正在伤害我们的社区和行业。有些人在他们的个人项目上进行实验,并将一些想法分享给C++社区的特定子集,这并不会伤害任何人。

我可以展示我收到的许多荒谬无味的推文,但这不是这篇帖子的重点。我想讨论一下游戏开发行业对现代C++的误解。

在关于现代C++如何成为编程行业毒瘤的一连串尖刻评论或自以为是的评论中,也提出了一些非常好的观点。许多游戏开发人员都有C++标准化委员会没有优先考虑的非常具体的要求。

最常见的需求是可调试性,这一点非常重要。使用现代C++有两种方式会阻碍代码调试能力。这一切都源于现代C++如何鼓励使用零成本抽象:

这样的抽象只有在启用编译器优化时才是零成本(在运行时)。在调试模式下运行应用程序时,与发布模式相比,性能可能会下降数百倍,这可能会使游戏无法真正玩。

这种抽象通常是通过利用分层体系结构和代码重用来实现的。这些都是有价值的软件工程原则,允许大型项目扩展和增长,但它们也往往会由于多个级别的间接而导致深层调用堆栈:这样的深度可能会使您很难理解调试过程中发生的事情。在使用标准库设施时,这个问题尤其明显,众所周知,标准库设施具有非常复杂且嵌套很深的实现细节。

这些观点都是公平的。为了缓解这些问题,大多数游戏开发人员寻求的解决方案是尽可能避免抽象,包括做出极端的决定,比如根本不使用标准库,或者使用std::Vector<;T>;::Data()+N而不是std::Vector<;T>;::Operator[](N)(甚至根本不使用容器)。

然而,这些解决方案中有一个方面经常被忽视。让我们(合理地)假设,除了您只想以交互方式探索代码库的情况外,必须调试的频率与程序中的bug数量成线性关系。让我们也(再一次,合理地)假设,使用经过战斗测试的抽象来提高安全性可以减少程序中出现错误的机会。

你看到谜题了吗?当然,如果您编写容易出错的类似C的代码,并且避免几十年来为帮助您避免错误而改进的实用程序,那么您将不得不进行更多的调试。

我相信这是一个平衡-不是代码的每一行都应该隐藏在20层抽象层下,但是正确使用标准库(或定制的类型和函数)将减少调试所需的时间,因为可以在编译过程中防止错误。丹·萨克斯(Dan Saks)在CppCon 2016(我很荣幸亲自参加)上做了一个精彩的演讲,其中涉及到这个话题-强烈推荐:

标准库实现者和C++委员会成员应该认真对待这一要求,并集思广益,研究如何改进这种情况;

游戏开发人员(以及通常对现代C++持怀疑态度的人)应该给更平衡的编码风格一个机会,在这种风格中,尽可能在编译时使用精心选择的抽象来避免错误。

我还可以在调试工具开发领域看到另一个有趣的机会,以减轻可能使人的调试体验变得次优的因素。例如,以一种用户友好的方式将调用堆栈的某些层标记为不重要(并记住调试会话之间的信息)可能是一个很好的起点。

然而,有一点我可以肯定:完全放弃现代C++特性和抽象提供的安全性、可读性、灵活性和表现力是对这个问题的极端和不明智的反应。

另一个被多次提及的问题是以前已经多次讨论过的问题:编译时间。C++有一个臭名昭著的坏名声,那就是编译时间慢,在我看来,当我们没有现代语言功能时,这是非常合理的。在C++11之前,任何元编程都需要大量使用模板,包括用于(例如)模拟类型列表的递归模板实例化。此外,考虑到C++03(如Boost)的限制,在保持数千种编译器/平台组合的可移植性的同时实现了令人难以置信的壮举的库不得不求助于神秘的技术,这最终导致了非常慢的编译时间。

不足为奇的是,过去经验不佳的游戏开发人员仍然不敢在他们的代码库中引入任何模板或抽象的想法,没有意识到现在的情况要好得多。

我无数次接触到的最大的误解之一是,现代C++等于标准库。这完全是错误的。标准库实现没有针对编译时间或可调试性进行优化-它们需要(1)通用、(2)易于维护和扩展、(3)运行时高效、(4)符合标准及其所有细微差别。

r/cpp(";UNIQUE_PTR-7个取消引用调用-为什么需要这个?";)上的这个线程很能说明问题。作者称自己是现代C++怀疑论者,并认为他们的怀疑是合理的,因为一个简单的std::ique_ptr取消引用需要调用堆栈中的7层间接引用。

一个更吸引人的资源是优秀的Magnum图形引擎的作者撰写的轻量级但仍然与STL兼容的唯一指针文章。

本文观察到,在项目中包含<;memory>;头文件会极大地增加编译时间。作者当时还测试了模块的实现,但帮助不大。作者没有成为反对现代C++的斗士,而是意识到问题出在std::Unique_ptr的实现上,并且该语言的最新标准为创建std::Unique_ptr的轻量级替代品提供了必要的功能(它足以满足Magnum的要求)。(=。这个思考过程导致了CorradePointer.h的创建,它是唯一的堆分配对象所有权的单头实现,对编译时间的影响最小。

我在这里想要说明的是,现代C++提供的大多数语言功能都可以使用,而不依赖于标准库。我也承认标准库并不完美,但这是可以理解的,因为它是一个需要满足大量用例的通用工具。

我刚才讨论的是最困扰我的误解。如果<;内存>;对于您的编译时间来说太昂贵了,那么当您可以在大约20行代码中实现类似的东西时,为什么要放弃std::Unique_ptr的安全性、便利性和可读性改进呢?

这一点几乎可以应用于所有标准库组件(除了非常低级的组件,如<;type_trait>;,它们不会显著影响编译时间)。

当您从整体上轻率地放弃了现代C++,转而成为Twitter键盘勇士时,您就错过了C++提供的真正而重要的好处。

在游戏开发的上下文中,如果没有C++11/14/17中引入的许多特性,我将无法生存。其中一些是简单的生活质量改进,对编译时间或可调试性没有任何影响,但却提供了巨大的价值。

我列出了一系列我认为(1)简单,(2)对可调试性和编译时间影响最小,(3)仍然非常有价值的特性。

枚举类-非常适合表示一组选项或选择。非常适合人工智能、菜单、网络协议、强类型定义等等。

Lambda表达式-通过引入将相关逻辑捆绑在一起的本地函数来划分代码,或者更清晰地表达异步操作。

覆盖-有时虚拟多态性对游戏很有用,覆盖有助于避免错误并增加可读性。当然,我并不是建议您应该有一个具有虚拟更新成员函数的实体类(即使这对于较小的游戏来说是完全合理的),但是任何类型的辅助系统(例如场景管理器)都可以从多态性中受益。

类型别名和模板别名-避免代码重复并使代码更易维护/更灵活的绝佳方法,因为您可以在单个位置更改类型,而不是在整个代码库中查找-替换。

原始字符串文字-没有它们我活不下去。如果我想在我的游戏中嵌入GLSL代码并保持它的可读性,这些是必须的。非常有用。

二进制文字-告别需要心智操练的位掩码。只需用二进制显示您的掩码应该是什么样子,就可以非常清楚地知道哪些位将受到影响。

数字分隔符-游戏充满了硬编码常量。";最大粒子数";。";最大实体数";。诸若此类。用一个简单的字符使它们更具可读性--区分100&39;000和1&39;000非常容易。

嵌套的命名空间定义--以细粒度的方式组织代码,避免所有额外缩进级别的麻烦。

[[Fallthrough]]-自从切片面包以来最好的东西,特别是因为我知道你们游戏开发人员喜欢SWITCH语句。此属性允许您明确表示要失败的意图,从而避免错误并增加可读性。

[[nodiscard]]-我撒谎了。这是自切片面包以来最好的事情,特别是因为我知道你们游戏开发人员讨厌使用异常。任何返回不应被忽略的值(例如错误代码)的函数现在都可以使用此属性进行修饰,如果您忘记检查重要的返回值,编译器会发出警告。我一直在使用这个功能,无论是在工作中还是在我的个人项目中。它是救命稻草。

结构化绑定-我也知道您喜欢普通和平面结构类型。我也是。它们快速、简单、易于使用。C++17使它们变得更好,因为您现在可以分解它们的成员并为它们指定单独的名称。

我相信双方都可以从对方身上学到很多东西,我为一条推文所经历的负面和负面影响(这只是我个人对现代C++特性的观察)表明了情况是多么令人难过。

我还强烈认为,每一位对现代C++持怀疑态度的游戏开发人员都应该停止错误地认为标准库是现代C++,而应该尝试一下我上面列出的所有功能。我保证它们不会影响您的编译时间,并且它们很可能会对您的代码的质量、可读性和安全性产生积极的影响。

最后,我希望这篇帖子能拉近双方的距离,而不是制造一场新的垃圾风暴--这不是我的本意。我喜欢一些竞争,我也喜欢一些争论.。但有时这太过分了,我们互相大声表达意见所带来的乐趣至少应该让双方都有所收获。

..