《游戏引擎架构》笔记6:资源及文件系统
1. 文件系统
1.1 文件名和路径
- Unix路径分隔符是
/,Windows路径分隔符是\。 - 路径分离目录/文件名/扩展名,路径规范化,在绝对路径和相对路径之间切换,WIndows提供了API,由
shlwapi.dll库提供,提供shlwapi.h头文件,但只用于Win32平台。为了跨平台游戏引擎通常实现自己轻量化的路径处理API。
1.2 基本文件I/O
- C程序库提供IO输入输出API,每次调用都需要缓冲区的数据区块,供程序和磁盘之间传送来源或目的字节,分为有缓冲功能的I/O API(API负责管理所需的输入/输出数据缓冲)和无缓冲功能的I/O API(程序员负责管理数据缓冲)。

Q: 为什么需要自己管理缓冲区?
A: 比如往日志写数据会降低性能,所以可以先把数据积累到内存缓冲,漫溢后写进硬盘,之后把缓冲输出函数放到另一个线程里,避免游戏循环发生流水线停顿。
- 包装I/O API好处:跨平台兼容,简化API,延伸功能。
1.3 文件I/O模式
- 同步文件I/O:程序发出I/O请求,等待I/O完成,返回结果程序再继续运行。
- 异步文件I/O:为了支持streaming,需要异步文件I/O,程序发出I/O请求,I/O操作在后台进行,程序继续运行。
- 优先权:异步I/O操作有不同的优先权,会根据优先权进行处理。
- 工作原理:异步文件I/O利用另一单独线程处理I/O请求,主线程调用异步函数以后,会把请求放入一个队列,并立即返回,之后I/O线程从队列取出请求,以阻塞I/O函数处理请求,处理完成后,调用主线程之前提供的回调函数,告之操作已经完成,若主线程选择等待I/O请求,就会使用信号量处理。(每个请求对应一个信号量,主线程把自身处于休眠状态,等待I/O线程在完成请求工作后通知信号量)
2. 资源管理器
2.1 离线资源管理及其工具链
- 资源数据库:资源经过ACP(Asset Conditioning Pipeline)处理,需要用一些元数据描述如何对资源进行处理,资源数据库需要提供的功能包括:
- 处理多种类型的资源,理想地以一致的方式处理。
- 创建新资源。
- 删除资源。
- 查看和修改现存的资源。
- 资源从一个位置移到磁盘的另一位置。
- 让资源交叉引用其他资源(网格引用材质,关卡引用一组动画),交叉引用同时驱动资源管理生成过程及其运行时的载入过程。
- 维持数据库内所有交叉引用的引用完整性,执行删除或移动资源等常见操作后,仍能保持引用完整性。
- 保存版本历史,记录资源的修改历史,方便回滚。
- 支持不同形式的搜索和查询。
- 一些游戏引擎的资源管理器设计:
- UE4:UnrealEd管理资源元数据、资产创建、关卡布局。一站式存取引擎支持的一切资源。资源存于少量的大型二进制包文件,不好做版本合并,一个方式是分包,另外引用完整性可能也有一些问题:资源移动或重命名会生成虚拟对象,这些虚拟对象可能会闲置累积起来造成问题。
- 顽皮狗引擎:MySQL数据库存储,编写GUI封装SQL语言。资源粒度小,不易冲突,仅提供必需的特性,显而易见的文件映射,容易更改DCC数据的导出和处理方式,容易生成资产:命令行加资源名即可生成资产。
- OGRE:提供一致简单的接口可以扩展接入任何类型的资源,缺乏离线数据库,只提供导出器,DCC工具数据接入麻烦。
- ACP(Asset Conditioning Pipeline)
- DCC工具的资产经过3个阶段到达引擎:
- 导出器:DCC工具导出中间格式,供ACP后续阶段使用。
- 资源编译器:数据改造,比如把Mesh三角形重排为triangle strip,压缩纹理,计算spline每段弧长。
- 资源链接器:多个资源结合成单个有用的包,比如多个Mesh文件、多个材质文件、一个骨骼文件、多个动画文件内的数据结合成完整的资源。
- 资源依赖关系和生成规则:
- 根据资源引用关系确定资产改动后需要重新生成那些资产。
- 数据格式可以应付版本变动。
- 可靠的依赖和生成系统:源文件变动要能触发重新生成资产。
2.2 运行时资源管理
2.2.1 运行时资源管理器的责任
- 确保任何时候,同一个资源在内存只有一个副本。
- 管理每个资源的生命期,载入需要的资源,在不需要的时候卸载。
- 处理复合资源(多个资源组成的资源)的导入。
- 维护引用完整性:内部引用完整性(单个资源内的交叉引用)和外部引用完整性(资源间的交叉引用),资源的载入要保证所有依赖的子资源也一并载入。
- 管理资源载入后的内存用量,确保资源存储在内存中合适的地方。
- 允许按资源类型,载入资源后执行自定义的处理(资源登录/资源载入初始化)。
- 提供单一统一接口管理多种资源类型,方便扩展处理。
- 支持streaming异步资源载入。
2.2.2 资源文件及目录组织
- 一些引擎的设计:
- 太空逃亡者游戏:资源目录树。
- OGRE:资源文件放在zip存档,好处有:zip是开放格式、可被压缩、可视为模块、内部的虚拟文件也有相对路径。
- UE3:资源放在大型合成文件(package),不允许资源在磁盘用独立文件出现,包文件用自定义格式,可以用UnrealEd编辑。
2.2.3 资源文件格式
游戏引擎会自定义文件格式:
- 引擎所需的部分信息可能没有标准格式可以存储;
- 脱机处理降低运行时载入和处理资源数据时间需求,脱机时规范数据内存布局如适合硬件读取的二进制格式,而无需运行时实时解算(如解析FBX转换顶点结构)。
2.2.4 资源全局统一标识符
全局唯一标识符GUID将每个资源映射到硬盘的物理文件,保证游戏里是唯一的。
2.2.5 资源注册表
资源注册表保证在任何时间载入内存的每个资源只有一份副本,最简单的实现是一个哈希表,键是资源的GUID,值是指向资源的指针。
为什么不自动载入资源?不缓存的话载入可能会带来停顿,解决:
- 游戏运行时进制加载资源,游戏关卡的所有资源在游戏进行前全部加载(载入动画或载入进度条)。
- 资源使用streaming异步载入,玩家在玩关卡A,关卡B的资源在背景加载,但实现困难。
2.2.6 资源的生命周期
资源的生命周期有不同的需求,有的是常驻内存,有的则是在需要的时候载入,不需要的时候卸载。问题是:资源会进行共享,不希望在刚卸载了一些资源以后,在其他地方🈶重新加载相同的资源,解决方案是:
- 资源引用记数,加载资源引用记数+1,卸载资源引用记数-1,引用记数为0时,卸载资源。
2.7 资源所需的内存管理
载入/卸载资源要避免内存碎片,常见的解决方案包括:
- 基于堆的资源分配:这种方法直接忽略内存碎片问题,通过通用的堆分配器分配资源所需的内存(C的
malloc或C++的全局new运算符),适合运行在个人计算机,因为操作系统支持高级的虚拟内存分配,对于物理内存有限的游戏机内存碎片会是一个问题,于是也可以定期整理内存碎片。 - 基于堆栈的资源分配,不会产生内存碎片,因为内存是连续分配的,会以分配的反方向进行释放内存,使用需要满足两个条件:
- 游戏是线性及以关卡为中心的。
- 内存足够容纳各个完整关卡。

