《游戏引擎架构》笔记六

资源及文件系统程序员

载入及管理多种媒体,是游戏引擎必须具有的能力。多数引擎会采用某种类型的资源(或资产)管理器,载入并管理游戏所需的资源,并确保在同一时间每一个媒体文件只可载入一份。每一个资源管理器都会大量使用文件系统。本文将介绍现代三维游戏引擎中的各类文件系统API,再分析典型资源管理器的运做方式。数据库

文件系统网络

文件名和路径数据结构

关于文件和文件夹路径的概念,绝对路径和相对路径的概念,它们在各类操做系统之间的区别,属于常识范畴,此处不赘述。异步

关于搜寻路径,是指含若干个路径(以特殊字符分隔)的字符串,寻找文件时会从这些路径逐个寻找,PATH环境变量就是一种搜寻路径。在运行期搜寻资产是费时的作法,而一般资产路径会在运行期以前就得知,因此应该彻底避免搜寻资产。数据库设计

关于路径API,通常用于对路径进行多种操做,如分离“目录/文件名/扩展名”、使路径规范化、绝对和相对路径互转等等。游戏引擎一般会实现或封装轻量化的路径处理API,以便实现跨平台,从各类特殊的储存媒体(如记忆棒、DVD盘、网络文件系统等等)中存取数据,以及提供操做系统API未能提供的功能,如串流(即在游戏运行中同时载入数据)。ide

基本文件I/O

许多游戏引擎都会把文件I/O API封装成自定义的API,这样至少有三个好处:函数

保证I/O API在全部目标平台上均有相同行为;工具

API能够简化到只剩下实际须要的函数,使维护开支维持最小限度;布局

可提供延伸功能,如处理各类特殊的储存媒体(同自定义路径处理API)。

每次调用输入/输出,都须要称为缓冲区的数据区块,以供程序和磁盘之间传送字节。当API负责管理数据缓冲,就称之为有缓冲功能的API,不然为无缓冲。C标准程序库中,以f开头的文件API是带缓冲的,如fread(),没有f开头是无缓冲的,如read()。有时自行管理缓冲区是有必要的。例如往日志写数据可能会显著下降性能,能够先把数据累积在内存缓冲,满溢后才写进盘内,甚至把缓冲输出函数置于另外一线程里,以免令主游戏循环发生流水线停顿。

同步与异步

C标准库的两种文件I/O库都是同步的,即程序发出I/O请求之后,必须等待读/写数据完毕,程序才能继续运行。

串流是指在背景载入数据,而主程序同时继续运行。为了支持串流,必须使用异步文件I/O库。多数异步I/O库允许主程序在请求发出后一段时间,等待I/O操做完成才继续运行。有些异步I/O库允许程序员取得某异步操做所需时间的估算,一些API也能够为请求设置时限,并设置请求超时的安排(例如取消请求、通知程序、继续尝试等)。

异步I/O操做常有不一样的优先权,例如从硬盘中串流音频,而且在串流其余资源时播放音频,显然前者优先权高于后者。异步I/O系统必须能暂停较低优先权的请求,才可让较高优先权的I/O请求有机会在时限前完成。

异步文件I/O的实现原理,通常是利用另外一线程进行同步操做来实现。主线程调用异步函数时,会把请求放入一个队列,并当即传回。同时,I/O线程从队列中取出请求,并以阻塞I/O函数处理这些请求。请求的工做完成后,就会调用主线程以前提供的回调函数告之该操做己完成。若主线程选择等待完成I/O请求,就会使用信号量处理(每一个请求对应一个信号量,主线程把自身处于休眠状态,等待I/O线程在完成请求工做后通知信号量)。

资源管理器

资源管理器由两部分组成:一部分负责管理离线工具链,用来建立资产并把它们转换成引擎可用的形式;另外一部分在执行期管理资源,确保资源在使用前已载入内存,不须要时从内存卸下。

离线资源管理与工具链

资产的版本控制

小型的游戏项目中,游戏资产的管理方式能够是把组织不严谨的文件以项目特设的目录结构置于公用网盘中;有些游戏团队使用源码版本控制工具来管理资源。

可是,艺术资产一般有极大的数据量,直接从中央版本库复制到本地每每是低效的。如下是一些参考解决方案:

  • 使用如Alienbrain这种特别针对极大量数据的商业版本控制系统
  • 在版本控制工具上设计一套系统,保证用户只会取得其真正所需的文件到本地
  • 顽皮狗开发了一款私有工具。用户拥有资产版本库的完整本地视图,只要文件未签出,本地就一直是UNIX的符号连接(Windows可使用junction实现)以消除数据复制。当签出文件时则移除符号连接,更换为本地副本,签入时则相反。

