zhouqijie

第二部分:资源管理器

每个游戏都是由很多资源(Resource)构成的,例如网格、材质、纹理、着色器、音频、关卡场景等组成的,资源又称为资产(Asset)。

资源管理器可以设计为单独的子系统,也可以分散在各个子系统中。

资源管理器有两个元件组成:

  1. 一个元件复制离线工具链,用来创建资产以及把它们转换成可用的形式。
  2. 一个元件负责在运行时管理资源,确保一些资源在使用之前已经载入内存,并且在不需要时卸下它们。





一、离线资源管理



版本控制

很多游戏团队用版本控制系统管理美术资产。最大的问题是美术资源文件都很大。可以使用Alienbrain这种针对巨大数据量的商业版本控制系统。



元数据(metadata)

对于大部分资产来说,游戏引擎并不会使用其原本格式。而是经过一些资产调节管道(Asset Conditioning Pipeline),把资产转换成引擎所需格式。当流过资产调节管道时,每个资源都需要有些元数据(metadata)描述如何对其进行处理。



资源数据库

为了管理所有这些元数据,需要有某种形式的数据库。(如果是非常小型的游戏,这种数据库可能只需要开发者记在脑海里)
不同的游戏引擎的资源数据库形式有巨大差异。有些引擎直接把元数据嵌入资源文件本身。有些引擎中资源文件可能会伴随一个小文本文件存储元数据。有些引擎会采用真正的数据库例如Access、MySql、Oracle等。

无论资源数据库采用什么形式,都最好提供以下功能:

  1. 能用尽量一致的方法处理多种类型的资源。
  2. 能创建和删除资源。
  3. 能查看和修改现存资源。
  4. 能移动资源在磁盘的的位置。
  5. 能让资源交叉引用其他资源。交叉引用通常同时驱动资源管理生成过程已经运行时的载入过程。
  6. 当删除或者移动资源时,仍然能维持所有引用的引用完整性(referential integrity)。
  7. 保存版本历史。
  8. 支持查询搜索。



资产调节管道

大多数资源经由资产调节管道(ACP / RCP)或称工具链(tool chain)才能成为引擎所用数据。

  1. 导出器(exproter):把软件的原生格式导出为我们可以处理的格式。(通常需要为DCC工具写自定义插件)(有些软件能导出通用格式)
  2. 资源编译器(resource ompiler):导出的数据需要进行一些处理才能让引擎使用。(例如压缩纹理、网格三角形处理)(有些数据导出后就能使用无需处理)
  3. 资源链接器(resource linker):多个资源需要先结合成单个包,然后再载入引擎。(例如把网格材质骨骼动画文件结合为单一资源)(并不是所有资产都需要链接)

各资产之间有互相依赖的关系,每个ACP都需要一组规则来描述资产间的依赖关系。

当某个资源改动后,这些依赖关系信息告诉我们哪些资源需要重新生成,并确保以正确次序重新生成所需资源。

当数据格式发生改动时,最好还是硬着头皮重新处理所有文件。(也可以使用版本识别代码来处理Legacy资产)





二、运行时资源管理

关于引擎运行时资源怎样从资源数据库载入、管理、卸载。



运行时资源管理器功能:

把资源载入至内存。

  1. 管理每个资源的生命周期。载入需要的资源,并在不需要时卸载资源。
  2. 处理复合资源的载入。
  3. 维护引用完整性。(包括单个资源的内部引用完整性和资源间的外部引用完整性)
  4. 管理资源占用的内存。
  5. 载入资源后进行自定义处理。(初始化)
  6. 使用单一接口管理多种资源。(并且应该便于扩展)
  7. 应该支持异步加载。(使用串流)



资源文件和目录组织:

  1. 有些游戏引擎把每个资源储存为单独文件,并置于树状目录中。
  2. 有些引擎把多个资源打包为单一文件例如ZIP或者其他自定义格式。

使用单一文件打包优点是减少了载入时间。(文件载入三大开销:寻道时间、文件开启时间、载入内存时间)
有些引擎支持两种模式。(例如OGRE)



资源文件格式

每类资源都可能有不同的文件格式,有时单一文件格式可以储存多种类型的资产。

  1. 有些需要的部分数据没有标准格式可以储存。
  2. 很多引擎会进行资源的离线处理和数据布局,避免在运行时载入时才做处理。



资源全局唯一标识符

游戏中所有资源都必须有某种全局唯一标识符(GUID)。



资源注册表

