中EN
概念页

资源交叉引用与加载后初始化

Resource Cross-References & Post-Load Initialization

资源之间以任意方式相互引用(网格引用材质,材质引用纹理),整个资源数据库是一张有向依赖图。内存中的指针离开运行中的程序就失去意义,所以存盘时要把引用改写为 GUID 或文件内偏移,加载后再修正 (fix up) 回指针;含非 POD 的 C++ 对象还要用 placement new 调用构造函数;跨文件的外部引用要先加载全部相关文件。加载后通常还需要按资源类型做加载后初始化(以及释放前的拆除)。

复合资源与引用完整性(卷 I 7.2.2.8)

  • 资源数据库由多个资源文件组成,每个文件含一个或多个数据对象,对象间可任意引用;交叉引用通常意味着依赖(A 引用 B,则两者都要在内存中才能用)。整体可表示为数据对象的有向图。
  • 内部引用:同一文件内对象间的引用;外部引用:指向另一文件中对象的引用。两者常用不同方式实现。画图时用虚线框表示文件边界,跨越边界的边就是外部引用。
  • 复合资源:自给自足的一簇相互依赖的资源,如模型 = 一个或多个三角形网格 + 可选骨骼 + 可选动画集合,每个网格映射一个材质,每个材质引用一张或多张纹理。完整加载复合资源要加载其全部依赖。

下图示意书中图 7.6 的结构(文件内的边是内部引用,跨文件的边是外部引用)。

flowchart LR
  subgraph F4["文件 4"]
    M1["网格 1"]
    M2["网格 2"]
    S1["骨骼 1"]
  end
  subgraph F5["文件 5"]
    Mat1["材质 1"]
    Mat2["材质 2"]
  end
  subgraph F1["文件 1"]
    T1["纹理 1"]
  end
  M1 --> Mat1
  M2 --> Mat2
  Mat1 --> T1
  A1["动画 1,文件 6"] --> S1

(图为简化示意,仅保留几条代表性的边。)

在内存与磁盘中表示交叉引用(卷 I 7.2.2.9)

C++ 中交叉引用通常是指针或引用(如 Material* m_pMaterial),但指针只是地址,离开运行中的程序就没有意义,同一程序两次运行地址也会变,所以不能直接存盘。

方案一:GUID 作为交叉引用

  • 每个引用存为被引用对象的唯一 ID(字符串或哈希码),所以每个可能被引用的对象都要有 GUID。
  • 运行时维护全局资源查找表:对象载入时以 GUID 为键登记指针;全部载入后遍历所有对象,通过查表把每个交叉引用转换为指针。

方案二:指针修正表 (pointer fix-up table)

  • 存二进制文件时,按任意顺序把每个对象(恰好一次)的内存映像依次写入文件,于是在内存中不连续的对象在文件中变为连续映像——即序列化。
  • 写文件时找到每个对象里的每个指针,换算为相对文件开头的偏移并覆盖原指针(偏移所需位数不会多于指针)。偏移就是指针在二进制文件中的等价物。注意开发平台与目标平台的差异:在 64 位 Windows 上写出的内存映像指针都是 64 位,与 32 位主机不兼容。
  • 加载后映像仍连续,偏移加上文件映像基址即得指针:pObject = pAddressOfFileImage + objectOffset,即指针修正 (pointer fix-up)。
  • 难点是找到所有需要转换的指针:写文件的代码了解所写的类型,知道每个对象中指针的位置,于是把这些位置记入一张指针修正表随映像一起写入文件;加载后查表修正每个指针。表本身就是文件内偏移的列表,每项代表一个待修正的指针。

用位数组存修正表 64 位系统中指针 8 字节宽,必须 8 字节对齐(见 C++ 内存布局与对齐)。只要整个文件载入在 8 字节边界上,文件就是连续的 8 字节「四字」数组,每个指针恰好占一个四字、不会跨两个。所以只需记录哪些四字是指针:N 字节文件最多 ⌈N/8⌉\lceil N/8\rceil 个四字,用 ⌈N/8⌉\lceil N/8\rceil 位的位数组标记即可,修正表只有 ⌈N/64⌉\lceil N/64\rceil 字节——可能的最小修正表。

  • 注意:这一结论依赖指针都按自然对齐存放;若用 #pragma pack(1) 之类压缩布局,指针可能跨四字,位数组方案就失效(对齐规则见 C++ 内存布局与对齐)。

