最近看了B站Uinty官方有关性能优化技巧的视频,自己做一些整理。
视频链接:
堆栈(Stack)和堆积(Heap)
我们先来看下Unity内存中重要的两部分,堆栈和堆积,因为只有了解了它们,我们才能知道应该如何优化内存,提高性能。
堆栈:
堆栈是内存中存储函数和值类型的地方。
例如我们调用一个函数A,会将这个函数体与函数收到的参数放入到堆栈中,若在函数A中调用函数B,同样会把函数B存放到堆栈中。当函数B运行结束,会将其从堆栈中移除,然后当A运行结束,把A从堆栈中移除。
因此我们在看Debug信息的时候,就会发现Log里面能够做到一层层的方法回溯,方便我们查看整体的调用过程,这也就是堆栈回溯。
由于是堆栈的结构,因此不会遇到碎片化或是垃圾收集(GC)的问题。但是可能会碰见堆栈溢出的问题,比如调用了太多的函数导致一直push东西进堆栈,占据越来越多的内存空间,导致堆栈溢出。
堆积:
堆积是内存中另一个区域,要比堆栈大,我们将所有的引用类型存放在这。通常我们每创建一个新的对象,会在堆积中找到下一个足够存放的空位置,将其存储。但是当我们销毁对象后,内存空间不会马上释放出来,而是标记成未使用,之后垃圾收集器会释放这部分空间。
对象实例化和摧毁的过程其实很慢,所以我们要尽可能地避免在堆积中配置内存的行为。如果我们需要的内存比之前已经配置好的还多,在放不下的情况下,堆积会膨胀,并且每次都增长两倍,且不会再缩回去,过大的堆积就会影响到我们游戏的性能。当我们在堆积中释放了一些占用空间小的对象,而后添加一些占用空间大的对象时,由于前面释放的空间不足以存放下,就会导致这些空间空出来,使得内存的使用情况就变得断断续续起来,这也就是内存的碎片化,同样降低我们的游戏性能。
垃圾收集(GC)的原理:每一次GC,都会遍历堆积上所有的对象,找到需要释放的东西,然后将其释放。
假如游戏玩到一半,GC必须要释放数十或数百个游戏对象的内存,那么这会对你的游戏过程造成一个负载峰值,我们要避免这样的负载峰值。
编程过程中的一些优化建议
1.选择合适的数据结构
数据结构,也就是Array,List和Dictionary等,例如在Array或List中使用索引的成本很低,那么就适合要经常通过索引读取的情况。而要频繁增加和移除对象时,使用Dictionary是最合适的。
2.对象池
在游戏程序中,创建和销毁对象事很常见的操作,通常会通过 Instantiate 和 Destroy 方法来实现,如果频繁的进行这些操作,GC的时候会导致负载很重,因为会有大量的已摧毁对象的存在,不仅会造成CPU的负载峰值,还可能导致堆积碎片化。因此我们可以使用对象池来处理这类问题。
使用对象池时需要注意,要决定对象池的大小,以及一开始要产生多少数量的对象在池中。因为如果你需要的对象数量多过池中现有的,就必须将对象池变大,扩的太大可能造成浪费,扩的小可能又造成频繁的添加。
3.Scriptable Objects
假设我们有一个控制敌人的组件,名叫Enemy,代码如下:
public class Enemy : MonoBehaviour
{
public float maxSpeed;
public float attackRadius;
}
这个组件挂载在每个敌人身上,但是其中这两个浮点数(maxSpeed 和 attachRadius)的数值都是不变的。那么当场景中存在很多的敌人时,每次生成敌人的时候,这些数据就会重复一份。
所以即使所有数据都一样,这两个浮点数还是重复的出现在有此脚本的对象上。所以建议改用Scriptable Objects,这样就只会耗费一组这样数据的内存,代码如下:
public class EnemyConfiguration : ScriptableObject
{
public float maxSpeed;
public float attackRadius;
}
public class Enemy : MonoBehaviour
{
public EnemyConfiguration enemyConfiguration;
}
4.变量or属性
通常我们为了封装安全性,开发时会选择使用属性(getter/setter),而属性本质上是函数的调用,前面提到调用函数时,会在堆栈上分配内存,因此调用属性也是如此。当调用多次时,花费在堆栈中的时间就会增加。当然了,一般来说问题不大,但是如果在使用频繁的循环体中使用属性,可能就需要针对性的优化。
我们可以通过宏命令进行处理,例如在开发时使用属性,发布版本时使用变量,如下:
#if DELELOPMENT_BUILD
int m_health;
public int health { get => m_health; }
#else
public int health;
#endif
5.Resources目录
当项目被构建时,所有名为Resources的文件夹中的所有Asset和Object都会合并到同一个序列化文件中。这个序列化文件中还含有元数据(Metadata)和索引(Indexing)信息。同时加载Resources文件这一操作无法跳过,它会在应用程序启动显示不可交互的启动画面(Splash Screen)时执行,即使里面很多资源我们此时都没有用到,这就会直接影响游戏的启动时间,同时也会占用很大的内存。
所以建议直接弃用Resources,使用AssetBundle,以更有效的方式管理资源的载入和卸载。(也可以试试Addressable资源系统)
6.删除空的Unity事件
Monobehaviour中的Start,Update这些方法即使是空的,也会带来些微的性能消耗,因此若为空,就删除它们。
7.避免在Awake和Start中添加大量的逻辑
这对游戏启动很重要,Unity会在Awake和Start方法执行后渲染第一个画面,某些情况可能会导致启动画面或是载入画面需要花更长的时间渲染,因为你必须等每个游戏对象都完成Awake和Start的执行。(游戏启动时,黑屏太久,可能会被退审)
8.缓存一些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);
9.层次结构
某些情况下,场景中的物体可能有很深的嵌套结构,当我们对父节点的GameObject进行坐标转换时,就会产生OnTransformChanged事件,这消息会传递给该GameObject下所有子对象,即使这些对象没有任何渲染组件(也就是我们看不见任何变化),造成一些不必要的转换运算,包括平移,旋转和缩放。
此外,较深的结构也会导致在GC时,花费更多的时间在层级结构间遍历。
10.Accelerometer Frequency
这个设置在Project Settings->Player->IOS->Other Settings中,这个功能定义Unity从设备读取加速度仪信息的频率,在不需要加速仪的游戏中,将它启动或设置了高于需求的频率,会影响性能表现。因为读取硬件设备信息,会增加CPU的处理时间。
11.移动物体
Unity中有许多移动游戏对象的方法,例如 transform.Translate,如果对象需要碰撞判定,我们则会添加刚体和碰撞体,如果还是使用 transform.Translate 方法,会造成PhysX物理引擎整体重新计算,对于复杂的场景,成本可能很高。因此若要移动带有刚体的对象,使用rigidBody.MovePosition,并且要在FixedUpdate方法中执行。
建议使用transform.Translate就在Update中执行,使用rigidBody.MovePosition或AddForce方法在FixedUpdate中执行。
12.添加组件
在运行时调用AddComponent其实很没效率,尤其在一帧中多次启用这类调用。
当我们添加一个组件的时候,Unity会做下列操作:
- 先看组件有没有DisallowMultipleComponent的设置,如果有,就要去检查是否有同类型的组件已加入
- 然后检查RequireComponent设置是否存在,如果设置了,就代表这个组件需要别的组件同步加入(重复做添加组件的操作)
- 最后调用所有被加入的MonoBehaviour的Awake方法
上述这些步骤都发生在堆积上,所以可能会影响性能和增加GC的处理时间。
13.缓存引用对象(与第8条类似)
例如我们常常会在游戏运行的时候去查找一些对象,GameObject.Find与其他所有关联的方法,需要遍历所有内存中的游戏对象以及组件,因此在复杂场景中,效率会很低。GameObject.GetComponent,会查询所有附加到GameObject上的组件,组件越多,GetComponent的成本就越高。若使用的是GetComponentInChildren,随着查询变复杂,成本会更高。
因此不要多次查询相同的对象或组件,而且查询一次后将其缓存起来,方便后续的使用。
资源导入的一些优化建议
例如下图中左右两边使用的都是相同的模型与贴图,但是最终所占的磁盘大小却差了很多,就是因为一些设置导致的。
有关纹理导入设置的建议:
1.根据平台不同,纹理的 Max Size 设成该平台最小值
2.纹理的大小为2的幂次方(POT),因为有些压缩格式可能不支持非2的幂次方的。
3.尽量将多张纹理合并成为大图
4.对于不透明纹理,关闭其 alpha 通道
5.除非你必须从代码来访问纹理的底层数据,否则关闭 Read/Write Enabled 选项,减少内存使用
6.选择合适的Format,可减少占用的空间
7.例如UI元素这类相对于相机Z轴的值不会有任何变化的纹理,关闭Generate Mip Map选项
Mesh的导入设置建议:
1.试着用高比率的Mesh压缩,来减少磁盘容量。注意:运行时的内存不受这项设置影响
2.尽量关闭 Read/Write Enabled 选项,若开启,Unity会存储两份Mesh,导致运行时的内存用量变成两倍。
3.如果没有使用动画,请关闭Rig,例如房子,石头这些
4.如果没有用到 Blendshapes,也关闭
5.如果Material没有用到法向量和切线信息,关闭可以减少额外信息。
图像(Graphics)的一些优化建议
基本上当Unity渲染游戏图像时,会调用 draw call 来对GPU下指令,让场景能成功渲染。对象,材质和纹理越多,处理起来需要的时间也越多。所以过多的drawcall就会影响游戏的优化,这对于瓶颈在GPU上的游戏影响特别大,也就是我们的游戏已经给GPU太大的压力了。
使用批处理:
我们可以使用批处理来尽量减少drawcall,使用批处理需要满足一些情况,例如,要批处理的对象必须引用一样的材质,并使用相同的纹理(纹理合并在这就很重要),但是使用的模型可以不一样。
动态批处理:可以减少对于移动对象的drawcall。只能用于少于900个顶点信息的情况,包含坐标、法线、uv0、uv1、切线。动态批处理每帧评估一次,由CPU负责。
静态批处理:即对开启 static 标记的对象做批处理,在构建期完成。适用于绝大部分的静态Mesh,因此任何不会动的对象都应标记为静态的。如果我们在运行时要添加静态对象,可以看一下 StaticBatchUtility.Combine() 的API
有关SRP Batcher可以看下:https://blog.csdn.net/wangjiangrong/article/details/105518220
Cast Shadows
默认情况下,MeshRenderder组件的Cast Shadows是开启的。
阴影的渲染可以让游戏的光线增加真实度和深度感,但是某些情况下可能并不需要。在复杂场景中,可能会造成多余的阴影计算,阴影效果最后也看不见。
因此若场景有的对象是否有阴影对整体效果没有影响的话,就关闭这个选项。不计算阴影可以省下CPU时间。(具体渲染步骤可以在 Frame Debugger的Shadows.Draw中查看)
Light Culling Mask
在复杂场景中,许多光线紧靠彼此,你可能觉得光线不能影响特定对象。根据渲染流程的设置,场景中越多的光照,性能可能就会越差。因此我们要确保光照只影响特定的对象层(例如专门给角色打光的光源,设置成只影响角色),尤其是多光源和多对象彼此紧靠的时候。
避免使用手机原生分辨率
现在的手机分辨率非常的高,在手机呈现高分辨率可能会影响性能和手机过热的问题。因为会有大量的计算需求,如后期处理。如果游戏本身很耗GPU,高分辨率会恶化这些问题。建议使用 Screen.SetResolution 来降低游戏预设的解析设置(根据不同的设备来找到一些合适的值),来提高性能。
UI的一些优化建议
显示与隐藏
UI的隐藏我们可以使用将其移到Canvas外的方法,而不是利用SetActive(false)的方法来隐藏。
视频中建议的似乎是SetActive(false)
UI的批处理
如果UI元素会改变数值或是位置,会影响批处理,导致向GPU发送更多的drawcall。因此建议:
1.将更新频率不同的UI放在不同的Canvas上。
2.相同Canvas中的UI元素的Z值要相同,这样才不会打断批处理。
3.相同Canvas中的UI元素要使用相同的材质和纹理,材质或着色器可以有动态变换(例如一些特效),这不会影响批处理。
4.相同Canvas中的UI元素要使用相同裁剪矩阵。
Graphic Raycaster
该组件是用来处理输入事件,默认挂载在每个Canvas上。有时不能互动的对象仍是canvas中的一部分,并附带了该组件,所以当每次鼠标或触控点击时,系统就要遍历所有可能接受输入事件的UI元素,就会造成多次的 “点落在矩形中” 的检查,来判断对象是否该作出反应。在UI很复杂的情况下,这个运算成本就会很高。因此建议确保只有可互动的Canvas才有该组件,节省CPU运行时间。
全屏UI的处理
游戏中可能会有些全屏UI(例如一些设置界面),会遮挡住场景物体或其他UI元素。然而它们即使被遮挡看不见,CPU和GPU还是会有消耗,因此建议:
1.3D场景完全被遮挡的话,关闭渲染3D场景的摄像机。
2.被遮蔽的UI,Disable这些Canvas,注意不是SetActive(false)。
3.尽可能的降低帧率,因为这些UI一般不需要刷新那么频繁。
音频(Audio)一些优化建议
音频文件常以不正确的方式导入的Unity中,原因可能是对硬件或格式不熟悉,或是导入过程中出现了问题。这将造成运行时内存使用过高。打包中占用大量的空间。以及没有善用底层硬件提供的解压缩方式。因此建议:
1.可以的话,将音频文件设置为Force To Mono,这样做可以省下一半的内存和磁盘空间。
2.如果需要额外的压缩,可以降低文件的比特率(bitrate),前提音频品质不会被破坏太严重。
3.IOS适合使用ADPCM和MP3格式,Android适合使用Vorbis格式。
载入方式
小型音频文件(< 200kb) | Decompress On Load |
中型音频文件(>= 200kb) |
Compressed In Memory |
大型音频文件,例如背景音 | Streaming |
注:文件必须小于200kb,因为内部内存管理的问题,大于200kb的文件也还是只会被分配到这么多。
静音处理
一般游戏中都会有静音的设置,我们往往我们只是把AudioSource或Mixer的音量设置为0,这样还是会造成不必要的内存和CPU占用,关音量并不会释放音频的内存。
因此建议在内存中卸载音频相关的来源或是内存中的音频文件,将AudioSource组件Disable,同时有个上层管理系统负责过滤和音频相关的API调用。当然卸载和重新载入音频的成本也很高,要是玩家频繁的开启和关闭静音的话,就不适用了(一般情况下不会)
来源:oschina
链接:https://my.oschina.net/u/4312036/blog/4560896