内存(Memory)
unity 内存部分也是优化过程中非常重要的一个环节,也会影像渲染过程中的同步等待与带宽问题。因此内存的优化也可能会给我们渲染开销带来精简,今天我们先来了解unity中的内存与使用到的内存工具。
Unity中的内存
- 托管内存:主要是指使用托管堆或者垃圾收集器自动分配和管理的内存也包括脚本堆栈与虚拟机内存。
- C#非托管内存:可以在C#与Unity Collection名字空间和包结合使用,不使用垃圾收集器管理的内存部分。如果使用数据结构,不建议使用system下的collection的数据结构,而是要使用unity collection下的数据结构进行开发。
- Native 内存:Unity 用于运行引擎的C++内存。
性能分析工具
下面我们聊一聊unity引擎提供了哪儿些内存方面的工具
- Unity Profiler下的Memory标签:这里显示了unity当下内存使用的追踪状态,包括各类内存的分配和使用情况,以及当前unity下分配的对象与资源占用的内存情况。2021以后版本,profiler不再提供对象抓取快照功能了。而是使用memory profiler 直接抓取内存快照了。
- Memory Profiler:我们可以通过它来抓取内存快照,也可以对比两个内存快照下unity对象与资源的差异。通过Tree Map来查看内存分配的视图。通过Object and Allocation标签查看具体对象内存快照,并可以通过链接直接找到原工程中对应的资源和对象,通过fragmentation标签可以查看内存片段,unity2022以后 memory profiler变得更加简洁了,针对native内存甚至可以查看到具体是分配到哪儿个allocators中的。
- Memory Settings中的设置:大家现在可以在权衡时间和空间维度上的性能指标做更精准的设置了。
- UPR中的内存快照功能:主要是针对移动设备,当你使用UPR做性能调试时会经常用到,它可以脱离unity编辑器,在运行时抓取内存信息与对象分配信息,并可以做到多帧对比。具体操作请参阅UPR手册,后期我们可能会单独做写一个帖子,纯翻译供大家阅读。
- mac或者ios上我们可以选择xcode提供的instrument下的allocation工具。
- android上可以使用android相关的系统命令或者android studio profiler工具。
Profiler-Memory
Total Memory Breakdown(总内存分解)
- ManagedHeap:托管堆,重点监控对象,不要让它超过20MB,否则可能会有性能问题!
- Graphics & Graphics Driver:驱动程序在纹理、渲染目标、着色器和网格数据上使用的估计内存量。
- Audio:音效及声音文件,重点优化对象,播放时长较长的音乐文件需要进行压缩成.mp3或.ogg格式,时长较短的音效文件可以使用.wav 或.aiff格式。
- Video:视频系统的估计内存使用量。
- Other:显示Unity跟踪的本机内存,但不在特定计数器下报告。
- Profiler:探查器功能从系统中使用和保留的内存。
Objects Status(对象状态,显示通常占用大量内存的资源类型(纹理,网格,材质,动画剪辑)的对象实例数量,以及它们在内存中的累积大小(资源,游戏对象,场景对象))
-
Texture2D: 2D贴图及纹理。重点优化对象,有以下几点可以优化:
1.许多贴图采用的Format格式是ARGB 32 bit所以保真度很高但占用的内存也很大。在不失真的前提下,适当压缩贴图,使用ARGB 16 bit就会减少一倍,如果继续Android采用RGBA Compressed ETC2 8 bits(iOS采用RGBA Compressed PVRTC 4 bits),又可以再减少一倍。把不需要透贴但有alpha通道的贴图,全都转换格式Android:RGB Compressed ETC 4 bits,iOS:RGB Compressed PVRTC 4 bits。
2.当加载一个新的Prefab或贴图,不及时回收,它就会永驻在内存中,就算切换场景也不会销毁。应该确定物体不再使用或长时间不使用就先把物体制空(null),然后调用Resources.UnloadUnusedAssets(),才能真正释放内存。
3.有大量空白的图集贴图,可以用TexturePacker等工具进行优化或考虑合并到其他图集中。
4.存在空白的或者纯色的贴图,可以用颜色节点代替
- Mesh:场景中使用的网格模型,注意网格模型的点面数,能合并的mesh尽量合并。
- Materials:加载的材质和它们使用的内存的总数。
- AnimationClip: 加载的动画和它们使用的内存的总数。
Assets(已加载资产的总数)
- Game Objects:游戏对象总数
- Scene Objects:这个数字包括游戏对象的数量,加上组件的总数,以及场景中所有不属于资产的东西。
- GC allocated in frame:显示选定帧中托管分配的数量及其总大小(以字节为单位)。
Memory Profiler
主要用来查看托管内存和本机内存的详细分配情况。它通过捕获、检查、比对内存快照的方式来检测内存泄漏和内存碎片。本篇文章中使用的版本是0.7.1版本。
安装
add PackageManager- >Add By Name- >输入com.unity.memoryprofiler
查看
Windows - > Analysis - > Memory Profiler
Memory Profiler界面,可以链接真机检测,也可以在Editor检测。
点击Capture New Snapshot截取保存当下帧的内容
点击上图中3号的位置Snap来查看详细的内容
通过观察,我们能看到上图的数据与Profiler中的Memory的数据是一致的
单帧检测
一般去看工程内的资源, 去检查占用内存特别大的游戏对象。
Memory Breakdowns界面可以查看unity内的具体游戏对象,也同样可以进行筛选
TreeMap界面进行检查, 这里已经分好类, 同时可以根据Size的大小进行排序,查看内存占用较大的游戏对象进行优化处理。
Fragmentation 页签进行查看, 点击对应的地址块,下方可显示详细信息。
1.该视图会将内存数据可视化成虚拟内存布局。如下图所示:
2.每一行都会显示一个内存块和起始地址标签。当起始地址标签上面有黑色背景时,就表明该起始地址就是内存块的开始部分,并且与之前的内存块之间存在不连续性;否则,就表明该起始地址就是内存块的一部分。如下图所示:
3.既可以通过单击起始地址标签来选择关联的内存块;也可以通过单击鼠标拖动的方式来选择感兴趣的内存块;甚至可以通过单击内存块中的虚拟内存来选择该内存块。
4.当选择内存块时,就会在Filters面板中将相关的虚拟内存按照指定的列表类型(区域列表-Regions list、分配列表-Allocations list、对象列表-Objects list)进行展示详细信息。
区域列表类型展示信息如下图所示:
分配列表类型展示信息如下图所示:
还有对象列表类型展示信息,操作同上。
Objects and Allocations页面可查看详细的对比内容,可以进行筛选
筛选方式:Select Table View
筛选之后就可以进行详细分析了,可以通过Type,Size, Referenced By等标签查看对应的游戏对象。
也可以鼠标右键点击下图1或者2来对类型和名字进行具体筛选。
两帧对比检测
一般使用两帧率对比用于检测内存泄漏。
在要对比的节点分别进行Capture New Snapshot截取, 点击Compare Snapshots进行对比,在分别点击两个Snap,进行对比。
Summary页签可看汇总的对比内容:
Objects and Allocations页面可查看详细的对比内容,可以进行筛选
筛选方式:Select Table View来查看以下几种类型数据:
- [Diff] Raw Data:从原始数据列表中选择一项原始数据(Root Reference、Native Allocation、Native Object等)进行查看。
- [Diff] All Managed Objects:查看所有的托管对象(IL2CPP、Mono)。
- [Diff] All Native Objects:查看所有继承自Unity.Object类型的本机对象。
- [Diff] All Objects:查看所有本机对象和托管对象。
- [Diff] Alloc:从分配列表中选择一项分配数据(ByNativeObject、ByRoot、ByMemRegion)进行查看。
筛选之后就可以进行详细分析了,可以通过Type,Size, Referenced By等标签查看对应的游戏对象。
也可以鼠标右键来对类型和名字进行具体筛选。
总结
MemoryProfiler 是一个非常好用的检查内存问题的工具,以下问题都可以通过该工具进行排查
- 查找有问题的游戏资源,例如:Mesh和贴图非常大的美术资源
- 内存泄漏问题
检测内存占用:可以使用Unity Memory Profiler来检测托管内存和本机内存的占用情况。检测流程如下所示:
- 首先打开Unity Memory Profiler窗口;然后打开想要检查的内存快照;最后在主视图区域以树形视图的方式来显示内存快照中深度内存数据。
- 查看树形视图中不同的对象类别。
- 单击树形视图中某一个对象类别,此时会展开该对象类别中所有的对象以及在主视图区域下方以对象表格的方式来显示该对象类别中所有的对象。
- 单击对象类别中某一个对象或者单击对象表格中某一个对象,进而可以在对象表格中查看该对象的具体信息。
- 首先将对象表格中所有的对象按照从高到低的顺序进行排序;然后优先从纹理、着色器变体、预分配缓冲区这三种对象来制定好减少内存的目标。
检测内存泄漏:可以使用Unity Memory Profiler来检测托管内存和本机内存的泄漏情况。如下所示:
1.出现内存泄漏的危害如下所示:
- 应用程序可能因为GC遍历对象时间变长的原因而出现卡顿现象。
- 应用程序可能因为可用内存空间不足的原因而出现闪退现象。
2.出现内存泄漏的原因如下所示:
- 对于自动垃圾回收而言,对象的引用计数不为0。
- 对于被动垃圾回收而言,对象没有被代码手动释放。
3.查找并修复场景卸载后发生的内存泄漏:流程如下所示:
- 使用Unity Memory Profiler来设置捕获目标。
- 首先在捕获目标上加载一个空场景;然后在该场景上拍摄一张内存快照。
- 首先在捕获目标上加载一个要检测内存泄漏的场景;然后在该场景上执行业务模块;最后将该场景卸载(调用Resources.UnloadUnusedAssets函数)掉或者切换到一个空场景。
- 在捕获目标上再拍摄一张内存快照。
- 为了避免处理内存快照文件和捕获目标之间竞争系统资源,建议此时关闭掉捕获目标。
- 首先在工作台区域打开第一张和第二张内存快照文件;然后单击Diff按钮来对两个打开的内存快照进行差异比对;最后将差异比对生成的数据显示在主视图区域中。
- 首先在主视图区域中选择Diff表格属性;然后选择Group排序规则来将相同值(Deleted、New、Same)的对象合并在一个组内;最后查看数值为New的分组,如果存在对象是在第二张内存快照中的话,就表明该对象的内存泄漏了。
4.查找并修复小的连续分配可能造成的内存泄漏:流程如下所示:
- 使用Unity Memory Profiler来设置捕获目标。
- 首先在捕获目标上加载一个要检测内存泄漏的场景;然后在该场景上拍摄第一张内存快照。
- 首先播放要检测内存泄漏的场景;接着在该场景上拍摄第二张内存快照;然后继续播放该场景;最后在该场景上拍摄第三张内存快照。
- 为了避免处理内存快照文件和捕获目标之间竞争系统资源,建议此时关闭掉捕获目标。
- 首先在工作台区域打开拍摄的第二张和第三张内存快照文件;然后单击Diff按钮来对两个打开的内存快照进行差异比对;最后将差异比对生成的数据显示在主视图区域中。
- 首先在主视图区域中选择Diff表格属性;然后选择Group排序规则来将相同值(Deleted、New、Same)的对象合并在一个组内。
- 首先在主视图中选择Owned Size表格属性;然后选择Group和Sort Descending排序规则来将相同值的对象合并在一个组内,并按照从大到小的顺序来排列组。
- 查看较大内存分配组中的对象是否同时存在于Same组和New组中,记录好满足条件的对象。
- 首先在工作台区域打开拍摄的第一张和第二张内存快照文件;接着单击Diff按钮来对两个打开的内存快照进行差异比对;然后将差异比对生成的数据显示在主视图区域中;最后执行4.6 ~ 4.8步骤,进而了解系统内潜在的内存泄漏。
元数据:如下所示:
1.元数据类型为MetaData,包含的字段如下所示:
- content:包含项目名称和捕获目标为Unity Editor时的脚本版本。
- platform:应用程序对应的目标平台。
- screenshot:针对捕获目标截取的屏幕截图(像素大小小于480x240)。
2.首先在捕获目标上拍摄内存快照时就会生成元数据;然后该元数据会自动添加到内存快照中;最后开发人员可以通过元数据来更好地了解内存快照的内容。
3.拍摄内存快照的方式如下所示:
- 当项目中有安装Unity Memory Profiler时,此时就可以在工具栏区域中点击Capture控件来针对捕获目标来拍摄一张内存快照。
- 在代码中通过MemoryProfiler.TakeSnapshot/TakeTempSnapshot函数来针对捕获目标拍摄一张内存快照。在调用该函数时,可以设置包含内存快照文件路径字符串和是否拍摄成功布尔值两个参数的结束回调函数。
4.生成元数据的方式如下所示:
- 当项目中没有安装Unity Memory Profiler时,此时可以首先给MemoryProfiler.createMetaData委托注册一个监听函数;然后在该监听函数中设置元数据。
- 当项目中有安装Unity Memory Profiler时,此时就会生成默认的元数据。
- 当项目中有安装Unity Memory Profiler时,此时就可以首先创建一个继承自MetadataCollect类型的元数据收集类型;然后在该类型里面重写CollectMetadata函数;最后在该函数中设置元数据。
项目中可能遇到的问题
首先要明确一点,在Editor中运行时,“Unity”大是正常的,因为在Editor中运行项目时,引擎包含了所有的资源占用的内存(除了部分纹理和Mesh是在GFX中),同时自身会进行很多的辅助操作来记录各种游戏运行信息。一般来说,在查看游戏运行时的真实消耗内存,我们均是推荐直接在发布游戏上通过Profiler进行查看,在Editor中运行游戏所看到的内存是要大很多的。
1.Device.Present:
- GPU的presentdevice确实非常耗时,一般出现在使用了非常复杂的shader.
- GPU运行的非常快,而由于Vsync的原因,使得它需要等待较长的时间.
- 同样是Vsync的原因,但其他线程非常耗时,所以导致该等待时间很长,比如:过量AssetBundle加载时容易出现该问题.
- Shader.CreateGPUProgram:Shader在runtime阶段(非预加载)会出现卡顿(华为K3V2芯片).
- StackTraceUtility.PostprocessStacktrace()和StackTraceUtility.ExtractStackTrace(): 一般是由Debug.Log或类似API造成,游戏发布后需将Debug API进行屏蔽。
2.Overhead:
- 一般情况为Vsync所致.
- 通常出现在Android设备上.
3.GC.Collect:
原因:
- 代码分配内存过量(恶性的)
- 一定时间间隔由系统调用(良性的).
占用时间:
- 与现有Garbage size相关
- 与剩余内存使用颗粒相关(比如场景物件过多,利用率低的情况下,GC释放后需要做内存重排)
4.GarbageCollectAssetsProfile:
- 引擎在执行UnloadUnusedAssets操作(该操作是比较耗时的,建议在切场景的时候进行)。
- 尽可能地避免使用Unity内建GUI,避免GUI.Repaint过渡GCAllow.
- if(other.tag == a.tag)改为other.CompareTag(a.tag).因为other.tag为产生180B的GC Allow.
- 少用foreach,因为每次foreach为产生一个enumerator(约16B的内存分配),尽量改为for.
- Lambda表达式,使用不当会产生内存泄漏.
5.尽量少用LINQ:
- 部分功能无法在某些平台使用.
- 会分配大量GC Allow.
6.控制StartCoroutine的次数:
- 开启一个Coroutine(协程),至少分配37B的内存.
- Coroutine类的实例 -> 21B.
- Enumerator -> 16B.
7.使用StringBuilder替代字符串直接连接.
8.缓存组件:
- 每次GetComponent均会分配一定的GC Allow.
- 每次Object.name都会分配39B的堆内存.
9.ManagedHeap.UsedSize是项目逻辑代码在运行时申请的堆内存,该选项只能通过优化代码来进行降低。 优化方法一般如下:
- 尽可能地复用变量,减少new的次数;
- 使用StringBuilder代替String连接,使用for代替foreach;
- 对于局部变量或非常驻变量,尽可能使用Struct来代替Class。
ManagedHeap.UsedSize过大,一方面可能会影响一次GC的耗时;另一方面也可能反映出脚本中不合理的GC Alloc。
10.有些小伙伴会发现System.ExecutableAndDlls占内存巨大,且一直在增长,是怎么回事?
System.ExecutableAndDlls该项显示的是执行文件和所调用的库(物理、渲染、IO等系统库)的总和。开发团队不用太担心该选项的数值,因为很多应用均在共用这些库,并且它对于真实项目的内存压力非常小,几乎没有影响,而且OS也不会因为该内存而杀掉游戏或应用。
11.凡是在Unity Profiler中能看到的资源就会保留在内存中。对于这种资源,在切换场景时调一下UnloadUnusedAssets API就可以释放。
12.Profiler.BeginSample统计到的数据与直接看Memory下的不一样,前者比后者的数据更大,这怎么理解?
这种情况确实也是经常会遇到的。一帧中分配如此高的内存是会触发GC.Collect的,而Mono中显示的数值则是GC之后的Mono内存数值。
13.正常情况下游戏如果一直玩下去,Mono是不是会一直增加? 比如频繁打开一个界面,界面里有脚本会不断创建一些东西 ,那么Mono是否会不断增加?对性能上会不会造成影响呢?
在除开启IL2CPP功能的应用中,Mono 确实是不会下降,但并不应该一直上升。
创建出来的东西,如果被引用在一个容器里,或者被某些脚本的变量引用,那么这部分堆内存就释放不掉;但如果没有被任何容器或者变量引用(比如,临时拼一个 String),那么这部分堆内存会在 GC 的时候释放(释放是指变为空闲的堆内存,堆内存的总量是不会下降的)。
对于后者,频繁地 new 对象虽然不会一直增加堆内存,但是会加速 GC 调用的频率,所以同样是需要尽量避免的。
14:我想请教一下,下图这个函数中,每次我都申请了一个List temp = list();在这里存放6KB的数据,但是如果不做GC处理,这6KB是否就一直累加,直到做GC处理了才会释放掉,是这样么?如果调用次数很多,每次都调用一点点,也会推高内存占用吗?
是的,这个6KB堆内存会随着Update的执行一直分配内存,所累积的堆内存会在GC触发时进行销毁。一般来说,研发团队需要尽可能避免在高频次调用函数中进行堆内存的分配。
15:在进行内存优化时,Unity Profiler给出的数据和Android系统(adb dumpsys meminfo,已经考虑memtrack的影响 )的数据差距较大(已经分析了Profiler自身的内存占用),如何分析这部分差异,比如包括对显存消耗进行准确统计,OS消耗的统计等等?
内存差异较大是正常的,一般来说,Profiler统计的内存较为一致,而Android系统通过ADB反馈的PSS、Private Dirty等值则是差别很大。这主要是因为芯片和OS的不同而导致。具体的Android内存,建议直接查看Google Android OS的相关文档。
Unity Profiler反馈的则是引擎的真实物理使用内存,一般我们都建议通过Profiler来查看内存是否存在冗余、泄露等问题。
16:已经预加载怪物,然后显示怪物 PSS上升,并且在隐藏怪物后并没有下降,这是什么原因导致?显存上去了吗?
仅仅隐藏怪物的话,内存是不会下降的。因为隐藏只是改变了GameObject的状态,并没有对内存中的Object和资源进行移除。同时,即使是提前加载了怪物,也依然可能存在以上问题,因为某些资源是在显示的时候,才会传输一份到GPU的,比如Mesh。一般情况下,显存都不会即刻降低,这个是由Graphics Driver来管理的。建议可以看Profiler是否增长,如果Profiler没有问题而PSS持续增长,就有可能发生了内存泄露。
对于这个问题,建议查看《性能优化,进无止境---内存篇(下)》加深理解。
17:对于Handheld.PlayFullScreenMovie 这个Unity播放开场动画的API,会有内存问题吗?比如我的mp4动画有20MB,那么这个动画会撑高mono堆内存吗?
Android上PlayFullScreenMovie 的实现实际上是通过Android原生的接口直接播放的,播放过程中Unity也是停止更新的,因此这部分的内存理论上并不会记录在 Unity 中,同样也不影响Mono。
18:Texture占用内存总是双倍,这个是我们自己的问题,还是Unity引擎的机制?
出现这种情况的原因有两种:一种是你在真机运行时开启了Read&Write。另一种可能是Unity的Bug,目前的Unity 5.2.3 release note如下 :
(735644) - OpenGL: Fixed texture memory usage reporting in profiler, was twice the actual size for most textures.
开发者需要关注下自己的开发版本,5.2.3以前类似情况的项目可以参考一下。
19:如果脚本引用了GameObject,那转换场景的时候脚本和GameObject都没了,还会产生堆内存的吗?
如果脚本是MonoBehaviour,而且在切换场景后所挂的Game Object被释放了,那么这个脚本对象所引用的堆内存就会在GC的时候被释放。 但有一种例外,如果是通过Static变量引用的堆内存,那么依然是释放不掉的,除非手动解开引用,比如变量置Null,数组Clear等等。
移动平台内存经验数据参考
Textures:80M-160M
Mesh:50M-70M
Render Textures:50M-80M
AnimationClips:30M-60M
Audio:10M-20M
Cubemap:0-50M
Font:5M-15M
Shader:20M-40M
System.xxx总和:15M-30M
AssetBundle:0-10M
其他各类对象单项:0-10M ,数量小于10000
ReservedMono:<100M
ReservedGFX:<300M
ReservedTotal:<650M
这些指标的上下限分别代表了在移动设备上的高低配数据的差异,其中Render Texture会根据目标设备的分辨率的不同会有差异变化。这里给出的是1080P分辨率下的经验数据指标。一些下限为零的指标为不使用此功能,可能没有这方面的开销数据,如果各个指标都在上述范围内,不优化也没有问题。
移动平台其他经验数据参考
DrawCall:300-600
SetPassCall:80-120
Triangles Count:60W-100W
Material Count:200-400
建议你的游戏相关指标也控制在此范围内,当然数据仅供参考。
在我的文章里你可能会看到重复的内容,原因是我的文章很多都是各路大神的心得,会有重复的,我没有删除,我觉得重复的多代表重要。
今天是2024年12月16日
重复一段毒鸡汤来勉励我和你
你的对手在看书
你的仇人在磨刀
你的闺蜜在减肥
隔壁的老王在练腰
而你在干嘛?