参考链接:
Best practice guidesdocs.unity3d.com本文为上述链接的读书笔记,又称二手知识。主要内容为:官方也知道自己提供了一大堆功能相似的API,担心你觉得不好用去骂官方,所以特地告诉你应该注意哪些地方,使用怎样的API以便得到更高的效率。我根据我的阅读进度进行更新整理,如有兴趣补充,请贴上本段,做成个系列。
声明:以下提到的官方建议,官方说明都为我阅读官方文档后对官方建议的转译,并不保证跟官方的想法完全一致。
Intro
先对定义进行说明:
Asset:存储在硬盘上的文件,在unity的项目里位于Assets 文件夹下,诸如纹理,三维模型或者音频序列。
Object:在本文中会使用“对象”一词表示,指的是序列化的用来描述资源实例(instance)的数据。可以是unity的任何类型的资源,像mesh,sprite,AudioClip 或者AnimationClip。所有的对象都是UnityEngine.Object 的子类。
这两个术语在特定情况下可以理解为同一个物体的两种不同场景下的叫法,比如说材质,在使用时Material为对象,而在硬盘上存储的Material和.meta文件则被称为Asset。
对象之间的引用(references)
这一部分讲述unity内部资源之间如何相互引用,是后文的基础。
一个Material对象可能引用了多个Texture对象,这些Texture可能从不同的地方导入,放在不同的文件下,可能有着不同的类型。当你导入Material的时候,至少要知道这个Material引用的纹理是什么,所以需要何时的机制记录它们之间的关系。
序列化对象的时候(把对象保存成Asset 文件),引用关系由这两种ID组成:File GUID 和 Local ID 。File GUID定义了存储资源的Asset文件,Local ID定义了在那个Asset 文件中每个对象分别的ID。
其中,File GUID存储在.meta文件中,当第一次导入该Asset的时候,这些.meta文件就会被unity创建,存储在相同目录中。
可以用文本编辑器打开.meta,该asset的guid就会出现在文件的差不多最上面的地方。
fileFormatVersion: 2
guid: e4cb888f4b6bfd54e8d75542d2c5f02f
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
打开cube.prefab文件
&6099925571170157480
GameObject:
...
--- !u!4
"&"号后面的部分就是当前对象的Local ID,通过file GUID和local ID就能唯一确定一个对象。可以看到一个简单的Cube的asset,包含了多个子对象。
资源导入
任何资源导入到unity 中,都会自动调用asset importer,你也可以通过AssetImporter API写脚本控制导入过程。
导入的结果是一个或者多个对象(多个对象的例子是:导入了一个包含sprites atlas的texture asset,那么父asset中就会包含多个子asset)。这些对象存放在一个相同的asset文件中,包含父对象的File GUID 是单个.meta文件 ,子asset以local ID区分。
在unity中,导入过程往往还伴随着资源的处理,包括纹理、模型的压缩等等,这些过程会浪费很多时间,所以一旦被导入,其结果会以二进制形式存放在Library文件夹下,文件夹名字是File GUID的头两位:
序列化和实例过程中的底层细节
尽管同时用GUID和Local ID的方法是鲁棒的,但是GUID的比较和查找是比较慢的。unity内部并不直接对GUID进行比较,而是把GUID和local ID合并成一个叫做“Instance ID”的整型(从名字大概可以看出它是实例化的对象的ID),用一个叫做PersistentManager的缓存存放这个数据。这里是简单的线性数组存放.
这个缓存定义了一个映射关系——用来查找源数据位置的ID和内存中已经实例化的对象的关系。有点绕口,概括来说,通过这个映射关系,Instance ID可以用来快速的加载一个对象。如果某个对象至今还没有被加载,就可以通过File GUID和local ID找到源数据的位置然后及时加载进来。
InstanceID的初始化和更新
在应用开始的时候,会用一系列对象初始化Instance ID的缓存,包括场景需要的所有对象,包括在Resources文件夹下的所有对象。所以官方不建议你使用Resources文件夹,第一个缺点就是会导致加载变慢。
当新的asset被导入进来,缓存就相应的增加条目;当asset被卸载,缓存就删除相应的条目以节省内存。
Monoscripts
这一部分是为了解释,为什么你打开其他asset文本文件,像Scene, Prefab等,有各种诸如MeshFilter,Material等等的子对象,但是唯独没有代码对象的原因。
首先毋庸置疑,MonoBehavior也是一个对象!所有MonoBehavior对象都引用了一个叫做MonoScripts的对象,因为MonoScripts定义了每个class所在的位置,可以用来找到某个特定的class,并且允许不同的MonoBehavior引用特定的公共的class,即便它们的MonoBehavior在不同的assetbundles中。
MonoScripts 包含三个字符串:assembly name, class name, namespace.
build项目的时候,unity会把所有松散的脚本文件编译到Mono assemblies里。在Plugins文件夹外的C#脚本放在Assembly-CShapr.dll 里,plugins文件夹下的脚本放在 Assembly-CSharp-firstpass.dll之类的dll里。
所有的assemblies都会在unity 应用启动的时候被加载进来(这就是官方强调的你的脚本也会占内存啊!你再乱用模板泛型试试看!)
资源的生命周期
当一个对象被加载进来,会发生如下三件事:
- Instance ID被间接映射到这个对象
- 对象此刻还没有被加载进内存
- 对象的源数据已经可以被定位到了,随时可以实例化。
底层细节:
当一个对象被加载进来,unity会尝试解析该对象的每一个引用对象的File GUID 和Local ID,得到Instance ID,然后加载进Instance ID所对应的对象。这需要:
- Instance ID所引用的对象现在还没有被加载进来
- Instance ID有着有效的File GUID和Local ID,以便定位到对象的位置
如果解析过程失败,没有得到Instance ID;或者Instance ID没有有效的File GUID和Local ID,我们加载的对象和它引用的对象的引用关系依旧会存在,但是会显示大家最常见到的”Missing“。
对象的卸载
- Asset Cleanup 发生的时候会卸载对象,比如说当前场景被销毁,或者开发者调用了Resources.UnloadUnusedAssets API(实际上场景被销毁的时候系统会帮我们调用这个API)。这个过程只会卸载一些没有引用其他对象的对象。
- 在Resources文件夹下的对象可以通过Resources.UnloadAsset APi卸载,但是其Instance ID仍然会保留,如果有什么活跃的对象间接引用了该对象,该对象又会被重新加载进来。
在Assetbundles中的对象通过AssetBundle.Unload(true)卸载。这个过程同时会销毁File GUID和Local ID和instance ID的联系,任何活跃的对象,如果不小心引用了这个被卸载的对象,都会出现”Missing“。但是即便需要很谨慎的使用这个方法,官方仍然建议使用这样的卸载方法,具体说明可以看后文。
如果AssetBundle.unload(false)被调用,活跃的对象不会被销毁(不会出现Missing),但是ID之间的联系会被销毁,也就是说当你重新加载这个对象的时候,系统并不知道这个对象已经有instance ID了,也就是说它会创建一个一模一样的对象。
加载大的层级结构
层级结构指的是你的prefab,GameObjects之间的套娃等。我们需要记住的是整个层级结构都会被完整的序列化,这意味着在层级结构中的每个GameObjects, Components都会被单独的序列化成自己的assets。
创建gameobject的层级结构的时候,CPU会在如下几个地方花时间
- 从存储,assetbundle或者另一个对象里读取源数据的时候
- 给新的Transforms设置父子关系的时候
- Instantiating新的gameobjects的时候
- 在主线程上awakening新的gameobjects和components的时候
后三个时间花费基本不会怎么变化,但是从源数据读取数据的时候,时间会因为components和gameobject的数量增加而线性增加。
换句话说,IO时间会决定loading操作的上界。
对于庞大的prefab,序列化的时候会单独的序列化里面的每个组件数据,即便它们是相同的,也就是说,如果你有个UI界面,有30个相同的元素,这个元素就会被序列化30次。在实际加载时,instantiate 该对象之前,系统就会先加载30次这个元素
Resource folder
官方建议最好的使用Resource folder的方法为:
Don't use it.
理由如下:
- 让细粒度的内存管理机制更困难
- 会导致程序启动时间变慢
- 不能适应特定的平台
如果你不接受官方的建议,官方也提供了一些正确使用Resource folder的方法
- Resource folder确实比较简单易用,所以你可以在快速迭代原型的时候使用它,但是上线之后就别用了。
- 如果你的内容不怎么占内存,需要在整个项目生命周期都使用,不需要跨平台等等的话,还是可以用一下的。
Resources序列化的原理
在Resources文件夹下的所有assets和对象在build的时候都会被打入单个序列化文件。这个文件也包含metadata和索引的信息,这点倒是和assetbundle一样(assetbundle可以继续往下看)。索引包含了一棵可以根据对象名字解析FIleGuid和local ID的树,可以通过这个树定位某个对象在asset文件的位置。
大多数平台下这棵树是平衡二叉树,pc和OSX下这棵树是红黑树,反正构建时间是O(NlogN),所以会随着对象数量非线性增加build的时间。
游戏开始的时候,Resoucrces下的对象一定会被加载,而且会在整个项目生命周期都存在,慢且占内存,所以官方这么不喜欢。
AssetBundle基础和原理
用处: assetbundle是游戏安装之后,对非代码内容进行更新的主要方式(从前面我们可以知道,代码对象和其他对象的处理是很不同的)。这句话意味着通过assetbundle,我们可以在运行时对游戏内容进行更新。这允许开发者提交更小的应用程序,然后通过网络将数据进行传输,可以减轻运行时的压力,可以有选择的加载和卸载内容来进行优化。
简介: AssetBundle 是一个“archive” 文件,包含的是特定平台的非代码资产(assets),比如说Models, Textures, Prefabs等。并且可以表达这些资产的互相引用,甚至不在同一个assetbundle中的资产的引用,比如这个assetbundle里面的Material可以关联到另一个Assetbundle里的Texture。
压缩:AssetBundle可以使用压缩方法进行压缩,提供了LZMA和LZ4两种压缩方法,官方建议使用LZ4方法。压缩率会低一点但是速度快了10倍。
Layout
Assetbundle包含两个部分:头和数据。
头存放了一些元信息(metadata),包括这个assetbundle的索引,压缩方法和内容的菜单(manifest)。内容菜单的Key为对象名字,值为索引(一个byte大小),我们可以通过对象名字来查询菜单,得到内容在bundle中的位置。
菜单的存储方式为balanced search tree,但是对于windows和苹果家的设备我们用了红黑树,创建时间复杂度为O(NlogN)。所以Assets数量增加的时候,创建红黑树的时间是非线性增加的。这影响了你如何选择每个assetbundle放多少东西。
Loading Assetbundles
AssetBundles 可以通过4种不同的API加载,基于两个不同的原则:
- Assetbundle被哪种方法压缩,或者没有压缩
- Assetbundle被加载到什么平台上
这些API为:
AssetBundle
AssetBundle.LoadFromMemory(Async)
官方不建议使用这个API。
对于异步加载已经存储在内存中的AssetBundle(如果你通过下载或者其他方法已经把Assetbundle的字节保存到了内存中的一个数组中),就能通过此函数进行加载。
使用这个方法的时候,底层会把内存中已经加载好的assetbundle数组byte[]拷贝到一个新分配的、相邻的地址上,如果使用了LZMA压缩方法,拷贝的时候还会顺便解压。造成的问题是内存使用的峰值至少是assetbundle的两倍。
AssetBundle.LoadFromFile(Async)
官方强烈推荐的方法,官方希望你只要有可能一定要用这个方法。(然而我们玩家什么时候考虑过官方?)
这个方法可以高性能的加载没有压缩或者通过LZ4方法压缩的AssetBundle,还记得当初我们说过的官方不建议使用LZMA方法吗?
使用这个方法的时候,大部分平台只加载AssetBundle的头,剩下的数据仍然在硬盘上。AssetBundles的对象会按需要进行加载,或者被间接引用到的时候加载。在这个情境下不会有额外的内存被消耗。
但是在运行时期,为了debug方便,实际上还是会加载整个bundle,就像 <em>AssetBundle.LoadFromMemoryAsync</em> 那样,所以我们查看profiler的时候,你会发现内存消耗依旧上去了,但是实际上不会影响到打包后的程序性能。
AssetBundleDownloadHandler
UnityWebRequest类允许开发者定义unity应该怎么处理下载的数据,避免不必要的内存消耗。其中,可以使用UnityWebRequest.GetAssetBundle对assetbundle进行下载。
使用DownloadHandlerAssetBundle 类可以对下载进行配置,使用一个worker线程流式下载数据,并存放在一个可变大小的buffer,或者某个临时的存储空间中。DownloadHandler 不会复制已下载的bytes。
对于LZMA压缩的Assetbundles,下载的时候会顺便解压,存放的时候通过LZ4再次压缩,可以通过Caching.CompressionEnabled 设置,下载完成后,可以通过DownloadHandler的属性对assetbundle进行访问,这个行为类似于AssetBundle.LoadFromFile。
WWW.LoadFromCacheOrDownload
从名字就可以看出来,可以用这个方法下载或者加载本地的Assetbundle,如果AssetBundle在本地的cache中,它就等同于AssetBundle.LoadFromFile。
如果不在本地,会从源路径读取,如果AssetBundle被压缩了,会先解压然后放进cache中,否则就直接放在cache中。然后将assetbundle的头部信息加载进来。和LoadFromFile没有本质区别。
和上面的UnityWebRequest不同的是,每次调用这个API都会创建一个新的worker线程,如果你需要加载多个Assetbundles,请创建一个下载队列确保只有少数assetbundle被同时加载。
官方建议
尽可能使用AssetBundle.LoadFromFile。如果需要下载,在unity5.3或者更新的版本使用UnityWebRequest。
Loading Assets From AssetBundles
当AssetBundle被加载进来,应该考虑如何加载其中的资源
这里又有三种不同的API了,每种API都支持同步或者异步
LoadAsset(LoadAssetAsync)
LoadAllAssets(LoadAllAssetsAsync)
LoadAssetWithSubAssets(LoadAssetWithSubAssetsAsync)
每种API的同步版本总是快于异步版本。异步版本会在一帧内加载多个对象直到达到time-slice limits,官方建议如果想要了解这背后的技术细节,去看看 Low-level loading details 那一章(本文下一章)
LoadAllAssets 会比调用多次LoadAssets快一点,所以如果你需要一次性加载AssetBundle中的大部分对象,建议使用LoadAllAssets,如果你要加载很多对象,但这些对象在bundle占据少于66.6%,这边建议你把它们单独打一个bundle哦~
LoadAssetWithSubAssets用于加载一些合并的Asset,举个例子,FBX模型会包含多个动画,sprites atlas会包含一系列sprites,如果这些对象都在同一个AssetBundle中,那么使用这个API。
对于其他任意情况,使用LoadAsset或者LoadAssetAsync
Low-level loading details
正如前面时不时提到的那样,加载object的时候并不会在主线程上执行,而是在worker线程。 除此之外,系统里的很多工作,像从Meshes建立VBOs,对Textures进行压缩也都会被转换成woker 线程。
在现代unity中,对象的加载是并行的,多个对象在worker 线程上会同时反序列化,被处理然后整合。当成功加载进一个对象,会调用它的Awake函数,然后从下一帧开始这个对象就会在整个运行时可用。
和异步加载区别开,同步加载的方法也是在worker线程上做,不过此时会暂停主线程,依旧会time-slice 加载确保对象的整合不会占据太多时间。可以通过Application.backgroundLoadingPriority设置:
- ThreadPriority.High: Maximum 50 milliseconds per frame
- ThreadPriority.Normal: Maximum 10 milliseconds per frame
- ThreadPriority.BelowNormal: Maximum 4 milliseconds per frame
- ThreadPriority.Low: Maximum 2 milliseconds per frame.
异步加载比较慢是因为它总会使用上述时间最低的版本。
AssetBundle dependencies
即便bundle内部对象所引用的资源互相依赖,只要资源所在的assetbundle都被加载进来,即使顺序不对,依旧能保证结果的准确性。但是在初始化对象本身之前,需要确保资源所在的assetbundle都被加载进来。
加载包含父对象的assetbundle的时候,unity并不会自动加载其子对象所在的assetbundle。所以这要求开发者自行加载相关的assetbundle。
Assetbundle 菜单(AssetBundle Manifest)
当使用BuildPipeline.BuildAssetBundles来构建所有Assetbundles的时候,unity会序列化一个额外对象,用一个单独的assetbundle, 这个AssetBundle只包含一个类型为AssetBundleManifest的对象,该对象包含每个Assetbundle 互相依赖信息。
这个单独的AssetBundle中的Asset会和AssetBundles创建时的父目录有着相同的名字。举例来说,如果一个项目在文件夹Client/下构建所有的Assetbundles,那个单独的,包含所有依赖信息的AssetBundle的名字叫/Client/Client.manifest。
具体我也没使用过,如果有人用了请记得放张截图给我康康。
这个特殊的assetbundle可以像其他assetbundle一样被加载,缓存和卸载,它包含的AssetBundleManifest对象提供API列出所有Assetbundles,并能查询特定的依赖:
AssetBundleManifest.GetAllDependencies//返回所有assetbundle的层级结构依赖,包括child,child的child等
AssetBundleManifest.GetDirectDependencies//返回一个Assetbundle的child
打包建议
大多数情况下应该尽可能在玩家进入游戏前尽可能加载需要的objects,如果必须在交互时加载和卸载对象,请看AssetBundle usage patterns中的Mangaing loaded assets章节。
AssetBundle usage patterns
上一部分介绍了Assetbundle的技术,包括了加载API的一些底层行为,这个章节主要讨论实际使用Assetbundles的问题。
Managing loaded Assets
卸载assetbundles的时候使用的API为
AssetBundle.Unload(unloadAllLoadedObjects)
需要额外注意参数,如果unloadAllLoadedObjects=true,不仅会卸载这个Assetbundle,还会卸载assetbundle生成的对象(即便该对象正在场景中,这就导致可能场景某个模型突然丢掉了材质,前文提到的”Missing“)。
如果需要重新加载某个对象,但该对象的assetbundles已经被卸载了,对象依旧会出现在hierarchy中,但会显示miss。
如果参数为false,对于场景中已经生成的对象,会把对象和assetbundle的联系断掉,下次重新加载进assetbundle的时候,会生成该对象的拷贝。
对于大多数应用来说,应该使用AssetBundle.Unload(true)以确保所有对象都被清除,但是为了防止正在场景中的对象被一同卸载,有两种常见的做法。
- 在切换场景或者加载屏幕的时候进行卸载
- 对每个对象的引用进行计数,确保所有对象都没被场景引用后再卸载
如果你不得不使用AssetBundle.Unload(false),对象和assetbundle连接断开,这些对象应该被用如下两种方法卸载:
- 确保该对象在场景中,在代码里都不被引用,然后调用Resources.UnloadUnusedAssets
- 加载另一个场景(不要加载在当前场景上面),这个行为会自动销毁当前场景所有对象,并自动调用Resources.UnloadUnusedAssets.
官方的建议是把整个项目划分成不同的场景,把这些场景和依赖关系build进AssetBundles里。应用进入加载场景的页面的时候,可以卸载包含旧场景的Assetbundle然后把新的assetbundle加载进来。
官方也提到了并不是所有开发者都会遵循这个建议。它只好说:“如果你要决定把什么东西放到一个bundle里,它们必须是需要同时加载卸载的”。
举例来说,如果要做一个角色扮演游戏,如地图等场景可以打包进场景的assetbundles里,而所有场景都共有的,像UI,不同角色模型和材质,可以被打包进第二个assetbundles里,在开始的时候加载进来并在整个运行周期不被卸载。
Distribution
我并不太在意这个部分,跳过了。
Asset的额外拷贝问题
当一个对象被built进一个assetbundle时,系统会找到该对象的所有依赖,依赖信息会被用来决定接下来被打进assetbundle内的一组对象。默认情况下,所有依赖都会被打包进这个assetbundle,如果有其他assetbundle也用到了相同的依赖,那么相同的依赖会再打包一份。
除非,给对象显式指定一个assetbundle的话,就会只built进该assetbundle。
如果一个对象没有被显式分配给某个assetbundle,它就会被分配给所有包含与它有依赖关系的对象的assetbundle中
举个例子,如果两个对象分配个两个不同的assetbundles,但是同时和一个常见的对象产生了依赖,那么这个对象就会被同时打包进两个assetbundles中,这不仅会造成应用大小的负担,而且如果场景加载这两个父对象,那它就会被加载两遍。
官方提出了三种解决方法:
- 确保打进不同assetbundles的对象不会共享依赖,有依赖的全部打进同一个assetbundles。这可能导致产生一个单一的庞大的assetbundles。
- 在时间上分块,两个共享依赖的assetbundles永远不会同时加载。对于关卡游戏这种做法是可以的,每个关卡都会有一些相同的依赖,但不会被同时加载。但是这会不必要的增加产品的大小,也增加了加载时间和构建时间。
- 确保所有有依赖的assets被打包进它们独有的assetbundles(而不是默认进所有的bundles),这会造成会造成程序的复杂性。应用必须追踪assetbundles之间的依赖,并且需要确保在调用AssetBundle.LoadAssets加载asset之前,正确的assetbundles被加载进来。
官方建议了第三种,在编辑器时期可以通过AssetDatabase API追踪对象的依赖关系,AssetDatabase.GetDependencies能被用来定位当前特定对象和asset的依赖,依赖可能也有自己的依赖。
在运行时,AssetImporter API可以用来查询任何特定对象被分配的Assetbundle。
通过使用上述两种工具,可以用一个编辑器脚本确保所有assetbundle的直接或者间接的依赖被分配给assetbundles。官方建议所有的项目最好都有这个东西(不知道官方现在有没有为我们做好这个了)
AssetBundle Variants
让你的AssetBundle更灵活。
允许两个对象共享相同的File GUID和局部ID,通过Variant ID进行区分。这意味着在不同的assetbundle里的不同对象可以被当作是“相同”的对象。
常见的应用场景:对于特定平台可以使用Variants简化加载流程(比如说对于不同的性能设置,可以加载不同的assetbundle,一个放高分辨率贴图一个放低分辨率贴图)
限制:
Variants必须应用在不同的assets,放在不同的文件里。
关于这个限制,官方的意思是,它的功能只是为了,根据环境的不同,用相同的方法加载不同的bundle而已,你根据你的需要自己实现一个类似的功能也不难嘛。
是否压缩
加载时间:不压缩的assetbundles加载更快
Build time: 压缩会占据时间
Application size: 压缩能降低应用体积,assetbundles可以先下载后安装。
Memory usage: 说的是5.3版本之前的事情,那时候assetbundle会先加载进内存然后解压,如果内存比较重要的话,使用未压缩的或者LZ4压缩版本的。
下载时间:如果只有几千万字节的数据,对于PC来说没必要压缩。