双端堆栈分配器也可使用这种方法,已有的一些方案有:
- 底端堆栈用于载入持久的数据,顶端堆栈用于为每帧临时分配内存(Midway)
- ping-pong载入关卡,将关卡B压缩后的版本载入顶端堆栈,当前关卡A(无压缩版本)则驻于底端堆栈。关卡A进入关卡B,简单释放关卡A的资源(清除底端堆栈),之后把关卡B从顶端堆栈解压到底端堆栈。解压缩通常比硬盘读入数据快得多,这一方法去掉了载入时间,让玩家过关时更感顺畅。
- 基于池的资源分配:资源数量以同等大小的组块载入,池的底层实现需要根据需要使用数组或者其他的数据结构(数组不适合大型连续数据结构,使用链表等更优)。池也存在缺点,可能存在空间浪费,组块的大小需要很好地权衡。为了解决组块浪费内存问题,一个方法是建立资源组块分配器,具体实现为:
- 资源组块分配器原理:管理一个链表,内涵所有未用满内存的组块,每笔数据包含自由内存块的位置和大小,于是可以从自由内存块按需分配(比如使用通用堆分配器管理自由内存块链表或为每个自由内存块设立小型栈分配器)。
- 资源组块分配器问题:若在资源组块未使用的区域分配内存,释放组块只能选择全部释放或不释放,从那些未用区域分配来的内存会在资源卸载时离奇消失。
- 解决方案:只利用资源组块分配器分配一些和对应生命周期的内存,A组块的自由内存数据属于关卡A的数据分配,B组块的自由内存数据属于关卡B的数据分配,需要资源组块分配器独立管理每个关卡的组块。用户分配要指明要从哪个关卡分配内存才可以让分配器选择正确的链表来满足要求。
- 资源组块分配器可行性:大部分游戏引擎在载入资源需要动态分配内存,内存需求可能大于资源文件本身,资源组块分配器可以重新利用组块原来浪费的内存。
- 组块资源的另一概念:文件段:资源文件可能包含多段,以配合上述基于池的资源分配。一段可能有临时数据,仅在载入过程使用,载入整个资源后临时数据就会丢弃,另一段可能包含调试信息,游戏在Debug模式运行载入这些调试数据,在最终发型版本不会载入。
2.8 复合资源及引用完整性
相关概念前面2.1和2.2节有提过。

