深入Unity资源格式,实现动态依赖资源加载
发表于2016-08-07
Unity的资源读取相信已经有很多分析的文章,本文将深入资源格式及引擎源码分析unity的资源读取,并尝试给assetbundle添加一个接口AssetBundle.LoadDependentResource(name)加载一个AssetBundle中依赖的但又没有显式路径的资源。
一、目的
对于特大规模的场景来说,我们往往希望能够释放掉一些不常用或远离的被依赖的资源来减少内存。假如我们有一个名为A的fbx角色模型依赖B、C两个Texture和D、E两个AnimChip,当这个A模型离镜头比较远时,并正在播放D这个AnimClip,我们可能希望1)暂时卸载掉Texture B和C中的minmip的精细几层,只保留粗糙的几层,2)卸载掉AnimClip E,并在A重新临近镜头时,3)重新加载纹理B和C中精细的几层minmip和4)重新加载AnimClip E,5)以及在一些特殊需求下直接需要加载资源包中的某个被依赖的资源。很多同学会想到调用Resouces.UnloadUnusedAssets来卸载不用的资源,但是并没有直接的方法能够卸载和重新加载正在被引用的资源中的一部分,如本文需求中的1)~5)。
Unity引擎的AssetBundle已经为我们带有了Load(path)和LoadAll等接口,其中:
Load (name : string, type : Type) : Object需要指定一个name/path,即处于包中的资源名字/相对路径,而这名字则是打包的时候往往通过
BuildPipeline.BuildAssetBundle,
BuildPipeline.BuildStreamedSceneAssetBundle
BuildPipeline.BuildAssetBundleExplicitAssetNames
等函数显示/非显示打包的资源名字,对于依赖的资源通常会通过BuildAssetBundleOptions.CollectDependencies等标记一并打包进AssetBundle,但是我们在加载的时候却无法直接通过Load指定名字来加载,除非把这个依赖资源也通过BuildPipeline.BuildAssetBundleExplicitAssetNames显式添加名字到assetNames集合。本文的主要目的就是让程序实现能够直接加载没有明确显式指定的却又因被依赖关系打包进AssetBundle的资源。
二、AssetBundle格式
AssetFile就是我们导出exe后bin/xxx_Data目录下的各个扩展名为.assets的文件,AssetBundle就是这些.assets文件的集合打包成一个可压缩的.unity3d扩展名的文件。因此我们对Unity源码进行修改,在原有AssetBundle.CreateFromFile基础上也添加AssetBundle.CreateFromFolder允许从磁盘目录上加载一堆.assets文件作为一个映射到AssetBundle进行统一管理。每个AssetBundle包括一个头BlockAssetBundleHeader,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | public struct AssetBundleHeader { public string signature; public int streamVersion; public UnityVersion unityVersion; public UnityVersion unityRevision; public uint minimumStreamedBytes; public int headerSize; public int numberOfLevelsToDownload; public int numberOfLevels; public struct LevelInfo { public uint PackSize; public uint UncompressedSize; }; public List levellist= new List(); public uint completeFileSize; public uint dataHeaderSize; public bool compressed; }; |
AssetBundleHeader由signature,streamVersion, unityVersion等字段组成。读完assetbundle的头信息之后后面就是lzma压缩数据包括可能的AssetFile集合,把数据解压缩到一个MemoryStream后,在这个MemoryStream里首先是每一个AssetFile在AssetBundle里的条目信息:
1 2 3 4 5 6 | public struct AssetBundleEntryInfo { public string name; public uint offset; public uint size; } |
指出了每一个AssetFile在解压缩后的MemoryStream字节流中的偏移和长度。接下来就可以提取回每个AssetFile出来。

