对于游戏引擎开发者和维护者来说,如何高效、便捷、低成本地维护引擎,使引擎能够快速优化更新,构建系统能够兼容多种开发平台和工具,是一个永恒的问题。
在GDC2023中,来自网易游戏《eve online: EVE Echoes》的引擎程序员KK分享了他们是如何借助开源软件优化自研引擎NeoX用于游戏开发的,并取得了不错的效果。
以下是演讲摘要:
大家好。我今天分享的话题是“开源软件对引擎开发的帮助”。这个分享分为两部分。第一部分是如何利用开源软件优化传统引擎。主要内容是如何将引擎移植到开源的建筑系统Bazel上。有了Bazel,我们显著减少了构建时间,提高了开发效率。
在第二部分,我还将分享我们如何更好地与开源社区合作的经验。
通过这次分享,我希望向大家展示开源工具是非常强大的。它们可以大大提高游戏引擎的开发效率。更重要的是,在某些情况下,开源软件几乎是不可或缺的。由于开源的特性,我们可以对工具本身进行修改,以满足我们在游戏开发中遇到的特殊而复杂的需求。
我先介绍一下背景。我来自eve online的团队:EVE Echoes。这是一款科幻背景的too 空游戏,由网易和CCP Games联合开发。游戏开发使用网易自研引擎NeoX。网易的爆款游戏也有使用NeoX引擎开发的,比如《明日之后》、《第五人格》等等。NeoX引擎在网易有着悠久而成功的历史,但和其他很多历史悠久的代码仓库一样,NeoX也不可避免地背负着一些“技术债”。游戏行业的技术日新月异。为了与时俱进,eve online团队从去年开始逐步更新迭代引擎。
1.为什么要用开源软件Bazel来优化传统引擎?
首先,我们需要做的是优化施工时间。
NeoX是一个多功能的跨平台游戏开发引擎,但是自带的构建时间相对较长。例如,在采用六核酷睿i7处理器的Windows上,我们引擎的完整构建时间为27分钟。在4核酷睿i7 Macbook Pro上编译iOS版本需要24分钟。Android平台的构建时间也差不多。如果我们更新一些与渲染相关的代码,增量构建时间大约是14分钟。
想象一下,一个简单的渲染器更改需要14分钟才能看到结果,这极大地影响了我们的开发效率。因此,我们希望在大规模重构代码之前优化构建时间。
相对于庞大的引擎,《eve online:没有余烬的银河系》的开发团队相对较小,我们没有独立的子团队负责不同的目标平台,所以我们希望新的建筑系统能够在所有平台上工作。
面临的挑战
以下是我们面临的挑战。eve online:没有余烬的银河主要目标平台是iOS和Android,我们主要开发平台是Windows。由于EVE Echoes是一款MMORPG游戏,所以我们还需要在服务器端,也就是Linux平台上,编译一些引擎代码,比如核心的战斗逻辑。换句话说,我们有四个不同的目标平台:iOS、Android、Windows和Linux。
为了构建iOS应用,我们需要使用macOS系统,这也是我们主要的Android构建平台。加上Windows和Linux,我们有三种不同的主机平台。
游戏开发往往非常复杂,需要Xcode、Android Studio、Visual Studio等集成开发环境提供的调试和分析工具。因此,新的构建系统需要能够与各种集成开发环境(IDE)集成,生成的二进制文件必须能够在这些IDE中进行调试和分析。
编程语言方面,主要使用C++,引擎使用部分ja代码与Android系统交互。同样,我们也有iOS集成的Objective C代码。
我们需要一个新的构建系统,它可以兼容三种操作系统,为四种目标平台构建我们的引擎,集成多种ide,支持我们使用的三种编程语言。
最重要的是,构建系统需要能够支持远程缓存或远程执行,从而减少构建时间。考虑到以上所有条件,这似乎是一个不可能完成的任务。
开源构建工具Bazel
但是经过一番研究,我们发现了Bazel,一个谷歌的开源建筑系统。Bazel网站首页写着:搭建并测试Ja、C++、Android、iOS、Go等语言平台,可以在Windows、macOS、Linux上运行。这个工具几乎像是为我们量身定做的。
事实上,在Bazel的远程缓存的帮助下,我们成功地将Windows平台的完整构建时间从27分钟缩短到了4分钟。因为更快的SSD,iOS和Android的完整构建时间可以进一步缩短到2.5分钟,这对我们的持续集成任务(CI jobs)有很大的帮助。此外,远程执行可以有效地减少增量构建时间。同样,以render的修改为例,iOS平台的构建时间从14分钟减少到4分钟。值得一提的是,这个构建是由三个MacMinis组成的“编译集群”完成的。如果我们向这个“集群”添加更多的设备,我们可以进一步提高增量构建的效率。
另一方面,考虑到如此复杂的使用场景,你可以想象即使是最好的开源工具也很难直接开箱使用,所以在下面的分享中,我将介绍我们的Bazel完整之旅。
二、如何将引擎迁移到开源软件Bazel
在此之前,我先简单介绍一下Bazel的工作原理。
Bazel的工作原理
在Bazel项目中,我们使用一个名为BUILD或BUILD的文件。Bazel描述构建目标。
这是一个用于构建小型数学库的构建文件。
Cc_library表示这是一个C或C++库的构造目标。
我们将其名称设置为“math ”,如果其他构建目标想要链接这个库,他们可以在其deps属性中添加这个“math”库。
hdrs属性包含所有头文件。
srcs属性包含所有源文件。
这个“数学”库本身有一个依赖,即“可移植性”,用来处理跨平台的开发逻辑。
大多数人对CMake比较熟悉,就是对应的CMakeList.txt文件。你可以和Bazel的构建文件对比一下。
构建文件的语言是一种Python方言:Starlark。Bazel的构建文件是独特的,因为它是声明性的,我们不能在构建文件中使用命令性的命令。
Bazel的设计主要是为了保证构造的“可重复性”,即对于相同的输入,总是会得到相同的输出结果。这是Bazel正确缓存构建结果的重要前提。例如,如果构建过程依赖于“date”命令,即使使用相同的输入文件,每次构建的结果也会不同,因此Bazel无法为您缓存结果。
Bazel的另一个独特功能是沙箱机制。为了解释这一点,假设我们的项目中有多个容器实现。我们定义了两个CC _ libraries,一个是hashmap,一个是set。我们分别定义它们,以便最小化增量构建时间。
在编译hashmap时,Bazel不会直接在源目录下编译,而是先创建一个沙盒目录,将hashmap头文件和源文件链接到这个目录下,然后在沙盒目录下编译。
这样做可以防止代码意外包含未列出的头文件,这对正确的增量构造非常重要,尤其是在涉及远程缓存时。
例如,如果我们不小心在hashmap.cpp中包含了set.h,并使用了它的一些函数,有了沙箱机制,编译就会失败,因为在沙箱中找不到set.h。如果没有沙盒机制,编译会成功,但是以后更新set.h时,构建系统不知道hashmap需要重新编译,可能会导致错误的构建结果。
这就是我们目前需要了解的关于巴泽尔的全部情况。
构建系统被迁移到Bazel。
下面是我们将构建系统迁移到Bazel的计划。
首先,我们创建所有的构建文件,以便可以使用 Bazel 构建我们的引擎。接着尝试将 Bazel 与我们正在使用的所有三个集成开发环境集成。最后,搭建远程服务器用于远程缓存和执行。
在使用Bazel之前,我们使用CMake生成原生Visual Studio、Xcode和Android Studio项目来构建我们的引擎。我们主要用柯南来管理第三方库依赖。我们的目标是在不影响开发进度的情况下,将构建系统从CMake无缝迁移到Bazel。
我们从依赖树的底部节点开始。下图是我们引擎的内部依赖关系树的简化版本。顶部是一个名为client的可执行目标。它依赖于其他库,如“世界”和“渲染”。
在依赖树的底部是通用工具库和可移植库。因为它们没有更多的内部依赖,所以可以很容易地在Bazel中构建。
我们来写一下portable的构建文件,用Bazel编译。
在我们成功地用Bazel构建了portable之后,我们需要允许CMake项目中的其他目标链接它。
CMake的& # 34;进口& # 34;Ku。我们可以在CMake项目中导入Bazel生成的二进制文件。这样,CMake项目中的其他库可以链接到它。
我们还想在CMake项目中自动触发Bazel构建。这里我们用的是add_custom_target,也就是我们定义了一个自定义的目标,当这个目标构建好了,就会触发Bazel build命令来触发Bazel构建。我们将导入的库portable设置为依赖于这个定制的目标portable_bazel_build,这样每当我们需要导入portable时,就会执行相关的bazel build命令。
现在我们可以在Bazel中构建“可移植的”,并成功地将其链接到CMake项目。我们需要做的就是从下到上重复这个过程,直到整个引擎可以用Bazel编译。
迁移过程示意图
在实践中,我们遇到的第一个问题其实来自柯南,而不是巴泽尔本身。
使用Bazel构建时,我们还需要第三方依赖。理论上我们可以用Bazel直接从源代码编译所有的第三方依赖管理,Bazel也非常适合这种模式。但是我们没有太多时间为我们所有的依赖项编写Bazel构建文件。此外,NeoX引擎团队一直使用柯南来管理所有第三方依赖和升级。我们不想花额外的时间单独维护第三方依赖,所以我们需要整合Bazel和Conan。
幸运的是,柯南提供了柯南巴泽尔发电机。和CMake一样,Bazel也支持使用cc_import导入预编译的lib。
柯南的Bazel build生成器是这样工作的。首先,它从Conan配置文件中读取所有依赖项。然后从服务器下载所有需要的库并保存在磁盘上。然后生成器会自动为所有第三方库生成相应的构建文件。所以你可以在Bazel项目中使用它们。
不幸的是,Bazel构建生成器当时仍处于实验阶段,它无法为我们的一些依赖项正确地生成构建文件。
如果柯南不是开源软件,我们需要软件厂商来解决这个问题。考虑到同时使用柯南和巴泽尔并不是一个共同的要求,我们可能要等很长时间才能解决这个问题,或者根本不会修复。即使在最好的情况下,我们的整个迁移进度也会受到影响。
好在柯南是开源工具,我们可以自己修复bug。
比如我们遇到的一个具体问题,就是在导入一个DLL库的时候,我们需要设置另一个属性,叫做“interface_library”。
它其实非常容易添加,所以我们为此创建了一个Pull Request(以下简称PR)。
我们还修复了另一个与lib文件的路径解析相关的问题。
在修复Bazel构建生成器之后,迁移进展顺利。我们遇到的大部分问题都和发动机本身有关。两个月后,我们得到了第一全由巴泽尔制造的发动机。
将Bazel集成到集成开发环境中
现在我们需要将Bazel集成到集成开发环境中。
对于Visual Studio项目,我们使用开源工具Lender。Lender生成一个包含所有源文件和编译器参数的Visual Studio项目。但是构造实际上是通过调用Bazel命令来完成的。这是Lender生成的Visual Studio项目文件。项目仍然是用Bazel构建的,但是Visual Studio可以正确获取源代码和生成库的位置,因此可以调试和分析库。
我们还提供了所有的预处理器定义和头文件搜索路径,这样代码智能就可以正常工作。
可惜薰衣草已经不保养了,还有些bug。例如,在某些情况下,代码智能将无法正常工作。但在分享的这一点上,相信有些观众可能已经猜到了。由于薰衣草是开源的,我们可以自己修复这些bug。
其实关于代码智能的bug原仓已经有PR了。于是我们创建了一个fork(个人项目分支),合并了相应分支的内容。此外,我们还做了一些小的更新,例如根据Bazel项目层次结构组织Visual Studio中的文件夹结构。经过改装,我们解决了拉文德的问题。这是拉文德生成的Visual Studio项目。
Xcode项目有一个类似的工具,叫做Tulsi。它的工作方式与薰衣草相似。在Tulsi生成的Xcode项目中,编译仍然由Bazel完成。值得一提的是,Tulsi最近被另一个更强大的工具rules_xcodeproj取代,但我们仍然在使用Tulsi。
我们在Xcode上遇到的唯一问题是调试远程编译库。我将在谈到远程执行时详细讨论这一点。
Bazel在Android Studio中的工作方式略有不同。Google和IntelliJ合作维护一个开源的IntelliJ Bazel插件。这个插件允许我们在所有IntelliJ集成开发环境中导入Bazel项目,包括Android Studio。工作原理简单直接。
我们也遇到过类似Xcode的远程编译器库的调试问题,用类似的方法解决了。
远程缓存和执行
现在,我们可以在Bazel中构建引擎,并使用集成环境调试它。要讨论的最后一个问题是远程缓存和执行。
关于远程缓存和远程执行,我认为Bazel的开源策略非常聪明。谷歌没有直接开源Bazel远程服务器。我觉得这是因为Google内部使用的Bazel服务器使用了很多Google内部的基础设施,所以很难直接开源。即使谷歌有,这个服务器对大多数Bazel用户来说也太复杂了。
谷歌的策略是开源远程API。Bazel使用这个API与远程服务器进行通信,以实现远程缓存和执行。每个人都可以使用这个API来创建自己的远程服务器。
我个人认为这是一个非常成功的策略。目前已经实现了很多远程服务器,而且很多都是开源的。当我们试图将远程缓存添加到项目中时,我们在短时间内成功地使用bazel-remote构建了一个只支持缓存的服务器。
另一方面,其他构建系统也开始使用Bazel远程API,这意味着这些构建系统可以使用远程API与任何远程服务器进行对话。
Bazel的远程API非常简洁。API协议是基于gRPC的,文档非常详细。其实我需要删除很多评论才能做出下面的截图。
目前Bazel remote API提供的服务主要有:接受执行请求的执行服务和存储构造操作结果的缓存服务。请注意,它并不存储由构建生成的实际输出文件(工件),而只是保存对它们的引用。
输出文件和输入文件一起存储在内容可寻址存储器(CAS)中。
每个文件都被一个摘要引用。摘要由文件的哈希值和文件大小(以字节为单位)组成。
如果设置了一个远程服务器,当试图构建一个目标时,Bazel客户机将首先询问Action Cache服务中是否有缓存。如果是这样,Bazel客户机将根据缓存中的文件引用,尝试从内容可寻址存储器下载缓存的结果。如果缓存丢失或下载失败,Bazel客户机将把所有输入文件上传到内容可寻址存储器,然后请求远程执行构建目标。
执行服务将从内容可寻址存储器获取所有输入文件,执行构建命令,将所有生成的结果上传到内容可寻址存储器,将操作结果上传到操作缓存服务,然后将结果返回给Bazel客户端。
最后,Bazel客户端下载内容可寻址存储器中的所有输出文件。
远程服务器也可以选择只实现ActionCache服务和ContentAddressCache服务,我们会得到一个只提供缓存功能的服务器。在这种情况下,Bazel客户端负责构建目标并上传结果。
正如我前面提到的,我们在很短的时间内完成了缓存服务器的部署。我们使用bazel-remote,这是一个用Go语言编写的缓存服务器。
通过使用远程缓存,可以节省构建过程中的大部分时间,特别是对于持续集成的自动化任务。例如,我们的平均检查时间从大约40分钟缩短到7分钟。但是我们仍然希望通过远程执行来进一步减少增量构建时间。
远程执行的部署更加复杂。对于缓存,我们只需要一个服务器来提供在构造期间生成的所有输出结果,而不管这些请求在什么平台上执行。
对于远程执行,一定要注意这些操作所在的执行平台。我们需要使用不同平台上的远程工作人员来执行这些操作,这意味着我们需要一台可以同时支持MacOS、Windows和Linux的远程服务器。
Bazel用户大多来自互联网行业,Linux是目前互联网开发环境下最常用的系统。因此,目前大多数远程服务器都支持Linux。因为MacOS平台经常用于构建IOS应用程序,所以一些远程服务器也支持MacOS。但是对于窗口平台,我们就没那么幸运了。Bazel在Windows中的应用并不广泛,远程执行就更少了。
我们尝试使用Buildbarn,一个用Go语言编写的开源远程服务器。
Buildbarn的一个优点是它可以支持多种类型的工人。它使用基于Linux上FUSE(用户空间中的文件系统)和MacOS上NFSv4(网络文件系统版本4)的工作器。两者都使用虚拟文件系统(VFS)。
基于VFS的工作者有一个很大的优势:他们只抓取实际需要被读取来进行编译的源文件。这是什么意思?假设我们有这样一个数学库和一个名为“render.cpp”的编译单元。这个编译单元依赖于数学库,但只需要包含matrix.h当我们使用基于虚拟文件系统的远程工作器时,顾名思义,工作器会为这个编译动作生成一个虚拟文件系统,实际上不会从内容可寻址存储器下载任何文件。只有当编译器真正需要打开文件时,VFS才会从内容寻址存储器中获取相应的文件。
这样,远程执行工作者可以大大减少设置编译操作的所有输入文件所需的时间。没有VFS,编译器需要获得所有可能用作源代码的文件。这是Buildbarn支持的第三种worker:native worker。但是在处理NeoX这样的大型项目时,每次编译操作都需要花费大量的时间来下载所有的输入文件,所以远程服务器方案并不实用。
不幸的是,Buildbarn没有VFS的Windows工作人员。Windows本身有一个非常好的VFS API,叫做ProjFS。微软用它实现了GitVFS。但由于Windows并不是Web行业发展中最常用的构建环境,因此在Windows上并没有相应的Buildbarn可用实现。
幸运的是,Buildbarn是一个开源工具。因为它支持多种工作器,所以它有一个非常简洁的API,用于工作器和任务调度器之间的交互。就像远程API一样,这个API基于gRPC协议,并且有一个非常标准化的文档。它只有210行Protobuf代码、一个服务、一个远程调用协议(RPC)和四个消息定义。这意味着我们可以自己构建一个定制工人。
目前市场上有支持Windows的商业远程构建服务,我们也相信它们能够提供很好的服务,但是现阶段我们更倾向于使用开源软件,因为我们可以修改开源软件来满足我们的任何需求。所以我们决定构建自己的Windows worker。
我们内部经常用Python编程,所以这个worker也是用Python内置的。我们的第一个版本worker没有实现VFS,而是从另一个Bazel服务器Buildfarm学习缓存输入目录,这样就没有必要每次都重新下载整个输入树。我们最终用2840行Python代码构建了一个worker,不包括注释、空行和测试。当然,这个计划并不完美。毕竟第一次构建项目,还是要花一些时间去获取大量的源文件。但是,用这个工人来加快施工速度,对我们来说是完全够用的。
如果你有兴趣,你可以看看这个仓库:https://github.com/kkpattern/bb-remote-execution-py.
第三,关于开源工具
我们正在使用更多的开源工具来帮助我们开发引擎。
例如,我们使用clang-format来自动格式化引擎代码;AddressSanitizer(ASan)用于检测内存访问错误。这两个工具都来自LLVM。我们的大多数持续集成管道都在Kubernetes集群中运行。我们还广泛使用Jenkins进行持续集成。我们的许多内部服务都是用FastAPI构建的。
这个分享的重点不是介绍一个具体的开源软件,因为不同的项目有不同的需求,需要不同的工具。我们想通过分享我们的项目经验,向大家展示开源软件在游戏开发中的帮助,我们也愿意尝试使用开源软件。
在我接触这个项目之前,我们没有太多与开源社区合作的经验。虽然我们使用了很多开源库和工具,但是之前并没有更多的参与开源社区。在这次经历中,我们也学到了很多如何与开源社区合作的知识,所以想和大家分享一下,希望能帮助大家更好的融入开源社区。
敢于提问。俗话说“没有愚蠢的问题,只有愚蠢的答案。”需要别人解答你的疑惑是很正常的。很多开源软件都有专门针对新人的邮件列表或者slack频道。如果你对一个开源软件有任何问题,你可以在这些地方随意提问。
问题得不到快速回答是很常见的,这并不意味着你在社区不受欢迎。许多人从工作中抽出时间来建立一个开源社区。他们可能没有足够的时间及时回答你的问题,这是正常的。如果你的一个问题没有得到回答,请不要气馁,当你有新的问题时,不要犹豫。
不要错过任何探索开源工具的机会。很多开源软件可能不能完美满足你的需求,或者不能开箱即用。但是开源意味着你可以随时更新和修改工具来满足你的需求。所以,当一个工具不能满足你的需求时,不妨看看它的源代码和实现,说不定还能给它添加新的功能。
最后,在修复错误或实现新功能后,记得回馈社区。其实这样做对自己也大有好处。很多开源工具都在不断更新,有些更新可能会破坏你添加的新功能。如果你把这个功能贡献回开源社区,加上一些单元测试,会大大降低这个功能在以后的更新中被破坏的几率。无论如何,为开源社区贡献资源总是一件好事。当你基于一个项目创建了一个fork,开发了一个很好的新功能,请尽量回馈社区。
像你的问题一样,你提交的PR可能不会被及时审核和合并。也不要气馁。如果实在无法将代码暴露给开源社区,可以尝试在项目中搭建一个管道,定时(比如每天)抓取最新的上游代码,合并到你的内部分叉中,然后执行管道,看是否能通过你的内部测试。这样,如果上游代码的更新导致你的代码无法运行,你可以马上知道。你甚至可以使用类似“git等分”这样的工具来找出到底是哪些代码变更使得代码无法运行。然后你就可以把你的问题及时汇报给上游的项目,让他们在下一个版本之前修复。这样做有助于你以后的fork更新。
这就是我们今天分享的内容。希望这篇分享能让你感受到开源工具的强大,理解它们在工作流中的关键作用。您可以修改开源工具来满足各种特殊需求。如果你已经在使用一些开源工具,记得在社区中多互动,多提问,创建拉请求。在开源工具变得越来越强大的同时,让你的项目得到优化。
在分享的最后,我要感谢《eve online:一条没有余烬的银河》的整个团队,感谢他们在整个旅程中的耐心、支持和建议。
也要感谢NeoX引擎团队的宝贵意见。
最后,我要感谢开源社区。没有这些伟大的开源社区,我们无法完成这项艰巨的任务。
免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。