《Unity3D高级编程之进阶主程》第五章,资源的加载与释放

我们在计算机上编程,始终逃不过计算机的体系范围。对于计算机来说不过是进程,线程,CPU,CPU缓存,内存,硬盘,GPU,GPU显存,我们在编程和优化时始终围绕着这个几个关键点在做文章。

===

从计算机宏观的角度上来看,计算机本身的内容就这么些,假如我们暂时不去细想具体的逻辑细节,我们可以从大体上明白我们需要做的工作与这些内容有多大的关系。

我们制作的软件运行在进程上,所以进程是我们的载体,线程可以分担进程的负担,有利于我们更大限度的利用多核CPU,这样就有不只一个CPU为我们工作。

在PC下CPU的负荷是由多个进程的消耗所组成的但我们只需要关心我们自己进程的消耗,它大部分都是由业务逻辑、算法,内存分配与回收等程序运行所造成的消耗。

CPU缓存的存在增加了CPU执行命令的命中率,使得CPU执行效率提高。只是CPU只认机器码由1和0组成的命令组合,于是在CPU之上又有了汇编这种语言做中间件,使得我们能够不用去记住0和1的世界,只是这个中间件还需要操作寄存器等直面硬件的事务让我们觉得还是太繁琐。对于庞大的软件系统来说人类无法承受如此庞大复杂度系统下的调试之苦,于是就有了更高级的语言+编译器来让编程变得更加简单,编译器它翻译了我们能容易识别的各种语言包括C++、Java、C#等,它先翻译成中间语言再将中间语言翻译成的二进制码被CPU识别。

这些高级语言能够让人类更加专注于编写复杂和庞大的软件系统,从而解放了我们直面硬件的痛苦。Unity3D引擎从这层意义上来说也是做了同样的事情,它将大部分对OpenGL/DirectX等图形接口底层的调用都封装好了等我们去用,解放了我们需要学习枯燥复杂的底层的时间,可以将更多的注意力放在对业务逻辑的框架结构和逻辑上。但是在我们制作过程中始终绕不过去的是硬件的工作流程和方式,如果我们想要编写更加优秀的程序,就得学习和理解这些底层原理,也只有这样才能明白我们在编写具体框架逻辑中对计算机是有利还是有弊。

内存是除了CPU缓存外最快的数据存取地点了,所以要想更快的取得内容就要更多的借助内存,不过也不能无止境的使用内存,比如我们的移动设备,在手机中内存还不是那么廉价或者说容量还不足以可以肆无忌惮的任意使用,即使是PC上内存已经足够大了也要考虑到其他软件进程的内存消耗,不能只顾自己进程的霸占更多内存。

硬盘在现代已经是很廉价了,硬盘占用的大小已经很少被大家所诟病,不过背后却需要考虑宽带问题。虽然硬盘廉价,但宽带并没有那么廉价,大部分对磁盘的占用都需要从网络上下载下来,这种宽带的占用量其实是紧张的,因此我们经常需要约束对硬盘的占用量,就是为了考虑下载时更多的节省宽带的占用量。

GPU对CPU的优势是对图形图像的处理比CPU更加强大,它的主要架构和运行模式都是为图形图像而生的,CPU常将大部分图形图像处理都交给GPU去做。从前都是CPU自己处理图形图像,但现代社会中GPU已经不像是以前那么昂贵了,成本已经到了平民化的水平,GPU也已经完全普及在各大设备中。即使是GPU普及的情况下,但GPU的性能好坏也是参差不齐,我们依旧要尽最大的努力去学习和理解GPU的运作原理,以在利用GPU算力时尽可能得发挥其最大优势,以及降低无用的等待与消耗。

虽然移动设备架构的设计没有将显存加入进去,但显存在PC上却是运用的玲离尽致,显存也确实让GPU在工作效率上升了一个台阶,就像CPU的缓存一样,显存让GPU得到数据和处理数据的效率更高。

