技术博客

IoStore 与 Cook 管线:.utoc/.ucas 容器格式和 Zen Server 的协作

第 80 / 100 篇 · 2026-02-04 · ue5 · 资产管线CookIoStore源码分析

UE5 打包产物里那个 .utoc + .ucas 文件对,很多人用了一两年也没拆开看过。基于 UE5.3(Engine/Source/Runtime/CoreUObject 的 IoStore 部分、Engine/Source/Programs/UnrealPak、Developer/DerivedDataCache)把 Cook 到装包的链路捋一遍。

Cook 到底干了什么

Cook 的输入是编辑器资产(uasset,带大量 WITH_EDITORONLY_DATA 的编辑器专用数据),输出是平台可直接加载的"熟"资产。核心变换:

  • 剥离编辑器数据:thumbnail、编辑器网格 LOD 之外的原始数据、未引用的节点图全部丢掉。这也是 cooked 包不能反向当编辑器资产用的原因——数据物理上没了;
  • 平台格式转换:纹理转成目标平台压缩格式(BC/ASTC),shader 编译成平台 RHI 的字节码,这一步产出的是 Derived Data,天然进 DDC(Derived Data Cache);
  • 依赖遍历:CookByTheBook 从启动地图和 PrimaryAsset 出发,按 asset registry 的引用图走,只 Cook 可达资产。

"剥离"和"转换"的性质不同,值得分开看:剥离是有损删除,信息没了就是没了;转换是确定性重计算——同一份源资产加同一份引擎配置,产出逐字节相同。正是第二条性质让缓存成为可能:Derived Data 可以用"输入内容的哈希"当键存起来,下次同输入直接取结果。DDC、Zen、Cook 增量全都建立在这一个性质上。

Cook 的入口是 UCookCommandlet(Engine/Source/Editor/UnrealEd/Private/Commandlets/CookCommandlet.cpp),5.x 的重头在多进程 Cook(MPCook,CookDirector 主进程分发,worker 进程并行处理包)和 Zen。

Zen Server:DDC 的服务化

Zen(Engine/Source/Programs/Zen,UE5 起内置)是把 DDC 从"本地文件夹"升级成"本地 HTTP 服务"的东西。Cook 时纹理压缩、shader 编译这些 CPU 重活的结果按内容哈希存进 Zen store,下次 Cook 同样的输入直接命中。

内容寻址(content-addressable storage)是这里的关键词:键是内容的哈希而不是路径。这带来两个性质:其一,相同内容自动去重——两个包里引用了同一张中间纹理,Zen 里只有一份;其二,命中与否与文件位置无关——资产改名、挪目录不会 invalidate 缓存,改内容才会。代价是每次存取要算哈希,以及小对象多时元数据开销占比上升,所以 Zen 的 record 聚合了小对象存储。

关键特性:

  • 跨 Cook 会话的增量:只重 Cook 变化的包。实测全量 Cook 几十分钟的项目,日常迭代 Cook 能压到几分钟,大头收益来自 Zen 命中纹理和 shader;
  • Zen snapshot:Cook 时可以打快照(BaseDirForIterative 相关参数),补丁 Cook 基于快照做 diff——新包和快照逐包比对,变化的才进补丁 pak。这是官方热更补丁体积控制的根基;
  • 可部署成团队共享 DDC(-ddc=... 指向远端 Zen),整个团队共享编译结果。

IoStore:.utoc/.ucas 的分工

老 Pak 是"文件系统式"归档:一个 .pak 里存完整的文件树。IoStore(FIoStoreReader,CoreUObject/Serialization/AsyncLoading2 系)换成了块容器思路:

  • .ucas:纯数据。所有包的序列化字节被切成定长 block(默认 64KB 对齐,可压缩)顺序堆进去,没有文件名概念;
  • .utoc:元数据。FIoContainerHeader(IoContainerHeader.h)里存三张关键表:Package ID → 包元信息的索引表、chunk 的 offset/size 表(CompressionBlock)、包间依赖图(FPackageStoreEntry 里的 ImportedPackages)。

为什么块容器胜过文件归档?算 IO 账就清楚了。Pak 时代加载要按文件逐个 open、读 header、解析出 import 表,文件句柄和目录项查找是 per-file 的系统调用开销,几万个小文件就是几万次系统调用;压缩以整个文件为粒度时,读文件中间一段要先解压前面所有内容,随机访问基本作废。IoStore 一次 open 两个文件,utoc 常驻内存,所有随机访问都降维成 (container, offset, length)——IO 调度器可以按优先级排序请求、合并相邻块读取;压缩以 64KB block 为粒度,读某一段只需解压目标 block,随机访问和压缩第一次兼得。

