和PC平台相比,移动平台上的GPU架构有很大的不同。由于处理资源等条件的限制,移动设备上的GPU架构专注于尽可能使用更小的贷款和功能,也由此带来了许多和PC平台完全不同的现象。
例如,为了尽可能移除那些隐藏的表面,减少Overdraw(即一个像素被绘制多次),PowerVR芯片(通常用于IOS设备和某些Android设备)使用了基于瓦片的延迟渲染(Tiled-based Deferred Rendering,TBDR)架构,把所有的渲染图像装入一个个的瓦片(tile)中,再由硬件找到可见的片元,而只有这些可见片元才会执行片元着色器。另一些基于瓦片的GPU架构,如Adreno(高通)和Mali(ARM)则会使用Early-Z或相似的技术进行一个低精度的深度检测,来剔除哪些不需要渲染的片元,还有一些GPU,如Tegra(英伟达),则使用了传统的架构设计,在这些设备上,overdraw更可能造成性能瓶颈。
影响性能的因素
对于一个游戏来说,它主要使用了两种计算资源:CPU和GPU。它们会互相合作,来让我们的游戏可以在预期的帧率和分辨率下工作。其中,CPU主要保证帧率,GPU主要负责分辨率相关的处理。
1.CPU
- 过多的DrawCall
- 复杂的脚本或者物理模拟。
2.GPU
- 顶点处理
- 过多的顶点
- 过多的逐顶点计算。
- 片元处理
- 过多的片元(既可能是由于分辨率造成的,也可能是overdraw)
- 过多的逐偏远计算。
3.带宽
- 使用了尺寸很大且未压缩的纹理。
- 分辨率过高的帧缓存。
对于CPU来说,限制它的主要是每一帧中的drawcall的数目。过多的drawcall会造成CPU的性能瓶颈,这是因为每次调用drawcall是,CPU往往都需要改变很多渲染状态的设置,而这些操作是非常耗时的。如果一帧中需要的draw call数目过多的话,就会导致CPU把大部分时间都花费在提交drawcall的工作上面了。当然,也可能是其他原因造成CPU瓶颈,例如物理、布料模拟、蒙皮、粒子模拟等,这些都是计算量很大的操作。
对于GPU来说,它负责整个渲染流水线。它从处理CPU传递过来的模型数据开始,进行顶点着色器、片元着色器等一些列工作,最后输出屏幕上的每个像素。因此,GPU的性能瓶颈和需要处理的顶点数目、屏幕分辨率、显存等因素有关。而优化策略可能从减少处理的数据规模(包括顶点数目和片元数目)、减少运算复杂度等方面入手。
减少drawcall数目
最常见的优化技术是批处理。其实现原理就是为了减少每一帧需要的drawcall数目。为了把一个对象渲染到屏幕上,CPU需要检查哪些光源影响了该物体,绑定Shader并设置它的参数,再把渲染命令发送给GPU。当场景中包含了大量对象时,这些操作就会非常耗时。一个极端的例子,如果我们需要渲染一千个三角形,把它们按一千个单独的网格进行渲染所花费的时间远远大于渲染一个包含了一千个三角形,把它们按一千个单独的网格进行渲染所花费的时间要远远大于渲染一个包含了一千个三角形的网格。在这两种情况下,CPU的性能消耗其实并没有多大的区别,但CPU的draw call 数目就会成为性能瓶颈。因此,批处理思想很简单,就是在每次面对draw call时尽可能多地处理多个物体。
Unity中支持两种批处理方式:动态批处理和静态批处理。对于动态批处理来说,优点是一切处理都是Unity自动完成的,不需要我们自己做任何操作,而且物体是可以移动的,但缺点是,限制很多,可能一不小心就会破坏了这种机制,导致Unity无法动态批处理一些使用了相同材质的物体。而对于静态批处理,优点是自由度很高,限制很少,缺点是可能会占用更多的内存,而且经过静态批处理后的所有物体都不可以再移动了(即使在脚本中尝试改变物体的位置也是无效的)。
动态批处理的限制:
- 网格的顶点属性规模要小于900。例如,如果Shader中需要使用顶点位置、发线和纹理坐标这3个顶点属性,那么要想让模型能够被动态批处理,它的顶点数目不能超过300。
- 一般来说,所有对象都需要使用同一个缩放尺度(1,1,1)。如果所有的物体都是用了不同的非统一缩放,那么它们也是在可以被动态批处理的。在Unity5之后,这个限制已经不存在了。
- 使用光照纹理(lightmap)的物体需要小心处理。这些物体需要额外的渲染参数,例如,在光照纹理上的索引、偏移量和缩放信息等。因此,为了让这些物体可以被动态批处理,我们需要保证它指向光照纹理的同一个位置。
- 多Pass的shader会终端批处理。在前向渲染中,我们有事需要使用额外的Pass来为模型添加更多的光照效果,但这样以来模型就不会被动态批处理了。
相对于动态批处理,静态批处理适用于任何大小的几何模型。实现原理是,只在运行开始阶段,把需要进行静态批处理的模型合并到一个新的网格结构中,这意味着这些模型不能以在运行时刻被移动。但由于它只需要进行一次合并操作,因此,比动态批处理更加高效,但它往往需要占用更多的内存来存储合并后的几何结构。在内部实现上,Unity首先把这些静态物体变换到世界空间下,然后为它们构建一个更大的顶点和婚姻缓存。对于使用了同一材质的物体,Unity只需要调用一个draw call就可以绘制全部的物体。而对于使用了不同材质的物体,静态批处理同样可以提升渲染性能。尽管还需要多次draw call,但静态批处理可以减少这些draw call之间的状态切换。
批处理的注意事项:
- 尽可能选择静态批处理,但得时刻小心对内存的消耗,并且记住经过静态批处理的物体不可以再被移动。
- 如果无法进行静态批处理,而需要使用动态批处理的话,则小心上边的限制条件。
- 对于游戏中的小道具,可以使用动态批处理。
- 对于包含动画的这类物体,我们无法全部使用静态批处理。
- 由于批处理需要把多个模型变换到世界空间下再合并他们,因此,如果Shader中存在一些基于模型空间下的坐标的运算,那么往往会得到错误的结果。一个解决方法是,在shader中使用DisableBatching标签来强制使用该Shader的材质不会被批处理。另外,使用半透明材质的物体通常需要使用严格的从后往前的绘制顺序来保证透明混合的正确性。对于这些物体,Unity会首先保证他们的绘制顺序,再尝试对他们进行批处理。这意味着,当绘制顺序无法满足是,批处理无法在这些物体上被成功应用。
减少需要处理的顶点数目
在Unity渲染统计窗口中,我们可以看到渲染当前帧需要的三角面片数目和顶点数目。Unity里显示的往往要比建模软件显示的顶点数大很多。原因是,建模软件站在人类角度去理解顶点,即组成几何体的每一个点就是单独的点。而Unity是站在GPU的角度去计算顶点数的。在GPU看来,有时需要把一个顶点拆分成两个或更多的顶点,这种将顶点一分为多的原因主要有两个:一个是为了分离纹理坐标(uv splits),另一个是为了产生平滑的边界(smoothing splits)。移除不必要的硬件及纹理衔接,避免边界平滑和纹理分离。
还有LOD技术和遮挡剔除技术,可以同时减少CPU和GPU的负荷。CPU可以提交更少的draw call,而GPU需要处理的顶点和片元数目也减少了。
减少需要处理的片元数目
另一个造成GPU瓶颈的是需要处理过多的片元。这部分优化的重点在于减少overdraw。简单来说,overdraw指的就是同一个像素被绘制了多次。
- 控制绘制顺序:为了最大限度的避免overdraw,一个重要的优化策略就是控制绘制顺序。由于深度测试的存在,如果我们可以保证物体都是从前往后绘制的,那么就可以很大程度上减少overdraw。这是因为,在后边绘制的物体由于无法通过深度测试,就不会再进行后边的渲染处理。Unity的渲染队列中,小于2500的(Background,Geometry,AlphaTest)的对象都是被认为是不透明的物体,这些物体总体上是从前往后绘制的,而使用其他的队列(Transparent,Overlay)的物体,则是从后往前渲染的。这因为着,我们可以尽可能的把物体的队列设置为不透明物体的渲染队列,尽量避免半透明队列。 我们还可以充分利用Unity的渲染队列来控制绘制顺序。例如,在第一人称射击游戏中,对于游戏中的主要角色来说,Shader往往比较复杂。但是,由于他们通常会挡住屏幕的很大一部分区域,因此我们可以先绘制他们(使用更小的渲染队列)。而对于一些地方角色,它们通常会出现在各种掩体后面,因此,我们可以在所以常规不透明物体后面渲染他们(使用更大的渲染队列)。而对于天空盒子来说,它几乎覆盖了所有的像素,而且永远在所有物体的后面。因此,它的队列可以设置为“Geometry+1”。这样,可以保证不会因为它造成overdraw。
- 时刻警惕透明物体:对于半透明物体来说,由于它们没有开启深度写入,因此,如果要得到正确的渲染效果,就必须从后往前渲染。这意味着,半透明物体一定会造成overdraw。例如,对于GUI对象来说,它们大多被设置成了半透明,如果屏幕中的GUI占据的比例太多,而主摄像机有没有进行调整而是投影整个屏幕,那么GUI就会造成大量overdraw。对于这种情况,我们可以尽量减少窗口中GUI所占的面积。如果实在无能无力,我们可以把GUI的绘制和三维场景的绘制交给不同的摄像机,而其中负责散味场景的摄像机的视角范围尽量不要和GUI的相互重叠。当然,这样会对游戏的美观度产生一定影响,因此,我们可以在代码中对机器的性能进行判断。在移动平台上,透明度测试也会影响游戏性能。虽然透明度测试没有关闭深度测试,当由于它的实现使用discard或clip操作,而这些操作会导致一些硬件的优化策略失效。例如,我们之前讲过了PowerVR使用的基于瓦片的延迟渲染技术,为了减少overdraw它会在调用片元着色器前就判断哪些瓦片被真正渲染的。但是,由于透明度测试在片元着色器中使用了discard函数改变了片元是否会被渲染的结果,因此,GPU就无法使用上述的优化策略了。也就是说,只要在执行了所有的片元着色器后,GPU才知道哪些片元会被真正渲染到屏幕上,这样,原先哪些可以减少overdraw的优化就都无效了。这种时候,使用透明度混合的性能往往比使用透明度测试更好。
- 减少实时光照和阴影:对于逐像素的光源来说,被这些光源照亮的物体需要被再渲染一次。更糟糕的是,无论是静态批处理还是动态批处理,对于这种额外的处理朱橡树光源的Pass都无法进行批处理,即会终端批处理。解决方法是使用烘焙。还有另一个God Ray。它们一般不是真的光源,很多情况都是通过透明纹理模拟得到的。在移动平台上,一个物体使用的逐像素光源数目应该小于1(不包括平行光)。如果一定要使用更多的实时光,可以选择用逐顶点光照来代替。实时阴影同样也非常消耗性能,不仅是CPU需要提交更多的draw call,GPU也要进行更多的处理。
节省带宽
大量使用未经压缩的纹理以及使用过大的分辨率都会造成由于带宽而引发的性能瓶颈。
- 减少纹理大小:纹理的长宽比最好都是2的整数幂,尽可能使用多级渐远纹理技术(mipmapping)和纹理压缩。纹理压缩同样可以节省带宽。但对于Android这样的平台,有很多不同架构的GPU,纹理压缩就变的有些复杂,因为不同的GPU架构有它自己的纹理压缩格式,例如,PowerVRAM的PVRTC格式,Tegra的DTX格式,Adreno的ATC格式。所幸的是,Unity可以根据不同的设备选择不同的压缩格式,我们只需要把纹理压缩格式设置为自动压缩即可。
- 利用分辨率缩放:过高的屏幕分辨率也是造成性能下降的原因之一,尤其是对于低端手机。使用Screen.SetResolution接口。
减少计算复杂度
1.Shader的LOD技术:该技术可以控制使用的Shader等级。它的原理是,只有Shader的LOD值小于某个设定的值,这个Shader才会被使用,而使用了那些超过设定值的Shader的物体将不会被渲染。
SubShader {
Tags { "RenderType"="Opaque" }
LOD 200
}
默认情况下,Shader的LOD值是无限大的。这意味着,任何被当前显卡支持的Shader都可以被使用。但是,在某些情况下,我们可能需要去掉一些使用了复杂计算的Shader渲染。这时,我们可以使用Shader.maximumLOD或Shader.globalMaximumLOD来设置允许的最大LOD值。
2.代码方面的优化:在实现游戏效果时,我们可以选择在哪里进行某些特定的运算。通常来讲,游戏需要计算的对象、顶点和像素的数目排序是对象数<顶点数<像素数。因此,我们应该尽可能的把计算放下每个对象或逐顶点上。在具体的代码编写时,不同的硬件甚至需要不同的处理。因此,一些普遍的规则在某些硬件上可能并不成立。
首先第一点是,尽可能使用低精度的浮点值进行运算。最高精度的float/highp适用于存储诸如顶点坐标等变量,但它的计算速度是最慢的,我们应该计量避免在片元着色器中使用这种精度的计算。而half/mediump适用于一些标量、纹理坐标等变量。它的计算速度大约是float的两倍。而fixed/lowp适用于绝大多数颜色变量和归一化后的方向矢量,在进行一些对精度要求不高的计算时,我们应该尽量使用这种精度的变量。它的计算速度大约是float的4倍,但要避免对这些低精度的变量进行频繁的swizzle操作(color.xwxw)。还需要注意的是,应该尽量避免在不同精度之间的转换,这有可能造成性能下降。
对于绝大多数GPU来说,再使用插值寄存器把数据从顶点着色器传递给下一个阶段时,我们应该使用尽可能少的插值变量。例如,如果需要对两个纹理坐标进行插值,我们通常会把他们打包在一个float4类型的变量中,两个纹理坐标分别对应了xy分量和zw分量。然而,对于PowerVR平台来说,这种插值变量是非常廉价的。直接把不同的纹理坐标存储在不同的插值变量中,有时反而性能更好。尤其是,如果在PowerVR上使用类似tex2D(_MainTex,uv.zw)这样的语句来进行纹理采样,GPU就无法进行一些纹理的预读取,因为他会认为这些纹理采样是需要依赖其他数据的。因此,如果我们特别关心游戏在PowerVR上的性能,就不应该把两个纹理坐标打包在同一个四维变量中。
尽量不要使用全屏的屏幕后处理效果。如果美术风格实在是需要使用类似Bloom、热扰动这样的屏幕特效,我们应该尽量使用fixed/lowp进行低精度运算(纹理坐标除外,可以使用half/mediump)。高精度的运算可以使用查找表(LUT)或者转移到顶点着色器中进行处理。除此之外,尽量把多个特效合并到一个Shader中。还有一些规则:
- 尽可能不要使用分置于句和循环语句
- 仅可能避免使用类似sin、tan、pow、log等较为复杂的数学运算。可以使用查找表作为代替。
- 尽可能不要使用discard操作,因为这会影响硬件的某些优化。