前面的章节说了这么多关于算法、框架、结构、数学、图形学等,我希望能用更大的角度来观察总结我们所天天需要接触的编程工作。每条语句,每个结构,每个编码都能知道我们现在所要围绕的是哪个节点,是否能通过节点优化来让当前程序的执行更加高效,让程序跑在设备上时更加流畅。
说了这些关于计算机本身的事,寓意是想从宏观的角度看问题,抛开具体的架构、系统逻辑、框架结构等细节来看看我们所面对的工作到底是个怎样的世界。从根本上看我所说这些并不是什么特别具体底层的东西,但是也可以从另一个角度了解我们所面对的编程工作,对我们未来的技术方向会有很大的帮助。

前面讲了很多关于计算机本质的东西,这节我们主要来讲一下关于资源的内容。

资源加载的多种方式

虽然资源的格式并不一定要依照引擎来,但如果自己另开辟一种格式来做为自定义资源格式确实耗时耗力,性价比很难适合,虽然也要按项目的需求来,如果是那种资源保密性要求很强的,其实也可以借助Unity3D自身的机制来完成加密工作(下面的章节中会介绍加密)。

这里我们还是主要来说说以Unity3D自身格式为重点的资源加载方式。

我们可以把资源加载分为阻塞式和非阻塞式。到底什么是阻塞式什么是非阻塞式呢?

简单来说,阻塞是当前资源加载完了才能执行下一条语句,非阻塞是开另一个线程(或协程)加载资源,当前的主线程可以继续执行程序,当加载完毕时再通知主线程。

在Unity3D中阻塞式的加载主要有:

    1.Resource.Load

Resource.Load是传统的资源加载方式,Unity3D通过Resources这个名字的文件夹来加载资源。

在移动设备下,Unity3D打包了Resources文件夹的所有资源文件成为1个或几个资源文件(是资源文件合并成1个或几个资源包文件)放入包内,当调用Resource.Load时从这几个资源文件中加载。

这个资源包文件会被Unity3D在打包时压缩,保证了包体的大小会适度的减少,压缩的另一面是解压,因此在通过Resource.Load加载时也增加了解压的CPU损耗。这也是很多项目不乐意使用Resources的缘由,解压消耗带给他们不必要的开销,因为CPU资源比硬盘资源珍贵的多。
    2.File read + AssetBundle.CreateFromMemory + AssetBundle.Load

我们也可以先通过文件操作加载资源文件,再通过AssetBundle.CreateFromMemory的方式把byte数据转换成AssetBundle格式,再通过AssetBundle.Load从AssetBundle中加载某个资源。

这种方式看起来费时费力,但是这种方式可以加入我们些许自定义功能。比如能在加载AssetBundle前做加解密操作,加载AssetBundle前自主加载了文件,文件的数据的加解密方式就可以自由的把控,我们可以先用文件操作获得数据后解密,再转换成AssetBundle实例,最后交给资源控制程序处理。

不过获得加解密AssetBundle的能力,是需要付出代价的,代价就是内存和GC(内存的分配与销毁)。

由于用文件操作时完全读入了整个文件的数据,导致当前还不需要的资源也一并读入内存,增大了内存消耗,另外转换成AssetBundle后的byte数据也不再由用处,等待GC的消耗动作,大大增加了内存分配和销毁的CPU负荷。

    3.AssetBundle.CreateFromFile + AssetBundle.Load

我们还可以使用通过直接加载文件变成AssetBundle的方式,再通过AssetBundle.Load接口来获得资源。

这种加载方式最大的好处是按需分配内存。AssetBundle.CreateFromFile并不会把所有资源文件整个加载进内存中,而是先加载数据头,通过数据头中的数据去识别各个资源在文件中的偏移位置,当调用AssetBundle.Load时,根据数据头中对应资源偏移量的记录,找到资源位置,加载数据进入内存,因此我们说它是按需分配内存的。