资源数据库

游戏引擎不会使用多数资产本来的格式,而是须要经过一些资产调节管道(ACP)将资产转换为引擎所需的格式,其中每一个资源须要有元数据描述如何对资源进行处理。例如描述压缩纹理时,使用哪一种压缩方法;描述导出动画片断时,导出哪一个范围的帧。

为了管理这类元数据便须要某种数据库。不一样的引擎差异巨大,有的是嵌入到资产源文件自己,有的是每一个资产源文件伴随一个小文本文件,有的将元数据写进XML文件中,有的使用真正的关系数据库。它通常提供如下功能:

  • 能处理多种资源,最好是以一致的方式处理
  • 建立、删除、查看、移动磁盘位置和修改资源
  • 资源交叉引用其余资源,并维持数据库内的引用完整性
  • 保存版本历史,含完整日志记录、改动者及事由
  • 支持不一样形式的搜索和查询

一些成功的资源数据库设计

  • 虚幻3:由万用工具UnrealEd管理,它是引擎的一部分顽皮狗的《神秘海域》引擎
    • 优势:建立资产后能当即看到资产在游戏中运行的模样;以单1、整合、一致的界面管理全部类型的资源;资产必须明确导入数据库,制做初期即可检查资源有效性
    • 缺点:全部资源存于少许的大型二进制包文件,不利于版本控制包合并;资源重命名或移动时,使用虚拟对象,即把旧资源映射到新名称/位置,问题是虚拟对象会闲置、累积起来形成问题,尤为是删除资源时变得严重

用Perforce以提供版本控制,元数据改成XML。Builder管理演员(包含行为的动态对象)和关卡(含静态背景网格和关卡信息等)两种类型的资源,动画能够组成名为动画包(buddle)的伪文件夹;引擎含一组基于命令行的工具,用于查询数据库,处理资源原生DCC文件,生成某演员或关卡。

    • 优势:资源粒度小;Builder仅提供必需的特性;源文件映射显而易见,用户容易得知某资源由哪些资产而来;容易更改DCC数据的处处及处理方式;依赖系统会自动处理,生成资产很是容易
    • 缺点:欠缺预览资产的可视化工具;各类类型的工具没有彻底整合
  • OGRE:拥有一个颇完备、设计很是好的运行时资源管理器,经过一组简单一致又有扩展性的接口就能载入任何类型的资源。缺点在于仅是运行时方案,自己提供的离线处理很弱
  • 微软的XNA:经过VS IDE的项目管理及生成系统,把游戏资产以一样形式管理及生成

资产调节管道

资产调节管道用于将DCC原生格式文件转换成引擎可用的形式,通常通过3个处理阶段:

  1. 导出器:为DCC工具编写自定义插件,将数据导出为某种中间格式。若是DCC不提供自定义方法,则应该把数据存成开放格式,或比较直观的文本格式,或其余可作反向工程的原生格式
  2. 资源编译器:对DCC导出的数据进行必定处理,如把网格的三角形从新排列成三角形带,或压缩纹理。并不是全部数据都要编译
  3. 资源连接器:将多个资源先结合成单个有用的包,如复杂的三维模型,而后才载入至游戏引擎。并不是全部数据都要连接

如同程序的源文件,各资产之间也有依赖关系。这些依赖关系一般会影响资产在管道内的处理次序,也可告诉咱们,当某个源资产作出改动后,要从新生成哪些资产。生成依赖不单围绕资产自己的改动,也关系到数据格式的改动。每一个资产调节管道都须要一组规则来描述资产间的依赖关系,并本身搭建系统或使用像make这样的工具来以正确顺序生成资产。必定要管理好资产间的依赖。

运行时资源管理

运行时资源管理器的责任

  • 确保任什么时候候,同一个资源在内存中只有一份副本
  • 管理每一个资源的生命期
  • 处理复合资源的载入(如三维模型)
  • 维护引用完整性:包括单个资源内的交叉引用,以及资源间的交叉引用
  • 管理资源载入后的内存用量,确保资源储存在内存中合适的地方
  • 允许按资源类型,载入资源后执行自定义的处理
  • 一般提供统一的易扩展的接口管理多种资源类型
  • 若引擎支持,则要处理串流

