影响性能的因素:对于一个游戏来说,有两种主要的计算资源:CPU和GPU,它们会互相合作,来让我们的游戏可以在预期的帧率和分辨率下工作。CPU负责其中的帧率,GPU主要负责分辨率相关的一些东西。本篇会介绍CPU的优化技巧~
CPU:
作用:计算。主要是在蒙皮骨骼计算,布料模拟,顶点动画,粒子模拟等,还有在各种顶点变换、光照、贴图混合等。在每次绘图前,我们都需要先准备好顶点数据(位置、法线、颜色、纹理坐标等),然后调用一系列API把它们放到GPU可以访问到的指定位置,最后,我们需要调用_glDraw*命令,来告诉GPU进行渲染。而调用_glDraw*命令的时候,就是一次Draw Call。影响CPU效率的因素主要有DrawCall、物理组件、GC、代码,CPU过高会影响帧率,卡顿、发热严重,游戏性能就会下降。
性能开销:引擎模块性能开销和自身代码性能开销。其中,引擎模块中又可细致划分为渲染模块、动画模块、物理模块、UI模块、粒子系统、加载模块和GC调用等等。其中渲染模块、UI模块和加载模块,往往占据了游戏CPU性能开销的Top3。
优化:
DrawCalls:Draw Call是渲染模块优化方面的重中之重,一般来说,Draw Call越高,则渲染模块的CPU开销越大。
Drawcall batching:Unity在运行时可以将一些物体进行合并,从而用一个批次调用来渲染他们。对于使用同一个材质的物体,它们之间的不同仅仅在于顶点数据的差别,即使用的网格不同而已。我们可以把这些顶点数据合并在一起,再一起发送给GPU,就可以完成一次批处理。Unity中有两种批处理方式:一种是动态批处理,一种是静态批处理。但不论静态批次还是动态批次都要求对象的材质是共享的,即不同材质的对象是无法进行批次的。而且要注意的一点:如果在脚本中调用材质时,使用Renderer.material会造成材质的拷贝,而使用Renderer.sharedMaterial来调用则不会拷贝材质。对于动态批处理来说,好消息是一切处理都是自动的,不需要我们自己做任何操作,而且物体是可以移动的,但坏消息是,限制很多,可能一不小心我们就会破坏了这种机制,导致Unity无法批处理一些使用了相同材质的物体。对于静态批处理来说,好消息是自由度很高,限制很少,坏消息是可能会占用更多的内存,而且经过静态批处理后的所有物体都不可以再移动了。
Unity进行动态批处理对模型的要求很多:
1.动态批处理仅支持小于900顶点的网格物体,如果你的着色器使用顶点位置,法线和UV值三种属性,那么你只能批处理300顶点以下的物体;如果你的着色器需要使用顶点位置,法线,UV0,UV1和切向量,那你只能批处理180顶点以下的物体。而且未来顶点值有可能会变,不要依赖这个数据。
2.一般来说,所有对象都必须需要使用同一个缩放尺度(可以是(1, 1, 1)、(1, 2, 3)、(1.5, 1.4, 1.3)等等,但必须都一样)。但如果是非统一缩放(即每个维度的缩放尺度不一样,例如(1, 2, 1)),那么如果所有的物体都使用不同的非统一缩放也是可以批处理的。
3.拥有lightmap的物体含有额外(隐藏)的材质属性,使用lightmap的物体不会批处理。多通道的shader会妨碍批处理操作。接受实时阴影的物体也不会批处理。
4.时刻警惕透明物体,透明对象要得到正确的渲染效果,就必须从后往前渲染(这里不讨论使用深度的方法),这意味着,透明物体几乎一定会造成overdraws。尽可能把多张小纹理合并到一张大纹理(Atlas图集)中是一个好主意。“Generate Mip Maps”会为同一张纹理创建出很多不同大小的小纹理,构成一个纹理金字塔。而在游戏中可以根据距离物体的远近,来动态选择使用哪一个纹理。这是因为,在距离物体很远的时候,就算我们使用了非常精细的纹理,但肉眼也是分辨不出来的,这种时候完全可以使用更小、更模糊的纹理来代替,而这大量可以节省访问的像素的数目。但它的缺点是,由于需要为每一个纹理建立一个图像金字塔,因此它会需要占用更多的内存。“Max Size”决定了纹理的长宽值,如果我们使用的纹理本身超过了这个最大值,Unity会对其进行缩小来满足这个条件。这里再重复一点,所有纹理的长宽比最好是正方形,而且长度值最好是2的整数幂。这是因为有很多优化策略只有在这种时候才可以发挥最大效用。
物理组件:从性能优化的角度考虑,物理组件能少用还是少用为好。1.设置一个合适的Fixed Timestep(物理计算频率)。2.就是不要使用网格碰撞器(mesh collider)。
GC优化:GC能释放内存,但会加重CPU的负担,因此对于GC的优化目标就是尽量少的触发GC。GC不是用来处理引擎的assets(纹理啦,音效啦等等)的内存释放的,GC也主要是针对Mono的对象来说的,而它管理的也是Mono的托管堆。引用类型,比如类的实例,字符串,数组等会被分配到托管堆。GC触发:首先是堆的内存不足时,会自动调用GC。其次,编程人员手动的调用GC。
UI模块:
1.使用图集:合理拆分UI图集,区分公共图集(常驻)和非公共图集。太大容易造成冗余加载,容易导致内存占用过大,导致内存显存交换开销。太小有容易导致显存碎片影响效率。
2.使用九宫格降低图片大小。使用镜像图片,镜像片图片使用链接:https://zhuanlan.zhihu.com/p/25995971
3.layout group, canvas group组件,任何子节点变了父节点都会用getcompent找到laygroup
4.不需要交互的UI的Raycast target关了。
5.尽量不用UI特效。
加载模块:场景切换时的主要性能开销主要体现在两个方面,前一场景的场景卸载和下一场景的场景加载。
1.场景卸载:destory:引擎在切换场景时会收集未标识成“DontDestoryOnLoad”的GameObject及其Component,然后进行Destroy。同时,代码中的OnDestory被触发执行,这里的性能开销主要取决于OnDestroy回调函数中的代码逻辑.Resources.UnloadUnusedAssets:一般情况下,场景切换过程中,该API会被调用两次,一次为引擎在切换场景时自动调用,另一次则为用户手动调用(一般出现在场景加载后,用户调用它来确保上一场景的资源被卸载干净)。其耗时开销主要取决于场景中Asset和Object的数量,数量越多,则耗时越慢。
2.场景加载:资源加载:其加载效率主要取决于资源的加载方式(Resource.Load或AssetBundle加载)、加载量(纹理、网格、材质等资源数据的大小)和资源格式(纹理格式、音频格式等)等等。Instantiate实例化:在Instantiate实例化时,引擎底层会查看其相关的资源是否已经被加载,如果没有,则会先加载其相关资源,再进行实例化,这其实是大家遇到的大多数“Instantiate耗时问题”的根本原因,这也是为什么我们在之前的AssetBundle文章中所提倡的资源依赖关系打包并进行预加载,从而来缓解Instantiate实例化时的压力。场景加载尽量使用使用加载的方式。
代码优化:
1.字符串连接的处理。因为将两个字符串连接的过程,其实是生成一个新的字符串的过程。而之前的旧的字符串自然而然就成为了垃圾。而作为引用类型的字符串,其空间是在堆上分配的,被弃置的旧的字符串的空间会被GC当做垃圾回收。字符串的链接使用StringBuilder进行链接。
2.尽量不要使用foreach,而是使用for。foreach其实会涉及到迭代器的使用,而据传说每一次循环所产生的迭代器会带来24 Bytes的垃圾。那么循环10次就是240Bytes。
3.不要直接访问gameobject的tag属性。比如if (go.tag == “human”)最好换成if (go.CompareTag (“human”))。因为访问物体的tag属性会在堆上额外的分配空间。如果在循环中这么处理,留下的垃圾就可想而知了。
4.使用对象“池”,以实现空间的重复利用。
5.最好不用LINQ的命令,因为它们会分配临时的空间,同样也是GC收集的目标。而且我很讨厌LINQ的一点就是它有可能在某些情况下无法很好的进行AOT编译。比如“OrderBy”会生成内部的泛型类“OrderedEnumerable”。这在AOT编译时是无法进行的,因为它只是在OrderBy的方法中才使用。所以如果你使用了OrderBy,那么在IOS平台上也许会报错。
6.最好不要频繁使用GetComponent,尤其是在循环中(频繁的调用GetComponent方法会造成CPU的开销,但是对GC几乎没有影响。GetComponent只会在EDITOR模式返回NULL时会造成额外的堆内存分配)。
7.善于使用OnBecameVisible()(当renderer(渲染器)在任何相机上可见时调用OnBecameVisible)和OnBecameInVisible(),来控制物体的update()函数的执行以减少开销。
8.使用内建的数组,比如用Vector3.zero而不是new Vector(0, 0, 0);
9.对于方法的参数的优化:善于使用ref关键字。值类型的参数,是通过将实参的值复制到形参,来实现按值传递到方法,也就是我们通常说的按值传递。复制嘛,总会让人感觉很笨重。比如Matrix4x4这样比较复杂的值类型,如果直接复制一份新的,反而不如将值类型的引用传递给方法作为参数。
10.如果可以避免使用浮点型(float),尽量使用整形(int),尽量少用复杂的数学函数比如 Sin 和 Cos 等等。