【GDC2023干货分享】开源软件在引擎开发中的帮助

学习委员

2023-04-2428292次浏览

2评论

4收藏

2点赞

分享

全球游戏开发者大会(Game Developers Conference,简称GDC)是具有全球最高影响力的游戏行业峰会,也是全球最大的游戏开发者聚集地,为游戏开发人员提供一个交流、学习和展示游戏技术的场所。

GDC2023中,网易互娱共有8个提案重磅入围。

对于游戏引擎开发者和维护人员来说,如何高效、便捷、低成本地进行引擎维护,让引擎能够得到快速优化和功能更新,并使其构建系统能够兼容多个开发平台和工具,是一个亘古不变的难题。

在GDC2023中,来自网易游戏《星战前夜:无烬星河(EVE Echoes)》团队的引擎程序师KK分享了他们是如何借助开源软件,对自研引擎NeoX做和游戏开发适配的针对性优化,并取得良好效果的。


以下为演讲摘选(根据英文演讲翻译整理)

大家好,今天我分享的主题是《开源软件在引擎开发中的帮助》。本次分享分为两部分,第一部分是如何使用开源软件来优化传统引擎;其中的主要内容是我们如何将引擎迁移至开源构建系统 Bazel。利用Bazel,我们显著减少了构建时间,提升了开发效率。

第二部分,我还会分享我们在这过程中学到的如何更好的与开源社区合作的经验。

通过这次分享,我希望能向你们展示开源工具是非常强大的。它们能够极大的提升游戏引擎的开发效率。更重要的是,在一些情况下,开源软件几乎是不可或缺的。因为开源的特性使得我们可以对工具本身进行修改,从而满足我们在游戏开发中遇到的特殊而复杂的需求。

 

先介绍下背景,我来自《星战前夜:无烬星河(EVE Echoes)》团队。这是一款科幻背景太空游戏,由网易和 CCP Games 合作研发,游戏开发用的是网易的自研引擎NeoX。网易旗下多个爆款游戏也使用了NeoX 引擎开发,如《明日之后》《第五人格》等等。NeoX引擎在网易有着悠久而成功的历史,但是就像很多其他拥有着较长历史的代码仓库一样,NeoX也难免背负着一些“技术债”。游戏行业技术日新月异,为了与时俱进,星战前夜团队自去年开始逐渐的更新迭代引擎。

 

为什么使用开源软件Bazel来优化传统引擎

首先,我们要做的就是优化构建时间

NeoX 是一个功能丰富的跨平台游戏开发引擎,但随之而来的是相对较长的构建时间。举例来说,在配备六核 Core i7 处理器的 Windows 上,我们引擎的完整构建时间为 27 分钟。而在 4 核 Core i7的Macbook Pro 上编译iOS版本时则是 24分钟。安卓平台的构建时间也差不多。如果我们更新一些与渲染相关的代码,增量构建时间约为 14 分钟。

想象一下,一个简单的渲染器更改需要 14 分钟才能看到结果,这极大地影响着我们的开发效率。因此,在大规模重构代码之前,我们希望首先优化构建时间。

和庞大的引擎相比,《星战前夜:无烬星河》拥有一个相对较小的开发团队,我们没有独立的子团队来负责不同的目标平台,因此我们希望新的构建系统能够在所有的平台上工作。

 

面临的挑战 

以下是我们所面临的挑战。《星战前夜: 无烬星河》的主要目标平台是 iOS 和Android,同时我们的主力开发平台为Windows;由于 EVE Echoes是一款 MMORPG游戏,我们还需要在服务器端,也就是Linux平台上,编译部分引擎代码,比如核心的战斗逻辑。也就是说,我们有 iOS 、Android、Windows、Linux四个不同的目标平台。

为了构建iOS应用,我们需要使用macOS系统,这也是我们的主要安卓构建平台。加上Windows和Linux,我们有三个不同的host平台。

游戏开发往往是非常复杂的,因此我们需要Xcode、Android Studio和Visual Studio等集成开发环境提供的调试和分析工具,因此新的构建系统需要能够与各种集成开发环境(IDE)进行集成,其生成的二进制文件必须可以在这些IDE 中进行调试和性能分析。