资源文件及目录组织

资源通常储存为磁盘上的文件,并位于使创做者方便而组织的树状目录中。但引擎一般不会理会资源被放置于资源树中的哪一个位置,引擎会把多个资源包裹为单一文件。文件载入时间和寻道时间、开启每一个文件的时间、从文件读至内存的时间相关。这种方法能减小文件载入时间。

OGRE使用ZIP存档资源,ZIP格式的好处:

  • ZIP是开放格式
  • 内部虚拟文件有相对路径
  • 可被压缩(载入数据后解压所花的时间,一般比读取无压缩数据所花的时间少)
  • 并可视为模块(例如把须要本地化的资产打包,针对不一样语言制做不一样版本的ZIP)

虚幻3采起相似的手法,可是其全部资源都必须置于大型的pak自定义格式文件中,并不允许资源以盘上独立文件出现。

资源文件格式

每类资源均可能有不一样的文件格式。单一文件格式也可储存多种不一样类型的资产。许多引擎会自定义文件格式,由于引擎所需部分信息可能没有标准格式能够支持,以及对资源脱机处理,以让其听从某种内存布局加速运行时载入。

资源全局统一标识符

全部资源都须要资源全局统一标识符(GUID)来识别,最多见就是使用资源的文件系统路径。也有使用128位散列码。虚幻3的GUID格式是包名和包内资源路径串接而成,如《战争机器》的一个资源GUID为Locust_Boomer.PhysicalMaterials.LocustBommerLeather

资源注册表

资源管理器都含某种形式的资源注册表,以保证在任什么时候间,载入内存的每一个资源只会有一份副本。最简单的实现方法是使用字典,键为资源的GUID,而值是指向内存中资源的指针。资源载入内存时,加进资源注册表字典。卸下资源时,就删除其注册表记录。

若不能从表中找到请求的资源,最直觉的处理手法就是自动载入该资源。但这样作可能会由于临时从硬盘或光驱等缓慢设备读取数据而严重拖慢游戏帧率。

所以引擎可采起这两种替代手法:

  • 游戏进行中彻底禁止加载资源(游戏关卡的全部资源在游戏进行前所有加载,那时候一般是loading界面);
  • 资源以相对较难实现的异步形式加载,如玩关卡A时,关卡B的资源在后台加载。

资源生命期

资源管理器的职责之一是自动管理资源生命期,或对游戏提供所需API供手动管理。每一个资源对生命期有不一样需求:

  • 资源的生命周期是游戏持续的全部时间(如角色网格、纹理、动画,HUD的纹理字形等等),被称为载入并驻留(load-and-stay-resident,LSR)资源;
  • 资源的生命周期是某一关卡的时间;
  • 资源的生命周期短于所在关卡的时间(如过场动画);
  • 即时串流(如BGM、环境音效等),每一个字节只是短暂停留在内存中,可是整个文件持续很长时间。

某资源的载入时期一般在玩家第一次看见该资源便能决定,但什么时候卸下资源归还内存,就难以回答,由于可能存在多个关卡共享的资源。解决方案之一就是对资源引用计数,即载入新关卡时,遍历所需资源并引用加1,再遍历即将结束的关卡的资源,全部引用减1。当有引用计数减为0是卸载,当有新的资源的引用计数由0变为1时载入。

资源的内存管理

资源加载的内存位置可能不一样,像纹理、顶点缓冲、着色器驻留在显存,大部分资源驻留在主内存,但不一样的资源可能须置于不一样的地址范围。设计游戏引擎时,内存分配器和资源系统要相互配合。有时用已有的内存分配器来设计资源系统,有时则要让内存分配器配合资源管理所需。

  • 基于堆的资源分配:忽略内存碎片,在我的笔记本上运行的游戏能够用该方法,由于操做系统支持高级的虚拟内存功能,能够解决内存碎片问题。
  • 基于堆栈分配器:若游戏是以线性关卡为中心,且内存足够容纳各个完整关卡,则可用堆栈分配器。注意栈顶端先分配驻留资源(LSR,各关卡共享的资源),再分配关卡所需内存。
  • 基于池分配器:支持串流的引擎中,把资源数据以大小相同的组块载入。可是,要注意设计资源数据时,必须避免大型连续数据结构,或允许资源能被切割成同等大小的块。这种分配方式天生的问题就是文件内最后的组块空间被浪费。虽然组块大小较小能减小空间浪费,可是,这样会极大的限制资源数据的布局。典型的大小时数千字节。选择组块大小时,能够考虑设为操做系统I/O缓冲区大小的倍数,如512KB。
  • 资源组块分配器:专为解决上述组块浪费内存而设的分配模式。只需管理一个链表,内含全部未用满内存的组块以及自由内存块的位置及大小。用堆分配器或栈分配器管理这些自由内存块。该方案有一个问题是卸下资源内存时,其“边角”的组块也会同时消失。解决方案是只利用该种分配器分配和对应关卡生命期相同的内存,这须要独立地管理每一个关卡的组块,且用户请求分配时指明从哪一个关卡分配内存。
  • 分段资源文件:将资源文件分为若干段,每段分为若干个组块(与池分配器配合)。各段的做用不一样,有的是为主内存而设的数据,有的是仅在载入过程当中使用、载入后被弃置的临时数据,有的是发行版本不会载入的调试信息

