技术博客

Cook 流程深潜:CookByTheBook 的依赖遍历与 EditorOnly 剥离

第 85 / 100 篇 · 2025-11-03 · ue5 · 资产管线Cook源码分析

UE5.3 里 Cook 的入口收敛在 UCookOnTheFlyServer(Editor/UnrealEd/Private/CookOnTheFlyServer.cpp)——名字带 OnTheFly,但 CookByTheBook(一次性批量 Cook,CookCommandlet 驱动)和 CookOnTheFly(按请求边玩边 Cook,主要给迭代开发用)共用同一套内核,区别只在队列怎么喂:ByTheBook 启动时把初始集合全算出来塞进去,OnTheFly 是运行中动态追加。这一篇走 ByTheBook 的完整流程。

初始包集:Cook 范围的定义

依赖遍历得有个起点。ByTheBook 的初始队列来自三个来源的并集:

  • 显式地图:-map= 参数和 Project Settings 里的地图清单;
  • 启动包:引擎启动必需的包(game singleton、默认材质这类,配置在 ini 的 startup 段);
  • AssetManager 的主资产清单:所有 PrimaryAsset 按 CookRule 展开——AlwaysCook 的直接进队列,其余等被引用。

用图论的话说,Cook 集合是可达性闭包:以初始集为源点、以"硬依赖 + 生效的 CookRule"为边做遍历。硬依赖就是第 9030 篇讲的 Import 表——包 A import 了包 B 的对象,B 必须在 Cook 结果里,否则加载时 CreateImport 找不到符号。软依赖(FSoftObjectPath)不强制 Cook,只在运行时解析,解析不到就是"打包后缺资源"的经典事故。

遍历本身没什么好神秘的,广度优先加去重,代价 $O(V + E)$。真正的工作量在"边从哪来":每条边要查 AssetRegistry 的依赖缓存(GetDependencies,UE5 起依赖按类型分硬/软/可选),几万资产的项目的 Cook 预热时间大头在这里,而不是在遍历循环本身。

队列消费:分层保存

初始集合进队列后进入保存阶段。两个关键机制:

  • SCC 分组:包之间的循环依赖(A 引 B、B 引 A,蓝图互引很常见)构成强连通分量,同 SCC 的包必须一起保存——分批存会出现"先存的引用了后存的还没生成的导出"。Cook 用 Tarjan 之类做 SCC 缩点,缩点后的 DAG 才有拓扑序;
  • 分层并行:拓扑序把 SCC 分层,同层之间无依赖,可以并行保存——这是 MPCook(多进程 Cook,第 9020 篇)的并行粒度来源。每层的串行时间才是加速比的上限约束:$T \ge \max_{\text{层}} T_{\text{layer}}$,所以依赖图"扁而宽"的项目并行收益大,"深链"项目(一串蓝图继承到底)收益小,瓶颈在最长链。

保存动作本身走 UPackage::Save 的目标平台路径(FSavePackageArgs),序列化上下文挂上 Cook 标志(ArIsCooking 一族),后面所有"剥离"决策都读这个标志。

EditorOnly 剥离发生在写盘路径上

很多人以为剥离是"Cook 前删掉编辑器对象",实际是保存时跳过:属性序列化入口(FProperty::SerializeItem 路径)检查 CPF_EditorOnly 标志,Cook 上下文直接不写;对象层面,编辑器专用的 UObject(缩略图、Ubergraph 节点)根本不进 cooked 包的 Export 表。所以:

  • 剥离是保存的副作用,不是独立的清理 pass——不存在"剥离前的中间产物"可以给你抓;
  • Blueprint 的节点图属于 EditorOnly 数据,Cook 后只剩编译出的字节码,这也是为什么 cooked 包不能当编辑器资产用(第 9030 篇的不可逆性);
  • 有例外开关:SAVE_KeepEditorOnlyCookedPackages 一类标志保留 EditorOnly 数据(调试和某些平台需要),体积代价自负。

对应的数学事实:Cook 是有损变换 $f: \text{EditorData} \to \text{CookData}$,$f$ 不满射——编辑器数据空间远大于 Cook 数据空间,不存在逆映射。所有"拿 cooked 包反推编辑器资产"的工具都是在猜,不是在还原。

CookByTheBook 主流程:初始集 → 依赖闭包 → SCC 分层 → 并行保存 初始包集 地图 + 启动包 + 主资产 依赖闭包(BFS) 硬依赖:Import 表 SCC 缩点 + 拓扑分层 循环依赖必须同批保存 第 1 层 第 2 层 第 3 层 同层无依赖,可并行 层间严格串行 MPCook worker 并行保存(ArIsCooking 上下文挂起) 主进程串行:容器写出收口 剥离点:保存路径查 CPF_EditorOnly 与编辑器对象 → cooked 包不可逆

增量:对上一次的 diff

全量 Cook 每次重算所有派生数据,增量 Cook 靠"和上一次 Cook 的输出比":Cook 完成后会把每个包的产出元数据(内容哈希一类)落盘,下次 Cook 时逐包对账,没变的包跳过保存、直接从缓存(Zen,第 9050 篇)取产物。这里有个细节:对账的粒度是包,不是对象——包里任何一处改动,整个包重存。所以 Blueprint 拆分要按改动频率切,一个天天改的数据结构挂在几百 MB 的蓝图包里,等于每次 Cook 都重存几百 MB。

排查 Cook 异常的入口

  • Cook 集不对(该 Cook 的没进):查 AssetRegistry 依赖缓存是不是过期(编辑器里强制全量刷新资产注册表再 Cook),查软引用是否标了强制 Cook;
  • 保存阶段卡死或爆内存:大概率是巨型 SCC——两个互相引用的"万包大户"会把整层变成单点,Insights 里看保存线程的并行度,一层只有一个任务就是这个症状;
  • cooked 包体积异常大:开包对比编辑器版,看是否有 EditorOnly 数据漏剥(某个自定义 UStruct 忘了标 EditorOnly,或者走了 KeepEditorOnly 标志)。

Cook 流程本身不复杂,复杂度全在依赖图的形状上。把"初始集、硬依赖、SCC、分层"四个概念钉死,看 MPCook 日志和 Cook 报告时就不再是玄学。