性能优化是游戏项目开发过程中一个永恒的话题。项目的性能优化主要围绕CPU、GPU和内存三大方面进行。但是如此的总结我感觉太繁杂不能成系统,例如:影响内存的主要三个部分1.资源内存占用;2.引擎模块自身内存占用;3.托管堆内存占用。你得资源和代码质量都会影响内存。这里主要从五个方面对项目进行优化,分别是资源内存,图形和GPU,编程和代码框架,项目中各种资源组件的配置。
目录
资源内存
正确导入纹理
调整网格导入设置
检查多边形面数
图形和GPU
批处理执行绘制调用
灯光
相机
编程和代码框架
Update
字符串
不要使用LINQ
注意装箱过程
确保外部代码库不产生垃圾
小心循环代码
仅在需要时运行代码
使用Array数组代替List列表
使用Float运算替代Vector运算
Camera.main
使用LocalPosition替代Position
对象池
协程
AssetBundle的卸载
避免在运行时添加组件
删除调试日志语句
使用ScriptableObject
使用NonAlloc函数
重构代码来减小GC的影响
项目中各种资源组件的配置
UI
音频
动画
物理
总结
资源内存
资源管线可以大幅影响应用程序的性能。在一个较为复杂的大中型项目中,资源的内存占用往往占据了总体内存的70%以上。
正确导入纹理
纹理会占用大部分内存,因此,导入设置非常重要。通常,请遵循以下指导原则 :
-
减小 Max Size :使用能生成视觉上可接受的结果的最低设置。这种非破坏性方式,可以快速降低纹理内存。
-
使用 2 的幂 (POT) :Unity 要求移动端纹理压缩格式 (PVRCT 或 ETC)采用 POT 纹理尺寸。
-
制作纹理图集 :将多个纹理放置到单个纹理中,可以减少绘制调用和加快渲染速度。使用 或Unity 精灵图集第三方Texture Packer可以制作纹理图集。
-
关闭 Read/Write Enabled 选项 :如果启用,此选项在 CPU 和 GPU 可寻址内存中都会创建副本,纹理会占用双倍内存。大多数情况下,应保持此选项为禁用状态。如果要在运行时生成纹理,请通过 Texture2D.Apply 强制执行,并且传入设置为 true 的 makeNoLongerReadable。
-
禁用不必要的 Mip Map :对于在屏幕上大小保持不变的纹理(如 2D 精灵和 UI 图形),Mip Map 不是必需的,对于与摄像机的距离会变化的 3D 模型,请保留 Mip Map为启用状态。
-
压缩纹理。
尽可能减少过度绘制和Alpha 混合
避免绘制不必要的透明或半透明图像。这种方式导致的过度绘制和 Alpha 混合会极大影响移动平台。
调整网格导入设置
与纹理很像,如果导入时不小心,网格可能占用过多内存。要尽可能减少网格占用的内存,请执行以下操作 :
-
压缩网格 :高性能压缩可以减少占用磁盘空间(但不会影响运行时的内存)。请注意,网格量化可能造成不准确,因此应试验不同的压缩级别,从而找到适合模型的压缩级别。
-
禁用 Read/Write :如果启用此选项,内存中会有重复网格,网格的一个副本在系统内存中,另一个在 GPU 内存中。大多数情况下,应将其禁用(在 Unity 2019.2 以及更早版本中,此选项默认为选中状态)。
-
禁用骨骼和 BlendShape :如果网格不需要骨架或 BlendShape 动画,请尽可能禁用这些选项。
-
尽可能禁用法线和切线 :如果确信网格的材质不需要法线或切线,请取消选中这些选项,以节省更多内存。
检查多边形面数
分辨率越高的模型,需要的内存使用量越大,并且可能占用更长的 GPU 时间。
使用细节级别 (lOD)
随着对象移向远处,细节级别可以将它们切换为使用更简单的网格,以及更简单的材质和着色器,从而帮助提高 GPU 性能。
使用遮挡剔除来移除隐藏的对象
隐藏在其他对象之后的对象仍然可能渲染和使用资源。使用遮挡剔除可以将它们丢弃。摄像机之外的视锥体剔除 (frustum culling) 是自动执行的,遮挡剔除 (occlusion culling) 是则要经过烘焙过程。只需将对象标记为静态遮挡物或被遮挡物,然后通过 Window > Rendering > Occlusion Culling 对话框进行烘焙。尽管不是所有场景都适合,剔除在很多情况下都能改善性能。
图形和GPU
每一帧,Unity 都需要确定必须渲染哪些对象,然后创建绘制调用。绘制调用是调用图形 API 来绘制对象(如三角形),而批处理是要一起执行的一组绘制调用。
批处理执行绘制调用
将要绘制的对象组合为批次,可以尽可能减少在批次中绘制每个对象所需的状态更改。这种方式通过减少渲染对象的 CPU 开销,可以改善性能。Unity 可以使用以下几种方法将多个对象组合为较少的批次 :
-
动态批处理 :对于小网格,Unity 在 CPU 上分组和转换顶点,然后一次性绘制它们。注意 :只在有足够低复杂度网格(少于 900 个顶点属性和不超过 300 个顶点)时使用这一方法。动态批处理程序不会对更大的网格进行批处理,如果启用会浪费 CPU 时间在每一帧都去查找要批处理的小网格。
-
静态批处理 :对于不移动的几何体,Unity 可以减少所有共享相同材质的网格的绘制调用。它比动态批处理更有效,但使用更多内存。
-
GPU 实例化 :如果有大量相同的对象,这种方法通过图像硬件对它们进行更有效地批处理
-
SRP 批处理 :在 Advanced 下面的 Universal Render Pipeline Asset 中启用 。这样可以SRP Batcher大幅提高 CPU 渲染速度,具体取决于场景。
灯光
避免使用过多动态光线
避免向移动端应用程序添加过多动态光线。考虑采用其他方式,如对动态网格使用自定义着色器效果和光照探针,以及对静态网格使用烘焙光照。
禁用阴影
阴影投射可按 MeshRenderer 和光线禁用。尽可能禁用阴影可以减少绘制调用。
您也可以通过向简单网格应用模糊纹理或在角色下面应用四边形来创建伪阴影。另外,可以使用自定义着色器创建模糊阴影。
将光照烘焙到光照贴图中
烘焙阴影和光照的渲染不会影响运行时性能。
光照探针
光照探针存储场景中的空白空间的烘焙光照信息并且提供高质量的光照(直接和间接)。它们使用球谐函数,这种函数的计算速度比动态光照快很多。
相机
限制摄像机的使用
每个摄像机都会产生开销,无论它是否在做有意义的工作。只在有必要渲染时才使用摄像机组件。在低端移动平台,每个摄像机最多可以使用 1 ms CPU 时间。
限制后期处理效果
全屏幕后期处理效果(如发光)会极大降低性能。请在游戏的美术设计中谨慎使用这些效果。
限制使用一些相机特效。
编程和代码框架
因为我是程序出身就仔细说一下代码的问题吧。
每个 Unity 脚本都将按预定顺序运行多个事件函数。您应该了解 Awake、Start、Update 及其他创建脚本生命周期的函数之间的区别。
Update
尽可能减少每帧运行的代码。
考虑代码是否必须每一帧都运行。将不必要的逻辑移出 Update、 LateUpdate 和 FixedUpdate。可在这些事件函数中方便地放置必须每帧更新的代码,但应提取出任何不需要以这种频率更新的逻辑。尽可能只在情况发生改变时才执行逻辑。
如果确实 需要使用 Update,可以考虑每 n 帧运行一次代码。这是一种应用时间切片 (将繁重的工作负载分布到多个帧的常用技术)的方法,逻辑层和表现层分离,分层限帧和动态负载均衡。
private int interval = 3;
void Update()
{
if (Time.frameCount % interval == 0)
Function();
}
void Function()
{
}
不在Update方法中创建新对象
理想情况下,开发者在Update、FixedUpdate或LateUpdate方法中不应该使用New关键字,而是应该使用已有对象。
一次创建,多次重用
这条规则的意思是:要在Start方法和Awake方法中分配所有内容。这条规则和第一条类似,其实只是从Update方法移除new关键字的另一种方式。
开发者应该从Update方法移除有以下行为的代码:
-
创建新实例
-
寻找任意游戏对象
然后,将这些代码移动到Start方法或Awake方法中。
//未优化的代码
private List<GameObject> objectsList;void Update()
{
objectsList = new List<GameObject>();
objectsList.Add(......)
}
//优化后的代码
private List<GameObject> objectsList;void Start()
{
objectsList = new List<GameObject>();
}
void Update()
{
objectsList.Clear();
objectsList.Add(......)
}
GameObject.Find、GameObject.GetComponent 和 Camera.main( 在 2020.2之前的版本中)可能开销较大,应避免在 Update 方法中调用它们。而应在 Start 中调用它们,并且缓存相应结果。
//未优化的代码
void Update()
{
var levelObstacles = FindObjectsOfType<Obstacle>();
foreach(var obstacle in levelObstacles) { ....... }
}
//优化后的代码
private Object[] levelObstacles;
void Start()
{
levelObstacles = FindObjectsOfType<Obstacle>();
}
void Update()
{
foreach(var obstacle in levelObstacles) { ....... }
}
尝试避免在Update方法中使用访问器,只在Start方法中调用一次访问器,并缓存返回的数值。
//未优化的代码
void Update()
{
//分配包含所有touches的新数组
Input.touches[0];
}
//优化后的代码
void Update()
{
Input.GetTouch(0);
}
//未优化的代码
void Update()
{
//返回新的字符串(垃圾),然后对比2个字符串
gameObject.Tag == "MyTag";
}
//优化后的代码
void Update()
{
gameObject.CompareTag("MyTag");
}
避免空Unity 事件
即使是空的 MonoBehaviour 也需要资源,因此应删除空的 Update 或 LateUpdate 方法。
字符串
使用哈希值而不是字符串参数
Unity 不使用字符串名称对 Animator、Material 和 Shader 属性进行内部寻址。为了加快速度,所有属性名称都经过哈希处理为属性 ID,实际上是这些 ID 用于寻址属性。
每当在 Animator、Material 或 Shader 上使用 Set 或 Get 方法时,请使用整数值方法而非字符串值方法。字符串方法只执行字符串哈希处理,然后将经过哈希处理的 ID 转发给整数值方法。
避免字符串连接
在涉及到垃圾分配的时候,字符串要特别注意。即使是基本的字符串操作,也可能产生大量垃圾。这是为什么呢?
因为字符串是无法改变的数组。这意味着,如果要把两个字符串连接起来,我们会创建新数组,而旧数组会成为垃圾。所以我们可以使用StringBuilder避免或最小化这类垃圾分配。
//未优化的代码
void Start()
{
text = GetComponent<Text>();
}
void Update()
{
text.text = "Player " + name + " has score " + score.toString();
}
//优化后的代码
void Start()
{
text = GetComponent<Text>();
builder = new StringBuilder(50);
}
void Update()
{
//StringBuilder为所有类型重载了Append方法
builder.Length = 0;
builder.Append("Player ");
builder.Append(name);
builder.Append(" has score ");
builder.Append(score);
text.text = builder.ToString();
}
不要使用LINQ
尽可能不要使用LINQ。也就是说,不要在任何经常执行的代码中使用LINQ。
虽然使用LINQ可以使代码更容易阅读,但在很多情况下,这类代码的性能和内存分配都非常糟糕。
注意装箱过程
装箱过程会生成垃圾。什么是装箱过程呢?
最常见的装箱过程是将数值类型,例如int,float,bool等传递到需要Object类型参数的函数时,所发生的过程。
确保外部代码库不产生垃圾
如果发现部分垃圾由Asset Store资源商店下载的代码产生,我们有多个解决方法。但在我们进行逆向工程并调试前,请再次查看Asset Store资源商店的相应页面,代码库是否有进行更新。
在我们的项目中,我们使用的所有资源一直由资源的开发者进行维护,他们一直在进行性能更新,从而解决了我们的所有问题。
所以,一定要让项目使用的依赖保持更新。如果遇到没有维护的代码库,建议放弃这类代码库。
小心循环代码
同Update一样。
仅在需要时运行代码
建议通过使用C#事件实现观察者设计模式。
使用Array数组代替List列表
在代码中,我们发现大多数列表有固定的长度,或是可以计算出最大成员数量。因此我们使用数组重新实现了这些列表,在特定情况下,可以在迭代数据的时候得到原先2倍的速度。
在某些情况下,我们无法避免使用列表或其它复杂的数据结构。常见的情况是:需要经常添加或移除元素的时候,使用列表的效果更好的时候。通常来说,我们会对固定大小的列表使用数组。
在堆内存上进行链表的分配的时候,如果该链表需要多次反复的分配,我们可以采用链表的clear函数来清空链表从而替代反复多次的创建分配链表。
//优化前
void Update()
{
List myList = new List();
PopulateList(myList);
}
//优化后
private List myList = new List();
void Update()
{
myList.Clear();
PopulateList(myList);
}
使用Float运算替代Vector运算
Float运算和Vector运算的区别不是很明显,除非像我们一样进行上千次运算,因此对我们来说,这项改动的性能提升效果非常明显。
Camera.main
使用Camera.main很简单,但是这种操作的性能非常糟糕。这是因为在每个Camera.main调用背后,Unity其实会执行FindGameObjectsWithTag()来获取结果,因此频繁调用Camera.main并不好。
最好的解决方法是在Start或Awake方法中缓存Camera.main的引用。
使用LocalPosition替代Position
在代码允许的位置,为获取函数(Getter)和设置函数(Setter)使用Transform.LocalPosition替代Transform.Position。
这样的原因是:每次Transform.Position调用的背后,都会有更多操作要执行,包括在调用获取函数时计算全局位置,或是在调用设置函数时从全局位置计算出本地位置。
在项目中,我们发现在出现Transform.Position的几乎所有情况中都可以用LocalPosition替代Transform.Position,无需在代码中做其它改动。
对象池
对象池用于减少内存开销,其原理就是把可能用到到的对象,先存在一个地方(池),要用的时候就调出来,不用就放回去。而不是要用的时候创建,不用的时候销毁。
协程
调用 StartCoroutine()会产生少量的内存垃圾,因为unity会生成实体来管理协程。所以在游戏的关键时刻应该限制该函数的调用。基于此,任何在游戏关键时刻调用的协程都需要特别的注意,特别是包含延迟回调的协程,另外还要注意不要同时开启多个携程,必要的时候可以手动关闭协程StopCoroutine();
AssetBundle的卸载
不用的资源记得卸载。
避免在运行时添加组件
在运行时调用 AddComponent 需要一些开销。每当在运行时添加组件时,Unity 都必须检查是否有重复项或是否需要其他组件。
删除调试日志语句
日志语句(尤其是在 Update、LateUpdate 或 FixedUpdate 中)可能会降低性能。在进行构建之前,请禁用日志语句。
使用ScriptableObject
在 ScriptableObject 中而不是 MonoBehaviour 中存储不变的值或设置。ScriptableObject 这种资源只需设置一次就可以在项目中一直使用。它不能直接附加到游戏对象。
在 ScriptableObject 中创建字段来存储值或设置,然后在 Monobehaviour 中引用该 ScriptableObject。
使用 ScriptableObject 的这些字段可以防止每次使用该 Monobehaviour 实例化对象时出现不必要的数据重复。
使用NonAlloc函数
对于特定Unity函数,我们可以找到不分配任何内存的替代函数。在我们的项目中,这些函数都和物理功能有关。我们在碰撞检测使用的函数如下。
Physics2D. CircleCast();
对于该函数,我们可以找到不分配任何内存的版本。
Physics2D. CircleCastNonAlloc();
许多其它函数都有类似的替代函数,因此请记得查看文档,了解函数是否有相应的NonAlloc版本。
重构代码来减小GC的影响
即使我们减小了代码在堆内存上的分配操作,代码也会增加GC的工作量。最常见的增加GC工作量的方式是让其检查它不必检查的对象。struct是值类型的变量,但是如果struct中包含有引用类型的变量,那么GC就必须检测整个struct。如果这样的操作很多,那么GC的工作量就大大增加。在下面的例子中struct包含一个string,那么整个struct都必须在GC中被检查:
//优化前
public struct ItemData
{
public string name;
public int cost;
public Vector3 position;
}
private ItemData[] itemData;
//优化后
private string[] itemNames;
private int[] itemCosts;
private Vector3[] itemPositions;
如果我们知道堆内存在被分配后并没有被使用,我们希望可以主动地调用GC操作,或者在GC操作并不影响游戏体验的时候(例如场景切换的时候),我们可以主动的调用GC操作:
System.GC.Collect()
总之代码的优化还有很多,平时要多注意这些细节养成良好的书写代码的习惯。
项目中各种资源组件的配置
这一块的内容主要是开发者对Unity引擎的熟悉程度,如果你工作时间长经历的项目多自然会知道这些组件的优化点。
UI
UGUI)常常是性能问题的来源。Canvas 组件生成和更新 UI 组件的网格并向 GPU 发出绘制调用。它的运行开销很大,因此,在使用 UGUI 时,请注意以下因素。
隐藏不可见的UI 元素
可能有些 UI 元素(如仅当角色收到伤害时才出现的生命值血条)只偶尔在游戏中出现。如果不可见的 UI 元素是活动的,它仍然可能使用绘制调用。显式禁用所有不可见的 UI 组件,在需要时再重新启用。
如果只需要关闭画布的可见性,请禁用 Canvas 组件而不是游戏对象。这样就不必重新构建网格和顶点。
限制GraphicRaycaster 和禁用Raycast Target
输入事件(如屏上触摸或单击)需要 GraphicRaycaster 组件。它只是循环处理屏幕上的每个输入点,检查它是否在 UI 的 RectTransform 之内。
从层级视图的顶层画布中移除默认的 GraphicRaycaster。只向需要交互的各元素(按钮、滚动矩形等)添加 GraphicRaycaster。
另外,在所有不需要 Raycast Target 的 UI 文本和图像上将其禁用。如果是包含很多元素的复杂 UI,所有这些小更改都可以减少不必要的计算。
避免使用布局组
布局组的更新很低效,应少量使用。如果内容是动态的,应完全避免不用,而是使用锚点进行比例布局。或者,创建自定义代码,在Layout Group 组件设置 UI 之后,将该组件禁用。
如果动态元素确实需要使用布局组(水平、垂直、网格),应避免嵌套它们,从而改善性能。
使用全屏UI 时,隐藏其他全部内容
如果暂停屏幕或者启动屏幕遮住场景中的其他全部内容,则禁用摄像机对 3D 场景的渲染。同样,禁用隐藏在顶层画布之后的所有背景画布元素。
由于不需要以 60 fps 的帧率进行更新,可以考虑在全屏 UI 过程中降低 Application.targetFrameRate。
合并图集
减少DrawCall,多张图片需要多次DrawCall,合并成一张大图只需要调用一次DrawCall
减少对内存的占用。
音频
尽管音频通常不会造成性能瓶颈,还是可以进行优化以节省内存。
尽量使用单声道声音剪辑
如果要使用 3D 空间音频, 请以单声道 (single channel) 的形式创作声音剪辑,或者启用 Force To Mono 设置。在运行时定位使用的多声道声音会扁平化为单声道源,因此会增加 CPU 开销和浪费内存。
尽可能使用原始未压缩WAV 文件作为源资源
如果使用任何压缩格式(如 MP3 或 Vorbis),Unity 会将其解压并在构建时重新压缩。这样会导致两个有损通道,从而降低最终质量。
压缩剪辑并降低压缩比特率
通过压缩减小剪辑的大小和内存使用量 :
-
对大多数声音使用 Vorbis(或者对不循环的声音使用 MP3)。
-
对常用的短声音使用 ADPCM(如脚步声、枪声)。相比于未压缩的 PCM,这样可以减小文件大小,在播放时又可以很快解码。
移动设备上的音效最高为 22,050 Hz。使用较低设置通常对最终质量影响很小,当然,请使用您自己的耳朵来判断
。
选择正确的加载类型
每个剪辑大小的设置都不同。
-
小剪辑 (< 200 kb) 应采用 Decompress on Load。将声音解压缩为原始 16 位 PCM 音频数据,会导致 CPU 开销和内存占用,因此,这仅适用于短声音。
-
中等剪辑 (>= 200 kb) 应保持为 Compressed in Memory。
-
大文件(背景音乐)应设置为 Streaming。否则,整个资源会一次性加载到内存中。
从内存中卸载静音的音频源 (AudioSources)
实现静音按钮时,不要只是将音量设置为 0。可以销毁 AudioSource 组件,从而将其从内存中卸载,这样,播放器不需要过于频繁地切换开关。
动画
Unity的动画相当复杂。尽可能限制在移动设备上使用下面的设置。
使用通用还是人形骨架
默认情况下,Unity 通过通用骨架导入动画模型,但在动画化角色时,开发人员常常切换为人形骨架。
人形骨架每一帧(即使未使用)都计算反向动力学和动画重定向,占用的 CPU 时间比等效的通用骨架多 30-50%。如果不需要这些特定人形骨架功能,请使用通用骨架。
避免过多使用 Animator
Animator 主要用于人形角色,但也常用于动画化单个值(如 UI 元素的 Alpha 通道)。避免过多使用 Animator,尤其是与 UI 元素结合使用。只要可能,对移动设备使用旧版 Animation 组件。
考虑创建补间函数或者使用第三方库来实现简单动画(如 DOTween)。
物理
Unity 的内置物理系统 (Nvidia PhysX) 在移动设备上开销较大。下面的提示可以帮助您每秒减少更多帧。
优化设置
在 PlayerSettings 中,尽可能选中 Prebake Collision Meshes。
请务必同时编辑 Physics 设置 (Project Settings > Physics)。尽可能简化 Layer Collision Matrix。
禁用 Auto Sync Transforms 并启用 Reuse Collision Callbacks。
简化碰撞体
网格碰撞体开销较大。用简单的原始碰撞体或网格碰撞体代替更复杂的网格碰撞体来近似原始形状。
使用物理方法移动刚体
使用类方法(如 MovePosition 或 AddForce)来移动 Rigidbody 对象。直接转换其 Transform 组件可能导致重新计算物理世界,在复杂场景中,这样需要较大开销。
在 FixedUpdate 中而不是 Update 中移动物理体。
修改固定时间间隔
Project Settings 中的默认 Fixed Timestep 是 0.02 (50 Hz)。根据目标帧率对此进行更改(例如,对 30 fps 设置为 0.03)。
否则,如果帧率在运行时下降,也就是说 Unity 每帧都多次调用 FixedUpdate,可能会因物理内容过多而造成 CPU 性能问题。
Maximum Allowed Timestep 对帧率下降时物理计算和 FixedUpdate 事件可以使用的时间进行限制。降低该值意味着在性能顿挫过程中,物理系统和动画会缓慢下来,但也会减小其对帧率的影响。
总结
性能优化是一个广泛的话题。理解移动硬件的运行方式及其限制。要找到符合您的设计要求的有效解决方案,您需要掌握 Unity 的类和组件、算法和数据结构,以及平台的性能分析工具。