编程语言方面,我们主要使用了C++,引擎使用了部分java代码用于与Android系统交互。同样,我们也有用于iOS集成的 Objective C代码。

我们需要一个新的构建系统,能够兼容三个操作系统、面向四个目标平台构建我们的引擎,能够与多种IDE集成,并支持我们使用的三种编程语言。

最重要的是,构建系统需要能够支持远程缓存或远程执行,从而减少构建时间。考虑到上面的所有条件,这似乎是一个不可能完成的任务。

 

开源构建工具 Bazel 

但经过一番研究之后,我们发现了由Google 开源的构建系统 Bazel。Bazel网站首页上写着:构建和测试 Java、C++、Android、iOS、Go 和其他各种语言平台,可以在Windows、macOS 和Linux 上运行。这个工具几乎就像是为我们量身定做的。

事实上也是如此,借助Bazel的远程缓存,我们成功地将 Windows 平台的完整构建时间从 27 分钟降到 4 分钟。因为更快的SSD, iOS 和 Android 的完整构建时间甚至可以进一步缩短到了 2.5 分钟,这对我们的持续集成任务(CI jobs) 有着显著帮助。 此外,远程执行可以有效地减少增量构建时间,同样以对render的修改为例,面向iOS平台的构建时间从 14 分钟减少到了4 分钟。值得一提的是,这个构建是由3台mac mini所组成的“编译集群”完成的。如果我们向这个“集群”中添加更多的设备,我们还能进一步提高增量构建的效率。

另一方面,考虑到如此复杂的使用场景,你可以想象到,再优秀的开源工具都很难做到直接开箱即用,因此,在接下来的分享中,我将介绍我们迁移至Bazel的完整旅程。

 

如何将引擎迁移至开源软件 Bazel

在此之前,让我简要介绍一下 Bazel 的工作原理。 

 

Bazel 的工作原理

在 Bazel 项目中,我们使用名为 BUILD 或BUILD.Bazel 的文件来描述构建目标。

这是一个用于构建一个小型数学库的构建文件。

- cc_library表示这是一个C或C++库的构建目标。

- 我们将其名字设置为“math”,其他构建目标如果想要链接这个库,可以通过在其deps属性添加这个“math”库。

- hdrs属性包含所有的头文件。

- srcs属性包含所有的源码文件。

- 这个“math” 库本身也有一个依赖,也就是“portable”,用于处理跨平台开发逻辑。

大部分人更熟悉CMake,这是与之对应的CMakeList.txt文件,大家可以将其和Bazel的BUILD文件进行对比。

BUILD 文件使用的语言为一种Python方言: Starlark。Bazel 的构建文件的独特之处在于它是声明式的,我们不能在BUILD文件中使用命令式命令。

Bazel这样设计主要是为了保证构建的“可重复性”,即对于相同的输入,始终会得到相同的输出结果。这是Bazel能够正确缓存你的构建结果的一个重要前提。例如,如果构建过程依赖于 “date” 命令,即使使用相同的输入文件,每次构建的结果也会不同,而这样 Bazel 就无法为你缓存结果。

Bazel 的另一个独特功能是 Sandbox 机制。为了解释这一点,假设我们的项目中有多个容器的实现。我们定义了两个 cc_library,一个是 hashmap, 一个是 set。我们对它们分别定义,以便可以最大限度地减少增量构建时间。

在编译 hashmap 时,Bazel 不会直接在源目录下进行编译,而是先创建一个 sandbox 目录,将 hashmap 头文件和源文件链接到该目录,然后在 sandbox 目录下进行编译。

这么做可以防止代码意外include未列出的头文件,对于正确的增量构建非常重要,尤其是在涉及远程缓存时。

例如,如果我们不小心在了hashmap.cpp中include了set.h,并使用了其中的一些函数,在有sandbox机制的情况下,编译会失败因为在sandbox中找不到set.h。如果没有sandbox机制则编译会成功,但当未来set.h进行更新时,构建系统不知道hashmap需要被重新编译,从而可能导致错误的构建结果。

以上就是我们目前所需要了解的关于 Bazel 的信息。

 

构建系统迁移到 Bazel

以下是我们将构建系统迁移到 Bazel 的计划。

- 首先,我们创建所有的构建文件,以便可以使用 Bazel 构建我们的引擎。