三、AssetFile格式
每个AssetFile由头信息Header、类型树TypeTree、资源对象信息ObjectInfo、外部结构externalsStruct、对象数据ObjectData等Block组成。头信息Header Block如下:
1 2 3 4 5 6 7 8 | public struct AssetFileHeader { public long metadataSize; // size of the structure data public long fileSize; // size of the whole asset file public long dataOffset; // offset to the serialized data public byte endianness; // byte order of the serialized data? public byte [] reserved = new byte [3]; // unused } |
包含了metadataSize,fileSize,versionInfo,dataOffset等字段以及表示编码顺序的endianness字段。紧随其后的TypeTree Block保存了各种序列化的类型节点,也包括了嵌入的类型信息节点。ObjectInfo包括了AssetFile里面每个Asset对象的元数据(条目信息,FileID指示了对象处于AssetFile的相对序号,offset指示字节偏移,length指示Asset对象长度,typeID指示类型类型,classID类型id,isDestroyed是否删掉)如下:
1 2 3 4 5 6 7 8 9 | public class ObjectInfo : UnityStruct { public long localID; //LocalIdentifierInFileType public long offset; // Object data offset public long length; // Object data size public int typeID; // Type ID, equal to classID if it's not a MonoBehaviour public int classID; // Class ID, probably something else in asset format <=5 public short isDestroyed; // set to 1 if destroyed object instances are stored? } |
跟随而来后面的ObjectData Block就是每个对象的具体字节流。每个对象的名字,序列化的变量,属性等,纹理的像素数据等都是存在对象的字节流里。对于打包进AssetBundle的AssetFile的第一个对象往往是一个xxx对象,而独立存在于目录上的AssetFile的第一个对象往往是一个xxx对象,分别用于反序列化这两种资源管理类。接下来就是个资源对象Asset了,比如xxx, yyy, zzz等。以纹理资源为例,xxx这样一个名字为xxx的纹理资源,首先有一堆原来在磁盘上.meta文件里记录的字段,接下来就是像素的字节流。下面给出了一些常用的类型的字段例子:

A3563f73

229bed14

Box001

AlphaTest-VertexList

unknown

UVMesh
常见的一些资源类型及其属性字段
Unity在加载这些资源的时候就是先反序列化这些字段到对象实例对应的变量上。
每个Asset对象的元数据只包含了Asset资源的引用对{FileID,localID},Asset对象自身的属性又没有相对路径,那么unity的Resouces.Load等究竟又是如何通过相对路径映射到这各个对象呢?这个相对路径到{FileID,localID}的映射表存在那?我们再次阅读unity源码,发现Unity初始化AssetBundle类实例会首先反序列化m_Container,m_MainAsset,m_PreloadTable等变量,而该类实例正是对应AssetFile中的第一个没名字的资源对象。对于目录上而非打包的AssetFile则是对应其中的第一个没名字的 资源对象。m_Container变量是一个key-value表保存了所有打包时显式资源从相对路径path到Asset{FileID,localID}的映射。我们想直接加载的依赖资源并没有在m_Container的映射表中,为了能直接加载非显式资源,我们另外建立一个映射表来实现从依赖的隐式对象名字name到引用对{FileID,localID}的映射表。
四、实现
AssetBundle导出给C#的接口定义在Runtime/Export/AssetBundleBindings.txt
具体实现在runtime/misc/AssetBundleUnity.cpp,AssetBundle.CreateFromFile 调用 ExtractAssetBundle加载AssetBundle到内存,具体加载过程是读入文件头后把AssetBundle压缩数据解开,得到多个AssetFile的头(一个AssetBundle包含多个AssetFile),名字可能包括”CAB”以识别,得到AssetFile头后按普通AssetFile调用持久化管理器PersistentManager.LoadExternalStream把AssetFile中的资源对象映射入内存。
AssetBundle.Load加载对象具体在AssetBundleUtility:oadNamedObjectFromAssetBundle实现,首先调用ResourceManager::GetPathRange获得AssetBundleBindings.txt增加LoadDependent
1 2 3 4 5 6 7 8 9 10 | // Loads object with /name/ of a given /type/ from the bundle. CSRAW [TypeInferenceRule(TypeInferenceRules.TypeReferencedBySecondArgument)] CONSTRUCTOR_SAFE CUSTOM Object LoadDependent ( string name, Type type) { Scripting::RaiseIfNull (type); Object* o = LoadNonNamedObjectFromAssetBundle (*self, name, type); if (o==0) return SCRIPTING_NULL; return Scripting::ScriptingWrapperFor(o); |
AssetBundleUnity.cpp中添加
1 2 3 4 5 6 7 8 9 10 | Object* LoadNonNamedObjectFromAssetBundle (AssetBundle& bundle, const std:: string & name, ScriptingObjectPtr type) { LocalSerializedObjectIdentifier localID = bundle.GetLocalID(name); vector< object *> result; ProcessAssetBundleEntries(bundle,localID,type,result, true ); if (!result.empty()) return result[0]; return NULL; } |
在bundle.GetLocalID 里从自定义的另外一个映射表根据名字查找资源引用对LocalSerializedObjectIdentifier {FileID,localID},得到后传入重载的另外一个以LocalSerializedObjectIdentifier为参数ProcessAssetBundleEntries逐个加载具体的Asset对象即可。
腾讯GAD游戏程序交流群:484290331