DevOps:虚幻引擎的CI/CD实践

DevOps: CI/CD Practices for Unreal Engine

DevOps是一种思想,以自动化进行持续集成(CI)和持续部署(CD)为基础,优化开发、测试、运维等所有环节。强调软件开发测试运维的一体化,减少各个部门或流程之间的沟通成本从而实现快速高质量的发布和迭代。

虽然在游戏领域的开发情况和传统互联网有很大区别,但借用DevOps的概念把项目中所有的生产环节最大限度地接入自动化,能够大幅提升效率。

本篇文章会介绍,UE实现自动化的基础概念,及我在虚幻引擎自动化构建方面的一些尝试和实际的工程实践。

Forward

我认为,在游戏开发中进行自动化的目标,核心诉求为以下五点:

  1. 降低繁杂任务的人工参与
  2. 降低排查问题的成本,提高效率
  3. 快速迭代版本、产出可体验内容
  4. 参数化控制、流程可扩展

这些需求对应了开发过程中的各个阶段,后面我会针对这些诉求在UE中的实际痛点进行分析。在UE的就业市场中,甚至逐渐诞生了一种“构建工程师“的概念,专门处理项目中的构建需求。

在工作中,面对需要处理构建任务的工作,要持有一种深入研究的心态。因为它不仅仅只是打包一种事情这么简单,而是能够涉及到整个开发流程中的所有方面去做优化。发现瓶颈和痛点,优化和解决,价值自然能得到体现。

如果视野只是局限于打包这些浅层构建需要,无法深入积累对于技术人有很大劣势,积累自己很重要。

DevOps平台

所谓的DevOps平台,简单地说,是提供一个方便可编排的执行环境。能够把自定义的机器托管上去,编排执行顺序、拉起执行命令、收集结果和任务调度。

这方面的产品很多,开源的有Jenkins,Github有Github Actions。大公司里可能还有自研的CI/CD系统,如腾讯有蓝盾等等。

可以编辑需要执行的流水线:

本文不会赘述它们的部署,因为本质上来说它们只是编排流程和拉起执行命令的环境,并不是UE在CI/CD实践中的重点。

构建

Engine/Editor

在项目开发中,除了程序开发人员之外,对于策划或者美术同学,他们没有开发能力和代码需要,通常不会使用源码版引擎,而是会构建出一个二进制的引擎版本,供他们使用。

就像从EpicLauncher中安装的引擎那样:

在UE项目中实现CI/CD构建的一个方面,就是要方便地构建出二进制的引擎和工程Editor版本,让非程序同学在本地完全不考虑编译。并且这个二进制引擎是可以复用的,引擎代码没有变动则不需要重新构建,只增量编译工程。

这个我在文章BuildGraph:构建支持多平台打包的二进制引擎中做了详细介绍,已有的东西不再重复。

最终要实现的流程是这样一种逻辑:

每次执行都更新引擎和Client代码,构建出一个二进制的引擎和工程,并且处于安全的考虑,也可以剔除引擎和客户端的C++代码。

实现上简单来说,是使用BuildGraph从源码版引擎构建出一个二进制引擎版本:

1
RunUAT.bat BuildGraph -target="Make Installed Build Win64" -script=Engine/Build/InstalledEngineBuild.xml -set:WithMac=false -set:WithAndroid=false -set:WithIOS=false -set:WithTVOS=false -set:WithLinux=false -set:WithHTML5=false -set:WithSwitch=false -set:WithDDC=false -set:WithWin32=false -set:WithLumin=false -set:WithPS4=false -set:WithXboxOne=false -set:WithHoloLens=false -set:GameConfigurations=Development

然后用生成的二进制引擎去编译工程,就会只编译项目内的模块了:

1
2
3
4
Engine\Build\BatchFiles\Build.bat
-Target="XXXXEditor Win64 Development"
-Project="D:\Client\Client.uproject"
-WaitMutex

与在VS中启动编译执行的是相同命令。

传递编译参数

有时在编译二进制引擎和项目时,想要传递给UE参数,根据参数来控制编译行为。这样可以动态地控制一些功能的开关。
在编译引擎和编译客户端时传递编译参数,是两种情况。

在通过BuildGraph构建二进制引擎时,引擎默认的InstalledEngineBuild.xml不能给UBT指定参数。
可以自己拷贝一份InstalledEngineBuild.xml,在其上修改:

1
2
3
4
<Option Name="UBTArgs" DefaultValue="" Description="compile UE4Editor UBT Args" />
<Property Name="UseUBTArgs" Value="$(UBTArgs)" />
<!-- ... -->
<Compile Target="UE4Editor" Platform="Win64" Configuration="Development" Tag="#UE4Editor Win64" Arguments="-precompile $(AllModules) $(CrashReporterCompileArgsWin) -WaitMutex -FromMsBuild -NoXGE $(UseUBTArgs)"/>

在调用BuildGraph传递就可以了:-set:UBTArgs=

在编译Client项目时,可以直接在调用UBT编译工程的命令中追加新参数:

1
2
3
4
5
Engine\Build\BatchFiles\Build.bat
-Target="XXXXEditor Win64 Development"
-Project="D:\Client\Client.uproject"
-WaitMutex
-test

在build.cs或target.cs中可以获取UBT的命令行参数,对编译选项进行控制:

1
2
3
4
5
string[] CmdArgs = Environment.GetCommandLineArgs();
foreach (string CmdLineArg in CmdArgs)
{
Console.WriteLine("CmdLineArg: " + CmdLineArg);
}

编译时输出:

1
2
3
4
5
6
7
8
9
0>E:\UnrealEngine\Launcher\UE_4.26\Engine\Build\BatchFiles\Build.bat GWorldEditor Win64 Development -Project="E:\UnrealProjects\GWorld\GWorld.uproject" -WaitMutex -FromMsBuild -test
0>CmdLineArg: ..\..\Engine\Binaries\DotNET\UnrealBuildTool.exe
0>CmdLineArg: GWorldEditor
0>CmdLineArg: Win64
0>CmdLineArg: Development
0>CmdLineArg: -Project=E:\UnrealProjects\GWorld\GWorld.uproject
0>CmdLineArg: -WaitMutex
0>CmdLineArg: -FromMsBuild
0>CmdLineArg: -test

基于传递的参数可以动态地修改编译行为,比如添加宏、依赖的模块等。

而且,基于这种方式,影响的只是当前模块和依赖它的模块,并不需要完全重新编译,能充分里用未变动模块的编译缓存。

打包

打包,是UE构建中最基础的需求。在UE编辑器内,提供了两种方式用于打包的选项。
第一种是File-Package Project

第二种则是ProjectLauncher,可以编辑一些配置项,如Cook的平台、Cultures等选项:

但它们都是通过UAT拉起来BuildCookRun执行的,UAT它是一个独立的程序,相当于是一个任务调度器,拉起多个进程执行不同阶段地任务,用于统筹整个打包过程,并且可以控制跳过某些阶段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Engine\Engine\Build\BatchFiles\RunUAT.bat
-ScriptsForProject=D:/Client/Blank425.uproject
BuildCookRun
-targetplatform=Win64
-project=D:/Client/Blank425.uproject
-nocompileeditor
-nop4
-cook
-stage
-archive
-archivedirectory=D:/Client/Package
-package
-ue4exe=D:\GameEngine\Engine\Engine\Binaries\Win64\UE4Editor-Cmd.exe
-pak
-prereqs
-nodebuginfo
-build
-target=Blank425
-clientconfig=Development
-utf8output
-compile

当我们想要打包时,就可以执行上面的命令。

但当要把打包集成到CI/CD系统时还需要对它的参数进行封装,比如:

  1. 指定平台(Win64/Android/IOS等)
  2. Cook的平台(WindowsNoEditor/Android_ASTC/IOS等)
  3. Configuration(Debug/Development/Shipping)
  4. UBT编译参数
  5. 传递给CookCommandlet的参数
  6. 是否启用加密
  7. 输出路径

等等,所有需要进行动态控制的选项,都需要暴露成参数。

可以使用python等脚本语言做个封装:

传递编译参数

在BuildCookRun的命令中,可以通过-ubtargs=来指定传递给UBT的参数,用于在编译时进行动态控制行为。

1
2
3
4
5
6
7
Engine\Engine\Build\BatchFiles\RunUAT.bat
-ScriptsForProject=D:/Client/Blank425.uproject
BuildCookRun
-targetplatform=Win64
-project=D:/Client/Blank425.uproject
...
-ubtargs="-test1 -test2 -test3"

可以在UBT的调用命令里看到,参数传递过来了:

同样可以在build.cstarget.cs中获取:

1
2
3
4
5
string[] CmdArgs = Environment.GetCommandLineArgs();
foreach (string CmdLineArg in CmdArgs)
{
Console.WriteLine("CmdLineArg: " + CmdLineArg);
}

传递Cook参数

打包时,UAT在代码编译完成后,会拉起引擎执行CookCommandlet,用于处理资源。

UAT默认调用Cook的参数为:

1
2
3
4
5
6
7
8
9
10
11
12
13
D:\UnrealEngine\Source\UE_4.27.2\Engine\Binaries\Win64\UE4Editor-Cmd.exe
E:\UnrealProjects\BlankExample\BlankExample.uproject
-run=Cook
-TargetPlatform=WindowsNoEditor
-fileopenlog
-ddc=DerivedDataBackendGraph
-unversioned
-abslog=D:\UnrealEngine\Source\UE_4.27.2\Engine\Programs\AutomationTool\Saved\Cook-2023.04.15-10.08.49.txt
-stdout
-CrashForUAT
-unattended
-NoLogTimes
-UTF8Output

但是,该怎么通过UAT的命令,传递给Cook参数呢?

UE为UAT的BuildCookRun提供了一个参数来实现这种需求:-AdditionalCookerOptions=

1
2
3
4
5
6
7
Engine\Engine\Build\BatchFiles\RunUAT.bat
-ScriptsForProject=D:/Client/Blank425.uproject
BuildCookRun
-targetplatform=Win64
-project=D:/Client/Blank425.uproject
...
-AdditionalCookerOptions="-test1 -test2 -test3"

当UAT调用CookCommandlet时,就会传递这些参数:

1
2
3
4
5
6
7
8
D:\UnrealEngine\Source\UE_4.27.2\Engine\Binaries\Win64\UE4Editor-Cmd.exe
E:\UnrealProjects\BlankExample\BlankExample.uproject
-run=Cook
-TargetPlatform=WindowsNoEditor
...
-test1
-test2
-test3

它的作用,在于不用自己拆分和管理打包的过程,通过插件的形式接收这些参数,就能自动地介入到打包的过程中去。

实现类似下面这张图的效果:

我之前的几篇文章中都用到了这个方式:

除此之外,如果想要使用Unreal Insights分析打包过程中Cook的各部分耗时,同样可以传递Profiling参数:

1
2
3
4
5
6
7
Engine\Engine\Build\BatchFiles\RunUAT.bat
-ScriptsForProject=D:/Client/Blank425.uproject
BuildCookRun
-targetplatform=Win64
-project=D:/Client/Blank425.uproject
...
-AdditionalCookerOptions="-tracehost=127.0.0.1 -trace=cpu,memory,loadtime -statnamedevents implies -llm"


Cmdlet自动化

在UE的CI/CD实践中,有相当于部分需求,是自动地从引擎中进行数据的导入导出。通过命令行拉起引擎自动化处理资源等等。

我前几天写了一篇文章,介绍Commandlet的机制和工程技巧,可以详细查看:UE 插件与工具开发:Commandlet

以我博客中之前的文章中利用到Cmdlet自动化的为例:

Cmdlet自动化,能导出NavMesh数据、生成基础包数据、热更包、介入到CookCmdlet过程中自动执行包拆分、扫描资源规范等等。

充分利用Comdlet实现自动化,最大限度地在对资源处理等需求上无人工参与,降低出错概率,提升效率。


预检查

在工程实践中,通常也会针对跨平台代码编译和资源检查进行预处理,这样能够尽早暴露问题,不把有问题的部分遗留到打包和运行时、确保构建产出的稳定性。

编译

在绝大部分项目中,都是针对跨平台的开发。同一份工程,会出各个平台的包。最常见的就是Windows/Android/IOS等。

大多数情况下,对于”编译“而言,同一份代码要同时兼容以下平台:

  • Win64Editor
  • Win64
  • MacEditor
  • Android
  • IOS

但是因为系统环境、不同平台编译器的标准差异、警告级别、链接库差异和UE中不同Target的编译参数差异,同一份代码在Win64Editor编译的过,不见得就能在Android或IOS编译的过。

所以,在这种情况下,针对代码的跨平台编译检查是十分必要的,当代码变动之后,自动地把所有需要的平台编译一遍,检查是否有错误。