- 接着尝试将 Bazel 与我们正在使用的所有三个集成开发环境集成。

- 最后,搭建远程服务器用于远程缓存和执行。 

在使用 Bazel 之前,我们使用 CMake 生成原生的 Visual Studio、Xcode 和Android Studio工程来构建我们的引擎。我们主要使用 Conan 来管理第三方库依赖。我们的目标是在不影响开发进度的情况下,将构建系统从 CMake 无缝迁移到 Bazel。

我们从依赖树的底层节点开始。下图是我们引擎内部依赖树的简化版本。顶部是名为 client 的可执行目标。它依赖于其他库,如“world”和“render”。

在依赖树的底部是通用的utility lib (通用工具库)和 portable lib (可移植库)。因为它们没有更多的内部依赖项,所以可以轻松的在 Bazel 中构建。

我们先编写portable的构建文件并使用Bazel对其进行编译。 

在我们使用 Bazel 成功构建 portable 之后,我们需要允许CMake 项目中的其他目标能够链接它。

这里要用到 CMake 的"IMPORTED" 库。我们可以在 CMake 项目中导入 Bazel 生成的二进制文件。这样一来,CMake 项目中的其他库就可以链接到它。

我们还希望在 CMake 项目中自动触发 Bazel 构建。在这里我们使用add_custom_target,即定义一个自定义目标,当这个目标被构建时会触发bazel build命令来触发 Bazel 构建。我们设置IMPORTED库portable依赖于这个自定义目标portable_bazel_build,这样每当我们需要import portable时,相关的bazel构建命令就会被执行。

现在我们可以在 Bazel 中构建 “Portable” 并成功链接到 CMake 项目中。我们需要做的就是自下而上重复这个过程,直到整个引擎都可以使用Bazel进行编译。

迁移流程示意图

在实践中,我们遇到的第一个问题实际上是来自 Conan ,而不是 Bazel 本身。

使用Bazel构建时,我们同样需要第三方依赖。理论上我们可以使用Bazel直接从源码编译所有的第三方依赖管理, Bazel也非常适合这种模式。但是我们没有太多时间为我们所有的依赖项编写 Bazel 构建文件。此外,NeoX 引擎团队已经在使用 Conan 管理所有第三方依赖项和升级了,我们不希望花费额外的时间单独维护第三方依赖,因此我们需要实现 Bazel 和 Conan 的集成。

好在 Conan 提供了 Bazel 的构建生成器(Conan Bazel Generator)。和 CMake一样,Bazel也支持使用cc_import 导入预编译的lib。

Conan 的 Bazel 构建生成器工作原理是这样的。首先其从 Conan 配置文件中读取所有依赖项。接着从服务器下载所有需要的库,并将它们保存在磁盘上。然后生成器会为所有的第三方库自动生成对应的BUILD文件。这样你就可以在Bazel工程中使用它们了。

不幸的是 Bazel 构建生成器在当时还处于试验阶段,无法为我们的某些依赖正确生成BUILD文件。

如果 Conan 不是开源软件,我们就需要软件供应商来解决这个问题。考虑到同时使用 Conan 和 Bazel 并不是一个常见的需求,我们可能需要等待很长一段时间这个问题才能被解决,或者根本不会被修复。即便是在最好的情况下,我们的整个迁移进度也会受到影响。

好在 Conan 是一个开源工具,所以我们可以自行修复 bug。

例如,我们碰到的其中一个具体问题是,在导入 DLL 库时,我们需要设置另一个名为“interface_library”的属性。

添加起来其实很容易,所以我们为此创建了一个合并请求(Pull Request,下文简称PR)。

我们还修复了另一个lib 文件路径解析相关的问题。

在修复了 Bazel 构建生成器之后,迁移工作进展顺利。我们遇到的大多数问题都与引擎本身有关。两个月后,我们得到了第一个完全使用 Bazel 构建的引擎。

 

将 Bazel 集成到集成开发环境

现在我们需要将 Bazel 集成到集成开发环境中。 

针对 Visual Studio项目,我们使用了开源工具 Lavender 。Lavender 会生成一个包含所有源文件和编译器参数的 Visual Studio 项目。但构建实际上是通过调用 Bazel 命令完成的。这是 Lavender 生成的 Visual Studio 项目文件。项目仍然是用 Bazel 构建的,但是 Visual Studio 能够正确获取源代码和生成的库的位置,就可以对库进行debug和分析。