复合资源及引用完整性

每一个资源文件可包含一个或多个数据对象,这些对象可能以不一样的方式引用或依赖其余对象,资源数据库能够表达为相互依赖的数据对象所组成的有向图。交叉引用能够分为内部(单个文件里对象间的引用)和外部(引用另外一个文件的对象)。

处理资源内部引用

在C++中, 因为指针的内存地址总会变,并且离开运行中的程序就失去意义,因此不能用指针来表示对象间的依赖。

将资源引用存为包含全局惟一标识符(GUID)的字符串或散列码,资源管理器要维护一个全局资源查找表,其中键为GUID,值为资源在内存中的地址。这样每次经过全剧资源查找表就能够将资源对象的GUID转换为指针。

储存对象到二进制文件的另外一经常使用方法是,把指针转换为文件偏移值,并创建指针修正表。

下图给出了储存二进制文件以及将文件载入内存的指针修正示意图,具体过程为:

  • 把每一个对象的内存影响遍历一次,顺序写至文件成为连续映像;
  • 写进文件的代码,清楚知道对象的数据类型和类,也就知道每一个对象的指针在哪里,把这些指针位置储存到指针修正表并一同写进文件;
  • 载入文件至内存时,映像内对象仍保持连续,并凭借修正表修正全部指针。

从文件载入C++对象,建立对象时必须调用构造函数。这个问题有两个常看法决方案:

  • 使用纯C结构体来储存数据或使用无虚函数、只含不作事情的平凡构造函数的C++ struct/class;
  • 把非PODS(plain old data structure)对象的偏移值组成一个表,表中记录对象属于哪一个类,并将它写入二进制文件中。以后加载二进制映像时遍历该表,并使用placement new语法调用构造函数。
void* pObject = ConvertOffsetToPointer(objectOffset);
::new(pObject) ClassName;  // placement new语法,ClassName为对象所属的类名

处理资源外部引用

要正确表示外部引用,除了指明偏移值或GUID,还要加上资源对象所属文件的路径。通常作法是:载入每一个资源文件时,扫描文件中的交叉引用表,并载入全部被外部引用但未载入的资源文件,当载入全部互相依赖的资源时,就用主查找表把全部指针转换成真实的内存地址(经过GUID或文件偏移值)。

资源载入后初始化

有一些资源载入后须要一些处理才能供引擎使用,这种载入后的全部处理被称为载入后初始化。

  • 某些状况下,没法避免载入后初始化,例如三维网格的顶点和索引载入主内存后,几乎老是要传送至显存,并且只能在运行时进行。
  • 其余可能载入后初始化能够避免,但为了方便。

资源的载入后初始化和拆除,都有独特的需求。在C中,可使用查找表,把每一个资源类型映射到一对函数指针,一个负责载入后初始化,一个负责拆除。在C++中,可使用构造函数和析构函数来处理载入后初始化和拆除。可是为了方便多态,通常为每一个类设置如Init()Destroy()的虚函数用于独立初始化和销毁工做。

载入后初始化和资源内存分配策略息息相关,有时初始化会在文件的数据上新增数据(如额外计算类中的成员数据),有时初始化的数据用来取代己载入的数据(如引擎载入过期格式的网格数据,自动转换为最新格式,以保证向后兼容)。能够采用先载入到临时内存区域,初始化完成后再把相关数据复制到内存最终位置(例如《迅雷赛艇》的引擎)。

相关文章
相关标签/搜索