前言
这篇文章是个人笔记。记录了在研究Unity资源管理与优化方面的一些成果,倘若读者是带着相关疑问找到这篇文章的,我希望读者能在看完整篇文章有所收获;倘若读者是带着批判的眼光来看这篇文章的,我希望读者能就文章中的错误向我致信,并予以斧正。
Unity版本:Unity 2022.3.14f1c1
目录
Unity 资源
什么是 Unity Assets?
直译为 Unity 资源/资产。Unity 项目中用来创建游戏或应用的任何项(官方文档)。我个人鲁莽地将Unity项目的Assets文件夹中的全部内容视为Unity Assets,即 Unity 资源。
在大部分官方文档中,Unity貌似更喜欢称“Asset”为“资产”,但是本文中主要称其为“资源”。
为什么要用到 Unity Assets?
可能这是一句废话,因为制作游戏当然需要资源。但Unity作为游戏引擎,通过对资源的特殊处理,使得开发者可以轻松利用资源,提高开发效率。
怎么使用 Unity Assets?
官方文档中介绍了基本工作流程:
1.导入:“就是制作素材,然后导入时调调参数,设置什么的。”
2.创建:“就是用这些七七八八的玩意做游戏啦!”
3.构建:“就是Build,指将项目打包成游戏的过程。”
4.分发:“怎么让用户接触到你的游戏。”
5.加载:“怎么给用户加载资源的相关问题。”
我没有进行具体阐述,因为实际情况都比较复杂,请自行按需查找相关资料。
Unity 对资源的特殊处理
官方文档的描述得挺好的:
怎么做?
Unity 读取并处理您添加到 Assets 文件夹的任何文件,将文件内容转换为可直接用于游戏的内部数据(所以在Project视图下看到的资源 ≠ 导入的资源)。资源文件本身保持不变,内部数据存储在项目的 Library 文件夹中。此数据是 Unity 编辑器的资源数据库(AssetDatabase)的组成部分。
为什么这么做?
通过使用资源的内部格式,Unity 可以准备好可直接用于游戏的资源版本,以便在 Editor 中在运行时使用,同时将未修改的源文件保留在 Assets 文件夹中。使内部格式与资源文件分开,意味着您可以快速编辑它们并让 Editor 自动获取更改。例如,Photoshop 文件格式很方便,可以将 .psd 文件直接保存到 Assets 文件夹中,但移动设备和 PC 显卡等硬件不能直接处理该格式来渲染为纹理。
Library文件夹是什么?
Unity 将资源的内部表示形式存储在 Library 文件夹中,该文件夹类似于缓存文件夹。作为用户,切勿手动更改 Library 文件夹,尝试这样做可能会给 Unity Editor 中的项目带来负面影响。但是,只要该项目未在 Unity 中打开,就可以安全删除 Library 文件夹,因为 Unity 可以通过 Assets 和 ProjectSettings 文件夹重新生成其所有数据。这也意味着不应将 Library 文件夹纳入版本控制。
Unity 资源元文件(.meta)
点开Assets文件夹,你会发现每个文件都有对应的同名.meta文件。资源导入时,Unity会为该资源创建一个唯一标识(GUID),并生成一个伴随资源文件的同名.meta文件。这些.meta文件记录了对应资源的GUID和导入设置。其中的GUID是Unity内部使用的ID,用于引用资源。而导入设置则是指Unity内置导入器类及其成员配置。所以.meta文件非常重要,它间接记录了Unity对资源的引用和使用方式。
.meta文件本质是用YAML编写的文本文件,你可以直接用文本编辑器一探究竟。YAML是一种给人看的数据序列化语言,类比Json。
正因如此,当你想通过从资源管理器移动资源文件时,请确保资源文件和它的.meta文件“同生死共存亡”。否则容易破坏资源之间的引用关系。在Editor中的正常操作不会有这些问题,所以尽量避免在Editor编辑器外移动资源文件。
更多细节在官方手册。
Unity 内置资源类型
除了常规的通用资源(音频,贴图,模型等文件),Unity有一些内置的资源类型,如场景(Scene),预制体(Prefab),脚本化对象(ScriptableObject)等。
如果用文本编辑器打开场景文件(后缀.unity)会看到第一行(或者别的哪里)有醒目的“YAML”四个字母。是的,.unity文件也是一个以YAML编写的文本文件。事实上,诸如Prefab,ScriptableObject的资源文件同样是用YAML编写的。在这些文件中,你除了能看到GUID,还能看到fileID这个字眼,顾名思义,它是该文件内部的ID,也叫本地ID(Local ID)。GUID和fileID共同构成Unity资源引用关系,其中GUID负责文件外部之间的引用,fileID负责文件内部之间的引用。仔细研究一下就很容易理解。
预制体文件的后缀是.prefab,ScriptableObject的是.asset。
除此之外,还有例如 ProBuilder 网格 (ProBuilder Mesh)、Animator Controller、混音器 (Audio Mixer) 或渲染纹理 (Render Texture)之类的。不过这些它们在编辑器内部创建。
Unity 资源管理
什么是 Unity 资源管理?
依靠Unity内置方案或开发者自定义方案管理Unity资源。
为什么要管理 Unity 资源?
游戏就是资源的呈现,表达,相互作用。对资源的不恰当管理会对游戏运行产生不良影响,甚至导致游戏崩溃。优秀的资源管理是优秀游戏的技术基础。
如何管理 Unity 资源?
想要管理好Unity资源,必须先理解以下内容:
Unity 资源加载的方式:
只有两种普遍方式:静态加载和动态加载。
静态加载的主要体现是场景(Scene),包括场景中的一切对象及其引用(或者更Unity的说法,依赖项Dependency)。详细地说,Hierarchy里的游戏对象(GameObject),它们身上的组件(Component),组件里引用对象等等。除此之外还有一些维持Unity运行的对象,看不见的或看得见的。
动态加载一般是由开发者控制的。根据游戏设计对资源加载的时机,规模等进行规划,并最终通过资源管理方案加载资源。更广泛的动态加载也包括Unity自身系统运行产生的对象。
Unity 资源管理方案:
Unity 有一些内置的方案/方法来辅助开发者管理资源:
1.AssetDatabase “编辑器下的辅助小能手。”
2.Resources “脆弱又强大的Prototype Maker。”
3.AssetBundle “简单朴素的得力干将。”
4.Addressables “时代新星。”
这里我只对这些方案作一个简单概述,因为篇幅原因,就不详细介绍了。
其他:
对Assets中文件夹的管理也同样重要,美观,直观的目录能加快开发效率。而且Unity中有不少特殊名称文件夹,如上面的Resources文件夹,同样参与资源管理。
Unity 资源管理跟优化的关系
Unity 资源管理跟优化有什么关系?
当你对Unity资源管理有了一定的了解后,肯定会 开始思考它与游戏优化的有什么联系。但要谈这个不得不引入内存这个家伙。
内存是什么?
内存 (Memory)是计算机的重要部件,也称内存储器和 主存储器 ,它用于暂时存放CPU中的运算数据,以及与硬盘等 外部存储器 交换的数据。 它是 外存 与 CPU 进行沟通的桥梁,计算机中所有程序的运行都在内存中进行,内存性能的强弱影响计算机整体发挥的水平。(百度)
等等,我们当然不可能从这么基础的内容开始讲,不过如果你在仔细看看。事实上,上面只有两句话是我们需要的:
“它是外存与CPU进行沟通的桥梁”与“内存性能的强弱影响计算机整体发挥的水平”。
Unity 中的内存(Memory in Unity)简述:
Unity有着自己的一套内存管理方案,分为三个层级:托管内存(Managed memory),C#非托管内存(C# unmanaged memory)和原生内存(Native memory)。
其中托管内存主要是设计来给游戏脚本执行预留空间的,而且它是“托管的”,通过垃圾回收器(GC)释放空间。所以在这篇主要讨论“资源”的文章我先暂且放下。另外一个C#非托管内存也是和代码挂钩的。我们也先不管。但是这个原生内存(也叫本地内存)可就与我们的主题息息相关。更多详细信息请务必看看官方手册或相关文章。
官方文档中有对原生内存存储对象的详细描述:“Unity stores the scenes in your project, assets, graphics APIs, graphics drivers, subsystem and plug-in buffers, and allocations inside native memory, which means that you can indirectly access the native memory via Unity’s C# API.”“Unity将项目中的场景、资产、图形API、图形驱动程序、子系统和插件缓冲区,及其分配存储在原生内存中,这意味着您可以通过Unity的C#API间接访问原生内存。”(翻译可能有误)
但是我非常疑惑文档中的一段话:“Most of the time, you won’t need to interact with Unity’s native memory,”“大多数时候,你不需要和原生内存打交道,”
事实上,我认为原生内存应该成为内存优化的首要目标。而实现这一目标的最重要步骤就是资源管理。
Unity 资源管理与内存优化的关系
还记得我们谈到内存时那两句话吗?“它是外存与CPU进行沟通的桥梁”与“内存性能的强弱影响计算机整体发挥的水平”。当一个游戏开启时,游戏里的资源需要被加载到内存中,才能被CPU访问,才能实现游戏中的资源交互和逻辑碰撞。倘如加载的资源过多,内存分配不合理则会造成游戏性能损耗,甚至崩溃。所以我们才要进行资源管理,资源管理对游戏的优化表现在对内存的优化上。因此我们可以说“Unity 内存是 Unity 资源 与 CPU 进行沟通的桥梁”“Unity 内存性能的强弱影响游戏整体发挥的水平”。当然Unity内存优化并不仅仅和资源管理相关,可以将它们理解成包含关系:Unity内存优化包括Unity资源管理。
所以游戏中所用到资源需要被管理,无论是静态加载的资源,还是动态加载的资源,或者是别的什么地方冒出来的资源。
Unity 资源的 静态加载与优化
还记得我在介绍静态加载时说过的一句话吗?“静态加载的主要体现是场景,包括场景中的一切对象及其引用。” 那么Unity加载场景时到底发生了什么?
首先,Unity识别场景文件并读取数据(一般是序列化数据),还记得场景文件中都写了什么吗?你可以打开项目中的.unity场景文件看一下。里面描述的了场景里有哪些资源,资源的配置信息,资源之间引用关系,还有场景的配置信息等等用来构建一个场景的资源信息。在游戏打包后的场景文件也是一样的,不过经过了加密。请注意,此时这些“资源”还只是序列化数据(一堆字符串)。
然后,根据数据加载出资源(Asset),此时这些资源就作为一个“Object”真正存在并能作用到游戏中了,请注意,我这里所说的“作用”是指资源发挥自己作为资源的作用。简单的说就是“被人引用和引用别人”(Referenced By & References To)。此时这些场景资源依然被存入Unity的原生内存中。不过它们还只是存在内存中的Object,看不到摸不着。
最后,Unity根据这些数据和场景信息依依通过Unity实例化(instantiate)创造出来,最终形成了我们看到的游戏对象(GameObject)。这正是一个游戏引擎,或者更广泛的说这正是一个游戏该做事之一 —— 具象化数据。
如果还不够清晰,这里还有一个很具体的例子:请考虑我有一个场景,场景中有一个Player(GameObject),它身上有Transform,Sprite Render等组件(Component)。我将项目保存打包,Unity生成加密场景文件。我点开游戏,Unity读取场景文件,加载场景资源,此时Player就是一个资源,一个叫作"Player"的GameObject,一个GameObject资源(因为GameObject本质是一串代码,是一个类,是一个Object)。加载GameObject时发现身上挂载有Sprite Render组件,好的,加载Sprite Render组件(此时也是一个资源,因为Component本质也是一个Object),唉?Sprite Render组件中有个属性是Sprite,Sprite引用了XXX纹理(Texture),好! 加载XXX纹理......以此类推。然后GameObject资源及其引用资源全部完毕,将他们依依实例化:GameObject实例化变成我们所能看到的GameObject(或者更贴切的叫法:场景对象 Scene Object);Sprite Render实例化变成了挂载在Player身上的组件;XXX纹理实例化......等等,这个东西可不能随便实例化。
事实上,在绝大部分游戏中,纹理资源一直是占用内存的大头,当然还有诸如着色器(Shader),模型文件那种听着就很大的文件类型。设想一下,倘若每个场景中的GameObject都有只属于自己的XXX纹理,每个XXX有1mb大,生成10000个GameObject,单单XXX就占用了10000mb,那么你的内存就爆炸了(夸张说法,我知道你电脑内存很大)。而且有些精美的纹理可不止1mb的大小。
作为一个合格“黑暗技术”引擎,Unity当然想到了这一点。所以有些资源并不需要我们去多次实例化。就像XXX纹理资源,我们只用加载一次到内存中,Unity会根据对象之间的引用关系自动赋值。具体实现方法就是利用之前介绍到的GUID,fileID,还有特别的InstanceID。这些东西不需要我们去操心,这也是游戏引擎存在的意义之一。
你也许会想知道到底哪些资源会被实例化,哪些只会被引用。那么我强烈推荐你使用Unity自带的内存分析器(Memory Profiler),自行去进行探索,分析器功能全面而且非常直观,不仅有详细信息还有相关介绍说明,你甚至可以看到资源彼此之间的引用关系,资源和Scene Object之间的引用关系,并以此溯源,追踪,探索它们之间“剪不断,理还乱“的青涩隐晦关系。就像如果你查看texture资源,你会发现它们只会被引用,不会引用别人(References To = 0)。
好的,别忘了我们还有一个场景正在加载呢。当合成GameObject需要的东西都实例化好,引用好了之后,我们所看到游戏对象就活灵活现眼前。游戏对象(Scene Object)和组件(Component)需要被实例化的原因也同样出于游戏设计需要,比如每个GameObject都必须要有自己的Transform来表示存在位置。回想一下我们写过的那些代码,利用的那些API。Resources.Load<>( )的时候我们怎么做的?Instantiate( )的时候我们怎么做的?......也许我们能有更深的体会。
当我们切换场景时。像这些属于场景的静态资源就会被Unity清除出内存。但是只有失去所有引用(包括被人引用和引用别人)了的资源才会被卸载。那些仍然被某些对象引用的资源不会被清理,比如用DontDestroyOnLoad( )方法标记过的对象若是引用了场景中的某些资源,则在场景切换后,那些被引用资源不会被卸载。基本上场景静态资源与场景”同生死共存亡“。
说了这么多,我相信你对如何优化静态加载相关的这部分资源已经很了解了。下面我列出的一些可能方案仅供参考,实际还得按照游戏具体设计,和开发者个人习惯而决定:
1.减少构建场景的大小。场景的大小不仅决定了需要加载的资源也决定了最终打包后场景文件的大小,并最终影响包体大小和场景加载时间。如果不是很需要一个庞大的静态场景,请考虑尽可能缩小场景大小。值得一提,在场景中Active = false ,即没有激活的对象同样会被加载,所以请确保未激活对象都是可以动态激活的。
2.规划好静态资源和动态资源。明确哪些资源需要静态加载,哪些需要动态加载可以节省很多内存。比如玩家进入了”森林地形“(是一个场景)那么里面的花草树木可以是静态资源,除此之外还可以有”雪原地形“(也是一个场景),里面的积雪冰窟也是静态资源等等。而主角本身和敌人都是动态生成的。比如有一个叫”劫匪“的敌人,它不属于任何一个场景而是作为动态资源等待加载,因为我们不希望劫匪仅存在于森林地形,我想雪原地形也有劫匪。所以我们根据设计动态加载劫匪,就省去了一笔基于”劫匪“这一资源的开销,因为倘若我们即在场景里摆放了一些劫匪,又在游戏时动态加载了一些劫匪,那么它们场景原本需要的劫匪资源被加载后,动态加载劫匪需要的资源也被加载进来了,虽然Unity并不会加载重复资源,但还是会花费一丁点开销在判断等方面。
3.减少在Inspector中的引用赋值。一直拖拽一直爽,我也很喜欢拖拽赋值,你甚至可以仅依靠拖拽赋值,不依靠动态加载也能正常实现游戏功能,这也是Unity官方提倡的,因为就像前面所说的,所有引用到的资源都会被加载(还经过了Unity相关计算)。但是重点是这些资源的信息保存在哪里的问题,如果是对场景中对象的引用赋值,那么它们会被保存在场景文件中;而动态加载是依靠资源管理方案,它们可以保存在相关文件中,我们只用修改相关文件而不用重新打开编辑器构建场景文件。当然这也属于规划静态动态资源的范畴。
Unity 资源的 动态加载与优化
谈到动态加载就更离不开资源管理方案们了。动态加载本质上与静态加载差不多,区别仅在于是谁主导的加载,静态加载比如加载场景,是由Unity主导的(虽然内容是由开发者决定的);而动态加载基本都是是开发者”手动的“。所以动态加载应该根据具体实现方案来阐述优化内容。
1.AssetDatabase
因为这个方案仅适用于编辑器环境,打包后就没有作用了,所以不能作为实际动态加载手段。
2.Resources
这种方案一般适用于原型开发,不过都原型开发了,自然不需要在意性能优化方面。但是万一有一天你很需要它,请记住不要往Resources文件夹塞太多东西,因为那个文件夹会开游戏一打开时就加载,倘若内容过多会导致短时间黑屏。而且即使是用不到的资源放进Resources里也会被一起打包,不像在Inspector中为分配引用。用不上的资源就躺在那一动不动占包体大小。
除此之外Resources还有一个很重要的API —— Resources.UnloadUnusedAssets。这个方法可以卸载所有没被使用的资源,具体一点就和上面场景切换时的资源卸载一样,只会卸载那些没有人引用也没有引用别人的资源。实际上,场景切换时就调用了这个函数。
3.AssetBundle
直译资源包,资产包,也叫AB包。是通过自己的一套文件体系存储资源的,可以把AB包文件都理解为特别的Resources文件,而且是不限数量的那种。使用AB包需要先把AB包资源加载进内存,再通过AB包资源加载里面保存的资源。不过需要注意,场景切换不会卸载AB包资源,得用Unload( )卸载掉自己才行,否则会一直存在在游戏运行过程中。而且这个函数填入的参数还有些说法,填false的话就只会卸载掉AB包资源自己而言;但是填true的话会连自己加载的其他资源一起卸载掉。这个看使用情况,可能很有用也容易出BUG,请见机行事。
4.Addressables
直译可寻址,俗称aa。正如我对它的描述“时代新星”一样,几乎没有缺点。最大的缺点可能就是学习成本吧。
Addressables系统基于AssetBundle做了拓展和优化,解决了AB包以前的一些问题,你可以将其视为”AssetBundle Plus“。
优化举例:
1.解决了AB包可以部分加载却不能部分卸载的问题。考虑当你用完AB包中的某个资源后,无法将其卸载,因为你需要等到所有AB包中的其他资源都不被使用才能卸载Unload(true)。那么即使你Destroy掉一个游戏对象,游戏对象用到的纹理还是残留在内存中。aa解决方法是简单粗暴地减小AB包体量。就是修改打包方式为(Pack Separately),细分到每个资源一个AB包(Asset Bundle),因为每个AB包仅保存了一个资源和它的依赖项,卸载的时候会一起被卸载。
2.解决了资源复制问题(Asset Duplication)。这个问题其实是上述细化分包可能会产生的问题,考虑两个AB包:每个AB都只存一个Prefab资源。两个Prefab虽然不同,但是却使用相同的纹理X。结果当导入这两个Prefab资源时,会出现两份X。这是因为aa在处理分包时,会自动补全没有被aa引用的依赖项。具体一点:两个Prefab引用的X纹理并没有添加到aa的引用中,所以aa会为两个包都补全X纹理(被补充的资源称为 Implicit)。解决方法则是将被复制的资源也添加到aa的引入中,并将被复制的资源同样划为单独的AB包。
3.以上问题可以通过aa自带的分析器解决,这也算是aa优化的工具之一。
当然这些只是冰山一角,aa还有更强大复杂的功能。
因为aa很重要,但又很复杂,所以安利一篇教程:
动态加载优化可能方案:
1.规划好动态资源被需要的时机和被卸载的时机。可以看出,静态资源的“生杀大权”很大一部分掌握在Unity手中,但是动态加载不一样,它相当一部分是由开发者管理的,充当开发者的“上帝之手”。开发者可以通过编程无限实例化复杂游戏对象和高质量贴图来让游戏崩溃,同样也可以通过高超的技巧让资源作用发挥到极致。就比如利用场景切换,场景加载这一段特殊时期,很适合给憋了很久的内存来一次大释放;还有引入诸如加载界面,过渡界面之类的东西为我们的资源加载或整理争取时间;又或者明确哪些资源是在哪些固定时机才刷新的。这些与游戏机制相关,更与开发者思维相关。
2.清楚代码对资源管理的影响。在不清楚某些API,某些操作对资源或游戏对象的影响下贸然行动可能会导致资源卸载不彻底,对象残留等内存陷阱,因为在进行资源方面相关编程应该谨慎对待。例如我们先前提到的Resources.UnloadUnusedAssets( )函数,期望的作用对象的引用关系有没有清除干净,否则函数就没用,而且这还是个开销比较大的函数。
3.善用设计模式。学习尽可能优秀的设计模式来对资源或游戏对象进行管理,可以从根本上减少不必要内存开销。例如最常见的对象池技术,通过复用对象避免频繁实例化和销毁过程。
因为动态加载的优化方案更多是取决于游戏设计,所以这里更多的是一些相关方面的建议。
结语
文章到现在我觉得真的写得差不多了,因为这毕竟是设计优化(Optimization)这个主题的文章,哪怕光是从网上搜集资料,光是记忆那些复杂的知识点,光是理解优化的意义都是费时费力的苦差。更何况还要将它们依依总结复述出来,因此我想先就优化方面休息一会。
优化是一个永恒的主题,就像我们人类追求的各种各样的极限一样,它只能无限靠近,不能抵达终点。而且越优化就越难优化,可真是一件麻烦事。所以我想对我自己说:不要拘泥于优化而对开发畏手畏脚,优化经验是靠开发积累而来的,不是一朝一夕能掌握的。哪怕现在敲了这么多字,也并不意味着已经掌握。所以开发时就大胆的去开发,把小优化当作习惯,大优化当作经验。
补充推荐
我想对读者说,这篇文章的结构和表述可能很有问题,如果不放心我推荐看看一下文章:
有些文章可能比较久远,有些表述会有差异
内存|内存的概念、内存的作用、内存的物理结构及内存使用_物理内存结构-CSDN博客https://blog.csdn.net/qq_59938293/article/details/128563047
Unity 3D中的内存管理 | OneV's Den (onevcat.com)https://onevcat.com/2012/11/memory-in-unity3d/
Unity开发实战探讨-资源的加载释放最佳策略 - 超爱煜 - 博客园 (cnblogs.com)https://www.cnblogs.com/zergcom/p/10964974.html
Unity的资源加载与卸载 - 周小菲 - 博客园 (cnblogs.com)https://www.cnblogs.com/zhoujiangyue/articles/7066070.html
Unity文件、文件引用、meta详解 - CodeGize - 博客园 (cnblogs.com)https://www.cnblogs.com/CodeGize/p/8697227.html
Unity的内存管理与性能优化 - 知乎 (zhihu.com)https://zhuanlan.zhihu.com/p/362941227