我们还提供了所有预处理器定义和头文件搜索路径,使得 Code Intelligence 可以正常工作。

不幸的是,Lavender 已经不再维护,而且还存在一些 bug。例如在某些情况下,Code Intelligence 会无法正常工作。不过分享进行到这里,我相信有的观众可能已经猜到了,由于 Lavender 是开源的,我们可以自己修复这些 bug。

实际上,关于Code Intelligence的bug原仓库已经有一个PR。所以我们创建了一个 fork (个人项目分支) ,合并了对应分支的内容。此外,我们还进行了一些微小更新,比如根据 Bazel 项目层次结构,在 Visual Studio 中相应组织文件夹结构。经过修改后,我们解决了 Lavender 的问题。这是 Lavender 生成的 Visual Studio 项目。 

对于Xcode 项目也有类似的工具,叫做 Tulsi。它的工作原理和 Lavender相似。在Tulsi生成的 Xcode 工程中,编译仍旧是通过 Bazel 完成的。值得一提的是Tulsi最近被另一个更强大的工具rules_xcodeproj替换了,不过我们目前仍然在使用Tulsi。

我们在 Xcode 上遇到的唯一问题是对远程编译库的调试。这个我在讲到远程执行时,会详细讨论。

Bazel 在 Android Studio 中的工作方式略有不同。谷歌和 IntelliJ 合作维护一个开源的 IntelliJ Bazel 插件。这个插件允许我们在所有 IntelliJ 集成开发环境中导入 Bazel 项目,包括 Android Studio。工作原理简单直接。

我们也遇到了和Xcode类似的远程编译库调试问题,并用类似方式解决了。 

 

远程缓存和执行

现在,我们已经可以在 Bazel 中构建引擎并使用集成环境对其进行调试。最后一个要探讨的问题是远程缓存和执行。 

关于远程缓存和远程执行,我觉得 Bazel 的开源策略是很聪明的。谷歌没有直接开源一个Bazel远端服务器,我认为这是因为谷歌内部使用的Bazel服务器使用了大量谷歌内部的基础架构,因此很难直接开源。即使谷歌做到了,这个服务器对于绝大部分Bazel用户来说也太复杂了。

谷歌的策略是开源远程 API。Bazel 使用此 API 与远程服务器通信来实现远程缓存和执行。每个人都可以使用此 API 创建自己的远程服务器。

我个人认为这是一个非常成功的策略,目前已经有很多远程服务器实现了,并且很多都是开源的。当我们尝试给项目添加远程缓存时,只用了很短的时间内就成功使用 bazel-remote 搭建了一个仅缓存服务器。

另一方面,其它的构建系统也都开始使用 Bazel 远程 API,也就意味着这些构建系统都可以使用远程 API 与任意这些远程服务器进行对话。

 

Bazel 的远程 API 非常简洁。API 协议基于 gRPC,文档也非常详细。事实上我需要删除大量注释才能做成下面的截图。

Bazel 远程API 目前主要提供的服务包括:用于接受执行请求的执行服务(Execution),用于存储构建操作结果的缓存服务(action) 。请注意,它不存储构建生成的实际输出文件(artifacts),只是保存对它们的引用。

输出文件和输入文件一起存储在内容可寻址存储(Content-addressable storage (CAS) )中。

每个文件都通过摘要(digest)来引用。摘要由文件的哈希值和文件大小(以字节为单位)组成。

如果设置了远程服务器,在尝试构建目标时,Bazel 客户端(Client) 会先询问操作缓存服务(Action Cache Service) 是否已经有缓存。如果有,Bazel 客户端会根据缓存中的文件引用,尝试从内容可寻址存储中下载缓存的结果。如果缓存丢失或者下载失败,Bazel 客户端会将所有输入文件上传到内容可寻址存储中,然后请求远程执行构建目标。

执行服务将从内容可寻址存储中获取所有输入文件,执行构建命令,再将所有生成的结果上传到内容可寻址存储,将操作结果上传到操作缓存服务服务,然后将结果返回给 Bazel 客户端。

最后,Bazel 客户端在内容可寻址存储下载所有输出文件。