而每个平台实际上所执行的命令都是对UBT的封装:

1
2
3
4
5
# win为.bat, Mac上则为.sh
Engine\Build\BatchFiles\Build.bat
-Target="BlankExample Android Development" 
-Project="C:\BlankExmaple\BlankExample.uproject"
-WaitMutex

通过修改-Target的参数,实现编译不同的Target、Platform和Configuration。

并且,在日常开发中最常见的一种编译问题,是UE合并翻译单元带来的。

具体表现是,一份代码A很久没有变动了,改动的其他的代码B,导致A代码报错。
这是因为合并翻译单元,之前A和其他的翻译单元合并到一起,虽然A没包含所有需要的头文件,但是其他的翻译单元可能有包含,合并编译就不会报错。

所以,在预编译检查中,可以关掉项目中的UnityBuild,让每个翻译单元独立编译,同样能够查找到这种错误。

但关闭bUnityBuild会导致编译变慢,所以只需要在预编译中关闭,在正式的流程中还是要开始,只作检查时用。如何在编译时区分这两种场景,可以通过前面介绍到的传递编译参数的方式来实现。

资源

在项目开发过程中,有相当一大部分运行时BUG的情况,是因为资源问题导致的:

  • 美术改了场景GameMode、误提交
  • 资源规格不规范
  • 在版本控制中覆盖了别人的资源
  • 引用了临时资源
  • 引用了NeverCook目录中的资源,运行时丢失

等等情况不胜枚举,如果当出现问题时再去排查,会浪费很多的人力。因为首先要先排查到底是程序的BUG还是资源问题、以及是哪一个资源问题,会耗费大量的额外精力。

如何一劳永逸地解决这些问题,我之前开发了一款资源扫描工具,将它介入到项目中的开发流程中去,可以多阶段地进行资源检查,最大限度的规避资源问题。

通过编辑时、提交时、CI/CD、以及打包时,四种阶段协同工作,把资源异常扼杀在摇篮里!

详见我之前的几篇文章:

效率提升

开发阶段的效率提升,我觉得主要分为以下几个方面:

  1. 开发、资源制作效率
  2. 问题排查效率
  3. 调试效率

开发和资源制作效率,可以通过充分地利用自动化工具和各种AI辅助工具。尤其是ChatGPT和Stable Diffusion爆发的当下,充分利用AI工具能够比较高效地快速提取信息和产出内容,但也不要完全相信AI,AI还是会胡诌的。

除此之外,在游戏开发中,需要发掘一些项目真实的痛点,解决它们都能够提高效率。如:

  • 快速验证代码变动,而不完整走打包流程,避免出包的等待时间。如可从包外加载so,实现快速更新C++代码的效果。或提供只编译代码和最基础资源的打包选项。
  • 方便地编辑引擎启动参数,避免手动编辑设备中的ue4commandlet.txt文件。(高效调试:命令行参数启动 UE Android App)
  • 根据不同模块的需要,允许美术、策划、程序本地打包资源,快速验证效果。(UE资源热更打包工具 HotPatcher
  • 每个版本都归档出丰富的数据,哪些资源参与打包、类型占比,有多少冗余,哪些资源是不规范的,并且记录触发了哪个规范,等等,当游戏体验出现问题时能快速定位到它们。(UE资源管理:引擎打包资源分析/UE中多阶段的自动化资源检查方案
  • 提高构建效率,降低代码和Shader的编译以及完整地出包时间。(FASTBuild及一些Cook优化方案)

在我看来,它们都是DevOps理念在游戏领域应用需要做的事情,这些方面都已经在我们实际的项目中落地,效果很好。有些实践在我之前的文章中可以看到实现思路和具体细节。

但对于开发效率提升能做的远不止这些,在UE开发流程中还有很多方面可以用DevOps思维优化,后续我会针对具体的方面再写文章深入探讨。

提高制作效率、减少Debug成本、快速验证和高效迭代版本是DevOps永恒的主题。把稀缺的人力资源应用到关键的地方中去产生更大的价值,也是降本增效的意义吧。

全文完,若有不足之处请评论指正。

微信扫描二维码,关注我的公众号。

本文标题:DevOps:虚幻引擎的CI/CD实践
文章作者:查利鹏
发布时间:2023年04月15日 14时30分
本文字数:本文一共有4.8k字
原始链接:https://imzlp.com/posts/96336/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!