在Unity3D中非阻塞式的加载有:

    1.AssetBundle.CreateFromFile + AssetBundle.LoadAsync
    2.WWW + AssetBundle.Load
    3.WWW + AssetBundle.LoadAsync
    4.File Read all + AssetBundle.CreateFromMemory + AssetBundle.Load
    5.File Read all + AssetBundle.CreateFromMemory + AssetBundle.LoadAsync
    6.File Read async + AssetBundle.CreateFromMemory + AssetBundle.Load
    7.File Read async + AssetBundle.CreateFromMemory + AssetBundle.Load
    8.File Read async + AssetBundle.CreateFromMemory + AssetBundle.LoadAsync

几种方式主要是由文件读取和AssetBundle异步加载形式组合而成。

前2种为主流的异步加载方式。其中第1种用的比较多,因为大多数资源文件都会在游戏开始前进行比对和下载,所以没必要使用WWW的形式从本地读取或从网络下载。

其实这里涉及到“为什么要用非阻塞加载”的问题。阻塞式加载这么好用,为什么还要用非阻塞式。

我们不要为了异步而异步,有人会觉得异步更高级,如果只是为了异步而做异步是没有意义的。大部分情况下我们在使用阻塞式加载资源时,都会遇到一个问题,在某一帧加载的资源很多,加载完毕后需要实例化的资源也很多,从而导致画面在这一帧耗时特别长,画面卡顿现象特别不严重,用运营同学们的话说“对用户来说不友好”。为了能更好更平滑的过度场景,我们需要把要加载和实例化的时间跨度拉长,虽然增加了些许等待时间,却能平滑过渡到最终我们需要的画面。

具体怎么做呢。其实不复杂的,可以先获取所有需要加载的资源,放入队列中,每次加载N个(N可以根据实际情况调整),如果已经加载过的就直接通知逻辑程序实例化,不曾被加载的则调用加载程序并将调用后的加载信息(AssetBundleRequest)放入‘加载中’队列,不开携程而是用Update帧更新去判断‘加载中’队列中是否有完成的,每加载完毕一个资源先从‘加载中’队列里移除,再通知逻辑程序再进行实例化,直到队列中的请求加载完毕为止,继续下一个N个加载请求。当然这里也需要做些判断,例如已经在加载队列里的资源不重复加载等一些避免重复加载的判断。

AssetBundle的引用计数方式卸载

Assetbundle在加载后我们需要寻求释放,只有加载没有释放内存只会不断攀升。该怎么释放就成了问题,因为资源使用的地方太多,太庞杂,所以为了能更好的知道什么时候该释放资源,我们需要制定一个规则,这个在遵守这个规则的前提下,我们就知道什么时候资源没有被再使用了,有多少个地方在使用。

引用计数就是判断这种释放依据的很好的技巧,具体方式为如下:

我们对AssetBundle包装一个计数器(是个整数),当需要某个AssetBundle时先加载所有依赖的AssetBundle,每加载一个AssetBundle就为该AssetBundle的引用计数加1。

如果调用的是Prefab,会通过Instantiate进行实例化,这里必须在每次实例化时对该AssetBundle引用计数加1,不过这样在实例化时才做引用计数加1的手法,又消耗了些许我们的注意力而且容易遗漏,我们可以选择一次实例化调用一次加载,这样就节省了人额外的注意力,少一点主意力的消耗,就少一些遗漏。

如果是Texture贴图这种不需要进行实例化的资源则最好不要被再次被引用,因为被再次引用会导致引用计数的错乱,我们可以选择每次当需要Texture时通过查看AssetBundle是否加载,有则直接取,没有则加载后再取,每次取资源时都对相应的AssetBundle计数加1。

当Destroy销毁实例或者不需要用资源时,则统一调用某个自定义的Unload(假设这个接口名字是自定义类AssetBundleMrg.Unload)接口并附上加载时的关键字(为了能更快的找到AssetBundle实例),从而将对应的AssetBundle的引用计数减1。

减少引用计数后,倘若该AssetBundle引用计数为0,则认为可以进行AssetBundle卸载,则立即卸载。