为了保证任何时间载入内存的每个资源只有一份副本,大部分资源管理器都含有某种形式的资源注册表(resource registry)来记录已载入资源。

最简单的实现方式就是字典(key是资源的唯一标识符,value通常是指向内存中资源的指针)。资源载入时就会以GUID为键加进注册表字典,卸载资源时就会删除其注册表记录。当游戏请求某资源时就用其GUID查找资源注册表。如果注册表中能找到就直接返回资源指针,否则就先载入资源或者返回失败码。

载入资源可能会造成卡顿。解决方法是使用异步加载。或者运行时完全禁止加载资源,全部在进行游戏前预先加载完毕。



资源生命期

资源的生命期是指该资源载入内存后至被释放的时段,资源管理器的职责之一就是管理资源生命期,可以是自动也可以手动调用。

  1. 有些游戏资源在开始时载入并驻留在内存直到游戏结束,称为LSR(load-and-stay-resident)。
  2. 有些游戏资源对应关卡。玩家离开关卡的时候才能卸载。
  3. 有些资源生命期更短。例如过场动画等。
  4. 有些资源是即时串流的,生命期很难定义。例如背景音乐或者全屏电影。(通常播放中一个区块和载入中一个区块)

使用引用计数能很好地解决一些资源卸载的问题。(例如在场景切换后根据引用计数来确定哪些资源被卸载)



资源的内存分配

不同的资源可能置于不同的地址范围。比如LSR可能会载入某内存区域,而经常载入卸下的资源可能置于其他地方。我们通常设计或者利用已有的内存分配器来配合资源系统。

推分配器会产生很多内存碎片。如果在PC上则问题不大因为操作系统支持高级的虚拟内存,但是如果运行一些游戏机上那么内存碎片就是个问题。需要定期整理内存碎片。

堆栈分配器不会有内存碎片问题,内存是连续分配的。应该注意栈分配器内存是按照分配的反方向来释放的。

把资源数据以同等大小的块(chunk)载入,就可以用池分配器。

基于块的分配方式,必须考虑所有资源的”块特性”,即能够被切割成块而不破坏其中连续的数据结构。 设计资源时应该避免使用大型连续数据结构,或是不需要连续内存仍可正常运作的数据结构。

资源组快分配器、分段的资源文件。



复合资源和引用完整性

一般把一组自给自足、由相互依赖资源所合成的资源称为复合资源。

要完整地载入复合资源至内存,也必须载入其所有其依赖的资源。



处理资源间的引用

在C++中两个对象的交叉引用通常以引用或者指针实现。然而指针只是内存地址,指针的值离开运行时就失去了意义。(事实上多次运行相同程序内存地址也会改变)
所以序列化存储数据至文件时,不能使用指针表示依赖。

一个解决方案就是把交叉引用存储为被引用对象的全局唯一标识符(GUID)。

把全部资源文件载入内存后,再扫描全部对象一次,把引用资源的GUID通过全局资源查找表换成指针。

即存储为文件时用偏移值代替指针,载入文件时再把偏移值转回指针。

偏移值和指针的转换很简单,问题是如何找出需要转换的指针。可以在写文件时把指针位置存储在一个列表中,即指针修正表。

(详细原理略)

  1. 可以只使用POD结构。(C结构体)(无虚函数、平凡构造函数的C++结构体和类)
  2. 或者把所有非POD对象属于哪个类记录在一个表里面,把这个表写进二进制文件里。(载入时调用placement-new语法,如下所示)
void * pObject = ConvertOffsetToPointer(objOffset);
::new(pObject) ClassName;  

Creedon: 使用fstream更好地支持存储C++对象。

以上两个方案对于内部引用非常有效。要表示外部交叉引用,就得采取不同的方法。
要正确表示外部交叉引用,除了要指明偏移值或GUID,还需加上资源对象所属文件的路径。

载入由多个文件组成的资源,关键在于要先载入所有互相依赖的文件。可行的做法是载入每个资源文件时,扫描文件中的交叉引用表,并载入所有被外部引用但是未载入的资源。

将每个数据对象载入内存时,可以把其地址加进主查找表。当载入所有互相依赖的资源文件后,所有对象已驻于内存,这时就可以使用主查找表把所有指针转换一遍,从GUID或文件偏移值转换为真实内存地址。



载入后初始化

许多资源种类在载入后,还需要一些“整理”才能供引擎使用。
许多资源管理器也支持在释放资源的内存之前,执行某种拆除(tear-down)步骤。

(END)