加载时的路径是:包名(比如软引用路径)→ 包名小写后哈希成 64-bit 的 FPackageId → utoc 查索引 → 拿到该包在 ucas 里的 chunk 列表 → 按需读块解压 → FAsyncPackage 走反序列化。只读需要的 block,所以 IoStore 的随机访问比 Pak 快,也更好做 IO 调度(配合 FIoDispatcher 的优先级队列)。

注意 FPackageId 是包名的哈希,不是内容的哈希——这是 IoStore 里两套哈希哲学并存的地方:定位包用名字哈希(名字是身份),缓存派生数据用内容哈希(内容是版本)。后文"软引用改名会让补丁 diff 把改名当成删除+新增"的红线,原理层原因就在这里:改了名就是新的 FPackageId,对容器来说是一个全新包。

还有一层性能设计:utoc 里直接内嵌了 script object 的导入导出图(FPackageStoreEntry 的 import map),加载器不用先反序列化 package summary 就能知道依赖顺序,预取可以提前一整个依赖层级。这是 AsyncLoading2(Zen 加载器)比老的 AsyncLoading 快的主要原因——老系统得先把 uasset 头部读出来才知道 import 表,依赖发现本身就串行化了 IO。

IoStore 容器结构:.utoc 元数据索引 + .ucas 数据块 .utoc(常驻内存) PackageId 索引表 包名 → CityHash → 64-bit FPackageId CompressionBlock 表 chunk → (offset, size),64KB 对齐 ImportedPackages 依赖图 不读包体即可预取下一层依赖 .ucas(纯数据,顺序堆叠) block 0 block 1 block 2 ... 查表得 chunk 列表 → 按需读块解压 所有随机访问降维成 (container, offset, length):FIoDispatcher 可按优先级排序、合并相邻读 压缩以 block 为粒度 → 随机访问与压缩兼得(Pak 时代整文件压缩做不到)

加载运行时:Event Driven Loader

Cooked 包的加载走 FAsyncLoadingThread2(EDL,AsyncLoading2.cpp)。它和老加载器的区别是事件驱动:每个包的加载状态机(CreateExportLinkers → SerializeExports → PostLoad)挂在一个全局事件图上,某个包 PostLoad 依赖的包就绪了才被调度。所以 LoadPackageAsync 的完成顺序不再是请求顺序,依赖深的包反而先就绪——写过自定义流送的人要注意,不能假设回调顺序。

MPCook 的 worker 边界

多进程 Cook 把"逐包处理"分发到 worker,但不是所有阶段都能并行:asset registry 的构建、最终 IoStore 容器的写出都在主进程串行收口。这是 Amdahl 定律的直接体现:可并行部分的加速比再高,总时间也被串行段钉住,$\text{总时间} \ge T_{serial} + T_{parallel}/N$。所以包特别多但每包很轻的项目,MPCook 的收益会被收口阶段吃掉一部分;反过来纹理重的项目收益最大——纹理压缩正是派给 worker 的部分。CookDirector 的日志里能看到 worker 利用率,长期跑不满时查的不是机器核数,而是包里可并行任务的粒度。

补丁和 diff 的实操

做热更新补丁时关键步骤:

  1. 基线版本 Cook 时保留 Zen snapshot(UAT 参数 -iterativecooking 或显式 snapshot 目录);
  2. 补丁版本基于 snapshot Cook,变化包进单独 chunk(用 PrimaryAssetLabel 或 chunk 规则指定);
  3. 产出补丁 pak/IoStore 容器,运行时挂载优先级高于基础包(FPakPlatformFile::Mount 的 Order)。

红线有三条,踩过才知道疼:

  • snapshot 丢了一切重来:没有基线快照,diff 就退化成全量;
  • 改了引擎版本或 Cook 配置(如压缩格式 Oodle 级别),内容哈希全变——"确定性重计算"的前提是同配置,配置也是哈希输入的一部分——补丁包爆炸;
  • 软引用路径改名会破坏 FPackageId 的稳定性(它是包名的哈希),补丁会把"改名"当成"删除+新增",体积翻倍。

验证手段

  • UnrealPak.exe <pak/utoc> -List 看容器内容;IoStore 的用 -Extract 拆出来对比;
  • Cook 日志里搜 MPCook / Zen 关键字确认多进程和缓存命中率,LogDerivedDataCache 看 miss 原因;
  • 运行时 -fileopenlog 记录实际加载的包,是精简 chunk 和查"补丁里混进了不该有的包"的直接证据。

IoStore 的设计取向很明确:把资产从"文件"降维成"带内容寻址的数据块",加载、缓存、diff 全部统一到哈希键上。理解了这一点,Zen、DDC、补丁 diff 这些看似分散的系统其实是同一块内容寻址存储的不同视图。