从Demo到Game Engine(六) -- Run-Time Resource Manage
仅供个人学习使用,请勿转载,勿用于任何商业用途
作者:clayman
资源可能包含所有类型的数据,比如mesh, texture, shader,音频文件,script,level data等等。面对现代游戏动辄上G的资源,一个统一的模型管理所有资源必不可少。游戏开发中的资源管理是个非常广的范围,涉及整个游戏开发阶段,不单需要引擎还需要周边工具的配合,这里把资源管理分为offline和run time阶段。
Offline: 这部分是content pipeline中重要的一个环节,主要由相关引擎工具完成,除了实现与一般程序开发版本管理类似的功能外,还包括:
资源编译:把来自DCC工具创建的文件转换为引擎可识别的格式;
创建资源数据库:允许使用某个key找到相应资源文件的位置以及资源之间的相互引用关系;
资源打包:把独立资源打包为一个数据包,被打包的数据可能需要压缩,加密;
Run-Time: 这部分属于引擎的核心功能,包括加载/卸载资源,管理资源的生命周期,保证资源唯一性。这篇文章主要讨论run time阶段的资源管理,先来看看一般引擎加载资源的流程:
create resource -----> parse resource ----> find resource file -----> compose resource
Create Resource: 发出创建资源的请求;
Parse Resource:检查是否支持所请求的资源类型,如果支持,则从资源数据库中找出相关的资源信息;
Find Resource:通过数据库中的信息,读出资源包或者来自网络的数据;
Compose Resource:组合复合资源(比如mesh可能包含顶点数据,材质,纹理….),完成资源加载;
为了实现资源的统一管理,首先要做的就是统一资源类型:
{
name;
id;
type; //string
}
所有游戏资源都必须继承或实现此接口。这里直接用string表示类型是为了方便将来扩展任意类型的资源。同样所有资源的创建也必须由一个类来完成:
{
IResource CreateResource(name, id, type, T resourceParam);
}
要避免用户可以直接通过具体资源的构造函数创建对象,否则就有可能破坏整个资源管理机制,Ogre也是这么做的。你可能会说:嘿,我只写一个小程序,创建一张空白纹理/render target,何必非弄个ResourceManager,直接暴露构造函数吧。No,我是在讲从demo到engine,你怎么还抱着老式的思想呢,不一致性只会迷惑引擎的用户。ResourceParam是一个泛型参数,传递关于特定资源的参数,比如创建一张空白纹理,resourceParam需要包含纹理大小,格式等信息:
CreateResource(name, id, “texture”, textureParam); //直接创建空白纹理
CreateResource检查特定id的资源是否已经存在,如果是,则直接返回已加载资源。上面的伪代码中name和id看起来有些重复,这样写的原因是提醒大家name不一定等于id。直接把资源名作为id当然是可行的,很多引擎就这样做,因此必须保证美术在创建资源时没有重复名称。此外,应该避免用文件路径作为id,因为真正的资源文件路径很可能会变化,路径信息应该通过查询资源数据库得到。也可以考虑用自定义的GUID,不过这也需要在off-line阶段有工具为资源生成合适的GUID。
如果ResourceManager发现要创建的资源不存在,则先检查是否支持所需要创建的资源类型。有些引擎中,ResourceManager直接负责加载所有不同类型的资源,这种设计最大的缺点就是难以扩展,每次添加新类型就不得不修改整个ResourceManager。更好的设计是使用不同对象创建不同类型的资源,我把这些对象称为ResourceLoader。ResourceLoader把自己注册到manager中,manager用dictionary,以type为key保存注册过的loader。
{
if( loadedResource.contains(id))
retrun loadedResource[id];
else
{
loader = loaders[type];
if(loader != null)
{
newRes = loader.CreateResource();
loadedResource.add(newRes);
return newRes;
}
}
}
异步加载资源是任何现代游戏引擎必不可少的特性,资源加载通常涉及到IO,解压,资源组合等一系列费时的操作,如果直接在主线程中执行,那结果显然惨不忍睹。上面的模型很容易添加对异步加载的支持。为了让资源创建不阻塞主线程,并尽可能保证无论同步还是异步加载,接口代码都不需要做改动,我们把资源分为create和initialize2个阶段。在create阶段,我们只创建一个”空”对象,并不加载实际资源数据,这一步和new普通对象并没有区别,非常快,因此不会引起阻塞;initialize阶段再加载实际资源数据。主线程调用CreateResource之后,马上得到一个新对象,继续往下执行。ResourceManager异步执行initialize,资源加载完之后,通过回调函数传递给resource。当然,需要添加一个标记,让上层系统知道资源是否”可用”。
{
name;
type;
resourceState;
LoadData();
SetData(T data, U resourceParam);
}
//ver 1:
CreateResource(……, bool asyn)
{
newRes = CreateResource();
if(asyn)
add newRes to loadTaskQueue;
else
{
data = loader.Load();
newRes.SetData(data);
}
return newRes;
}
//ver 2:
CreateResource()
{
return newRes = CreateResource();
}
ResourceManager.Load(resource, bool asyn)
{
if(asyn)
add to loadTaskQueue;
else
{
data = loader.load();
resource.SetData(data);
}
}
Resource.Load(bool asyn)
{
ResourceManager.Load(this,asyn);
}
上面列出了两种稍微有些区别的异步加载方式, 可以看到,无论异步还是同步,用户编写代码的方式都不需要改变。可以用一个简单的bool变量来表示resourceState,但资源状态并非只有2种,ogre中定义了4种不同状态,我也定义了4种,稍微有些不同:
unload : 默认值,资源已经创建,但还没有初始化;
loading:调用Load()之后,SetData()之前的状态,避免多次重复调用load();
Loaded: SetData()之后,并接通过验证;
LoadFailed:SetData()之后,但数据没有通过验证或者发生异常;
资源加载之后,相关的问题就是卸载。与创建相同,资源的卸载也必须通过ResourceManager完成,卸载方式也分为unload和destroy两种,前者仅卸载资源数据,后者则彻底删除整个对象。这里关键的问题并不是如何卸载,而是何时卸载资源。依赖用户手动卸载对象显然是困难的,传统的卸载方式有2中,一种是通过引用计数,判断对象是否有效;另一种则是把资源做为逻辑group,通过显示调用,卸载整个group数据。两种方法各有优缺,前者允许单独卸载某个资源,后者实现起来则比较容易。对group方式,只需为IResource添加一个groupId,在resourcemanager中保存group集合即可,这种方法非常适合管理静态场景数据。引用计数方式则是传统的重载操作符,实现addRef,removeRef这里不仔细介绍。
值得一提的是对于xna来说,因为C#无法重载=操作符,很难实现引用计数模式,但我们有更好的选择GC!你可能会想到,由于ResourceManager中保存了对resource的引用,除非显示删除,否则永远不会被GC回收。对,但我们可以让ResourceManager只保存resource的weak reference(详见<<clr via c#>>GC部分),这样就可以完全利用GC来进行管理了。还有一点需要注意的是,由于CG只记录托管对象的大小,对texture,vertex buffer来说,有可能native资源已经消耗的差不多,但GC只看到xna中相对较小的wrapper数据,不触发回收,因此,每次加载包含native资源的对象时,应该调用GC.AddMemoryPressure告诉GC native资源的存在。
本文仅介绍简单了资源管理run-time部分的资源加载和卸载,至于off-line阶段和rum-time下的资源定位,组合,通常与具体引擎的content pipeline,开发时所用的工具相关,内容涉及方方面面,过于庞大,就不继续讨论了。