导读:本文参考Unity官方教程和分享 。
先后介绍 1. 内存基础知识 2. unity内存知识 3. 内存优化技巧,内容较为初级入门但相对全面,适合Unity项目初期优化。
01 什么是内存?
操作系统有物理内存和虚拟内存两个概念:
物理内存
物理内存也就是我们真实的硬件设备,例如内存条。
我们需要知道,CPU访问内存是一个慢速过程。
访问过程具体为:先访问Cache,Cache包含L1,L2,L3,也就是一级缓存,二级缓存和三级缓存,若在这些缓存里全没找到我们要的数据,再去访问内存,接着会把找到的数据存放到Cache中,完成一次操作。
在Cache中没有找到数据,我们称之为Cache Miss。因此过多的Cache Miss就会导致大量的内存和Cache的IO交换,浪费大量时间。
因此我们需要尽量减少Cache Miss,来提高访问速度
台式设备和移动设备内存架构的差异
1、首先移动设备没有独立显卡。
2、移动设备没有独立显存(显存的作用是用来存储显卡芯片处理过或者即将提取的渲染数据),所有在移动端数据内存和显存是同一块内存。所以有可能我们游戏占用的内存并不大,但是依旧爆内存了,其实是因为显存分配不出来了。这种情况,我们可以去查看一下Log,例如Android会有一个 OpenGL Error:Out Of Memory。
3、移动设备的CPU面积更小,因此会导致缓存级数更少,大小也更小,例如一般的台式机三级缓存可能有8-16M,而移动设备则只有2M左右。
虚拟内存
虚拟内存是利用磁盘空间虚拟出的一块逻辑内存,用作虚拟内存的磁盘空间被称为交换空间(Swap Space)。
内存交换
操作系统在使用内存不够的情况下,会尝试把一些不用的内存(Dead Memory)交换到硬盘上,从而节省出更多的物理内存。这个操作我们称之为内存交换,它会占用大量的硬盘空间。
然而移动设备不做该操作,因为移动设备的IO速度很慢,而且移动设备的可存储物(例如sd卡,内存芯片等)的可擦写次数也比硬盘少很多,会影响使用寿命。
内存压缩
在IOS中(Android没有)会将不活跃的内存压缩起来存储到一个特定空间里,来节省出物理内存空间,来给活跃的app使用,这个操作称之为内存压缩。
(可以查看XCode的Virtual Memory)
内存寻址范围
内存寻址范围也称寻址空间,指的是CPU对于内存寻址的能力(最大能查找多大范围的地址)。数据在内存中存放是有规律的,CPU在运算的时候需要把数据提取出来就需要知道数据在那里,这时候就需要挨家挨户的找,这就叫做寻址,但如果地址太多超出了CPU的能力范围,CPU就无法找到数据了。
内存寻址范围和Memory Controller(内存控制器)有关,和运算位数(32位或64位)无直接关系。当然一般情况下,64位的CPU寻址范围更大。
02 Unity内存管理
Unity是一个C++引擎
Unity是一个C引擎,并不是C#引擎,底层代码全部是由c写的,除了一些Editor里面的Services可能会用到NodeJS这些网络的语言,Runtime里面用到的每一行Unity底层代码全是C++的。
他的脚本语言才是C#。
内存管理简介
Unity内存按照分配方式分为:Native Memory(原生内存)和Managed Memory(托管内存)。Native Memory并不会被系统自动管理,需要我们手动去释放。而Managed Memory的内存管理是自动的,会通过GC来释放。
此外Unity在Editor和Runtime下,内存的管理方式是不同的,除了内存大小不同,内存的分配时机以及分配方式也可能不同。
例如Asset,在Runtime时,只有我们Load的时候才会进内存。而Editor模式下,只要打开Unity就会进内存(所以打开很慢)。
Unity按照内存管理方式分为:引擎管理内存和用户管理内存。引擎管理内存即引擎运行的时候自己要分配一些内存,例如很多的Manager和Singleton,这些内存开发者一般是碰触不到的。用户管理内存也就是我们开发者开发时使用到的内存,需要我们重点注意。
Untiy检测不到的内存
即Unity Profilter无法检查到的内存,例如用户分配的Native内存。比如自己写的Native插件(C插件)导入Unity,这部分Unity是检测不到的,因为Unity没法分析已编译的C是如何分配和使用内存的。还有就是Lua,它完全自己管理的,Unity也没法统计到它内部的情况。
03 Native Memory介绍
Allocator与Memory Lable
Unity在里面重载了C++的所有分配内存的操作符,例如alloc,new等。每个操作符在被使用的时候要求有一个额外的参数就是Memory Lable,Profilter中查看Memory Detailed里的Name很多就是Memory Label。它指的就是当前的这一块内存内存要分配到哪个类型池里。
GetRuntimeMemory
Unity在底层会用Allocator,使用重载过的分配符分配内存的时候,会根据Memory Lable分配到不同的Allocator池里面。每个Allocator池,单独做自己的跟踪。当我们要在Runtime去Get一个Memory Lable下面池的时候,可以从对应的Allocator中取,可以从中知道有什么东西,有多少兆。
NewAsRoot
前面提到的Allocator的生成是使用NewAsRoot,生成一个所谓的Memory Island,它下面会有很多的子内存。例如一个Shader,当我们加载一个shader进内存的时候,首先会生成一个shader的Root,也就是Memory Island。然后Shader底下的数据,例如Subshader,Pass,Properties等,会作为该Root底下的成员,依次的分配。所以我们最后统计Runtime的内存时,统计这些Root即可。
会及时返还给系统
因为是C++的,所以当我们去delete或free一个内存的时候,会立刻返回给系统。这和托管内存堆不一样,需要GC后才返回。
堆栈(Stack)和堆积(Heap)
我们看下Unity内存中重要的两部分,堆栈和堆积,因为只有了解了它们,我们才能知道应该如何优化内存,提高性能。
堆栈:
堆栈是内存中存储函数和值类型的地方。
例如我们调用一个函数A,会将这个函数体与函数收到的参数放入到堆栈中,若在函数A中调用函数B,同样会把函数B存放到堆栈中。当函数B运行结束,会将其从堆栈中移除,然后当A运行结束,把A从堆栈中移除。
因此我们在看Debug信息的时候,就会发现Log里面能够做到一层层的方法回溯,方便我们查看整体的调用过程,这也就是堆栈回溯。
由于是堆栈的结构,因此不会遇到碎片化或是垃圾收集(GC)的问题。但是可能会碰见堆栈溢出的问题,比如调用了太多的函数导致一直push东西进堆栈,占据越来越多的内存空间,导致堆栈溢出。
堆积:
堆积是内存中另一个区域,要比堆栈大,我们将所有的引用类型存放在这。通常我们每创建一个新的对象,会在堆积中找到下一个足够存放的空位置,将其存储。但是当我们销毁对象后,内存空间不会马上释放出来,而是标记成未使用,之后垃圾收集器会释放这部分空间。
对象实例化和摧毁的过程其实很慢,所以我们要尽可能地避免在堆积中配置内存的行为。如果我们需要的内存比之前已经配置好的还多,在放不下的情况下,堆积会膨胀,并且每次都增长两倍,且不会再缩回去,过大的堆积就会影响到我们游戏的性能。当我们在堆积中释放了一些占用空间小的对象,而后添加一些占用空间大的对象时,由于前面释放的空间不足以存放下,就会导致这些空间空出来,使得内存的使用情况就变得断断续续起来,这也就是内存的碎片化,同样降低我们的游戏性能。
而我们前面所提到的GC就是在堆积上进行的,每一次GC,都会遍历堆积上所有的对象,找到需要释放的东西,也就是没有被引用的对象,然后将其释放。但是有时候我们的一些错误引用,导致一些我们希望释放掉的对象没有被GC掉,那么就会造成内存泄漏。
假如游戏玩到一半,GC必须要释放数十或数百个游戏对象的内存,那么这会对你的游戏过程造成一个负载峰值,我们要避免这样的负载峰值。
04 优化 Native Memory
以下东西都是和我们Native Memory相关的,使用不当可能导致Native Memory的增长,这块内容也就和我们的性能优化相关了。
05 优化Managed Memory
Destroy与null
用Destroy,别用null,显示的调用Destroy才能真正的销毁掉。
对象池
虽然VM自己有内存池,但是我们还是需要自己使用内存池来管理。
在游戏程序中,创建和销毁对象事很常见的操作,通常会通过 Instantiate 和 Destroy 方法来实现,如果频繁的进行这些操作,GC的时候会导致负载很重,因为会有大量的已摧毁对象的存在,不仅会造成CPU的负载峰值,还可能导致堆积碎片化。因此我们可以使用对象池来处理这类问题。
使用对象池时需要注意,要决定对象池的大小,以及一开始要产生多少数量的对象在池中。因为如果你需要的对象数量多过池中现有的,就必须将对象池变大,扩的太大可能造成浪费,扩的小可能又造成频繁的添加。
配置表
一个游戏,策划往往会通过excel配置很多的配置表,然后我会在游戏中加载这些excel来读取其中的数据。但是如果excel数量非常的庞大,我们最好不要一下子全丢到内存里,建议分关加载等。
单例
慎用单例,且不要什么都往里放,因为里面的变量会一直占用内存。
缓存一些Hash值
在我们想要在运行时修改动画或者材质的时候,可以使用下面方法来实现
animator.SetTrigger("Idle");``material.SetColor("Color", Color.white);
这类方法往往也可以通过索引来作为参数,使用字符串只是能显示的更加直观,但是当我们传递字符串时,程序内部会进行一些处理,频繁调用的话可能就会造成性能的消耗。因此我们可以先找到对应的索引,并将其缓存起来,供后续使用,如下:
int idleHash = Animator.StringToHash("Idle");``animator.SetTrigger(idleHash);``int colorId = Shader.PropertyToID("Color");``material.SetColor(colorId, Color.white);
缓存引用对象
例如我们常常会在游戏运行的时候去查找一些对象,GameObject.Find与其他所有关联的方法,需要遍历所有内存中的游戏对象以及组件,因此在复杂场景中,效率会很低。GameObject.GetComponent,会查询所有附加到GameObject上的组件,组件越多,GetComponent的成本就越高。若使用的是GetComponentInChildren,随着查询变复杂,成本会更高。
因此不要多次查询相同的对象或组件,而且查询一次后将其缓存起来,方便后续的使用。
- - —–
前面我们基本上介绍了内存的概念以及和内存直接相关的一些优化方法,当然了,优化除了优化内存意外还有很多其他的优化,例如DrawCall,算法的时间复杂度等,接下来我们看看其他方面相关的一些优化。
06 图像(Graphics)的一些优化建议
基本上当Unity渲染游戏图像时,会调用 draw call 来对GPU下指令,让场景能成功渲染。对象,材质和纹理越多,处理起来需要的时间也越多。所以过多的drawcall就会影响游戏的优化,这对于瓶颈在GPU上的游戏影响特别大,也就是我们的游戏已经给GPU太大的压力了。
使用批处理:
我们可以使用批处理来尽量减少drawcall,使用批处理需要满足一些情况,例如,要批处理的对象必须引用一样的材质,并使用相同的纹理(纹理合并在这就很重要),但是使用的模型可以不一样。
动态批处理:可以减少对于移动对象的drawcall。只能用于少于900个顶点信息的情况,包含坐标、法线、uv0、uv1、切线。动态批处理每帧评估一次,由CPU负责。
静态批处理:即对开启 static 标记的对象做批处理,在构建期完成。适用于绝大部分的静态Mesh,因此任何不会动的对象都应标记为静态的。如果我们在运行时要添加静态对象,可以看一下 StaticBatchUtility.Combine() 的API
Cast Shadows
默认情况下,MeshRenderder组件的Cast Shadows是开启的。
阴影的渲染可以让游戏的光线增加真实度和深度感,但是某些情况下可能并不需要。在复杂场景中,可能会造成多余的阴影计算,阴影效果最后也看不见。
因此若场景有的对象是否有阴影对整体效果没有影响的话,就关闭这个选项。
不计算阴影,可能为你省下CPU时间 某些情况下 这项建议可能看不出效果,但在特定情况下 你可能会想挤出哪一点CPU优化。(具体渲染步骤可以在 Frame Debugger的Shadows.Draw中查看)
Light Culling Mask
在复杂场景中,许多光线紧靠彼此,你可能觉得光线不能影响特定对象。根据渲染流程的设置,场景中越多的光照,性能可能就会越差。因此我们要确保光照只影响特定的对象层(例如专门给角色打光的光源,设置成只影响角色),尤其是多光源和多对象彼此紧靠的时候。
避免使用手机原生分辨率
现在的手机分辨率非常的高,在手机呈现高分辨率可能会影响性能和手机过热的问题。因为会有大量的计算需求,如后期处理。如果游戏本身很耗GPU,高分辨率会恶化这些问题。建议使用 Screen.SetResolution 来降低游戏预设的解析设置(根据不同的设备来找到一些合适的值),来提高性能。
07 UI的一些优化建议
显示与隐藏
UI的隐藏我们可以使用将其移到Canvas外的方法,而不是利用SetActive(false)的方法来隐藏。
批处理你的UI
如果UI元素会改变数值或是位置,会影响批处理,导致向GPU发送更多的drawcall。
因此建议:
1、将更新频率不同的UI放在不同的Canvas上。
2、相同Canvas中的UI元素的Z值要相同,这样才不会打断批处理draw call。
3、相同Canvas中的UI元素要使用相同的材质和纹理,材质或着色器可以有动态变换(例如一些特效),这不会影响批处理。
4、相同Canvas中的UI元素要使用相同裁剪矩阵。
全屏UI的处理
游戏中可能会有些全屏UI(例如一些设置界面),会遮挡住场景物体或其他UI元素。然而它们即使被遮挡看不见,CPU和GPU还是会有消耗,因此建议:
1、3D场景完全被遮挡的话,关闭渲染3D场景的摄像机。
2、被遮蔽的UI,Disable这些Canvas,注意不是SetActive(false)。
3、尽可能的降低帧率,因为这些UI一般不需要刷新那么频繁,如果有个静态的UI画面 或者画面帧率只有15 那游戏的帧率就没必要到60
08 其他一些优化
这个设置在Project Settings->Player->IOS->Other Settings中,这个功能定义Unity从设备读取加速度仪信息的频率,在不需要加速仪的游戏中,将它启动或设置了高于需求的频率,会影响性能表现。因为读取硬件设备信息,会增加CPU的处理时间。
移动物体
Unity中有许多移动游戏对象的方法,例如 transform.Translate,如果对象需要碰撞判定,我们则会添加刚体和碰撞体,如果还是使用 transform.Translate 方法,会造成PhysX物理引擎整体重新计算,对于复杂的场景,成本可能很高。因此若要移动带有刚体的对象,使用rigidBody.MovePosition,并且要在FixedUpdate方法中执行。
建议使用transform.Translate就在Update中执行,使用rigidBody.MovePosition或AddForce方法在FixedUpdate中执行。
添加组件
在运行时调用AddComponent其实很没效率,尤其在一帧中多次启用这类调用。当我们添加一个组件的时候,Unity会做下列操作:
1、先看组件有没有DisallowMultipleComponent的设置,如果有,就要去检查是否有同类型的组件已加入
2、然后检查RequireComponent设置是否存在,如果设置了,就代表这个组件需要别的组件同步加入(重复做添加组件的操作)
3、最后调用所有被加入的MonoBehaviour的Awake方法
上述这些步骤都发生在堆积上,所以可能会影响性能和增加GC的处理时间。
数据结构
也就是Array,List和Dictionary等,例如在Array或List中使用索引的成本很低,那么就适合要经常通过索引读取的情况。而要频繁增加和移除对象时,使用Dictionary是最合适的。
减少层级复杂度
当我们对某个对象做坐标转换的时候
就会产生 OnTransformChanged 事件
这消息会传递到这个对象旗下的所有子对象,及时他们没有任何视觉效果
我们还是做了很多不必要的坐标转换,包括平移旋转和缩放,
较深的层级结构,会让垃圾处理器 花更多事件在层级结构间遍历。
所以我建议, 如果可以,避免太深的层级结构
将哪些真正需要做坐标转换的对象移出来。这也能加快垃圾收集器的处理时间。
哈希字符串
在动画控制器或者shader之类都可以使用一些字符串来获得对象 这很简单也很易懂 但是底层并不是通过字符串找到对应的对象,而是会经过一层哈希寻址,如果这在循环中使用那就会有额外的性能消耗。
所以建议通过自己提前取好哈希值。
这看起来像是过度优化,但是如果在多次广泛使用之后,可以省下许多处理器的时间。
脚本化工具
如果对于预制体都通用一些属性,那可以考虑将这些属性用作设置,因为这些预制体可能会生成多个对象。我们等于让这些数据重复的出现在有此脚本的每个Enemy对象上。
所以这里建议使用ScriptableObject
如果使用这个对象,你就只会耗费一组这样数据的内存。
09 工具
Unity提供了很多工具,关键的就是 永远在目的设备上做分析
可以在unity上分析,但是结果会和手机上有出入。
Unity Editor Profiler
可以通过接入局域网或者数据线来和手机进行连接
也可以在编辑时,边开发边了解资源分配的情况。
Memory Profiler
可以查看运行时候的内存快照 检查碎片内存
Frame Debugger
能让进行中的游戏暂时冻结,停留在某一帧
并查看该帧单独的draw call 和渲染步骤
并且可以一个一个的单独查看draw call
这样就可以看到各种细节和和组成每个场景的图像元素
unity build report tool
通过此工具可以看到unity在打包过程中各资源的大小和占用比例
xcode的instruments
对于iOS设备是个非常好的检测工具
参考
浅谈Unity内存管理
性能优化技巧(上)
性能优化技巧(下)