技术博客

资产加载机制:FPackageIndex 与 Linker 的解析链路

第 79 / 100 篇 · 2025-05-29 · ue5 · 资产管线源码分析

编辑器里拖一个引用,存下来的是一串字符串(SoftObjectPath);运行时拿到的是 UObject*。中间隔着整个资产加载机制:文件怎么读进内存、对象怎么按依赖顺序建出来、引用怎么从索引翻译成指针。基于 UE5.3 源码(UObjectGlobals.cpp、LinkerLoad.cpp)走一遍链路。

Package:加载的基本单元

UE 的资产文件(.uasset)对应一个 UPackage,加载以 package 为原子单位——要么整个加载,要么不加载,不存在"加载半个 package"。LoadPackage(UObjectGlobals.cpp)的同步路径:

  1. FindPackage:内存里已有同名 package 直接返回(加载缓存的第一道);
  2. 找不到则 CreatePackage 建一个空的 UPackage 壳;
  3. CreateLinker 创建 FLinkerLoad,把文件头读进来——此时文件还关着,只有 Summary 段(包名、依赖列表、import/export 表的大小信息)在内存;
  4. SerializePackage:linker 逐对象反序列化,CreateImport/CreateExport 把表里每一项实例化成 UObject;
  5. Linker->Preload 收尾,UObject::EndLoad。

为什么要先建壳再填?因为依赖环允许存在——A 引用 B、B 引用 A 是合法的资产结构。空壳先注册进全局包表,CreateImport 递归加载 B 时如果发现 B 引用了 A,FindPackage 能立刻返回 A 的壳,避免无限递归。

FPackageIndex:正数 Export,负数 Import

package 文件内部用 FPackageIndex 互相引用——一个 32 位有符号整数,编码规则(LinkerLoad.cpp):

index > 0 → Export 表的第 index 项(本包导出的对象)
index < 0 → Import 表的第 (-index-1) 项(本包依赖的外部对象)
index = 0 → null

Import 表项(FObjectImport)记录三样东西:对象名、UClass 名、所在包名——全是名字,没有指针。Export 表项(FObjectExport)记录:对象名、class 的 FPackageIndex、outer 的 FPackageIndex、序列化数据在文件里的偏移和长度。

FPackageIndex 编码:+2 → Export[2],-3 → Import[2],0 → null .uasset 文件 Import 表(外部依赖) Export 表(本包对象) 序列化数据区 FLinkerLoad 持 Import/Export 数组 依赖 package Import 项解析为 外部包的 Export UObject 实例 Export 项 CreateExport 递归加载 Resolve 软引用字符串 → FSoftObjectPath → 包名 → LoadPackage → Linker 建表 → index 解析 → UObject*

序列化时引用怎么写

对象内部引用另一个对象时,FObjectProperty 的序列化写的是 FPackageIndex 而非指针——FLinkerSave 在建表阶段反向遍历:被引用的对象在本包 → 正数 index;在外部包 → 递归确保外部包在 import 表,写负数 index。这就是"存盘时引用是无指针的"的原因,也是资产文件能被改名工具重定向的基础(名字字符串在 import 表里,改完重存即可)。

加载端反过来:FLinkerLoad::CreateExport 处理某个 export 时,对象属性反序列化读到一个 index,调 FLinkerLoad::Resolve 查表:

  • 正数 → 本包 ExportMap,已有实例直接返回,没有则先 CreateExport(递归);
  • 负数 → ImportMap,拿到包名 + 对象名,走 LoadPackage 递归加载那个外部包,再在外部包的 export 表里找同名对象。

这就是为什么加载一个 package 会级联拉一串依赖:加载 SM_Crate.uasset 发现 import 了 M_Metal,M_Metal 又 import 了 T_Albedo……整个依赖闭包都被实例化。

同步与异步的分界

上面是同步路径(LoadPackage)。异步路径(LoadPackageAsync)把第 3、4 步拆细:linker 建表照样立即做(快),CreateExport 的密集反序列化丢进 FAsyncLoadingThread 的任务队列,按依赖拓扑分批跑,回调通过 FStreamableManager 或 AsyncLoading 的完成代理通知。

卡顿的根源通常在第 4 步的"级联同步化":异步加载到一半,某个对象的反序列化里碰到 FObjectProperty 引用了一个还没加载的包,旧代码路径会 FlushAsyncLoading 强制等——异步瞬间变同步,主线程卡住。5.x 里 EAsyncLoadingResult 和 FSoftObjectPath 的 RequestAsyncLoad 配合把"提前声明依赖"做进了资产本身(UPROPERTY(meta=(AsyncLoadPriority)) 之类),但手写的 StaticLoadObject 仍是卡顿高发区,审查代码时见一个改一个。

实操命令

  • obj list / obj list class=Package:看当前加载了哪些 package,排查"为什么这个包被拉进来了"。
  • log LogStreaming Verbose:看异步加载的级联链。
  • 查单个对象的加载来源:GetName() 反查 outer chain 到 UPackage,UPackage::GetLoadedBy(开发版本)看是谁触发的加载。

这条链路的精髓可以浓缩成一句话:文件里存名字和索引,内存里才是指针,linker 是两者之间的翻译器。后面几篇(UAsset 格式、DDC、Cook)全都是在这个模型上往外长:格式篇讲文件里那些表的具体二进制布局,Cook 篇讲这些文件怎么被批量生成。