远程服务器也可以选择只实现 ActionCache 服务和 ContentAddressCache 服务,我们就会得到一个只提供缓存功能的服务器,这种情况下,由 Bazel 客户端负责构建目标并上传结果。

 

正如我前面提到的,我们在非常短的时间内就完成了缓存服务器的部署。我们用的是 bazel-remote,是用 Go 语言编写的缓存服务器。

通过使用远程缓存,构建过程中的大部分时间可以被节省下来,尤其是争对持续集成的自动化任务。例如我们的 clang-tidy 平均检查时间从大约 40 分钟缩短到 7 分钟。但我们仍然希望通过远程执行,进一步减少增量构建时间。

远程执行的部署要更复杂一些。对于缓存,我们只需要一个服务器来提供构建期间生成的所有输出结果,并不在乎这些请求执行在什么样的平台上。

对于远程执行,我们则必须需要关注这些操作所在的执行平台。我们需要在不同的平台上使用远程工作器(remote workers)来执行这些操作(actions),这也就意味着,我们需要一个可以同时支持 MacOS、Windows 和 Linux 的远程服务器。

大多数 Bazel 用户都来自互联网行业,而 Linux 是目前互联网开发环境中最常用的系统。因此,目前大多数远程服务器都支持 Linux。由于 MacOS 平台也常常用于构建 IOS 应用程序,因此有的远程服务器也支持 MacOS 。但对于 Window 平台,我们就没有那么幸运了。Bazel 在 Windows 上的应用相对不多,远程执行则被使用得更少了。

我们尝试了使用 Buildbarn,一个用 Go 语言编写的开源远程服务器。

Buildbarn 的一个优点是,它可以支持多种类型的工作器。它在 Linux 上使用基于 FUSE(Filesystem in Userspace) 的工作器,在 MacOS 上则是基于 NFSv4(Network File System version 4)的工作器。两者都使用的是虚拟文件系统(VFS)。

基于VFS的工作器都有一个很大的优势:只抓取编译实际需要读取的源文件。这是什么意思呢?假设我们有这样一个数学库,还有一个名为“render.cpp”的编译单元,这个编译单元对数学库有依赖,但它只需要include matrix.h。当我们使用基于虚拟文件系统的远程工作器时,顾名思义,工作器会为该编译动作生成一个虚拟文件系统,实际上不会从内容可寻址存储中下载任何文件。只有在编译器需要真正打开文件时,VFS 才会从内容可寻址储存中( Content-addressable storage )获取对应文件。

这样一来,远程执行工作器可以大大减少设置编译操作的所有输入文件所需的时间。如果没有 VFS,执行编译操作的工作器需要获取所有可能被用作源代码的文件。这也就是Buildbarn 支持的第三种工作器:本地工作器(native worker)。但是当处理像 NeoX 这样的大型工程时,每个编译操作都需要花费大量时间来下载所有的输入文件,远程服务器方案就不太实际了。

不幸的是,Buildbarn 在 Windows 上并没有基于VFS的工作器。Windows本身有一个很不错的 VFS API 叫 ProjFS。微软用其实现了GitVFS 。但由于 Windows 不是 Web 行业开发中最常用的构建环境,因此Buildbarn 在 Windows 上没有相应的可用实现。

幸运的是,Buildbarn 是个开源工具。由于它支持多种工作器,它有一个非常简洁明了的 API,用于工作器和任务调度器之间的交互。正如远程 API 一样,这个API 也是基于 gRPC 协议,并且有非常规范的文档。它只有 210 行 Protobuf 编码、一个Service、一个远程调用协议(RPC)、和四个信息定义(message)。这意味着我们可以自己搭建一个自定义的工作器。

目前市面上存在支持 Windows 的商业化远程构建服务,我们也相信它们可以提供很好的服务,但是到这个阶段,我们更倾向于使用开源软件,因为我们可以修改开源软件,满足我们的任何需求。因此我们决定构建自己的Windows工作器。

我们内部经常用 Python 编程,所以这个工作器也是用 Python 构建的。我们的第一版工作器并没有实现 VFS,而是学习了另一个Bazel服务器Buildfarm对输入目录进行缓存,这样就不用每次都重新下载整个输入树(tree)了。我们最后用 2840 行 Python 代码搭建了一个工作器,不包括注释、空行、和测试。当然这个方案还不够完美。毕竟在首次构建项目时,还是需要花费一些时间来获取大量源文件的。但是,利用这个工作器来加快构建对我们来说完全足够了。

