UE5.8 编译、Cook、打包与发布:一个老程序员的踩坑地图
周三晚上的打包机
老周是我们项目的主程。上个月某个周三晚上十一点,他坐在打包机前抽烟,屏幕上是熟悉的红色日志:
LogCook: Warning: Unable to find cooking object for package /Game/Maps/TestMap
测试组等着周四早上十点的版本。美术刚把新手村场景替换完,策划加了三条支线任务,老周自己改了一套背包系统的 C++ 接口。所有人都在群里问:包什么时候好?
他先跑了 Development 编辑器,一切正常。切到 Shipping 配置开始打包,Cook 到一半报错,说有一张 Nanite 地形贴图没 cook 进去。更诡异的是,这张贴图在 Content Browser 里明明存在,引用也没断。
凌晨两点,他发现问题:那张贴图只被一个临时测试关卡引用,而这个关卡在 DefaultGame.ini 的 AlwaysCookMaps 列表里被注释掉了。Development 下编辑器能直接读源文件,所以看着没问题;Shipping 走 Cook 管线,引用链一断,资产就被优化掉了。
这个晚上让我意识到:UE5 的打包管线不是点一下按钮那么简单。它是一整套源码变成玩家硬盘上可执行文件的工程流程,里面藏着不少类似的坑。
编译:源码怎么变成可执行文件
UE5.8 的编译起点是三个工具协同工作:UnrealBuildTool、UnrealHeaderTool 和 ShaderCompileWorker。
UnrealBuildTool
UnrealBuildTool,项目里一般都叫 UBT,是用 C# 写的构建编排器。它不直接编译你的 C++ 文件,而是读两样东西:.Target.cs 和 .Build.cs。前者描述你要构建什么目标,比如 MyProjectEditor 还是 MyProjectGame;后者描述每个模块怎么编译、依赖哪些模块、用哪个 PCH、要不要链接第三方库。
UBT 的工作流程大概是这样:先编译 .Target.cs 和 .Build.cs 本身,拿到构建规则;再解析模块依赖图;然后决定哪些模块需要共享预编译头;最后生成真正的 CL.exe 和 LINK.exe 命令行,交给 MSBuild 或 Xcode 执行。
UnrealHeaderTool
只要你的头文件里写了 UCLASS、UFUNCTION、UPROPERTY,UHT 就会扫描它们,生成 .generated.h 和 .gen.cpp。这些生成文件里塞满了反射信息、序列化代码、蓝图调用胶水。
UE5.3 之后 UHT 不再以独立可执行文件形式存在,而是被整合进 UBT 的构建流程里。但老周遇到反射相关编译错误时,还是会下意识地说:UHT 又抽风了。比如你在头文件里漏了 GENERATED_BODY(),或者把 UFUNCTION(BlueprintCallable) 写在私有段,UHT 生成的东西就对不上,报错通常出现在 *.gen.cpp 里。
ShaderCompileWorker
ShaderCompileWorker 是独立进程,专门并行编译 Shader。UE5 项目里动辄几千上万条 Shader 变体,靠编辑器主线程编完不现实。SCW 会把任务拆成多个进程,同时吃满 CPU。
Windows 11 上有个坑:前台优先级模型会让后台进程被降级。你 Alt+Tab 切出去看网页、回 Slack,SCW 的调度优先级就掉了。老周的 24 核 i9 上,一次完整 Shader 编译能慢出将近三分之一。后来他把系统性能设置改成「后台服务」优先,速度才回来。这条经验来自 UE5.8 早期用户社区的实测反馈,做 PC 项目的同学可以记下来。
flowchart TD
A[源码与资产] --> B[UBT 解析 Target.Build.cs]
B --> C[UHT 生成反射代码]
C --> D[CL.exe 编译 C++]
D --> E[LINK.exe 链接可执行文件]
E --> F[ShaderCompileWorker 编译 Shader]
F --> G[Editor / Game 可执行文件]Development、Test、Shipping 到底差在哪
配置选错,后面全错。UE5 的三种主要构建配置区别非常大。
Development 配置下,断言开着,控制台命令能用,stat 和 show 系列指令随便敲,蓝图调试信息也保留。日常开发、编辑器、内部版本更新都用它。代价是运行时开销大,包体也偏大。
Test 配置介于中间。它接近 Shipping 的优化级别,但保留了一部分测试和统计代码。我们项目里性能压测会用 Test 包,因为它既能跑出接近真实的帧数,又还能挂 RenderDoc 和 Unreal Insights。
Shipping 是最终给玩家的配置。断言被剥离,控制台默认关闭,蓝图调试信息也清掉。编译器优化开到最高,link-time optimization 也会启用。Shipping 包启动最快、体积最小、运行最稳,但出了问题最难查。
这三个配置对应到源码里,就是一堆条件编译宏:UE_BUILD_DEVELOPMENT、UE_BUILD_TEST、UE_BUILD_SHIPPING。你的 C++ 代码里经常能看到 #if UE_BUILD_DEVELOPMENT 这种分支。老周有个习惯:所有调试用代码都包在这些宏里,而不是用运行时变量控制。这样 Shipping 包里连代码路径都不会进去。
Cook:资产怎么从编辑器格式变成运行时格式
Cook 是 UE5 打包管线的核心环节之一。编辑器里资产的格式是为了方便反复修改设计的,比如贴图可能还是原生的 TGA/PNG 导入版本,模型还是带完整编辑信息的 uasset。Cook 会把这些资产转换成目标平台能直接消费的格式。
Cook 还负责做引用收集。Content Browser 里有一大堆资产,但真正被关卡引用、被代码加载的只是一部分。Cook 只处理这部分被引用的资产,没引用的会被优化掉。这就是为什么老周那张贴图在编辑器里看得见,Shipping 包里却丢了。
引用链的起点通常是 DefaultGame.ini 里配置的 MapsToCook,以及项目设置里标记的 Always Cook 资产。插件里的内容、Game Features、DLC 包都需要在这里显式声明,否则 Cook 不认。
命令行 Cook
我们项目的构建机不用编辑器 GUI 打包,而是用命令行。一条典型的 Cook 命令长这样:
"C:\Program Files\Epic Games\UE_5.8\Engine\Binaries\Win64\UnrealEditor-Cmd.exe" ^
"D:\Projects\MyGame\MyGame.uproject" ^
-run=cook ^
-targetplatform=Win64 ^
-fileopenlog ^
-unversioned ^
-stdout -fullstdoutlogoutput-fileopenlog 会记录资产打开日志,用来追踪引用;-unversioned 让 cooked 资产不带版本号,减少包体。Cook 完成后,输出目录通常在 Saved\Cooked\ 下面。
Iterative Cook 是省时间的利器。它只重新 cook 自上次以来改过的资产。日常开发反复修改时,完整 Cook 可能要二十分钟,开 iterative 之后能压到两三分钟。但发版前我们一定会做一次干净 Cook,因为 incremental 偶尔会漏掉被间接引用的新资产。
Pak 文件与 IoStore
Cook 出来的资产需要被打包成玩家能读取的归档文件。UE4 时代默认是 .pak 文件,UE5 默认启用了 IoStore,生成的是 .ucas、.utoc 和可能的 .pak 组合。
.ucas 是真正压缩后的数据块,.utoc 是目录表,告诉引擎每个资产在哪个 ucas 块的什么偏移。.pak 在 IoStore 里通常只保留挂载信息或兼容层数据。IoStore 的加载性能比传统 Pak 好,尤其是开放世界项目里大量小文件随机读的场景。
如果你想退回传统 Pak,可以在 DefaultEngine.ini 里加:
[/Script/UnrealBuildTool.PakFileUtilities]
bUseIoStore=False老周的项目一开始用 IoStore,后来为了兼容一套旧的补丁分发后台,临时切回了 Pak。切换后包体大了约百分之八,但补丁系统不用改。
architecture-beta
group uegame(cloud)[UE5.8 Game Package]
service source(server)[Source Code & Assets] in uegame
service ubt(database)[UnrealBuildTool] in uegame
service uht(database)[UnrealHeaderTool] in uegame
service scw(database)[ShaderCompileWorker] in uegame
service cook(disk)[Cook Commandlet] in uegame
service pak(disk)[Pak / IoStore] in uegame
service stage(disk)[Staged Build] in uegame
service ship(internet)[Shipping Build] in uegame
source:B --> T:ubt
ubt:R --> L:uht
ubt:B --> T:scw
ubt:R --> L:cook
cook:R --> L:pak
pak:R --> L:stage
stage:R --> L:shipChunk 与 Patch 基础
所有资产默认都在 Chunk 0。当项目大了之后,你会想把内容拆成多个 Chunk。比如基础游戏放 Chunk 0,DLC 放 Chunk 100,高清材质包放 Chunk 200。
拆 Chunk 的主要手段是 Primary Asset Label。在 Content Browser 里右键创建一个 Primary Asset Label 数据资产,设置 Chunk ID 和 Priority,再把关卡或目录拖进去。Cook 时这些资产就会被分到对应的 Chunk。
graph LR
A[Chunk 0
基础游戏] --> B[Chunk 100
DLC 新手村]
A --> C[Chunk 200
高清材质包]
D[Primary Asset Label] --> B
D --> CPatch 的思路是:先打一个 Release 版本作为基线,后续改动只生成差异 Pak。UE5 的 Project Launcher 里有 Generate Patch 选项,它需要指定基于哪个 Release 版本。Patch 只包含新增或修改的资产,玩家下载量小。
DLC Workflow 则更像是单独打包一个 Chunk。你基于同一个项目,选择 Build DLC,指定 DLC 名称和 Chunk ID,生成独立的 Pak/IoStore 文件。游戏运行时再通过 FPakPlatformFile 或 ChunkDownloader 挂载。
这里有个坑:Patch 和 DLC 的 Chunk ID 规划要一开始就定好。我们项目中期才想起来拆 Chunk,结果 Chunk 0 里已经塞了九十多个 G 的内容,重新划分等于让全部玩家重新下一遍基础包。
Staged Build 是什么
Package 之前的步骤叫 Stage。Stage 会把编译好的可执行文件、Cook 好的资产、配置文件、依赖库按目标平台的目录结构组织好。Stage 出来的目录已经可以直接运行,只是还没压缩成最终分发包。
我们调试发布问题时,经常直接看 Staged Build。路径一般在 Saved\StagedBuilds\ 下面。如果 Stage 目录里缺了某个 dll 或 ini,最终包肯定也缺。
命令行里 -stage 和 -package 可以分开执行。开发阶段可以只 -stage,确认目录结构没问题再 -package。老周有时候还会加 -archive 把包压缩到指定目录,方便 CI 上传到内网盘。
一条完整的命令行打包脚本
下面是我们项目里 nightly build 用的简化版脚本,做了去敏感处理:
@echo off
setlocal
set ENGINE="C:\Program Files\Epic Games\UE_5.8"
set PROJECT="D:\BuildFarm\MyGame\MyGame.uproject"
set OUTPUT="D:\BuildFarm\Output"
%ENGINE%\Engine\Build\BatchFiles\RunUAT.bat ^
BuildCookRun ^
-project=%PROJECT% ^
-noP4 ^
-platform=Win64 ^
-clientconfig=Shipping ^
-serverconfig=Shipping ^
-cook ^
-allmaps ^
-stage ^
-package ^
-pak ^
-compressed ^
-prereqs ^
-archive ^
-archivedirectory=%OUTPUT% ^
-utf8output
if %errorlevel% neq 0 (
echo BUILD FAILED
exit /b 1
)
echo BUILD OK-compressed 会对 Pak 做压缩,-prereqs 会把 VC++ 运行时等依赖打包进安装程序。-utf8output 让日志里的中文路径不乱码。整段脚本四十六行,刚好卡在我们 CI 的行数限制内。
DLC 与 Patch 的实战流程
讲个我们项目第二季资料片的例子。策划想加一张新地图、两套皮肤、一个新职业。美术把资产扔进了 /Game/DLC/Season02 目录。老周的任务是:让玩家不用重新下整个游戏,只要下载新增内容。
第一步,在 Asset Manager 里给 Season02 目录分配 Chunk ID 101。UE5 的 Asset Manager 在 Project Settings -> Asset Manager 里可以配 Primary Asset Type 和 Directories,把目录映射到指定 Chunk。
第二步,打 Release 基线版本。Project Launcher 里新建一个 Profile,勾选 Create a release version of the game for distribution,起个名字叫 Release_1_0_0。这个版本会生成完整的 Pak/IoStore 和清单文件,一定要存档。
第三步,开发 DLC 内容。地图、皮肤、职业代码都提交后,再建一个 DLC Profile。Build DLC 打勾,基线版本选 Release_1_0_0,DLC 名称填 Season02,Chunk ID 填 101。Cook 和 Pak 完成后,输出目录里会出现对应的 Chunk 文件。
第四步,把 Chunk 文件上传到 CDN 或平台后台。PC 上我们自己写了个小工具把 pak 文件推到文件服务器;主机平台走各自的 DLC 审核流程。
第五步,游戏启动时调用 ChunkDownloader 或平台接口检查 DLC 可用性,下载完成后用 FPakPlatformFile::Mount 挂载。玩家进游戏就能看到新地图入口。
Patch 流程类似,只是不勾 Build DLC,而是勾 Generate patch。Patch 会对比基线和当前版本,只生成差异内容。老周建议 Patch 版本号一定要和 Release 基线对应,搞错了版本号,补丁要么打不上,要么覆盖错文件。
sequenceDiagram
participant Dev as 开发组
participant AM as Asset Manager
participant RL as Release 基线
participant DLC as DLC Profile
participant CDN as CDN / 平台
participant Player as 玩家客户端
Dev->>AM: 配置 Season02 -> Chunk 101
Dev->>RL: 打包 Release 1.0.0
RL-->>Dev: 存档基线 Pak/IoStore
Dev->>DLC: Build DLC Season02
DLC->>RL: 基于 Release 1.0.0
DLC-->>Dev: 输出 Chunk 101
Dev->>CDN: 上传 DLC 文件
Player->>CDN: 查询并下载 Season02
Player->>Player: 挂载 Chunk 101这里还有几个容易踩的坑。DLC 里的蓝图如果引用了基线包里的 C++ 类,接口必须保持二进制兼容,改函数签名会让老版本游戏加载新 DLC 时崩溃。另外 DLC 的 ini 文件路径要单独管理,别和主包 ini 冲突。我们曾经把 DLC 的默认地图写进了主 DefaultGame.ini,结果没买 DLC 的玩家也能看到入口图标,点进去却黑屏。
常用 BuildCookRun 参数拾遗
-nocompile 只 cook 和 package,不重新编译代码。适合美术和策划只想验证资产改动的时候。-nocheckelevatedprivilege 跳过管理员权限检查,CI 机器上常用。-distribution 会在包名里去掉 -Debug 之类的后缀,主机平台提交商店必须加。
-build 显式要求编译,-cook 要求 cook,-stage 要求 stage,-package 要求打包,-archive 要求归档。RunUAT 的 BuildCookRun 命令就是靠这些开关组合出不同流程。有时候我们只想 stage 不想 package,就把 -package 去掉。
-pak 启用 Pak 打包。不加这个,资产会散在目录里,调试方便但发布没人这么干。-compressed 对 Pak 做压缩,节省下载大小,但运行时解压会占一点 CPU。对 CPU 敏感的平台可以不开。
-iterativecooking 和 -iterativedeploy 分别开启增量 cook 和增量部署。本地快速验证时很有用,但发版前要做干净构建。
-createreleaseversion 和 -generatepatch 是 Patch 专用的。-basedonreleaseversion 指定基线版本名。这几个参数用错组合,Patch 会白打。
构建时间的账
构建时间可以粗略拆成三块:
编译时间跟代码量、PCH 命中率、链接优化级别有关。Shipping 的 LTO 链接经常比 Development 慢几倍。Cook 时间跟资产总量、引用深度、Shader 变体数有关。Stage 和 Package 时间主要看磁盘 IO 和压缩级别。
我们项目的数据:Development 编辑器完整构建约二十五分钟,Shipping 包完整构建约一小时四十分钟。其中 Cook 占了一半以上,Shader 编译又占 Cook 的一半。把 Cook 机从机械盘换到 NVMe,Cook 时间直接少了百分之三十。这个钱花的比加 CPU 还值。
Cook 日志里的常见报错
老周整理的报错清单里,排第一的是 Unable to find cooking object。意思 Cook 找不到某个资产的 cooked 版本。多半是这个资产虽然存在,但没有被任何引用链触达。解决办法是把它拖进引用它的关卡,或者加进 DirectoriesToAlwaysCook。
第二是 Shader compile job failed。SCW 进程崩溃、内存不足、或者 shader 代码本身写错都会导致。看 Engine\\Saved\\Logs\\ShaderCompileWorker 目录下的子日志,通常能找到具体是哪一个 material function 出错。
第三是 Package contains map references another package that is not cooked。说明关卡引用了未 cook 的资产。可能是资产被误删、改名,或者引用路径写错。用 Reference Viewer 查一下引用链,修复断点就行。
第四是 Cook failed. Unable to delete ...。文件被占用,常见于开了编辑器、开了游戏、或者杀毒软件在扫。关掉相关进程再试。
老周总结的十二条经验
- Shipping 包出问题,先用 Test 包复现,保留的调试信息多得多。
- 永远不要把未引用的测试资产塞进发布包,用 Asset Audit 定期扫。
DefaultGame.ini里的MapsToCook和DirectoriesToAlwaysCook是救命的。- 命令行打包用
-utf8output,否则中文日志会让你怀疑人生。 - ShaderCompileWorker 在 Windows 11 下注意后台优先级设置。
- IoStore 和 Pak 切换要评估补丁系统兼容性,别只看包体大小。
- Chunk 规划越早做越好,后期迁移成本极高。
- Patch 基线版本要存档,丢失了只能让玩家下全量包。
- Stage 目录里跑不通,Package 出来也跑不通。
- 构建机加 SSD 比加 CPU 对 Cook 更划算。
- 断言和调试代码用
UE_BUILD_DEVELOPMENT宏包住,别留到 Shipping。 - 凌晨两点遇到 Cook 报错,先检查引用链,八成的怪问题都出在这。
Project Launcher 与命令行该选哪个
Project Launcher 的好处是可视化,点几下就能跑。适合第一次配置、不熟悉参数的人。缺点是配置多了容易乱,而且不好版本控制。我们项目早期用 Project Launcher,后来配置文件一多,谁改了哪个 Profile 都记不住。
命令行的好处是可重复、可上 CI、可追溯。所有参数写在 bat 或 yml 里,Git 能 diff。缺点是第一次写的时候要查文档。老周的建议是:个人本地调试可以用 Project Launcher,团队正式发布一律走命令行脚本。他还加了一条:不管用哪种方式,Release 基线版本的输出目录必须进备份,否则半年后的 Patch 没法打。
写在最后
UE5.8 的打包管线复杂,但每一条报错和每一个配置都有它的道理。老周那晚修完引用链,周四早上八点四十五分包就出来了。测试组十点准时开测,周五晚上项目按时上线。
后来他把那次经历写进了项目 Wiki,标题叫《凌晨两点遇到 Cook 丢资源怎么办》。我觉得这个标题比任何官方文档都实在。
后来我翻到那篇 Wiki,发现他在最后写了一句话:打包这件事,怕的不是报错,而是报错之后不知道去哪里找原因。把 UBT、UHT、Cook、Pak、Stage 这几步拆开看,大部分问题都能定位到具体环节。这个思路帮我在后续项目里少熬了很多夜。如果你也正在被打包机折磨,希望这篇踩坑地图能让你比老周早几个小时回家。最后补充一句:不同项目的打包需求差别很大,不要照搬我们的脚本。关键是理解每一步在做什么,然后根据自己的 CI 和平台要求去裁剪。毕竟游戏再好玩,发不出去也是白搭。祝顺利,少踩坑,早日下班。