C++ 对象与构造函数 从二进制映像加载 C++ 对象时容易忘记调用构造函数。两种做法:

  1. 二进制文件中只允许 POD(plain old data:C 结构体,以及无虚函数、构造函数平凡的 C++ 结构体和类);
  2. 保存一张非 POD 对象的偏移表及其类别,加载后遍历,用 placement new 在已分配内存上调用相应构造函数:::new(pObject) ClassName;。 POD 的定义在 C++11/14/17/20 各版本间有变化,但基本思想不变(C++20 起标准已弃用 std::is_pod,改用「平凡 (trivial)」与「标准布局 (standard-layout)」两个概念描述——cppreference「PODType」「std::is_pod」)。
  • 补充注意:placement new 调用的构造函数会在已载入的数据之上运行,若构造函数(或成员默认初始化器)给成员赋值,就会覆盖刚从文件读入的内容;实践中常为此提供一个不触碰成员、只设置虚表指针等的专用「就地加载」构造函数。对于平凡类型,C++23 提供 std::start_lifetime_as<T>(p),可在不运行构造函数、不改动字节的前提下在已有存储上合法开始对象生命周期(cppreference「std::start_lifetime_as」)。GEA 未提这一点。

外部引用

  • 上述两法适用于只有内部引用的资源:载入映像、修正指针即可。外部引用除了对象的偏移或 GUID,还要记录所在资源文件的路径。
  • 加载多文件复合资源的关键是先加载全部相互依赖的文件:加载一个文件后扫描其交叉引用表,加载尚未加载的外部文件;每个对象载入时把地址登记到主查找表;全部载入后,最后统一用主查找表把 GUID 或文件偏移转换为真实地址。

加载后初始化(卷 I 7.2.2.10)

  • 理想情况是离线工具把一切准备好,载入即可用;实际上很多资源载入后还需要处理。GEA 称之为加载后初始化 (post-load initialization),Naughty Dog 称为 logging in;多数资源管理器还支持释放内存前的拆除 (tear-down) 步骤,ND 称为 logging out。
  • 两类:
    • 不可避免的:如 PC 上网格的顶点和索引先载入主内存,渲染前必须转到显存——只能在运行时创建 DirectX 顶点/索引缓冲、锁定、复制或读入数据、解锁。
    • 可避免但为方便而做的:如给样条库加精确弧长计算,与其先改工具,不如先在加载后初始化中运行时计算;算法完善后再挪进工具,省去运行时开销。
  • 按资源类型配置:C 中可用查找表把每种资源类型映射到一对函数指针(初始化、拆除);C++ 中用多态更简单。虽然可以用特殊构造函数做初始化、析构函数做拆除,但通常需要先构造所有对象、再修正指针、最后单独初始化,所以多数开发者用普通虚函数,如 Init() 与 Destroy()。
  • 与内存分配的关系:初始化常生成新数据——有时是补充载入数据(如计算 Catmull-Rom 样条各段弧长,需额外分配内存存放结果),有时是替换(如为向后兼容加载旧格式网格并自动转换为最新格式,转换后旧数据部分或全部丢弃)。
  • 《Hydro Thunder》的做法:资源可 (a) 直接载入最终位置,或 (b) 载入临时内存区,由加载后初始化例程把最终数据复制到目的地,完成后丢弃临时副本。这便于加载同时含有用和无用数据的文件:有用的复制到最终位置,无用的丢弃——例如旧格式网格在临时内存中转换为新格式,不必浪费内存保留旧数据。

加载流程小结

下图把 GEA 描述的步骤串起来(以多文件复合资源为例)。

flowchart TD
  A["加载资源文件映像"] --> B["扫描交叉引用表,加载尚未加载的外部文件"]
  B --> C["对象地址登记到主查找表"]
  C --> D{"全部依赖已载入?"}
  D -- "否" --> B
  D -- "是" --> E["placement new 构造非 POD 对象"]
  E --> F["按修正表把偏移/GUID 转为指针"]
  F --> G["加载后初始化 Init()(如上传显存、计算弧长、格式转换)"]