如果你们感兴趣的话,可以看看这个仓库:https://github.com/kkpattern/bb-remote-execution-py

 

关于开源工具

我们正在使用更多的开源工具来帮我们开发引擎。

例如,我们使用 clang-format 来自动格式化引擎代码;利用AddressSanitizer(ASan)来检测内存访问错误。这两个工具都来自LLVM。我们的大部分的持续集成管线都是在Kubernetes 集群运行的。我们也大量使用 Jenkins 进行持续集成。我们有很多内部服务都是用 FastAPI 构建的。

本次分享的重点,并不是为了介绍某个特定的开源软件,因为不同项目的需求不同,需要的工具也不同。我们更想通过分享我们的项目经验,向大家展示开源软件对游戏开发的帮助,并愿意去尝试使用开源软件。

在我接触这个项目之前,我们在和开源社区合作方面并没有太多经验。虽然我们使用了很多开源库和开源工具,但我们之前并未更多的参与到开源社区之中。在这次经历中,我们也学到了很多如何跟开源社区相互合作的经验,所以我们想跟大家分享一下,希望可以帮助大家更好地融入开源社区。

1、勇于提问。常言道“没有愚蠢的问题,只有愚蠢的答案。”需要他人为自己解答疑惑是再正常不过的事情。很多开源软件都有专门面向新人的邮件列表或者slack频道等,如果你对一个开源软件有任何问题都可以放心的在这些地方进行提问。

2、问题没有很快得到回复是非常常见的,这不代表社区不欢迎你。很多人都是抽出自己工作之外的时间来建设开源社区,他们可能没有足够的时间及时的回答你的问题,这是非常正常的。如果你的一个问题没有得到答案,请不要气馁,同时在你有了新的问题时也不要犹豫。

3、不要错过任何探索开源工具的机会,很多开源软件可能无法完美的满足你的需求,或者无法做到开箱即用。但开源意味着你可以随时更新修改工具以满足你的需求。因此,当一个工具无法满足的你的需求时,不妨多看看他的源码和实现,或许你能为他添加新的功能。

4、最后,在修复了错误或实现新功能后,记得也要为社区做出回馈,事实上这么做对你自身也是大有益处的。许多开源工具都在不断更新,某些更新可能会破坏你所添加的新功能。如果你将该功能贡献回开源社区,添加上一些单元测试(Unit test),就会大大减少该功能在未来的更新中被破坏的机率。无论如何,为开源社区贡献资源总是一件好事。当你创建了基于某个项目的 fork,开发了不错的新功能,请尽量尝试将该功能回馈给社区。

5、和你的问题一样,你提交的PR可能也无法被及时review以及合入。同样不要气馁。如果你实在无法将代码公开到开源社区,可以试着在项目内建立一个管线,定期(如每天)抓取最新的上游代码,并将它合并到你的内部 fork 里,然后执行这个管线,看看是否能通过你的内部测试。这样一来,如果上游代码的更新导致你的代码无法运行,就能立刻知道。你甚至可以使用“git bisect”之类的工具来找出具体是哪些代码的变更导致代码无法运行。然后你可以及时向上游项目反映你的问题,这样他们就能在下一个版本之前进行修复。这样做有利于你的 fork 在未来的更新。

 

以上就是今天分享的内容。希望本次分享可以让大家感受到开源工具的强大,并了解到它们对于工作流起到的关键作用。你可以通过修改开源工具,来满足各种各样的特殊需求。如果你已经在使用一些开源工具,记得要多在社区内互动,多问问题和创建 Pull Request 。在开源工具变得越来越强大的同时,让你的项目也不断优化。

在分享的最后,我想感谢整个《星战前夜:无烬星河》团队,感谢他们在整个旅程中的耐心、支持以及建议。

我还想感谢 NeoX 引擎团队为我们提供的宝贵建议。

最后,我还要感谢开源社区。没有这些了不起的开源社区,我们也无法完成这项艰巨的任务。

评论 (2)

0/1000
网易游学APP
为热爱赋能
扫描二维码下载APP