原文链接:https://docs.unity3d.com/Manual/BestPracticeUnderstandingPerformanceInUnity8.html
前面的章节叙述了对所有项目适用的优化手段,本章的优化手段不应该在收集性能分析数据之前应用。这可能是因为这些优化手段实现起来是劳动密集型的,可能是因为为了支持性能而损失代码的整洁性和可维护性,或者可能是因为这些问题只在某种特定的程度上才会出现。
多维数组和交错数组
像这个StackOverflow文章(https://stackoverflow.com/questions/597720/what-are-the-differences-between-a-multidimensional-array-and-an-array-of-arrays)中所叙述的,通常迭代交错数组比迭代多维数组更有效率,因为多维数组需要函数调用。
请注意:
·他们是数组的数组,使用type[x][y]声明而不是type[x,y]
·可以使用ILSpy或者类似工具监测访问多维数组的IL生成来发现。
在Unity5.3中进行性能测试,通过对一个三维的100x100x100的数组进行完全顺序迭代产生下面的时间数据,它们是运行10次测试取平均值的结果:
数组类型 花费时间 (100次迭代)
一维数组 660 ms
交错数组 730 ms
多维数组 3470 ms
额外函数调用的花费可以从访问多维数组和一维数组花费的不同中看出,对于迭代非紧凑内存数据结构的花费可以从访问交错数组和一维数组的比较中看到不同。
正如上面的演示,额外函数调用的消耗大大超过了使用非紧凑型内存数据结构消耗造成的影响。
对于高度性能敏感的操作来说,强烈建议使用一维数组。对于其他有多维需求的数组来说,应该使用交错数组。多维数组不应该使用。
粒子系统池
在缓存粒子系统时,请注意它们至少会消耗3500字节的内存。内存消耗会随着粒子系统上激活的模块数量而增长。这个内存当粒子系统被禁用时不会被释放,只有它们被销毁时才会被释放。
在Unity5.3中,大多数的粒子系统设置可以在运行时执行。对于那些必须缓存大量不同粒子效果的项目,把粒子系统的配置参数提取到一个数据承载类或是一个结构体上可能会更有效率。
当一个粒子特效被需要时,一个通用的粒子效果池可以提供必备的粒子效果对象。配置数据可以被应用到对象中来达到想要的图形效果。
这事实上比缓存所有给定场景的粒子系统的变体和配置更有内存效率,但这需要大量的编程工作才能达到。
更新管理器
内部,Unity追踪注册其回调的对象列表,比如Update, FixedUpdate和LateUpdate。它们是作为侵入式链表进行维护,这样来确保列表的更新在固定的时间内。当启用和禁用时,MonoBehaviour被从这些列表中添加或移除。
虽然给所需的MonoBehaviour简单的添加适当的回调函数是方便的,但是当回调函数数量增加时效率会变的越来越低。底层代码调用托管代码存在一个小但是显著的消耗。其结果不单是在执行大量的每帧函数时降低帧时间,也会在初始化包含大量MonoBehaviour的预制体时降低初始化时间(请注意:初始化的消耗取决于这个预制体每个组件中执行Awake和OnEnable回调的性能消耗)。
当每帧进行的回调的MonoBehaviour的数量增长到成百上千,移除这些回调转而将这些MonoBehaviour(或是独立的C#对象)关联到一个全局单例管理器上。这个全局单例管理器可以分发Update, LateUpdate和其他回调到感兴趣的对象上。这样所另外一个额外的好处是允许代码在没有操作的情况下巧妙的取消回调函数的注册,从而减少每帧必须调用函数的绝对数量。
通常通过很少执行的回调来实现最大的性能节省。考虑下面的伪代码:
void Update() {
if(!someVeryRareCondition) { return; }
// … some operation …
}
如果像上面那样有大量的拥有Update回调的MonoBehaviour,那么大量的时间消耗将花费在Update回调上,它们在MonoBehaviour执行时在底层和托管域切换,然后迅速退出。如果这些类转而注册到一个全局Update管理器上,只有当someVeryRareCondition为true时才生效,并且随后将其取消注册,那么时间将会从切换代码域和评估很少的条件中节省。
在一个update管理器中使用委托
简单的使用C#委托来实现这些回调是简单的。然而C#委托为低频率的注册和取消注册、少量的回调数量进行了优化。每当一个回调被添加或者移除时,C#委托都会对回调列表执行一个完全的深度拷贝。庞大的回调列表,或是大量的回调添加移除操作在同一帧会在内部的Delegate.Combine函数中造成一个性能尖峰。
对于那些添加和移除高频发生,考虑使用一个插入移除快的数据结构来代替委托。
加载线程控制
Unity允许开发者控制被用于加载数据的后台线程的优先级。这对于在后台流shift传输AssetBundle到磁盘上尤其重要。
主线程和图形线程的优先级都是ThreadPriority.Normal——任何高优先级的线程都会抢占主线程或是图形线程,并造成帧率卡顿,反之低优先级的线程就不会这样。如果一个线程与主线程有相同的优先级,CPU会尝试给这些线程相同的时间,如果有多个后台线程在进行比如说AssetBundle解压这样的繁重操作时,通常会造成帧率的卡顿。
目前,这个优先级可以在三个地方控制。
首先,对于Asset加载调用的默认优先级,比如说Resources.LoadAsync和AssetBundle.LoadAssetAsync,来自Application.backgroundLoadingPriority设置。正如文当中所说,这个调用也限制了主线程花费在集成Asset上的时间(请注意,大多数Unity的Asset类型必须被集成到主线程上。在集成期间,这些Asset的初始化工作会被完成,以及某些线程安全操作将会被执行。这包括脚本回调执行,比如说Awake回调。请看“Resource Management” 指导获取进一步的细节。),为了限制Asset加载对帧时间的影响。
其次,每个异步的Asset加载操作,比如UnityWebRequest请求,返回一个 AsyncOperation对象到监视器并且管理这个操作。这个AsyncOperation对象暴露一个priority属性可以用于调整一个独立操作的优先级。
最后,WWW对象,比如那些从WWW.LoadFromCacheOrDownload返回的调用,会暴露一个threadPriority属性。重要提示,WWW对象不会自动使用Application.backgroundLoadingPriority设置作为其默认值-WWW对象的默认值总是ThreadPriority.Normal。
值得注意的是,底层系统用于解压和加载的方式不同于这些api。Resources.LoadAsync和AssetBundle.LoadAssetAsync是通过Unity内部的PreloadManager系统来执行的,其会管理它自己的加载线程和执行它自己的帧率限制。UnityWebRequest有其专用的线程池,每当一个新的请求被创建时,WWW会生成一个全新的线程。
其他的加载机制有内置的队列系统,而WWW并没有。在大量被压缩的AssetBundle上调用WWW.LoadFromCacheOrDownload会生成相等数量的线程,它们将与主线程争夺CPU时间,很容易造成帧率卡顿。
所以,当使用WWW加载和解压AssetBundle时,最好是对于创建的每一个WWW对象都设置合适的threadPriority值。
其他的加载机制有内置的队列系统,而WWW并没有。在大量被压缩的AssetBundle上调用WWW.LoadFromCacheOrDownload会生成相等数量的线程,它们将与主线程争夺CPU时间,很容易造成帧率卡顿。
所以,当使用WWW加载和解压AssetBundle时,最好是对于创建的每一个WWW对象都设置合适的threadPriority值。
聚集的对象移动和剔除组
正如在变换处理章节中提到的,由于变更消息的传播,移动大量的变换层级会有一个相对高的cpu消耗。然而,在真实的开发环境中,通常无法将层级折叠成一个适度数量的游戏物体。
与此同时,良好的开发实践是运行足够的行为来维持游戏世界的可信度,同时消除用户不会注意到的行为。比如,在一个有大量角色的场景中,更优化的做法是只对屏幕上的角色进行蒙皮网格和动画驱动的移动变换。完全没有理由浪费CPU的时间在对屏幕外的角色进行计算完全的视觉元素模拟。
这些问题可以通过在Unity 5.1中引入的新API:CullingGroups巧妙的解决。
不是直接操纵场景中的一组大量的游戏物体,而是改变系统来操纵一个CullingGroup组中一组BoundingSphere的Vector3参数。每个BoundingSphere充当着一个游戏逻辑实体的世界空间坐标的命令库,并且当实体移动到CullingGroup的主摄像机附近或其中时接收回调函数。这些回调函数用于激活或是取消激活代码或是组件(比如Animator),管理那些只应该在实体可见时运行的行为。
减少函数调用消耗
C#的字符串库提供了一个非常好的研究案例,针对增加额外的简单代码库对的函数调用的花费。在内置的string API的String.StartsWith和String.EndsWith部分中,提到了用手写代码替代内置的函数,速度提升了10-100倍,即使当不必要的语言环境强制被抑制。
性能不同的关键问题是在紧凑的内部循环中简单的增加了额外函数的调用。每个执行的函数都必须定位到这个函数在内存中的地址,并将另一帧推到堆栈上。这些操作都不是免费的,带是在大多数代码中它都足够小到可以被忽略。
然而,当在紧凑的循环中运行小的函数时,引入额外函数调用造成的消耗增加会变得巨大-并且甚至会非常明显。
考虑下面两个简单的例子:
例1:
int Accum { get; set; }
Accum = 0;
for(int i = 0; i < myList.Count; i++) {
Accum += myList[i];
}
例2:
int accum = 0;
int len = myList.Count;
for(int i = 0; i < len; i++) {
accum += myList[i];
}
这些函数都计算了一个C#类List
虽然表面上讲这两段代码看起来是相等的,但是当代码使用函数调用来分析时其区别是显著的。
例1:
int Accum { get; set; }
Accum = 0;
for(int i = 0;
i < myList.Count; //调用List::getCount
i++) {
Accum //调用set_Accum
+= //调用get_Accum
myList[i]; //调用List::get_Value
}
所以在每次循环执行时会调用四个函数:
·myList.Count在Count属性上执行get函数
·在Accum属性上肯定会执行get和set函数
·执行get函数来获取Accum当前的值以便其可以传递到加法操作中
·执行set函数来将加法操作的结果设置到Accum中
·[]操作符执行list的get_Value函数来获取list中指定索引的元素值
Example 2:
int accum = 0;
int len = myList.Count;
for(int i = 0;
i < len;
i++) {
accum += myList[i]; //调用List::get_Value
}
在这第二个例子中,调用get_Value保留,但是其他所有的函数调用都被消除了或是不再每次循环迭代执行一次。
·由于accum现在使用了原始值来代替属性,在设置或是获取它的值时不再需要进行函数调用
·由于当循环进行时myList.Count采取了不再变化,其访问已经被移到了循环条件状态之外,所以其不再会在每次循环迭代的开始时执行。
这两个版本代码的时间花费显示了从这个特定代码片段中移除75%函数调用的益处。当我们在现代桌面平台机器上运行100000次时:
·例1需要324毫秒来执行
·例2需要128毫秒来执行
这里主要的问题是Unity几乎不执行函数的内联,如果有的话也非常少。即使在IL2CPP下,许多函数当前也没有正确的内联。尤其是对于属性。进一步说,虚函数和接口函数完全不能内联。
因此,一个在C#源码中的函数调用非常可能最终在二进制应用程序中产生一个函数调用。
琐碎的属性
Unity在其数据类型中提供了许多“简单的”常量使开发者能够方便的开发。然而,鉴于上面的情况,要非常注意,这些常量值通常是通过返回常量值的属性来实现的。
Vector3.zero的属性体如下:
get { return new Vector3(0,0,0); }
Quaternion.identity也非常相似:
get { return new Quaternion(0,0,0,1); }
虽然与围绕它们的实际代码相比,访问这些属性的消耗非常小。但是当它们每帧执行上千遍或是更多时就会造成小的影响。
对于简单的原始类型,使用const值来代替.Const值会在编译时被内联——到const变量的引用会被其值替换。
请注意:由于每个到const变量的引用都替换成了它的值,那么就不建议在const中声明长的字符串和其他大的数据类型。没必要因为在最终的指令代码中都是重复的数据而造成最终的二进制文件膨胀。
当const不合适时,使用一个static readonly来代替。在一些项目中,即使Unity内置的琐碎属性也已经被替换成了 static readonly变量,这样可以在性能上有小的提升。
琐碎的函数
琐碎的函数是一个骗局。它在声明一个功能并且在另一个地方重用时非常有效。然而在紧凑的循环中,也许有必要离开某些良好的编码实践并且转而使用“手动的内联”某些代码。
一些函数可以被完全消除。考虑Quaternion.Set,Transform.Translate或是Vector3.Scale。他们执行非常琐碎的操作并且可以被简单的赋值语句来代替。
对于更为复杂的函数,请权衡分析手动内联性能的证据与维持更高性能的代码的长期成本。