C#代码优化:拯救你的CPU耗时
要保证游戏在流畅的帧率下运行,就要保证CPU和GPU能够及时地完成它们在一帧当中的“任务”。而本文我们讲解的这些C#代码的性能,就会影响到每帧CPU自身的耗时。当游戏出现帧率降低、间歇性卡顿甚至卡死等现象时,我们就需要考虑这是否是由开发者自己所编写的脚本性能较差所造成的。
性能的优化问题,实际上是个“时空问题”:我们要尽可能地节省运算所需的时间,节约内存上占用的空间。对C#代码的优化亦是如此,一方面是对CPU耗时的优化,一方面是对内存分配的优化。而“空间问题”与“时间问题”又常常会相互转化——优化内存的目的之一,是减少GC,这又可以归结为减少CPU耗时。本文要讲解的一系列规则,就是主要针对CPU耗时的规则。
1、类中存在OnGUI方法
规则里涉及到的OnGUI,它是Unity的IMGUI系统绘制UI所调用的方法。该方法如果写在继承了Monobehavior的脚本上,那么Unity会在每一帧自动对其进行调用。
使用IMGUI进行UI绘制,想要更改任何内容,整个图形用户界面都要重新绘制,OnGUI会在一帧当中调用多次,这会导致CPU耗时增加。此外,如果OnGUI函数使用不当,容易造成堆内存的持续分配。因此,在游戏项目中,一般不使用IMGUI进行UI开发(常用的有UGUI、NGUI等)。IMGUI一般用于编辑器扩展开发、游戏调试面板绘制等。
2、类中存在空的Update、LateUpdate和FixedUpdate方法
我们在上面的规则中简单说到了“Monobehavior”,Unity中的脚本其实默认都是继承自这个Monobehavior。Update、LateUpdate和FixedUpdate属于Monobehaviour类的“Messages”,虽然不由Monobehavior类继承而来,但是在Monobehaviour类的脚本中会生效——如果脚本中写上了这些方法,相应的脚本放到场景中,并且enable为true,那么游戏运行过程中每帧都会对其进行调用。
即使这些方法为空,在运行时,它们依然会因为被调用而造成CPU时间的开销,其原因主要有两点:
- 这些方法是Native层对托管层的调用,C++与C#之间的通信本身存在一定的开销。
- 当调用这些方法时,Unity会进行一系列安全检测(比如确保GameObject没有被销毁等)导致CPU时间的消耗。
3、该类的方法中存在Camera.main的调用
Camera.main实际上是一个实现了Get方法的属性,每次调用它,都会寻找场景中第一个Tag为“MainCamera”的相机并将其返回。使用旧版本的Unity对Camera.main的调用,需要遍历所有带Tag的GameObject、进行Tag比较、查找Camera组件等操作,耗时较高,并且引擎不会自动缓存其结果。 不过Unity 2020.2版本中已经对Camera.main进行了优化,避免了它较高的CPU耗时,使用2020.2及以上Unity版本的团队可以忽略该规则
4、该类的方法中存在ComputeBuffer.GetData调用
ComputeBuffer.GetData会从GPU的Buffer中读取对应的计算结果并输入到相应的数组中,由于整个的过程是一个同步操作,调用时会堵塞调用线程,直到GPU返回数据为止,所以在数据量较大的时候会导致ComputeBuffer.GetData消耗很大一部分的CPU时间以及相应的堆内存空间。可以尝试通过其他的异步操作来达到相同的取值效果。
5、该类的方法中存在对纹理SetPixels的调用
SetPixels可用于对纹理特定的mipmap层的像素进行修改,它会将一组数组的像素值赋值到贴图的指定mipmap层,调用Apply()后会将像素传至显卡。需注意的是,由于Color32比Color类型所占的空间更小,使用SetPixels32比SetPixels造成的CPU耗时也更小。所以在效果允许的情况下,我们推荐使用SetPixels32()方法来取代SetPixels()。
6、该类的方法中存在GameObject.SendMessage调用
GameObject.SendMessage用于调用相应GameObject上的脚本中的给定名称的函数。该函数会遍历GameObject上的所有组件以及组件脚本中的所有函数,这会导致较高的CPU开销。所以开发者要减少不必要的SendMessage的使用。
7、该类的方法中存在GetComponentsInChildren调用/该类的方法中存在GetComponentsInParent调用
GetComponentsInChildren用于获得当前GameObject及其子节点的所有给定类型的组件,返回的是一个包含所有符合条件的对象的数组;而GetComponentsInParent则是用于获得当前对象及其父节点上的所有给定类型的组件。当然这里我们在说法上忽略了对组件隐藏情况的讨论。
这两者的使用都会涉及到较大范围内的搜索遍历,会挤占CPU较大的计算资源,所以开发团队应当尽量减少相关的调用,可以尝试缓存调用的结果,避免使其出现在Update这样的频繁调用的函数当中。此外,对于这两个函数,我们建议开发团队使用接受List类型的引用作为参数的版本,这样就可以避免每次调用都造成堆内存的分配。
8、该类的方法中存在FindObjectsOfType调用
如果使用FindObjectsOfType,它会对场景中的GameObject和Component进行遍历,并将与目标Type类型相同的组件以数组的方式返回。“Find”类的操作在小型项目当中可能不会有明显的影响,但随着项目体量的增大,场景中物体数量的增加,该操作造成的CPU耗时也将变得不容忽视。并且该函数会造成堆内存的分配。所以我们建议尽量避免这样的函数调用,或者通过调用一次,缓存结果的方式减少其带来的对项目性能的影响。
9、该类的方法中存在Reflection相关函数的调用
反射(Reflection)是一项用来在代码运行时做绑定的技术。如果代码需要获取的类型、调用的函数等信息是在运行时才能被明确的,那么就需要用到反射。
但运行时绑定就意味着更高的性能开销:项目在调用反射相关的方法时,需要获取类型与函数等信息,并且进行参数校验、错误处理、安全性检查等。这会导致相应的CPU计算开销较高,并且容易造成堆内存分配。因此我们建议在游戏项目中,尽可能避免使用反射。
10、该类的方法中存在对Renderer进行Material/Materials的获取
在Unity中,如果对Renderer类型调用.material和.materials,那么Unity就会生成新的材质球实例。其主要影响如下:
通过.material,创建材质实例,并修改属性的方式实现多样的渲染效果,时间开销会较高。
使用相同Shader,但因为Material实例不同的GameObject,所以无法进行合批,导致Draw Call增加,变相造成了CPU耗时的增加。
每次对新的GameObject的Renderer调用.material,都会生成一个新的Material实例,且GameObject销毁后,Material实例无法自动销毁,这会对资源管理造成一定的成本,想要处理的话就需要手动调用“UnloadUnusedAssets”来卸载,但这样就造成了性能开销;管理不好可能会造成材质球大量冗余甚至泄露,极端情况下甚至会导致过高的内存。
建议通过主动MaterialPropertyBlock的方式修改材质属性,或者人为对有限个材质实例进行管理,效果相同的物体通过sharedMaterial来共用材质实例。