但是问题又来了,及时的卸载也会有问题,因为每次都卸载后又需要该资源时需要再加载,中间消耗的IO和CPU也很多,我们可以通过增加空置倒计时时间来给卸载AssetBundle一个预留时间。

当需要卸载时,AssetBundle进入倒计时,比如5秒,5秒内仍然没有任何程序使用这个资源则立即进行卸载,如果5秒内又有程序加载该AssetBundle资源则继续使用引用计数来判断是否需要进入卸载倒计时。

不过还是有个小问题,如果大量资源在同一时间卸载,就会造成大量资源同一时间进入倒计时,倒计时完毕同时进行卸载,也会带来1帧消耗过大的问题,毕竟资源的卸载时内存的消耗,大量的内存在同一时间销毁会带来大量的CPU消耗。此时我们可以对倒计时进行随机2-5秒的时间内随机一个值,让卸载分散在这个时间段内,让卸载的消耗更加平滑。

AssetBundle的打包与颗粒度大小

Unity3D对AssetBundle的封装做的很好,当我们在打包AssetBundle时Unity3D会自动去计算AssetBundle与AssetBundle之间的依赖关系,所以我们能很轻松的将资源打的很细(贴图,网格,Shader,Prefab,每个资源分的很开)。

这使得我们能很轻松得让一个AssetBundle只装一个资源文件并且控制起来也得心应手,只要在加载时读取存有依赖关系的AssetBundle就能得到AssetBundle之间的依赖关系数据(AssetBundleManifest实例数据),根据这个依赖数据我们就能轻松的加载相关的其他AssetBundle。

既然AssetBundle颗粒度可以很容易的缩放,那么我们就需要考虑颗粒度的大小到底对项目产生多大的影响。

我们说说左右两种极端状态下的表现。

一种为颗粒度极粗状态,所有资源都打成一个AssetBundle包,所有逻辑程序要的资源都从这个AssetBundle里取。引用计数,在这里已经完全没有了用处,由于只有一个AssetBundle已经完全没有卸载的可能了。这导致了内存只会逐步增大,而绝不会因为不再需要某资源而卸载AssetBundle(当前AssetBundle的卸载机制中没有只销毁某部分资源的功能)。

我们来看看整个过程,从一个很大的文件包从网络上下载下来,解压后成为一个AssetBundle文件,然后我们读取它并从中获得资源。从这个过程来看只有一个AssetBundle的极限状态下,文件操作的数量极低,导致读取AssetBundle文件信息没有障碍,解压的IO连续性非常高,导致解压时不需要创建很多文件从IO上会相对比较快些,同时由于只有一个文件内容所以打包的压缩率也是最大的。

另一种为颗粒度极细状态,所有贴图、网格、动画、Shader、Prefab都各自打自己的一份AssetBundle(一份AssetBundle只带一个资源)。为了能更有效的控制内存,AssetBundle之间的依赖关系和引用计数在这里用处非常大。通过引用计数和依赖关系,我们能很有效的控制逻辑系统中需要的资源和内存中的资源是一致的。

我们来看看整个过程,从网上下载下来所有AssetBundle资源文件,对每个压缩过的资源文件进行解压,当需要某个资源时从AssetBundle读取资源并且读取前先根据依赖关系读取需要的资源,并且对所有加载过的AssetBundle引用计数加1。当卸载时,对当前卸载的AssetBundle引用计数减一,并且对存有需求上依赖关系的其他AssetBundle也减一(由于当前资源卸载后对其他依赖资源不再引用),如果引用计数为0则启动卸载。

我们从这个过程看来,一个极限细分颗粒度状态下的AssetBundle机制,文件操作数量会很大,IO操作的时间会因为文件增多的增大许多,导致下载时间拉长,下载完毕后解压的总时间也会拉长,打包时由于每个文件单独打包压缩因此压缩比率会降低压缩时间加长。

上述分析了两种极限状态下的利弊,我们可以根据自己项目的需求来定制AssetBundle打包机制。

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    《Unity3D高级编程之进阶主程》第五章,资源的加载与释放

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号