此文章为网上转载收集而成,非原创文章,请尊重别人的劳动成果,让分享成为一种美德,欢迎转载。另外,文章在表述和代码方面如有不妥之处,欢迎批评指正。同时大家有更好的优化方案,或者自己独立见解的优化想法,也请发相关邮件于我,我将持续更新这篇文章,努力将“浅谈”转变为“深入”!
观前提示:本文篇幅较长,请耐心观看或收藏
本文链接https://blog.csdn.net/qq_23420435/article/details/110109812
当前版本:V0.0.0
更新时间:2020-11-25
更新内容:首次整合更新
更新管理:小小Editor
收集整合
[email protected]
----转载收集整合 小小Editor
章节一《CG介绍》、章节二《GC分析及优化》
http://www.cnblogs.com/msxh/p/6531725.html
https://blog.csdn.net/worisaa/article/details/64121436
----原创 路人张德帅
----转载 worisaa
章节三《解读Unity性能优化》
https://www.jianshu.com/p/e427a38e91c3
----Jumbo
章节四《Unity3D性能优化——初识Unity-Statistics》
https://blog.csdn.net/wdmzjzlym/article/details/51335915
----萌萌的一天
章节五《Unity3D性能优化——Unity篇》
https://blog.csdn.net/weixin_43967425/article/details/84928476
----原创 Kyle_GISer
----原创 网络
章节五点二《5.2-Unity资源相关优化》
https://blog.csdn.net/u012565990/article/details/51794486
https://blog.csdn.net/u013709166/article/details/54959464?
----原创 CZandQZ
----原创 ResetOTER
章节五点三点一《Occlusion Culling遮挡剔除》
https://blog.csdn.net/qq_37672438/article/details/100933982
----原创 路人张德帅
章节五点三点二《Frustum Culling视锥体剔除》
https://blog.csdn.net/xinjay1992/article/details/107550433?
----原创 程序员茶馆
章节五点四《5.4-UnityUGUI优化》
https://mp.weixin.qq.com/s?__biz=MzU5MjQ1NTEwOA==&mid=2247495094&idx=1&sn=
4a948884855c5a6f26f73ff7845c8caf&chksm=fe1dd91dc96a500baf2970fed3b13009e89
7997472f6596325102dff0cad616defd7a2470d52&token=2019102974&lang=zh_CN#rd
----原创 Unity官方平台
----原创 网络
章节六《Unity3D性能优化——工具篇》
https://zhuanlan.zhihu.com/p/39529241
----朔宇
在游戏运行的时候,数据主要存储在内存中,当游戏的数据不在需要的时候,存储当前数据的内存就可以被回收再次使用。内存垃圾是指当前废弃数据所占用的内存,垃圾回收(GC)是指将废弃的内存重新回收再次使用的过程。
Unity中将垃圾回收当作内存管理的一部分,如果游戏中垃圾回收十分复杂,则游戏的性能会受到极大影响,此时垃圾回收会成为游戏性能的一大障碍点。
下面我们将会学习垃圾回收的机制,掌握垃圾回收如何被触发以及如何提高垃圾回收效率来减小其对游戏行性能的影响。
要想了解垃圾回收如何工作以及何时被触发,我们首先需要了解unity的内存管理机制。Unity主要采用自动内存管理的机制,开发时在代码中不需要详细地告诉unity如何进行内存管理,unity内部自身会进行内存管理。
unity的自动内存管理可以理解为以下几个部分:
在了解了GC的过程后,下面详细了解堆内存和堆栈内存的分配和回收机制的差别。
栈上的内存分配和回收十分快捷简单,主要是栈上只会存储短暂的较小的变量。内存分配和回收都会以一种可控制顺序和大小的方式进行。
栈的运行方式就像 stack :只是一个数据的集合,数据的进出都以一种固定的方式运行。正是这种简洁性和固定性使得堆栈的操作十分快捷。当数据被存储在栈上的时候,只需要简单地在其后进行扩展。当数据失效的时候,只需要将其从栈上移除复用。
堆内存上的内存分配和存储相对而言更加复杂,主要是堆内存上可以存储短期较小的数据,也可以存储各种类型和大小的数据。其上的内存分配和回收顺序并不可控,可能会要求分配不同大小的内存单元来存储数据。
堆上的变量在存储的时候,主要分为以下几步:
堆内存的分配有可能会变得十分缓慢,特别是需要垃圾回收和堆内存需要扩展的情况下。
当一个变量不再处于激活状态的时候,其所占用的内存并不会立刻被回收,不再使用的内存只会在GC的时候才会被回收。
每次运行GC的时候,主要进行下面的操作:
GC操作是一个极其耗费的操作,堆内存上的变量或者引用越多则其运行的操作会更多,耗费的时间越长。
主要有三个操作会触发垃圾回收:
GC操作可以被频繁触发,特别是在堆内存上进行内存分配时内存单元不足够的时候,这就意味着频繁在堆内存上进行内存分配和回收会触发频繁的GC操作。
在了解GC在unity内存管理中的作用后,我们需要考虑其带来的问题。最明显的问题是GC操作会需要大量的时间来运行,如果堆内存上有大量的变量或者引用需要检查,则检查的操作会十分缓慢,这就会使得游戏运行缓慢。其次GC可能会在关键时候运行,例如CPU处于游戏的性能运行关键时刻,其他的任何一个额外的操作都可能会带来极大的影响,使得游戏帧率下降。
另外一个GC带来的问题是堆内存碎片。当一个内存单元从堆内存上分配出来,其大小取决于其存储的变量的大小。当该内存被回收到堆内存上的时候,有可能使得堆内存被分割成碎片化的单元。也就是说堆内存总体可以使用的内存单元较大,但是单独的内存单元较小,在下次内存分配的时候不能找到合适大小的存储单元,这就会触发GC操作或者堆内存扩展操作。
堆内存碎片会造成两个结果,一个是游戏占用的内存会越来越大,一个是GC会更加频繁地被触发。
GC操作带来的问题主要表现为帧率运行低,性能间歇中断或者降低。如果游戏有这样的表现,则首先需要打开unity中的profiler window来确定是否是GC造成。
了解如何运用profiler window,可以参考此处,如果游戏确实是由GC造成的,可以继续阅读下面的内容。
如果GC造成游戏的性能问题,我们需要知道游戏中的哪部分代码会造成GC,内存垃圾在变量不再激活的时候产生,所以首先我们需要知道堆内存上分配的是什么变量。
在Unity中,值类型变量都在堆栈上进行内存分配,其他类型的变量都在堆内存上分配。如果你不知道值类型和引用类型的差别,可以查看此处。
下面的代码可以用来理解值类型的分配和释放,其对应的变量在函数调用完后会立即回收:
下面的代码可以用来理解值类型的分配和释放,其对应的变量在函数调用完后会立即回收:
void ExampleFunciton()
{
int localInt = 5;
}
对应的引用类型的参考代码如下,其对应的变量在GC的时候才回收:
void ExampleFunction()
{
List localList = new List();
}
我们可以在profier window中检查堆内存的分配操作:在CPU usage分析窗口中,我们可以检测任何一帧cpu的内存分配情况。其中一个选项是GC alloc,通过分析其来定位是什么函数造成大量的堆内存分配操作。一旦定位该函数,我们就可以分析解决其造成问题的原因从而减少内存垃圾的产生。
大体上来说,我们可以通过三种方法来降低GC的影响:
基于此,我们可以采用三种策略:
减少内存垃圾主要可以通过一些方法来减少:
如果在代码中反复调用某些造成堆内存分配的函数但是其返回结果并没有使用,这就会造成不必要的内存垃圾,我们可以缓存这些变量来重复利用,这就是缓存。
例如下面的代码每次调用的时候就会造成堆内存分配,主要是每次都会分配一个新的数组:
void OnTriggerEnter(Collider other)
{
Renderer[] allRenderers = FindObjectsOfType();
ExampleFunction(allRenderers);
}
对比下面的代码,只会生产一个数组用来缓存数据,实现反复利用而不需要造成更多的内存垃圾:
private Renderer[] allRenderers;
void Start()
{
allRenderers = FindObjectsOfType();
}
void OnTriggerEnter(Collider other)
{
ExampleFunction(allRenderers);
}
在MonoBehaviour中,如果我们需要进行堆内存分配,最坏的情况就是在其反复调用的函数中进行堆内存分配,例如Update()和LateUpdate()函数这种每帧都调用的函数,这会造成大量的内存垃圾。我们可以考虑在Start()或者Awake()函数中进行内存分配,这样可以减少内存垃圾。
下面的例子中,update函数会多次触发内存垃圾的产生:
void Update()
{
ExampleGarbageGenerationFunction(transform.position.x);
}
通过一个简单的改变,我们可以确保每次在x改变的时候才触发函数调用,这样避免每帧都进行堆内存分配:
private float previousTransformPositionX;
void Update()
{
float transformPositionX = transform.position.x;
if(transfromPositionX != previousTransformPositionX)
{
ExampleGarbageGenerationFunction(transformPositionX);
previousTransformPositionX = trasnformPositionX;
}
}
另外的一种方法是在update中采用计时器,特别是在运行有规律但是不需要每帧都运行的代码中,例如:
void Update()
{
ExampleGarbageGeneratiingFunction()
}
通过添加一个计时器,我们可以确保每隔1s才触发该函数一次:
private float timeSinceLastCalled;
private float delay = 1f;
void Update()
{
timSinceLastCalled += Time.deltaTime;
if(timeSinceLastCalled > delay)
{
ExampleGarbageGenerationFunction();
timeSinceLastCalled = 0f;
}
}
通过这样细小的改变,我们可以使得代码运行的更快同时减少内存垃圾的产生。
在堆内存上进行链表的分配的时候,如果该链表需要多次反复的分配,我们可以采用链表的clear函数来清空链表从而替代反复多次的创建分配链表。
void Update()
{
List myList = new List();
PopulateList(myList);
}
通过改进,我们可以将该链表只在第一次创建或者该链表必须重新设置的时候才进行堆内存分配,从而大大减少内存垃圾的产生:
private List myList = new List();
void Update()
{
myList.Clear();
PopulateList(myList);
}
即便我们在代码中尽可能地减少堆内存的分配行为,但是如果游戏有大量的对象需要产生和销毁依然会造成GC。对象池技术可以通过重复使用objects来降低堆内存的分配和回收频率。对象池在游戏中广泛的使用,特别是在游戏中需要频繁的创建和销毁相同的游戏对象的时候,例如枪的子弹。
要详细的讲解对象池已经超出本文的范围,但是该技术值得我们深入的研究This tutorial on object pooling on the Unity Learn site对于对象池有详细深入的讲解。
我们已经知道值类型变量在堆栈上分配,其他的变量在堆内存上分配,但是任然有一些情况下的堆内存分配会让我们感到吃惊。下面让我们分析一些常见的不必要的堆内存分配行为并对其进行优化。
在c#中,字符串是引用类型变量而不是值类型变量,即使看起来它是存储字符串的值的。这就意味着字符串会造成一定的内存垃圾,由于代码中经常使用字符串,所以我们需要对其格外小心。
c#中的字符串是不可变更的,也就是说其内部的值在创建后是不可被变更的。每次在对字符串进行操作的时候(例如运用字符串的“加”操作),unity会新建一个字符串用来存储新的字符串,使得旧的字符串被废弃,这样就会造成内存垃圾。
我们可以采用以下的一些方法来最小化字符串的影响:
1)减少不必要的字符串的创建,如果一个字符串被多次利用,我们可以创建并缓存该字符串。
2)减少不必要的字符串操作,例如如果在Text组件中,有一部分字符串需要经常改变,但是其他部分不会,则我们可以将其分为两个部分的组件。
3)如果我们需要实时的创建字符串,我们可以采用StringBuilderClass来代替,StringBuilder专为不需要进行内存分配而设计,从而减少字符串产生的内存垃圾。
4)移除游戏中的Debug.Log()函数的代码,尽管该函数可能输出为空,对该函数的调用依然会执行,该函数会创建至少一个字符(空字符)的字符串。如果游戏中有大量的该函数的调用,这会造成内存垃圾的增加。
在下面的代码中,在Update函数中会进行一个string的操作,这样的操作就会造成不必要的内存垃圾:
public Text timerText;
private float timer;
void Update()
{
timer += Time.deltaTime;
timerText.text = "Time:"+ timer.ToString();
}
通过将字符串进行分隔,我们可以剔除字符串的加操作,从而减少不必要的内存垃圾:
public Text timerHeaderText;
public Text timerValueText;
private float timer;
void Start()
{
timerHeaderText.text = "TIME:";
}
void Update()
{
timerValueText.text = timer.ToString();
}
在代码编程中,我们需要知道当我们调用不是我们自己编写的代码,无论是Unity自带的还是插件中的,我们都可能会产生内存垃圾。Unity的某些函数调用会产生内存垃圾,我们在使用的时候需要注意它的使用。
这儿没有明确的列表指出哪些函数需要注意,每个函数在不同的情况下有不同的使用,所以最好仔细地分析游戏,定位内存垃圾的产生原因以及如何解决问题。有时候缓存是一种有效的办法,有时候尽量降低函数的调用频率是一种办法,有时候用其他函数来重构代码是一种办法。现在来分析unity中中常见的造成堆内存分配的函数调用。
在Unity中如果函数需要返回一个数组,则一个新的数组会被分配出来用作结果返回,这不容易被注意到,特别是如果该函数含有迭代器,下面的代码中对于每个迭代器都会产生一个新的数组:
void ExampleFunction()
{
for(int i=0; i < myMesh.normals.Length;i++)
{
Vector3 normal = myMesh.normals[i];
}
}
对于这样的问题,我们可以缓存一个数组的引用,这样只需要分配一个数组就可以实现相同的功能,从而减少内存垃圾的产生:
void ExampleFunction()
{
Vector3[] meshNormals = myMesh.normals;
for(int i=0; i < meshNormals.Length;i++)
{
Vector3 normal = meshNormals[i];
}
}
此外另外的一个函数调用GameObject.name 或者 GameObject.tag也会造成预想不到的堆内存分配,这两个函数都会将结果存为新的字符串返回,这就会造成不必要的内存垃圾,对结果进行缓存是一种有效的办法,但是在Unity中都对应的有相关的函数来替代。对于比较gameObject的tag,可以采用GameObject.CompareTag()来替代。
在下面的代码中,调用gameobject.tag就会产生内存垃圾:
private string playerTag="Player";
void OnTriggerEnter(Collider other)
{
bool isPlayer = other.gameObject.tag ==playerTag;
}
采用GameObject.CompareTag()可以避免内存垃圾的产生:
private string playerTag = "Player";
void OnTriggerEnter(Collider other)
{
bool isPlayer = other.gameObject.CompareTag(playerTag);
}
不只是GameObject.CompareTag,unity中许多其他的函数也可以避免内存垃圾的生成。比如我们可以用Input.GetTouch()和Input.touchCount()来代替Input.touches,或者用Physics.SphereCastNonAlloc()来代替Physics.SphereCastAll()。
装箱操作是指一个值类型变量被用作引用类型变量时候的内部变换过程,如果我们向带有对象类型参数的函数传入值类型,这就会触发装箱操作。比如String.Format()函数需要传入字符串和对象类型参数,如果传入字符串和int类型数据,就会触发装箱操作。如下面代码所示:
void ExampleFunction()
{
int cost = 5;
string displayString = String.Format("Price:{0} gold",cost);
}
在Unity的装箱操作中,对于值类型会在堆内存上分配一个System.Object类型的引用来封装该值类型变量,其对应的缓存就会产生内存垃圾。装箱操作是非常普遍的一种产生内存垃圾的行为,即使代码中没有直接的对变量进行装箱操作,在插件或者其他的函数中也有可能会产生。最好的解决办法是尽可能的避免或者移除造成装箱操作的代码。
调用 StartCoroutine()会产生少量的内存垃圾,因为unity会生成实体来管理协程。所以在游戏的关键时刻应该限制该函数的调用。基于此,任何在游戏关键时刻调用的协程都需要特别的注意,特别是包含延迟回调的协程。
yield return 0;
由于需要返回0,引发了装箱操作,所以会产生内存垃圾。这种情况下,为了避免内存垃圾,我们可以这样返回:
yield return null;
另外一种对协程的错误使用是每次返回的时候都new同一个变量,例如:
while(!isComplete)
{
yield return new WaitForSeconds(1f);
}
我们可以采用缓存来避免这样的内存垃圾产生:
WaitForSeconds delay = new WaiForSeconds(1f);
while(!isComplete)
{
yield return delay;
}
如果游戏中的协程产生了内存垃圾,我们可以考虑用其他的方式来替代协程。重构代码对于游戏而言十分复杂,但是对于协程而言我们也可以注意一些常见的操作,比如如果用协程来管理时间,最好在update函数中保持对时间的记录。如果用协程来控制游戏中事件的发生顺序,最好对于不同事件之间有一定的信息通信的方式。对于协程而言没有适合各种情况的方法,只有根据具体的代码来选择最好的解决办法。
函数的引用,无论是指向匿名函数还是显式函数,在unity中都是引用类型变量,这都会在堆内存上进行分配。匿名函数的调用完成后都会增加内存的使用和堆内存的分配。具体函数的引用和终止都取决于操作平台和编译器设置,但是如果想减少GC最好减少函数的引用。
由于LINQ和常量表达式以装箱的方式实现,所以在使用的时候最好进行性能测试。
即使我们减小了代码在堆内存上的分配操作,代码也会增加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;
我们可以将该struct拆分为多个数组的形式,从而减小GC的工作量:
private string[] itemNames;
private int[] itemCosts;
private Vector3[] itemPositions;
另外一种在代码中增加GC工作量的方式是保存不必要的Object引用,在进行GC操作的时候会对堆内存上的object引用进行检查,越少的引用就意味着越少的检查工作量。在下面的例子中,当前的对话框中包含一个对下一个对话框引用,这就使得GC的时候回去检查下一个对象框:
public class DialogData
{
private DialogData nextDialog;
public DialogData GetNextDialog()
{
return nextDialog;
}
}
通过重构代码,我们可以返回下一个对话框实体的标记,而不是对话框实体本身,这样就没有多余的object引用,从而减少GC的工作量:
public class DialogData
{
private int nextDialogID;
public int GetNextDialogID()
{
return nextDialogID;
}
}
当然这个例子本身并不重要,但是如果我们的游戏中包含大量的含有对其他Object引用的object,我们可以考虑通过重构代码来减少GC的工作量。
如果我们知道堆内存在被分配后并没有被使用,我们希望可以主动地调用GC操作,或者在GC操作并不影响游戏体验的时候(例如场景切换的时候),我们可以主动的调用GC操作:
System.GC.Collect()
通过主动的调用,我们可以主动驱使GC操作来回收堆内存。
VSS:Virtual Set Size,虚拟耗用内存。它是一个进程能访问的所有内存空间地址的大小。这个大小包含了 一些没有驻留在RAM中的内存,就像mallocs已经被分配,但还没有写入。VSS很少用来测量程序的实际使 用内存。
RSS:Resident Set Size,实际使用物理内存。RSS是一个进程在RAM中实际持有的内存大小。RSS可能会 产生误导,因为它包含了所有该进程使用的共享库所占用的内存,一个被加载到内存中的共享库可能有很 多进程会使用它。RSS不是单个进程使用内存量的精确表示。
PSS:Proportional Set Size,实际使用的物理内存,它与RSS不同,它会按比例分配共享库所占用的内存。 例如,如果有三个进程共享一个占30页内存控件的共享库,每个进程在计算PSS的时候,只会计算10页。 PSS是一个非常有用的数值,如果系统中所有的进程的PSS相加,所得和即为系统占用内存的总和。当一个 进程被杀死后,它所占用的共享库内存将会被其他仍然使用该共享库的进程所分担。在这种方式下,PSS 也会带来误导,因为当一个进程被杀后,PSS并不代表系统回收的内存大小。
VSS:Virtual Set Size,虚拟耗用内存。它是一个进程能访问的所有内存空间地址的大小。这个大小包含了 一些没有驻留在RAM中的内存,就像mallocs已经被分配,但还没有写入。VSS很少用来测量程序的实际使 用内存。
RSS:Resident Set Size,实际使用物理内存。RSS是一个进程在RAM中实际持有的内存大小。RSS可能会 产生误导,因为它包含了所有该进程使用的共享库所占用的内存,一个被加载到内存中的共享库可能有很 多进程会使用它。RSS不是单个进程使用内存量的精确表示。
PSS:Proportional Set Size,实际使用的物理内存,它与RSS不同,它会按比例分配共享库所占用的内存。 例如,如果有三个进程共享一个占30页内存控件的共享库,每个进程在计算PSS的时候,只会计算10页。 PSS是一个非常有用的数值,如果系统中所有的进程的PSS相加,所得和即为系统占用内存的总和。当一个 进程被杀死后,它所占用的共享库内存将会被其他仍然使用该共享库的进程所分担。在这种方式下,PSS 也会带来误导,因为当一个进程被杀后,PSS并不代表系统回收的内存大小。
先了解下DrawCall相关概念,便于优化
DrawCall是CPU调用底层图形接口的操作
DrawCall_Num = 25K * CPU_Frame * CPU_Percentage / FPS
DrawCall_Num : DrawCall数量(最大支持)
CPU_Frame : CPU 工作频率(GHz单位)
CPU_Percentage:CPU 分配在DrawCall这件事情上的时间率 (百分比) FPS:希望的游戏帧率
DrawCall Batching(DC批处理)
Dynamic Batching(动态批处理)
Static Batching(静态批处理)
Bus总线带宽
CPU完成一次DrawCall,除了需要调用一次DrawCall的命令之外,还需要把内存中顶点数据、纹理贴图、shader参数通过bus总线拷贝到内存分配给GPU的显存之中,注意这是拷贝,不是指针传递,速度不快。项目中不会同时出现的资源不要打包到一起,保证单张合并纹理不大于1024*1024一般就不会有问题了。
VSync(垂直同步)是CPU优化最直接的方式(发热、耗电原因之一)
打开Edit-Project Settings-Quality找到V Sync Count
V Sync Count
Don’t Sync 不同步
Every V Blank 每一个垂直同步
Every Second V Blank 每一秒垂直同步
通常我们选择Don’t Sync,同时Application.targetFrameRate设置目标FPS,让性能保持一个好的状态。注意选择其他项,Application.targetFrameRate设置不生效。
垂直同步讲会在下节“5.2-Unity Profile中详细介绍”
渲染流程
GPU接收顶点数据作为输入传递给顶点着色器。顶点着色器的处理单元是顶点,输入进来的每个顶点都会调用一次顶点着色器。(顶点着色器本身不可以创建或销毁任何顶点,并无法得到顶点与顶点之间的关系)。顶点着色器是完全可编程的,它主要完成的工作有:坐标变换和逐顶点光照。 坐标变换:就是对顶点的坐标进行某种变换—把顶点坐标从模型空间转换到齐次裁剪空间。顶点的多少直接决定了三角形面的多少,也直接决定了GPU的渲染流水线的工作量,所以减少顶点数是一个比较重要的优化点。那么减少顶点怎么操作呢,又有哪些途径?
片元着色器的输入就是上一阶段对顶点信息插值得到的结果,更具体点说,是根据从顶点着色器中输出的数据插值得到的。而这一阶段的输出是一个或者多个颜色值。这一阶段可以完成很多重要的渲染技术,如纹理采样,但是它的局限在于,它仅可以影响单个片元。片元着色器是比较花时间的,因为它是最终颜色的计算者,在某些情况下,例如复杂灯光环境下,片元着色器会出现GPU流水线主要的拖后腿的存在。为了让片元着色器的计算更加快,我们需要从很多方面进行提前的优化:片元着色器最容易拖后腿的情况就是,overdraw!和Android app的开发一样,就是同一个像素点绘制了多次,某些情况会造成计算力的浪费,增加耗电量。前面提到的遮挡剔除有减少overdraw非常有用。在PC上,资源无限,为了得到最准确的渲染结果,绘制顺序可能是从后往前绘制不透明物体,然后再绘制透明物体进行混合。但是在移动平台上,对于不透明物体,我们可以设置从前往后绘制,对于有透明通道的物体(很多UI纹理就是含有透明通道的),再设置从后往前绘制。unity中shader设置为“Geometry” 队列的对象总是从前往后绘制的,而其他固定队列(如“Transparent”“Overla”等)的物体,则都是从后往前绘制的。这意味这,我们可以尽量把物体的队列设置为“Geometry” 。对于GUI,尤其要注意和设计师商量,能用不透明的设计就用不透明的,对于粒子效果,也要注意不要引入透明值,多半情况下,移动平台的粒子效果透明值没有作用。
移动平台的最大敌人。一个场景里如果包含了三个逐像素的点光源,而且使用了逐像素的shader,那么很有可能将Draw Calls提高了三倍,同时也会增加overdraws。这是因为,对于逐像素的光源来说,被这些光源照亮的物体要被再渲染一次。更糟糕的是,无论是动态批处理还是动态批处理(其实文档中只提到了对动态批处理的影响,但不知道为什么实验结果对静态批处理也没有用),对于这种逐像素的pass都无法进行批处理,也就是说,它们会中断批处理。所以当你需要光照效果时,可以使用Lightmaps,提前烘焙好,提前把场景中的光照信息存储在一张光照纹理中,然后在运行时刻只需要根据纹理采样得到光照信息即可。当你需要金属性强(镜面)的效果,可以使用Light Probes。当你需要一束光的时候,可以使用体积光去模拟这个效果。
动态阴影很酷,但是对于片元着色器来说是灾难,阴影计算是三角投影计算,非常耗性能。如果想要阴影,可以使用
- 简单的使用一个带阴影的贴图
- 烘焙场景,拿到lightmaps
- 创建投影生成器的方法
- 使用ShadowMap的方法
前面已经介绍了性能相关概念以及需关注模块,接下来该开始优化工作,具体步骤如下:
当运行一个U3D场景后,可以在界面右上方看到一个叫做"Status"的按钮,点开它就会出现一个重叠界面显示出实时统计数据,比如下图这种样子:
如果你是一名U3D开发新手,或者对此功能非常不熟悉,那么你可能会在游戏优化过程中遇到很多麻烦。接下来的篇幅着重讲讲该窗口的作用和必要的相关名词解释。
Statistics窗口,全称叫做 Rendering Statistics Window,即渲染统计窗口(或渲染数据统计窗口),窗口中罗列出关于声音、图像、网络状况等多种统计信息
首先解释下什么叫做“Draw call”,CPU每次通知GPU发出一个glDrawElements(OpenGl中的图元渲染函数)或DrawIndexedPrimitive(DirectX中的顶点绘制方法)的过程称为一次Draw call,一般来说,引擎每对一个物体进行一次DrawCall,就会产生一个Batch,这个Batch里包含着该物体所有的网格和顶点数据,当渲染另一个相同的物体时,引擎会直接调用Batch里的信息,将相关顶点数据直接送到GPU,从而让渲染过程更加高效,即Batching技术是将所有材质相近的物体进行合并渲染。
对于含有多个不同Shader和Material的物体,渲染的过程比较耗时,因为会产生多个Batches。每次对物体的材质或者贴图进行修改,都会影响Batches里数据集的构成。因此,如果场景中有大量材质不同的物体,会很明显的影响到GPU的渲染效率。这里说几点关于Batches优化相关的方案。
虽然Unity引擎自带Draw Call Batching技术,我们也可以通过手动的方式合并材质接近的物体;
尽量不要修改Batches里物体的Scale,因为这样会生成新的Batch。
为了提升GPU的渲染效率,应当尽可能的在一个物体上使用较少的材质,减少Batches过多的开销;
对于场景中不会运动的物体,考虑设置Static属性,Static声明的物体会自动进行内部批处理优化。
关于Tris和Verts,突然想到一些问题,这里需要多嘴说几句:
Camera的渲染性能受到Draw calls的影响。之前说过,对一个物体进行渲染,会生成相应的Draw call,处理一个Draw Call的时间是由它上边的Tris和Verts数目决定。尽可能得合并物体,会很大程度的提高性能。举个很简单例子,比如场景一种有1000个不同的物体,每个物体都有10个Tris;场景二中有10个不同的物体,每个物体有1000个Tris。在渲染处理中,场景一中会产生1000个Draw Calls,它的渲染时间明显比场景二慢。
Unity stats 视图中的 Tris 和 Verts 并不仅仅是视锥中的梯形内的 Tris 和 Verts,而是Camera中 field of view所有取值下的tris和verts,换句话说,哪怕你在当前game视图中看不到这个 cube,如果当你把 field of view调大到 179 过程中都看不到这个cube,stats面板才不会统计,GPU才不会渲染,否则都会渲染,而且unity不会把模型拆分,这个模型哪怕只有1个顶点需要渲染,unity也会把整个模型都渲出来。(参考自Mess的《Unity Camera组件部分参数详解》)
之前有童鞋问过我,新建一个空的场景,里边没有添加任何物体,为什么Status面板上显示有1.7k Tris以及5.0kVerts。这是因为空的场景自带默认的天空盒。点击Windows—Lighting打开Lighting下的Scene面板,把Skybox里的材质设为空,比如像我下图这样:**
可以看到,场景中的Tris数量变为2,Verts数量变为了4,这是由于摄像机存在的关系,删掉它,你就会发现Tris 和 Verts 都变为0了。
感觉又要说一大堆东西了…之前有讲到Batches,比如说场景中有100个gameobject,它们拥有完全一样的Material,那么这100个物体很可能会被Unity里的Batching机制结合成一个Batch。所以用“Batches”来描述Unity的渲染性能是不太合适的,它只能反映出场景中需要批处理物体的数量。那么可否用“Draw calls”来描述呢?答案同样是不适合。每一个“Draw calls”是CPU发送个GPU的一个渲染请求,请求中包括渲染对象所有的顶点参数、三角面、索引值、图元个数等,这个请求并不会占用过多的消耗,真正消耗渲染资源的是在GPU得到请求指令后,把指令发送给对应物体的Shader,让Shader读取指令并通知相应的渲染通道(Pass)进行渲染操作。
基本思想减少动态内存分配和释放,减少耗时函数的调用,尽可能的使用缓存。
1:控制Startcorountien 的使用次数,避免频繁的开启协程,开启一个协程至少分配37B的内存空间(corountien类的实例 分配21B Enumerator 分配16B)
如果仅仅是延迟运行,定时运行函数,可以使用invoke invokerepeating 代替
2:对象查找 gameobject.find(); 全局的查找方式,无法查找隐藏的对象,效率低下
Transform.find 从自身位置查找,性能较优的推荐方法
Gameobject.findobjectswithTag 通过标签查找,效率较高,但是需要设置标签稍显麻烦。
建议在Awake 或者Start 函数中查找对象并保存引用,切忌在Update中动态的查找
3:缓存组件
注意获取组件的方法GetCompent 方法大约会分配39B的堆内存,并且GetCompent是去访问unity原生代码效率很低,使用缓存访问,性能相差几十倍。缓存访问性能要高得多。
(a)尽量避免在Update函数中做计算,可以使用InvokeRepeating,间隔一段计算一次。
(b)避免使用SendMessage 性能底下,使用了反射机制,要使用委托 delegate的事件模拟消息机制。
(c)删除无用的预制体上的组件,删除类中无用的函数。
(d)脚本的禁用,脚本使用的时候开启,不用的时候关闭,需要专门写一套管理机制。
4.减少.Count .Length的调用
for(int i = 0; i < list.Count; i++) { //do something}
应改为:
for(int i = 0, j = list.Count; i < j ; i++) { //do something}
5.减少 gameobject,transform,GetComponent 的使用
我们可以在Start()方法中预先存储好这些值,之后使用的时候调用预先存储好的值即可。
GameObject m_gameObject;
Transform m_transform;
Text m_text;
void Start ()
{
m_gameObject = gameObject;
m_transform = transform;
m_text = GetComponent();
}
6.减少SetActive(bool)的使用
对于要频繁显示隐藏的物体,我们可以减少使用SetActive(bool),而是通过transform.scale信息将其缩小为0,例如UI的隐藏。
7.如果某个活动状态(gameObject.active == true)的GameObject上的脚本中含有Awake()方法,即使这个脚本没有被启用(enabled==false),Awake()方法也会执行。如果游戏中含有非常多的带有Update()方法的MonoBehavior,应该尝试改变代码结构来减少开销,
8.Camera.main所引起的问题与Find()方法类似,应该避免使用Camera.main并且手动管理对相机的引用。
9.Update()和LateUpdate()等事件方法的每次调用都需要引擎代码与托管代码之间进行通信,还要Unity进行安全检查(GameObject状态是否合法等),即使这些事件方法的方法体是空的,引擎任然会对其进行调用。因此,为避免浪费CPU时间,应该 删除空的事件方法。
10.设置position和rotation会触发内部的OnTransformChanged事件并传播到所有的子级对象中,对于含有非常对子物体的对象来说,这种操作开销很大,应该 减少对position和rotation的修改。
11.尝试使用localPosition替代position。localPosition存储在transform中,访问该值时,Unity会直接将其返回,而position在每次访问时都会重新计算,如果要经常获取position,可以将其缓存起来。
12.不要附加 Animation Component 在静态实体上附加 Animation 部件虽然对结果没有影响,但却会增加一定的 CPU 开销来调用这一组件,所以尽量去掉该组件。
13.关于相机相机
裁剪平面
将远平面设置成合适的距离。远平面过大会将一些不必要的物体加入渲染,降低效率。
根据不同的物体设置不同的远裁剪平面
Unity 提供了可以根据不同的 layer 来设置不同的 view distance ,所以我们可以实现将物体进行分层,大物体层设置的可视距离大些,而小物体层可以设置地小些,另外,一些开销比较大的实体(如粒子系统)可以设置得更小些等等。
14.非运动物体尽量打上 Static 标签
Unity 在运行时会对 static 物体进行自动优化处理,所以应该尽可能将非运行实体勾上 static 标签。
15.不要实例化(Instantiate)对象,事先建好对象池,并使用Translate“生成”对象;
16.使用 Resource.Load 方法在需要的时候再读取资源;各种资源在使用完成后,尽快用Resource.UnloadAsset和UnloadUnusedAsset卸载掉;
17.灵活运用AssetBundle的Load和Unload方法动态加载资源,避免主要场景内的初始化内存占用过高;(实现起来真的很难…)
18.采用www加载了AssetBundle后,要用www.Dispose 及时释放;
19.在关卡内谨慎使用DontDestroyOnLoad,被标注的资源会常驻内存;
20.尽量少使用FindObjectsOfType函数,这个函数非常慢,尽量少用且一定不要在Update里调用;
21.场景中若存在不需要显示的模型,尽量不使用SetActive方法,而是将其Scale置为Vectory.Zero,并禁用其MeshRenderer
24.场景中绝对静态的模型可以使用合并Mesh操作来进行减少DrawCall
private void CombineMesh()
{
for (int i = 0; i < rootTr.Count; i++)
{
GameObject rootModel = rootTr[i].gameObject;
//给父物体添加meshfilter
MeshFilter combineMeshFilter = rootModel.GetComponent();
if (combineMeshFilter == null)
{
combineMeshFilter = rootModel.AddComponent();
}
//获取子物体中所有的meshfilter
var filters = rootModel.GetComponentsInChildren();
//将子物体的meshfilter添加到combineInstance中
CombineInstance[] combines = new CombineInstance[filters.Length];
for (int j = 0; j < filters.Length; j++)
{
combines[j].mesh = filters[j].sharedMesh;
combines[j].transform = filters[j].transform.localToWorldMatrix;
}
//合并mesh并将mesh赋值给父物体
Mesh finalmesh = new Mesh();
finalmesh.CombineMeshes(combines);
rootModel.gameObject.GetComponent().sharedMesh = finalmesh;
}
}
25.支持分级Log(自定义logger),避免大量且频繁的Log,在构建时屏蔽log。
26.使用gameObject.CompareTag(“XXX”)而非gameObject.tag,后者会产生额外的内存与性能消耗。
27.使用内建的常量,例如Vector3.zero等等,避免频繁创建相同的对象。
这节主要讲unity资源优化,先学习一个概念:资源管道,原始文件通过内容管道变成了一个可被Unity高效使用的中间文件 ,这不是Unity里面的一个概念,但Unity的工作行为和它很类似;Unity在导入资源的时候可以使用类型丰富的文件,这不意味着在我们生成的App中同样也是这些文件,资源通过Unity的资源管道变成了较为统一的格式。我们在导入资源的时候有很多参数可以调整,
在Unity中导入声音文件我们能看到类似下面的面板 , 我们先看第一个选择Load Type,他有三个可值:
Decompress On Load,Compressed In Memory ,Streaming。
Decompress On Load
在硬盘上压缩这个文件,并在第一次加载到内存的时候解压它,这是加载声音文件的默认选项,大多数情况下我们应该使用这个选择 。
加载后解压缩声音,声音文件将在他们加载不久后就解压,这个选项适用于较小的压缩声音,以避免即时解压缩的性能开销。要知道在加载时解压 Vorbis编码的声音将使用的内存是压缩状态的十倍或更多(ADPCM编码大概3.5倍)所以不要使用此选项用于大文件。
Decompress On Load 默认选项适用于小文件。
Compressed In Memory
保持声音在内存中是压缩的并在播放时解压缩。这有更多的CPU开销(尤其是OGG / Vorbis格式的压缩文件),但可以提高加载速度并减少内存消 耗,因此这个选项适用于大文件。在性能窗口可以看"DSP CPU"。
Compressed In Memory 适用于大文件。
Streaming 选项
直接从磁盘流音频数据。这只使用了原始声音占内存大小的很小一部分。 该方法使用最少的内存和最多的CPU,它有个很明显的缺点就是不能被引用超过一次。试着让 Audio Clip产生多个副本的时候会每个都产生数据缓冲区,如果非要这么做会产生大量的内存和cpu消耗。因此这个选择最好是给单实例的Audio Clip,如背景和环境音效。对于手游而言不要优先考虑使用这种方式。
在场景中把一个Audio Clip赋值给Audio Source组件,这个音频文件将在场景初始化的时候加载到内存中。但是如果它启用了,加载Audio Clip就变成了一个后台任务它将推迟到场景初始化完成后再加载,换句话说就是在游戏开始后开始加载。 通过启用这个选择我们可以提高场景的启动速度,但Play声音的时候它还在后台加载的话播放将推迟到加载完成,我们可以使用AudioClip对象的loadState 属性来检查是否加载完成以保证声音在不会在一个不恰当的时间播放。
Preload Audio Data 默认是启用的,它表示Unity自动在场景初始化时加载文件。禁用此选项将推迟加载直到 AudioSource的 Play() 或 PlayOneShot() 方法执行的时候。从硬盘加载声音文件,然后解压,再push到内存里然后再播放,这么一系列的动作可能 会导致 CPU的峰值。
由于播放延迟和性能消耗,不建议在播放的瞬间加载。我们应该控制加载在播放之前的某些方便的时间使用AudioClip对象的LoadAudioData()方法加载。我们也可以用AudioClip对象的UnloadAudioData()方法手动控制声音文件的内存释放。
Unity支持三种声音文件编码格式的,由于平台依赖性在某些特殊情况下会有其他的选项(如Xbox One的XMA和PS Vita的HEVAG )
我们导入到Unity的音频文件可能是各种各样常见的音频格式,但通过内容管道,Standalone、WebGL和其他的一些非移动平台使用Ogg-Vorbis格式的压缩,而移动平台使用MPEG-3(MP3)格式。
PCM 提供高品质但牺牲文件大小最适合使用在很短的音效上。
ADPCM 这种格式适用于大量音效上如脚步爆破和武器,它比PCM小3.5倍但CPU使用率远低于Vorbis/MP3
Vorbis/MP3 比PCM小但是品质比PCM低,比ADPCM消耗更多CPU。但大多数情况下我们还是应该使用这种格式,这个选择还多了个Quality可以调节质量改变文件大小增强音频性能
**尽量减少音频数量 **
因为每个 Audio Source的播放都会消耗一定量的cpu,所以我们可以控制场景中 Audio Source的数量来节省cpu。
一种方式是控制我们的音频来源,这种方式我们硬盘上 的AudioClip可以同时播放的数量和总共播放的总数进行节流控制。我们通常会做一个AudioPool做这些节流操作,这很适合2d声音和单实例的3d声音(3d声音在播放的时候仍然要放在场景中具体的位置 )。 更甚至是移除一些音频,这种做法会影响到用户体验,我们应该在质量和性能上做权衡。
减少AudioClip引用
场景中每个Audio Source的Audio Clip引用和Preload Audio Data 启用都将消耗一定量的内存(压缩解压或者缓存),这些内存将贯彻整个场景,如果两个或两个以上的Audio Source引用相同的Audio Clip没有额外的内存消耗。Audio Clips在Unity中是非托管资源这意味着他们不会通过设置为null释放内存。
Unity希望我们能够加载和释放这些资源,经常使用的音效长久的在内存中保存文件是合理的,因为每次加载一个文件到
内存中都会消耗CPU。然而,如果我们发现因为音效而使用太多的内存的时候,我们必须做出艰难的选择是降低音频质量还是完全移除他们来节省内存。另一方面,保存不常用的音效在一个很长的场景中将会造成重大问题。
我们可能会有很多一次性音效,如对话片段,它们没有需 要长久保存在内存中。创建Audio Sources并分配一个AudioClip如果只是这样即使在游戏中只有一次使用也会导致内存消耗过剩,我们应该利用Resources.Load()和Resources.UnloadAsset()来保持需要播放的音频数据在内存中,一旦它不在需要就立即释放它。
术语“Texture” 和“Sprite”新手在游戏开发中经常会产生困惑,所以值得区分下,在Unity3d中Texture仅仅是个图片,我们习惯读作贴图。本质上来讲它是一个大的颜色数组告诉程序每个像素是什么颜色。
而Sprite是一个2d网格,一个平对着当前摄像机的quad,我们习惯读作精灵。
还有一种东西叫做Sprite Sheets,我们称它为图集;它用一张大图包含了很多图片,最常见的使用时2d角色动画。这些文件可以用Unity的Sprite Editor工具分割成小图做帧动画。
别管这些令人困扰的命名,来简单谈下Texture:我们导入的这些图片文件一般都生成于Adobe Photoshop国内基本都是或者一些小众软件Gimp等。在运行的时候这些文件被加载到内存中,然后Push到GPU,应用Shader完成一次Draw Call。一般情况下,渲染一次拥有一个网格并携带一种材质的物体便会使用一次Draw Call,Draw Call的次数是决定性能比较重要的指标。
压缩格式
像音频文件一样,Unity为我们多种文件压缩技术来更有效的储存文件。在导入Texture文件时,有几个选项我们可以设置。首先是Texture Type ,这个设置不会影响到文件本身而是说Unity将如何解析,操作和压缩它。
在Texture Type为Texture模式下Unity只给我们看到四个格式选项
Compressed, 16-bit,True Color和Crunched
如果我们将Texture Type设置为Advanced,那么我们就有了更多的设置选项,它给了我们更多的 Texture解析的控制权。
纹理的压缩方式取决于大小和质量的平衡,更多的需要做的是了解每种格式来选择最适合的。但是这里着重要提一下的是Crunched格式,这个格式压缩需要很长时间,但在运行时减压是非常快。在新版本的Unity中加入了这个选择可以看到它被压缩成PVRTC格式,推荐在开发手机应用程序时使用 PVRTC 纹理。你应该知道当使用 PVRTC 时与标准JPEG或PNG图像相比有可能有些图像质量的下降。是否值得在你的程序中做出一些牺牲取决于一些因素,但使用 PVRTC 纹理可以节省大量的内存空间,PVRTC 是个很好的选择。
明智的使用 Mip Maps
呈现小物件的时候,像岩石树木这样的远处物体,一个高精度的贴图对于玩家来说是没有意义的,因为看不到那么多的细节。但是如果使用这张高精度的贴图会损失原本不应该的性能,这时候需要一个较少细节的纹理来提升程序性能。
Mip Maps的发明就是来解决这个问题 的,通过启用 Generate Mip Maps
听着好像很爽的样子但是他在我们手游中几乎没用,启用Mip Maps还会让最后生成的纹理文件增大33%左右。那他存在的意义何在如何什么时候使用它呢?它唯一有用的地方是摄像机需要渲染不同距离的纹理时。如果我们的纹理总是呈现在摄像头的同样距离,这时候开启Mip Map就一点用都没只是在浪费空间。还有如果只有单一的一种远距离物体我们也应该禁用它,应该从原纹理缩放一个小纹理给这个远处物体。
**以下时候应该禁用这个选项: **
考虑把图片打包起来变成图集
把图块变成图集之后会减少Draw Calls的次数(其实就是减少Material 数量)明显减少CPU消耗,内存消耗上基本相同。一个图集下的所有贴图都应该是使用相同Shader的,不然后果很严重做了还不如不做。
图集经常使用在UI界面,2d游戏中,它在手游中几乎是必不可少的,因为Draw Calls往往会成为手游的瓶颈。
我们不应该手动去生成图集,有很多现成的工具有Unity自己的有第三方的还有一些源码工程。要注意图集不能大于目标平台的最大要求,图集最好合理的分布不要为了节省空间而视图把图集塞满。如果渲染器需要从不同的图集中调用纹理会导致大量的高速缓存命中失败,从而有引发内存带宽不足等等问题。 如果做pc游戏的话图集就没有特别的必要了,因为Draw Calls很少会成为pc游戏的性能瓶颈。正确的使用图集它不是仅仅把图片堆起来。
对于通用纹理,尽可能的使用九宫格。如果用大块的纹理则会占用较大的内存空间。而针对对称纹理则可以使用shader或者Scale翻转等方法来重复利用以减小内存消耗。
**调整非正方形纹理的压缩率 **
不建议导入非正方形或者非2的次幂的纹理到我们的应用程序,因为GPU往往需要把纹理是变成正方形和2的次幂导致不必要的处理畸形的纹理尺 寸工作量。如果纹理不是2的次幂,Unity会scale、pad纹理,以使得达到2的次幂,这样会花费更多内存,让加载更慢,所以建议是避免导入非正方形和2的次幂的纹理。
Other
目前市面上的大多数游戏都要涉及到热更新,从Unity5.0开始,简化的API让AssetBundle的打包似乎不再是一件困难的工作,但是想要管理好AssetBundle的关系却并不是那么容易的一件事。
在游戏中加载资源我使用过两种做法,都需要自己对加载方式进行封装,通过多态来灵活加载工程内或者热更的资源:
一种是讲所有需要加载的资源,例如prefab、文本等等放入到Resources下面(尽可能减少资源否则会影响启动速度),而依赖的资源放在别的文件夹下,热更的时候将资源加载到沙盒目录下。在资源加载的时候,首先对沙盒目录进行检查,若有的话,则使用热更资源,否则就加载Resource下的资源。优点是在工程内不需要打包,而缺点是由于没有打包,导致在最后出包的时候打包缓慢。
而另一种是将所有的资源打成AssetBundle放入StreamingAssets下,热更的时候同样十八AssetBundle下载到沙盒目录下。资源加载的时候,首先对沙盒目录进行检查,若有的话使用热更资源,否则就加载StreamingAssets下的资源。在加载的时候需要提供资源名和包名,在编辑器下可以通过AssetDatabase来直接从编辑器中加载资源,通过将场景加入到BuildSetting中来加载场景,避免每次进行修改的时候都需要重新打AssetBundle。这种方法在最后出包的时候比较快,在最终确定下资源正确性的时候构建AssetBundle。
通过延迟加载来对AssetBundle进行加载,在一般的使用场景下,我们并不需要将所有的AssetBundle进行载入。在游戏中,我们将建立一张常用的Bundle列表,用于进入场景时加载该场景中的常驻资源。而不一定会出现的资源则在需要的时候进行即时加载,并且放入Bundle池中随时准备取用,当Bundle闲置达到一定的时间后对其进行自动的卸载,这取决于该Bundle的使用频度。在切换场景之后卸载该场景的常用Bundle而去加载另一个场景的常用Bundle。
要注意Bundle的细粒度,如果Bundle的细粒度超过一定数量的话必然会引起热更包体积过大,玩家的更新需要下载更多的资源包,而在场景中也需要加载更多原本并不被需要的资源,而过小细粒度则会造成场景加载的缓慢,给管理上也会增加难度。所以适当的细粒度在AssetBundle的分包中也非常重要。
将公用的资源单独打成包:如果一个资源本身没有标记任何的Bundle包,而被许多别的Bundle包引用则会被打入每一个引用它的Bundle中,造成包体积的膨胀。例如Shader这样的材质就很有必要单独打成一个包用作公用,否则包体积和游戏内存占用会变成一个大问题。
当手机的容量较小时,可以通过WWW.LoadFromCacheOrDownload来进行加载,而不必担心内存不足的问题。
在将代码打包到Prefab的时候对于Component要用动态加载的方式,考虑使用lua脚本进行操作,或者是直接动态从程序集加载,这样可以避免资源与代码不同步的情况发生。可以只对代码进行修改而不需要重新进行资源打包。
在使用第二种方案建立项目的时候可以建立一个或者几个资源项目,用于大量资源的打包,用于将AssetBundle打包并放入主项目中,在主项目的在打包的时候不必再对所有的AssetBundle资源进行再打包,可以大大提高打包效率,并且可以将工作流放入到资源项目当中,提高资源的迭代效率。
在为资源分包的时候可以按照文件夹来进行区分,以便于管理。
当在一个地方需要用到包中的一小个资源,例如一个2048*2048图集中的一个小icon。拷贝一份,并且放入到目前需要使用的包中,避免由于小的资源需求而引入大内存消耗。
作为程序,我们在资源上面花的精力自然是越少越好,但是如果没有好的工具链,好的流程,我们必定将会困在永远做不完的资源管理中。美术发过来的max文件或许需要经过你的导出、导入到Unity、拖成预制体、挂动画、挂碰撞盒等等的操作之后才能成为一个真正可用的资源。这个时候一个好的工具显得格外重要。
unity 中的剔除包括两种,一种是视角剔除,凡是不在摄像机视野内的物体,不进行渲染,第二种就是遮挡剔除,被挡住的物体不被渲染,即使它在相机视野内,两种方法可以共存
不进行视角剔除,也不进行遮挡剔除
只进行视角剔除
二者都进行
Occlusion Culling使用两种存储方式, 一个为 View Cells (静态物体) 另一种为Target Cells (移动的物体).二者的数据分别单独存储
较大的物体不易当被遮挡物,较小的物体不易当遮挡物
可以通过‘overdraw’来观看场景中的遮挡情况,注意该模式下视野就代表摄像机,
注意:黄色的区域为遮挡剔除区域,只有在这个区域内,遮挡剔除效果才会显现
一.Occlusion Culling Window
在 Window > Rendering打开Occlusion Culling Window
Object菜单栏:scene Filter:文件过滤器,all 就是显示场景中所有的物体,renderers 显示场景中带有render组建的物体,occlusion Areas 显示场景中带有occlusion Area组件的物体
选中一个物体后,也可以在这个面板设置它的Occluder Static和Oculudee Static
NOTE: Whenever your camera is outside occlusion areas, occlusion culling will not be applied. It is important to set up your Occlusion Areas to cover the places where the camera can potentially be, but making the areas too large incurs a cost during baking.
当你的相机在遮挡区域之外时,遮挡剔除将不被应用。设置遮挡区域以覆盖相机可能出现的位置是很重要的,但是在烘焙过程中,使遮挡区域过大会带来成本。
当使用l (LOD) 时. (LOD0) 作为 Occluder.也就是说如果当前时Lod1则不会发生遮挡
Smallest Occluder:遮挡物的最小体积,小于这个数则不会遮挡其他物体
Smallest Hole:有的墙上有缝隙,你可以通过这个缝隙看到后面的物体,这个值是缝隙的直径
Backface Threshold:背面阈值,有些背面你是不可以到达的,比如说地形的下面,一个封闭房间的外面,这些地方你始终看不了,但是烘焙之后这些数据是包含在unity里面的,100代表不剔除这些数据,值越小数据就会越小,只是剔除背面数据
Clear:清除烘焙数据 Bake:烘焙数据
二.Occlusion Area
Occlusion Area 遮挡区域:应用于遮挡剔除移动的物体. (移动的物体不能设置为 static). 你可以在一个空物体上面添加组件 Occlusion Area (Component -> Rendering -> Occlusion Area ).
添加Occlusion Area组件之后, 点击 Is View Volume 检测遮挡移动的物体
Size:遮挡区域的大小
Center:遮挡区域的位置
Is View Volume:是否应用遮挡剔除移动的物体
在该区域内的物体,可以移动,不用设置静态,只要被遮挡就不会渲染,不被遮挡就渲染,事先也得烘焙
一.应用背景
在现代游戏中,游戏资源越来越多,游戏场景也越来越大越来越复杂,虽说硬件设备更新迭代很快,性能也日渐强大,但这还远不能缓解复杂繁多的资源带来的性能压力,因而性能优化仍然很有必要。场景资源的剔除是性能优化的一个重要方面,剔除方式也有很多,比如OcclusionCulling、Frustum Culling、layerCullingDistance等。由于项目的需要,这里重点关注Frustum Culling(视锥体剔除)。
视锥体剔除的基本思想:判断对象是否在相机视锥体内(相交也算),在则不剔除,不在则剔除。判断的方法也有很多,比较常见的方法是判断对象的BoundingBox与相机视锥体的六个剪裁平面的关系,来判断对象是否在视锥体中。为此Unity也提供了原生API以支持基于视锥体的剔除方案。
二.问题要点
这里基于Unity提供的原生API来探讨基于视锥体的剔除流程,需要使用到GeometryUtility中提供的API。
1.获取相机的剪裁平面:
有多个API可获得剪裁平面:
①public static Plane[] CalculateFrustumPlanes(Camera camera);
② public static Plane[] CalculateFrustumPlanes(Matrix4x4 worldToProjectionMatrix);
③ public static void CalculateFrustumPlanes(Camera camera, Plane[] planes);
④ public static void CalculateFrustumPlanes(Matrix4x4 worldToProjectionMatrix, Plane[] planes);
前三个API最终都是调用了④来实现剪裁面获取功能的,其中①和②由于在内部创建了Plane数组,并返回,因此存在GC,而③和④需要预先定义一个长度为6的Plane数组,并传入方法,方法内部会修改这些对象的值,因而不存在GC。所以建议使用③或者④。
通过上述API获取的剪裁平面的顺序依次是:左、右、下、上、近、远。
2.传入需要检测对象的BondingBox:
public static bool TestPlanesAABB(Plane[] planes, Bounds bounds);
调用上述API,传入通过①获取的剪裁平面及对象的BoundingBox即可检测出该对象是否在视锥体内。
三.Demo源码
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using System.Linq;
public class FrustumTest : MonoBehaviour
{
public Camera CulingCamera;
public Renderer[] CullingTestObjects;
private Plane[] planes;
void OnEnable()
{
planes = new Plane[6];
}
void Update()
{
GeometryUtility.CalculateFrustumPlanes(CulingCamera, planes);
for (var index = 0; index < CullingTestObjects.Length; index++)
{
var bounds = CullingTestObjects[index].bounds;
var result = GeometryUtility.TestPlanesAABB(planes, bounds);
CullingTestObjects[index].enabled = result;
}
}
[MenuItem("Test/Create")]
static void Create()
{
var gos = new List();
var root = new GameObject("Root").transform;
for (var i = 0; i < 10; i++)
{
for (var j = 0; j < 10; j++)
{
for (var k = 0; k < 10; k++)
{
var go = GameObject.CreatePrimitive(PrimitiveType.Cube);
go.transform.position = new Vector3(i, j, k) * 2;
go.transform.parent = root;
gos.Add(go);
}
}
}
var test = new GameObject("FrustumTest").AddComponent();
test.CulingCamera = Camera.main;
test.CullingTestObjects = gos.Select(item => item.GetComponent()).ToArray();
}
}
四.实验效果
五.存在的问题:
通过上述API获取剪裁面时,只能一次性获所有的剪裁面,而在一些特殊情况下我们往往只需要部分剪裁面即可。同时上述API底层采用了P/Invoke方式调用了非托管C++库来实现剪裁面的计算,频繁调用会有一定的性能损耗。为了实现更加个性化的基于视锥体的裁剪方案,我们往往需要自行计算剪裁面,并进行包含检测。
1.UGUI中若不需要使用到RaycastTarget的,务必将其关闭,一般UI里也就是按钮才需要接收响应事件,那么大部分image和text是是不需要开RaycastTarget的。
但是问题就来了,Unity默认在hierarchy窗口Create->UI->Image 、Text的时候就会自动帮我们勾选上RaycastTarget, 一个复杂点的界面至少也300+个Image和Text, 总不能一个个取消吧。 所以我们可以重写Create->UI->Image的事件。
[MenuItem("GameObject/UI/Image")]
static void CreatImage()
{
if(Selection.activeTransform)
{
if(Selection.activeTransform.GetComponentInParent
2.禁用空的Image。在Unity项目中,有时候会用空的Image并将alpha设置为0来接收点击事件。这样会产生不必要的overdraw,增加性能负担,可以使用以下脚本,只监听事件,不画网格,从而减少overdraw。
using UnityEngine.UI;
public class EmptyRaycast : Graphic
{
public override void SetMaterialDirty()
{
}
public override void SetVerticesDirty()
{
}
protected override void OnPopulateMesh(VertexHelper vh)
{
vh.Clear();
}
}
按如下使用方式,可以实现点击事件的监听,并且不产生overdraw。
而如果是使用Image的话,则会产生overdraw。
3.场景中应尽量保持少许的Canvas,新增一个Canvas,都会增加一个Batches.
4.UI 上的的Color属性 不建议直接修改,修改会导致重绘mesh,建议修改材质球颜色不会出现这个问题
5.在Loading界面使用动态打图集技术 可以很大的优化DC。但是动态打图集很增加Loading时间。
6.设置UI点击其他区域退出当前UI这样的类似功能 会产生大量Overdraw,最好的方法写个代码 继承Image 重新 OnPopulateMesh 方法,只写一行 tofill.clear(); 就搞定了
7.Sprite导入 UI一般关闭 Read/Write 和 Generate MipMaps
8.GameObject.SetActive 尽量少使用。因为在OnEnable 和OnDisenable 都会重新设置一遍所有的脏标志. 尤其不要对大量的 text 进行 setactive。单个UI建议使用 canvas renderer的.cull属性。如果多个UI建议使用canvas group中的alpha 设置0。
9.避免使用Camera.main
当设置画布进行渲染时,不管该画布是在世界空间还是摄像机的屏幕空间,都可以指定用于为UI中Graphic Raycaster生成交互事件的摄像机。渲染模式为“Screen Space - Camera”的画布需要使用该设置,该设置名为“Render Camera”。
然而在渲染模式为“World Space”的画布上,该设置是可选的,名为“Event Camera”。
如果将世界空间画布的Event Camera字段留空,这不意味着该画布不会接收事件。它会使用游戏的主摄像机。为了确定哪个摄像机是主摄像机,该画布会访问Camera.main属性。
根据Unity所使用的代码路径,每帧中每有一个Graphic Raycaster和世界空间画布,该画布会访问7到10次Camera.main。每次访问Camera.main都会调用Object.FindObjectWithTag。这个做法在运行时并不合适。
解决方案:避免使用Camera.main
缓存摄像机的引用,然后创建系统来跟踪主摄像机。如果使用世界空间画布,要指定Event Camera,不要将该属性留空。如果需要修改Event Camera,编写代码来更新Event Camera属性。
10.避免使用布局分组
问题:每个影响布局的UI元素都会至少执行一次GetComponents调用。
当修改布局系统的一个或多个子元素时,会使布局变脏。修改后的子元素会使拥有该元素的布局系统(Layout System)无效化。
简单介绍一下布局系统:布局系统是一组连续的布局分组(Layout Group),它们在布局元素(Layout Element)之上。布局元素不只是名为Layout Element的组件,它们还包括UI图像、文字和Scroll Rect组件,而且Scroll Rect同时也是布局分组。
回到问题本身,每个使布局变脏的UI元素都会至少执行一次GetComponents调用,该调用会在布局元素父对象上寻找有效的布局分组。找到有效布局分组后,它会继续遍历Transform层级,直到停止寻找分组或是到达层级的根部分,无论先满足哪个条件都会停止寻找过程。因此。每个布局分组会给每个子布局元素的改变过程添加一次GetComponents调用,使嵌套布局分组的性能变差。
解决方案:避免使用布局分组。
使用锚点进行比例布局。在拥有动态元素数量的活跃UI上,考虑编写代码来计算布局,仅在需要时运行该代码,而不是每次发生改变的时候。
11.Other
动态打图集算法
Github: unityRuntimeSpriteSheetsGenerator
图片压缩格式选择
- 安卓主要用RGB ETC 4bit 不支持A通道,如果透明 用ETC2 8bit 或者 再用一张ETC 1通道
- Crunched 是untiy的二次压缩方式,缺点就是压缩时间过久
- IOS主要用 RGBAPVRTC4 其次选用 SATC
- 大部分压缩格式要求 宽高是2的整数次幂(POT) 或4的整数倍数 .如果不能满足,就单独打图集
- 安卓 RGB ETC4通道分离 split alpha channel。要注意在Project setting中graphics shader 中添加 DefaultETC1。否则在真机可能出错 。或直接用ETC2(优先选择)
打图集的规则
- 尽量把同一个界面放在一个图集
- 尽量不要把同一个图放在多个公用图集
- 使用频率高的图 放在一个图集
- 大图UI尽量不打进图集
- 建议使用插件打包图集,效果会略微比Unity自带的图集打包好一点。节省空间很大一点
UI 合批流程
- 遍历UI
- 合批测试(当前UI会判断底下UI是否可以合批 如果不合批 深度底下最大的+1)
- 判断顺序 如果深度一样 然后判断材质是否一样 再判断 图片是否一样 最后再根据面板顺序排序,如果深度为-1 则不渲染
- 得出排序数组 看相邻元素是否一样 然后合批
会打断合批的操作
父物体 的 Pos 改变z轴 , 旋转改变 x y 轴,会退出批处理 会打断上下合批(UI和其他UI不在同一平面就不能合批)
UI 网格重建流程
某ui改变----将此UI设置脏标志—添加脏标志队列–下一帧重建mesh
主要思想是减少存的动态分配和释放,以及内存泄露,减少垃圾回收。
1:
(1)字符串的处理 使用stringbuild 类代替string 进行多个字符串的拼接。
(2)String.format();
避免使用“+”“aaa”+“bbb”这种方式 原因是“+”对字符串进行拼接,会导致临时
堆string 对象发生频繁的堆对象的分配和释放。
2.尽可能使用for循环代替foreach
数组 泛型list<> 一律使用for ,字典使用foreach
原因: 每次foreach产生一个临时的迭代器对象,迭代器会额外的分配内存。
3:频繁调用的函数中,如果有临时变量是引用类型,要将其改为成员变量。避免频繁的堆对象的创建和释放。
4:如果函数运行需要一个List 需要设置List为成员变量 独立与函数的运行,可以
通过clear()函数清空,重复使用全局成员集合,清空函数不会删除内存空间,大大的减轻性能的负担。
5:避免使用Lambda表达式,存在内存泄露的内存隐患。
6:尽量使用对象池 避免对象的频繁创建和释放,导致大量无效内存的积累,引发GC(垃圾回收)操作,造成性能的降低
7:避免装箱和拆箱的操作,会导致堆内存的分配。
8:避免使用AraayList会把所有插入的数据当作object类处理,存在一个装箱的过程。尽量使用泛型List<>;
9:避免使用反射机制,运行时获取类型信息,性能低下。
10:即时的将对象设置为null、 委托事件即时注销。
11.别把成员变量声明为 public 或 protected。都声明为 private 而使用 public/protected 的属性
12.不在代码中使用具体的路径和驱动器名。 使用相对路径,并使路径可编程。
13.避免不必要的调用 ToUpper 或 ToLower 方法
String是不变类,调用ToUpper或ToLower方法都会导致创建一个新的字符串。如果被频繁调用,将导致频繁创建字符串对象。这违背了“避免频繁创建对象”这一基本原则。
例如,bool.Parse方法本身已经是忽略大小写的,调用时不要调用ToLower方法。
另一个非常普遍的场景是字符串比较。高效的做法是使用 Compare 方法,这个方法可以做大小写忽略的比较,并且不会创建新字符串。
还有一种情况是使用 HashTable 的时候,有时候无法保证传递 key 的大小写是否符合预期,往往会把 key 强制转换到大写或小写方法。实际上 HashTable 有不同的构造形式,完全支持采用忽略大小写的 key: new HashTable(StringComparer.OrdinalIgnoreCase)。
14…绝对不要在循环中使用try-Catch.
15.利用using和try/finally语句来清理资源
16.尽量少用模运算和除法运算,比如a/5f,一定要写成a*0.2f。
17.不要滥用静态对象:由于静态对象始终存在于内存当中,过度滥用的话容易出现占用内存过多的情况。当不再需要的时候,将静态大对象置空,保证GC能够正常进行。
18.使用尾递归而非其他的递归,尾递归的性能好于头递归。
1.不要使用实时阴影,使用阴影纹理或者简单的图片模拟阴影。
2.减少顶点,减少三角面数,峰值小于10w面/每帧,使用LOD进行多层次模型设置,进行遮挡。
3.减少Drawcall 峰值<200 对场景物件 比如说:石头,树木等使用相同材质相同贴图的物体进行批处理,降低DC。人物模型(人物模型一般都是带骨骼的)SkinMesh。即便使用相同的材质贴图,DC也不能进行批处理。使用Lightmap技术降低DC。 UI图素进行合并(打包图集)降低DC。
4.尽量使用简单材质,适合移动平台(手机)Mobile这种Shader或者使用自定义一些优化过的Shader,高级Shader严重降低帧率,加大能耗,或者不可用。
5.在纹理和质量之间做平衡,在保证质量不受太大影响的情况下,尽量使用小尺寸的纹理,提升效率。减少安装包的大小,九宫格等
6.减少骨骼数量的使用15个上下左右,减少粒子系统,粒子的数量。尽量减少粒子数量。
7.共享材质 多一个材质DC就会上升 使用共享材质 将多张贴图画在一张较大的贴图上面,达到共享材质的作用。
8.减少,水面折射效果,或者雾效果。
9.尝试使用遮挡剔除Occlusion Culling进行渲染上面优化。
10.使用LOD技术,在LOD2和LOD1可以修改贴图的品质,Cast Shadow和 ReceiveShadow可以移除
11.能不使用实时光渲染则尽量不要使用实时光渲染,使用光照贴图(LightMap)替代
12.将场景中的静态物体进行合并Mesh处理
13.场景中如果没有使用灯光和像素灯,就不要使用法线贴图,因为法线效果只有在有光源(Direct Light/Point Light/Angle Light/Pixel Light)的情况下才有效果。
14.如果硬阴影可以解决问题就不要用软阴影,并且使用不影响效果的低分辨率阴影;
提起游戏性能,首先要提到的就是,不仅开发人员,所有游戏玩家都应该会接触到的一个名词:帧率(Frame rate)。
帧率是衡量游戏性能的基本指标。在游戏中,“一帧”便是绘制到屏幕上的一个静止画面。绘制一帧到屏幕上也叫做渲染一帧。每秒的帧数(fps)或者说帧率表示GPU处理时每秒钟能够更新的次数。高的帧率可以得到更流畅、更逼真的动画。
现阶段大多数游戏的理想帧率是60FPS,其带来的交互感和真实感会更加强烈。通常来说,帧率在30FPS以上都是可以接受的,特别是对于不需要快速反应互动的游戏,例如休闲、解密、冒险类游戏等。有些项目有特殊的需求,比如VR游戏,至少需要90FPS。当帧率降低到30FPS以下时,玩家通常会有不好的体验。
而现阶段随着支持144HZ刷新率的硬件设备的涌现,能否维持对应高帧率又是一项新的指标,尤其是在电竞领域
但在游戏中重要的不仅仅帧率的速度,帧率同时也必须非常稳定。玩家通常对帧率的变化比较敏感,不稳定的帧率通常会比低一些但是很稳定的帧率表现更差。
虽然帧率是一个我们谈论游戏性能的基本标准,但是当我们提升游戏性能时,更因该想到的是渲染一帧需要多少毫秒。帧率的相对改变在不同范围会有不同的变化。比如,从60到50FPS呈现出的是额外3.3毫秒的运行时间,但是从30到20FPS呈现出的是额外的16.6毫秒的运行时间。在这里,同样降低了10FPS,但是渲染一帧上时间的差别是很显著的。
我们还需要了解渲染一帧需要多少毫秒才能满足当前帧率。通过公式 1000/(想要达到的帧率)。通过这个公式可以得到,30FPS必须在33.3毫秒之内渲染完一帧,60FPS必须在16.6毫秒内渲染完一帧。
渲染一帧,Unity需要执行很多任务。比如,Unity需要更新游戏的状态。有一些任务在每一帧都需要执行,包括执行脚本,运行光照计算等。除此之外,有许多操作是在一帧执行多次的,例如物理运算。当所有这些任务都执行的足够快时,我们的游戏才会有稳定且理想的帧率。当这些任务执行不满足需求时,渲染一帧将花费更多的时间,并且帧率会因此下降。
知道哪些任务花费了过多的时间,是游戏性能问题的关键。一旦我们知道了哪些任务降低了帧率,便可以尝试优化游戏的这一部分。这就是为什么性能分析工具是游戏优化的重点之一。
工欲善其事必先利其器,这里我们来讲解Unity3D优化所需的工具
如果游戏存在性能问题,游戏运行就会出现缓慢、卡顿、掉帧甚至直接闪退等现象。在我们尝试解决问题前,需要先知道其问题的起因,而尝试不同的解决方案。若仅靠猜测或者依据自身原有的经验去解决问题,那我们可能会做无用功,甚至引申出更复杂的问题。
在这些时候我们就需要用到性能分析工具,性能分析工具主要测试游戏运行时各个方面性能,如CPU、GPU、内存等。通过性能分析工具,我们能够透过游戏运行的外在表现,获取内在信息,而这些信息便是我们锁定引起性能问题的关键所在。
在我们进行Unity性能优化的过程中,最主要用的到性能分析工具包括,Unity自带的Unity Profile,IOS端的XCode ,以及一些第三方插件,如腾讯推出的UPA性能分析工具。
我们主要针对Unity Profile进行讲解,之后也会略微介绍另外一些性能分析工具。
Unity Profile是Unity中最常用的官方性能分析工具,在使用Unity开发游戏的过程中,借助Profiler来分析CPU、GPU及内存使用状况是至关重要的。
首先我们来了解Unity Profile的面板:
我们通过Window——>Profiler来激活Unity Profile面板
在下图中我们可以看到Unity Profile面板,其中有很多profilers,每个profiler显示我们当前项目一个方面的信息,如CPU、GPU、渲染(Rendering)、内存(Memory)、声音(Audio)、视屏(Video)、物理(Physics)、ui及全局光照(global illumination)。
当项目运行时,每个profilers会随着运行时间来显示数据,有些性能问题是持续性的,有些仅在某一帧中出现,还有些性能问题可能会随时间推移而逐渐显出出来。
在面板的下半部分显示了我们选中的profilers当前帧的详细内容,我们可以通过选择列标题,通过这一列的信息值来排序。
在CPU usage profiler中的列表题分别为:
Total:当前任务的时间消耗占当前帧cpu消耗的时间比例。
Self:任务自身时间消耗占当前帧cpu消耗的时间比例。
Calls:当前任务在当前帧内被调用的次数。
GC Alloc:当前任务在当前帧内进行过内存回收和分配的次数。
Time ms:当前任务在当前帧内的耗时总时间。
Self ms:当前任务自身(不包含内部的子任务)时间消耗。
当我们在层级视图中点击函数名字时,CPU usage profiler将在Profiler窗口上部的图形视图中高亮显示这个函数的信息。比如我们选中Cameta.Render,Rendering的信息就会被高亮显示出来。
我们可以Profiler的左下的下拉菜单中选择Timeline。
Timeline显示了两件事:cpu任务的执行顺序和哪个线程负责什么任务。线程允许不同的任务同时执行。当一个线程执行一个任务时,另外的线程可以执行另一个完全不同的任务。和Unity的渲染过程相关的线程有三种:主线程,渲染线程和worker threads。了解哪个线程负责哪些任务的用处非常之大,一旦我们知道了在哪个线程上的任务执行的速率最低,那么我们就应该集中优化在那个线程上的操作。
以上所显示的数据依赖于我们当前选择的profiler。例如,当选中内存时,这个区域显示例如游戏资源使用的内存和总内存占用等。如果选中渲染profiler,这里会显示被渲染的对象数量或者渲染操作执行次数等数据。
这些profiler会提供很多详细信息,但是我们并不总需要使用所有的profiler。实际上,我们在分析游戏性能时通常只是观察一个或者两个profiler,而不需要观察的我们可以通过右上角的”X”关闭,如果需要在添加回来,可以通过左上角Add Profiler。
例如,当我们的游戏运行的比较慢时,我们可能一开始先查看CPU usage profiler,CPU usage profiler也是在我们进行优化分析时最常用的Profiler。
当然,除了CPU usage profiler,Unity Profiler中其他的Profiler在一些场合也非常的有用,比如GPU、内存、渲染等,其使用方法和CPU usage profiler也是大同小异,可以按照以上的步骤来查看并学习。
我们在观察数据时,需要观察的目标有如下几点:
CPU:
GC相关的问题和优化,在之后我们会详细的介绍。
Time ms:
注意占用5ms以上的选项
内存 :
实际项目中的优化建议
在了解了Unity Profiler后,现在我们在一个实际项目中来进行一次性能分析。同时来了解一般在实际项目中,主要会引起也是我们主要去观察的性能问题出现在什么地方。
以下是我做的一个简单的游戏项目,并未做任何性能优化并且有大量引起性能问题的代码,可以更方便大家观察其性能问题。
我们来看一下在CPU usage profiler面板中的可观察项,在项目中我们可以先关闭VSync垂直同步来提高帧率。
下图中我关闭了除VSync之外的显示,可以看到VSync的消耗
具体步骤是edit->project settings->Quality,在Inspector面板中,V Sync count选择don’t Sync.
我们来简单的介绍一下什么是垂直同步,以及关闭它之后会发生什么。
要理解垂直同步,首先明白显示器的工作原理。
显示器上的所有图像都是单个像素组成了水平扫描线,水平扫描线在垂直方向的堆积形成了完整的画面,无论是隔行扫描还是逐行扫描,显示器都有两种同步参数——水平同步和垂直同步。
垂直和水平是CRT显示器中两个基本的同步信号,水平同步信号决定了CRT画出一条横越屏幕线的时间,垂直同步信号决定了CRT从屏幕顶部画到底部,再返回原始位置的时间,而垂直同步代表着CRT显示器的刷新率水准。
在游戏项目中,如果我们选择等待垂直同步信号也就是打开垂直同步,在游戏中或许性能较强的显卡会迅速的绘制完一屏的图像,但是没有垂直同步信号的到达,显卡无法绘制下一屏,只有等85单位的信号到达,才可以绘制。这样FPS自然要受到刷新率运行值的制约。
而如果我们选择不等待垂直同步信号,那么游戏中绘制完一屏画面,显卡和显示器无需等待垂直同步信号就可以开始下一屏图像的绘制,自然可以完全发挥显卡的实力。
但是,正是因为垂直同步的存在,才能使得游戏进程和显示器刷新率同步,使得画面更加平滑和稳定。取消了垂直同步信号,固然可以换来更快的速度,但是在图像的连续性上势必会打折扣。
需要注意,LCD显示器其实也是存在刷新率的,但其机制与CRT不同,这里不做过多赘述,但是垂直同步和水平同步对于LCD显示器来说,一样是有必要的。
在关闭垂直同步后,我们继续看我们的项目
可以看到,我们以Total和Time ms排序,在图中拉黑的项(Camera Render)始终排在最前面。
Camera Render是相机渲染工作的CPU占用量,在实际项目中,渲染是最常见的引起性能问题的原因。 而因为渲染而引起的性能问题的优化是一个非常大的工程,这方面的优化方法在我们后续的文章中会有详细的教程去学习和分析。在这里,我们只需要先了解。
我们这个项目的优化中,无疑,渲染造成的性能损耗是一个大头。
如果说,在我们性能分析中,渲染已经没有什么问题,那么我们接下来要重点观察的就是GC,也就是垃圾回收性能分析。
我们按照GC Alloc的顺序来显示,可以看到下图。
在之前我们提到过,GC Alloc中,任何一次性内存分配大于2KB的选项,每帧都具有20B以上内存分配的选项 ,是需要我们重点关注的,显而易见,我们的项目中,对于GC的优化,也有很大的问题。
这里我们大致介绍一下GC的机制,要想了解垃圾回收如何工作以及何时被触发,我们首先需要了解unity的内存管理机制。Unity主要采用自动内存管理的机制,开发时在代码中不需要详细地告诉unity如何进行内存管理,unity内部自身会进行内存管理。
Unity内部有两个内存管理池,堆内存和栈内存,垃圾回收主要是指堆上的内存分配和回收,unity中会定时对堆内存进行GC操作。
当堆内存上一个变量不再处于激活状态的时候,其所占用的内存并不会立刻被回收,不再使用的内存只会在GC的时候才会被回收。
每次运行GC的时候,GC会检查堆内存上的每个存储变量,对每个变量会检测其引用是否处于激活状态,如果变量的引用不再处于激活状态,则会被标记为可回收,被标记的变量会被移除,其所占有的内存会被回收到堆内存上。
GC操作是一个极其耗费的操作,堆内存上的变量或者引用越多则其运行的操作会更多,耗费的时间越长。
如果我们也排除了GC的问题, 那么再接下来,我们就要考虑到是否是脚本的一些问题造成性能损耗。
这里的脚本,可能是我们自己写的代码,也有可能是我们使用的一些插件的代码。在CPU usage profiler面板中,我们可以关注Script这一项。
如果在一个很慢的帧中,一大部分时间被脚本运行所消耗,这意味着这些慢的脚本可能就是引起性能问题的主因。我们可以更加深入的分析数据来确认。
首先我们按照Time ms来排序,然后选择信息列表中的项目,如果是用户脚本的函数,那么在Profiler上方会有高亮脚本的部分。这种情况,说明游戏的性能问题是和用户脚本相关的,如下图中的显示,这部分脚本性能问题一定是与我们FixedUpdate有关。
同时,我们还可以再关注一些物理、ui方面的性能问题。
在上面我们讨论的,是几种最常见的性能问题,在实际项目优化中,如果有性能问题也逃不开这些,如果在这些方向都已经达到了我们的要求,但我们的游戏仍然有性能问题,我们应该遵循上面的方法解决问题:收集数据——>使用CPU usage profiler查看信息——>找到引起问题的函数。一旦我们知道了引起问题函数的名字,我们便可以针对性的,对其进行优化处理。
在开头我们说过,在我们进行Unity性能优化的过程中,最主要用的到性能分析工具包括,Unity自带的Unity Profile,IOS端XCode Capture GPU frame以及一些第三方插件,如腾讯推出的UPA性能分析工具。
这里我们简单的介绍一下XCode和UPA.
Xcode是 Mac OS X上的集成开发工具。在我们打包Unity IOS的项目时,必须使用到Xcode来帮助我们打包及发布。
Xcode的功能也十分的强大,在我们开发IOS端时,可以使用其GPU frame Capture 功能为我们的项目进行性能优化分析。
在unity中,我们打包时在Run In Xcode as 选择debug模式,并且勾选Development Build
打包完成后,使用Xcode打开文件,在Xcode中选择Product ——> Scheme——> Manage Schemes
然后会出现如下界面
我们双击这个项目会出现如下界面
然后我们在左侧选中Run,然后在右侧面板选择Options
在GPU frame Capture中选择OpenGL ES或者Metal。
在Debug模式下运行项目,当项目在真机上完全加载后,就可以进入Debug Navigator(View ——> Navigators ——> Show Debug Navigator)
以下是GPU frame Capture具体功能的界面,在图形化界面中,可以在游戏运行时清晰的了解到CPU、GPU、内存的使用情况。
XCode的Capture GPU frame功能能高效且定量地定位到GPU中shader的消耗。
UPA是腾讯和Unity联合打造的一款性能分析工具,据说王者荣耀的性能优化分析就有使用到UPA,具体的使用方法可以通过客户端性能测试【腾讯WeTest】去了解