构造、修正、初始化分为独立步骤,正是 GEA 主张用虚函数而非构造函数做初始化的原因。(流程图为本页归纳,各步骤均来自上文。)

  1. Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
  2. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 7 章 7.2.2.8–7.2.2.10 节

相关页面

游戏开发资源管理器Resource Manager游戏由网格、材质、纹理、着色器、动画、音频、关卡布局、碰撞与物理参数等大量资源(也叫资产、媒体)构成。内存有限,所以引擎要保证每个资源在内存中只有一份(五个网格共用一张纹理时只载入一份)。每个引擎都有某种资源管理器:有的是统一集中的子系统,有的散布在多年间由不同人写的各个子系统中,但职责和要解决的问题是一样的。本页讲运行时部分:职责、文件组织与归档、文件格式、资源 GUID、资源注册表、生命周期(引用计数)和资源的内存管理;交叉引用与加载后初始化见 资源交叉引用与加载后初始化,离线工具链见 资产管线与工具链。游戏开发资产管线与工具链Asset Conditioning Pipeline & Tools游戏引擎的输入数据来自美术使用的数字内容创作 (DCC) 工具,但 DCC 格式几乎从不直接用于游戏,必须经过资产调理管线 (Asset Conditioning Pipeline, ACP) 导出、处理并按平台打包。围绕它还有世界编辑器、记录资产元数据的资源数据库,以及几种工具架构(独立工具、与引擎共享底层、编辑器内嵌于引擎、Web 界面),各有取舍。游戏开发C++ 内存布局与对齐C++ Memory Layout & Alignment理解 C/C++ 程序在内存中的样子,是写高性能引擎代码的前提。主要内容包括:声明与定义、内部与外部链接;可执行映像的几个段(text、data、BSS、rodata)以及栈和堆;结构体成员的自然对齐和填充;继承和虚函数如何改变类的布局(vpointer 和 vtable)。GEA 也简要回顾了面向对象:多重继承容易产生致命菱形,所以通常只允许 mix-in;组合往往比继承更好。游戏开发自定义内存分配器Custom Memory Allocators内存从两方面影响性能:动态分配(malloc / 全局 new)很慢,以及访问模式主导现代 CPU 的性能。GEA 的经验法则是「尽量少做堆分配,绝不在紧密循环里做堆分配」,并介绍游戏引擎常用的自定义分配器:栈分配器(含双端栈)、池分配器、对齐分配、单帧与双缓冲分配器;以及如何用栈/池避免碎片,或用碎片整理加指针重定位(智能指针、句柄)解决碎片。游戏开发 · UE5存档系统SaveGameUE 的存档围绕自定义的 USaveGame 子类进行:保存时把需要持久化的数据从游戏世界拷进 SaveGame 对象,再用 UGameplayStatics 写入某个存档槽(槽名 + 用户索引);读档时反过来。官方推荐用 AsyncSaveGameToSlot 异步保存,避免掉帧,也避免某些平台的认证问题。游戏开发 · UE5UObject 与反射系统UObject & Reflection System虚幻引擎 (Unreal Engine) 的对象基类是 UObject。在 C++ 类、属性、函数上加 UCLASS/UPROPERTY/UFUNCTION 等宏,再由构建前的预处理工具虚幻头文件工具 (Unreal Header Tool, UHT) 扫描头文件生成代码,引擎就能在运行时“看见”这些类型——这就是反射系统 (Reflection System)。垃圾回收、序列化、编辑器面板、蓝图、网络复制都建立在它之上。游戏开发世界块数据格式与生成器World Chunk Data Formats & Spawners世界块中的动态部分要为每个游戏对象记录两样东西:属性初始值和对象类型。GEA 比较了三种存储方式:二进制对象映像(生成最简单,但要处理指针、虚表和字节序,且对变化很脆弱)、序列化描述(XML/JSON/私有二进制格式,配合反射与类工厂),以及生成器 (spawner) + 类型模式 (type schema):生成器是只含类型 id 和键值对的轻量数据块,彻底切断世界编辑器与运行时代码的耦合;模式里的默认值配合"与默认值相同就省略键"可以让默认值的修改自动传播到旧实例(GEA4 卷 II 17.3)。

被这些页面引用1 篇

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原