2.9 处理资源间的交叉引用
资源管理的难点在于:管理资源对象间的交叉引用,确保维系引用完整性。
C++里两个数据对象间的交叉引用用指针或引用实现,指针的地址在多次运行相同程序可能会改变,数据存储到文件里不能用指针来表示对象之间的依赖性。解决方案有:
-
GUID交叉引用,资源管理器维护一个全局资源查找表,每次资源对象载入内存,把指针以GUID为键加入查找表,当所有资源对象都载入内存,可以扫描所有对象,对其交叉引用的资源对象GUID,通过全局资源查找表换成指针。
-
指针修正表:把指针转换为文件偏移值。把对象序列化为二进制文件。

即使对象在内存里不是连续的,序列化以后在二进制文件里也是连续的,因而可以知道每个对象的映像相对文件开始的偏移值,可以简单写入偏移值(指针总有足够的位存放偏移值)。如果要把文件载入内存,就需要修正指针:把偏移值转换为指针,下面是代码的简单实现,载入内存以后对象依旧是连续的:
1 | U8* ConvertOffsetToPointer(U32 objectOffset, |

关键的是要找出需要转换的指针,我们可以在序列化的时候把每个对象的指针放到简单列表(指针修正表),然后连这个表一起写入二进制文件,这样就可以凭这个表修正所有指针。指针修正表的内容只是文件里的偏移值,每个偏移值代表一个需要修正的指针。下图右边的二进制文件通过修正表的偏移找到里面的指针,指针对应的对象正和左边的内存图是一样的。

- 存储C对象为二进制映像:构造函数,载入C对象需要调用对象的构造函数,有两个常见解决方案:
- 让二进制文件只支持PDDS结构(plain old data structure,包括C struct和无虚函数不做事情的平凡构造函数的C++ struct/Class)。
- 非PODS对象的偏移值组成一个表,并在表里记录对象属于哪个类,最后把表写入二进制文件,之后载入二进制映像遍历此表,对每个对象使用placement new调用构造函数,代码实现为:
1 | void* pObject = ConvertOffsetToPointer(objectOffset, pAddressOfFileImage); |
- 处理外部引用:外部交叉引用指明偏移值和GUID,加上资源对象所述文件的路径。关键在于先载入所有互相依赖的文件,可行的做法是:
- 载入每个资源文件扫描文件的交叉引用表,并载入所有被外部引用但未载入的资源文件,载入后把其地址加入住查找表,所有互相依赖的资源文件都载入后,再用主查找表转换:从GUID或文件偏移值转换为真实的内存地址(载入了知道了地址才知道转换到哪里去)。
2.10 载入后初始化
涉及到资源的载入后初始化和拆除,比如Mesh的顶点和索引载入主存渲染前要送到显存。C语言实现用以用查找表,每个资源类型映射一对函数指针分别负载载入后初始化和拆除。C++则可以用多态实现载入后初始化和拆除,比如虚函数Init()和Destroy()。
载入后初始化可能会增加新数据(样条计算额外空间保存结果),也可能会丢弃旧数据(兼容旧Mesh数据转换为新格式旧数据丢弃)。解决办法比如允许载入两种资源:(1)直接载入最终的内存位置;(2)载入临时的内存区域。载入后初始化在临时内存区域最终需要把处理后的数据复制到最终的内存为止,然后丢弃临时内存的资源数据,这样就不会浪费内存保存旧数据。