转自冯乐乐的 《Unity Shader 入门精要》
移动平台的特点
为了尽可能一处那些隐藏的表面,减少overdraw(即一个像素被绘制多次),PowerVR芯片(通常用于ios设备和某些Android设备)使用了基于瓦片的延迟渲染(TBDR)架构,把所有的渲染图像装入一个个瓦片中,再由硬件找到可见的片元,而只有这些可见片元才会执行片元着色器。另一些基于瓦片的GPU架构,如Adreno(高通的芯片)和Mali(ARM的芯片)则会适应early-Z 或相似的技术进行一个低精度的深度检测,来剔除那些不需要渲染的片元。还有一些GPU,如Tegra(英伟达的芯片),则使用了传统的架构设计,因此在这些设备上,overdraw更可能造成性能的瓶颈。
影响性能的因素
我们可以把造成游戏性能瓶颈的主要原因分成以下几个方面
1)CPU
过多的draw call
复杂的脚本或者物理模拟
2)GPU
顶点处理(过多的顶点、过多的逐顶点计算)
片元处理(过多的片元、过多的逐片元计算)
3)带宽
使用了尺寸很大且未压缩的纹理
分辨率过高的帧缓存
对于CPU来说,限制它的主要是每一帧的draw call 的数目。draw call ,简单来说,就是CPU在每次通知GPU进行渲染之前,都需要提前准备好顶点数据,然后调用一系列API把它们放到GPU可以访问到的指定位置,最后,调用一个绘制命令。而调用绘制命令时,就会产生一个draw call 。过多的draw call 会造成CPU的性能瓶颈,这是因为每次调用draw call 时,CPU往往都是需要改变很多渲染状态的设置,而这些操作是非常耗时的。当然,其他原因也可能造成CPU瓶颈,例如物理、不了模拟、蒙皮、粒子模拟等,这些都是计算了很大的操作。
对于GPU来说,它负责整个渲染流水线。它从处理CPU传递过来的模型数据开始,进行顶点着色器、片元着色器等一系列工作,最后输出屏幕上的每个像素。因此,GPU的性能瓶颈和需要处理的顶点数目、屏幕分辨率、显存等因素有关。
我们后面涉及的优化技术有
1)CPU优化
使用批处理技术减少draw call 数目。
2)GPU优化
减少需要处理的顶点数目(优化几何体、使用模型的LOD技术、使用模型的LOD技术、使用遮挡剔除技术)
减少需要处理的片元数目(控制绘制顺序、警惕透明物体、减少实时光照)
减少计算复杂度(使用Shader的LOD技术、代码方面优化)
3)节省内存带宽
减少纹理大小
利用分辨率缩放
使用模型的LOD技术Unity中的渲染分析工具 Unity内置了一些工具,来帮助我们方便地查看和渲染相关的各个统计数据。这些数据可以帮助我们分析游戏渲染性能,从而更有针对性地进行优化。 Unity5 提供了一个全新的窗口,即渲染统计窗口来显示当前游戏的各个渲染统计变量,可以通过在Game视图右上方的菜单中单击Stats按钮来打开它,如下图所示。
从上图可以看出,渲染统计窗口包含了音频、图像和网格3个方面信息。 渲染统计窗口显示了很多重要的渲染数据。下表给出了渲染统计窗口中显示的各个信息。
Unity 5 的渲染统计窗口相较于之前版本中的有了一些变化,最明显的区别之一就是去掉了draw call 数目的显示,而添加了批处理数目的显示。Bathes 和 Saved by batching 更容易让开发者理解批处理的优化结果。当然,我们想要查看draw call 的数目等其他更加详细的数据,可以通过Unity编辑器的性能分析器来查看。 我们可以通过单击Window -> Profiler 来打开Unity 的性能分析器。性能分析器中的渲染区域提供了更多关于渲染的统计信息,下图给出了渲染分析结果。
性能分析器显示了绝大部分在渲染统计窗口中提供的信息,例如,绿线显示了批处理数目、蓝线显示了Pass数目等,同时还给出了许多其他非常有用的信息,例如,draw call 数目、动态批处理/静态批处理的数目、渲染纹理的数目和内存占用等。 我们可以通过Window -> Frame Debugger 打开帧调试器,如下图显示。
帧调试器的调试面板上显示了渲染这一帧所需要的所有的渲染事件,在本例中,事件数目为14,其中包含了10个 draw call 事件(其他渲染事件多为清空缓存等)。单击面板上的每个事件,我们可以在Game视图查看该事件的绘制结果,同时渲染统计面板上的数据也会显示成截止到当前事件为止的各个渲染统计数据。
减少drawcall 数目 我们最常看到的优化技术大概就是批处理了。批处理的实现原理就是为了减少每一帧需要的draw call数目。为了把一个对象渲染到屏幕上,CPU需要检查哪些光源影响了该物体,绑定Shader 被设置它的参数,再把渲染名利发送给GPU。当场景中包含了大量对象时,这些操作就会非常耗时。一个极端的例子是,如果我们需要渲染一千个三角形,把它们按一千个单独的网格进行渲染所花费的时间要远远大于渲染一个包含了一千个三角形的网格。在这两种情况下,GPU的性能消耗其实并没有多大的区别,但CPU的draw call数目就会成为性能瓶颈。因此,批处理的思想很简单,就是在每次面对draw call 时尽可能多地处理多个物体。 使用同一个材质的物体可以一起处理。对于使用同一个材质的物体,它们之间的不同仅仅在于顶点数据的差别。我们可以把这些顶点数据合并在一起,再一起发送给GPU,就可以完成一次批处理。 Unity中支持两种批处理方式:一种是动态批处理,一种是静态批处理。对于动态批处理来说,有点是一切处理都是Unity自动完成的,不需要我们自己做任何操作,而且物体是可以移动的,但缺点是,限制很多,可能一不小心就会破坏了这种机制,导致Unity无法动态批处理一些使用了相同材质的物体。而对于静态批处理来说,它的优点是*度很高,限制很少;但缺点是可能会占用更多的内存,而且经过静态批出里的所有物体都不可以再移动了。 动态批处理的原理是,每一帧把可以进行批处理的模型网格进行合并,再把合并后模型数据传递给GPU,然后使用同一个材质对其渲染。处理实现方便,动态批处理的另一个好处是,经过批处理的物体仍然可以移动,这是由于在处理每帧时Unity都会重新合并一次网格。 虽然Unity 的动态批处理不需要我们进行任何额外工作,但只有满足条件的模型和才会可以被动态批处理。需要注意的限制有: 1)能够进行动态批处理的网格顶点属性规模要小于900.例如,如果Shader中需要使用顶点位置、法线和纹理坐标这3个顶点属性,那么想要让模型能够被动态批处理,它的顶点数目不能超过300。需要注意的是,这个数字未来有可能会发生变化,因此不要依赖这个数据。 2)一般来说,所有对象都需要使用同一个缩放尺度。一个例外的情况是,如果所有的物体都使用了不同的非统一缩放,那么它们也是可以被动态批处理的。但在Unity 5 中,这种对模型缩放的限制已经不存在了。 3)使用光照纹理的物体需要格外小心处理。这些物体需要额外的渲染参数,例如,在光照纹理上的索引、偏移量和缩放信息等。因此,为了让这些物体可以被动态批处理,我们需要保证它们指向光照纹理中的同一个位置。 4)多Pass的Shader会中断批处理。在前向渲染中,我们有时需要使用额外的Pass来为模型添加更多的光照效果,但这样一来模型就会被动态批处理了。 我们有个这样一个场景,包含了3个立方体,它们使用一个材质,同时还包含了一个使用其他材质的球体。场景中还包含了一个平行光,但我们关闭了它的阴影效果,以避免阴影计算对批处理数目的影响。这样一个场景的渲染统计数据如下图所示。
从上图可以看出,要渲染这样一个包含了4个物体的场景需要两个批处理。其中,一个批处理用于绘制经过动态批处理合并后的3个立方体网格,另一个批处理用于绘制球体。我们可以从Save by batching 看出批处理帮我们节省了两个draw call。 现在,我们再向场景中添加一个点光源,并调整它的位置使它可以照亮场景中的4个物体。由于场景中的物体都使用了多个Pass的Shader,因此,点光源会对它们产生光照影响。下图给出了添加点光源后的渲染统计数据。
从上图可以看出,渲染一帧所需的批处理数目增大到了8,而Save by batching 的数目也变成了。需要注意的是,只有物体在点光源影响范围内,Unity才会调用额外的Pass来处理它。因此,如果场景中点光源距离物体很远,那么它们仍然会被动态批处理的。 动态批处理的限制条件比较多,例如很多时候,我们的模型数据往往会超过900的顶点限制。这时我们可以使用静态批处理。 相对于动态批处理来说,静态批处理适用于任何大小的几何模型。它的实现原理是,只在运行开始阶段,把需要进行静态批处理的模型合并到一个新的网格结构中,这意味着这些模型不可以再运行时刻被移动。但由于它只需要进行一次合并操作,因此,比动态批处理更加高效。缺点在于,它往往需要占用更多的内存来存储合并后的几何结构。这是因为,如果在静态批处理前一些物体共享了相同的网格,那么在内存中每一个物体都会对应一个该网格的复制品,即一个网格会变成多个网格再发送给GPU。如果这类使用同一网格的对象很多,那么这就会成为一个性能瓶颈了。例如,在一个使用了1000个相同树模型的森林中使用静态批处理,那么,就会多使用1000倍的内存,这会造成严重的内存影响。这时候,解决方法是要么忍受,要么使用动态批处理,要么自己编写批处理方法。 静态批处理的实现非常简单,只需要把物体面板上的static 复选框勾选上即可(实际上我们只需要勾选Batching Static),如下图所示。
在内部实现上,Unity首先把这些静态物体变换到世界空间下,然后为它们构建一个更大顶点和所有缓存。对于使用了同一个材质的物体,Unity只需要调用一个draw call 就可以绘制全部物体。而对于使用了不同材质的物体,静态批处理同样可以提升渲染性能。经过这些物体仍然需要调用多个draw call。但静态批处理可以减少这些draw call之间的状态切换,而这些切换往往是费时的操作。 从之前的内容可以看出,无论是动态批处理还是静态批处理,都要求模型之间需要共享同一个材质。但不同的模型之间总会需要有不同的渲染属性,我们需要一些策略来尽可能地合并材质。 如果两个材质之间只有使用的纹理不同,我们可以把这些纹理合并到一张更大的纹理中,这张更大的纹理被称为是一张图集。一旦使用了同一张纹理,我们就可以使用同一个材质,再使用不同的采样坐标对纹理采样即可。 但有时,出了纹理不同外,不同的物体在材质上还有一些微小的参数变化,例如,颜色不同、某些浮点属性不同。但是不管是动态批处理还是静态批处理,它们的前提都是要使用一个材质。是同一个,而不是使用了同一种Shader 的材质,也就是说它们指向的材质必须是同一个实体。这意味着,只要我们调整了参数,就会影响到所有使用这个材质的对象。那么想要微小的调整怎么办?一种常用的方法就是使用网格的顶点数据来存储这些参数。 前面说过,经过批处理后的我退会被处理成更大的VBO发送给GPU,VBO中的数据可以作为输入传递给顶点着色器,因此,我们可以巧妙地对VBO中的数据进行控制,从而达到不同效果的目的。一个例子是,森林场景中所有的树使用了同一种材质,我们希望它们可以通过批处理来减少draw call,但不同树的颜色可能不同。这时,我们可以利用网格的顶点的颜色数据来调整。 需要注意的是,如果我们需要在脚本中访问共享材质,应该使用Renderer.shaderdMaterial 来保证修改的是和其他物体共享的材质,但这意味着修改会应用到所有使用该材质的物体上。另一个类似的API 是Renderer.material ,如果使用 Renderer.material 来修改材质,Unity会创建一个该材质的复制品,从而破坏批处理在该物体上的应用。 在选择使用动态批处理还是静态批处理时,建议是: 1)尽可能使用静态批处理,但得时间小心对内存的消耗,并且记住经过静态批处理的物体不可以再被移动。 2)如果无法进行静态批处理,而要使用动态批处理的话,小心上面提到的各种限制。 3)对于游戏中的小道具,例如可以捡拾的金币,可以使用动态批处理。 4)对于包含动画的这类物体,我们无法全部使用静态批处理,但其中如果有不动的部分,可以把这部分标识成"Static" 除了上述提示外,在使用批处理时还有一些需要注意的地方。由于批处理需要把多个模型变换到世界空间下再合并它们,因此,如果Shader 中存在一些基于模型空间下的坐标的运算,那么往往会得到错误的结果。一个解决方法是,在Shader中使用DisableBatching标签来强制该Shader的材质不会被批处理。另一个注意事项是,使用半透明材质的物体通常需要使用严格的从后往前的绘制顺序来保证透明度混合的正确性。对于这些物体,Unity会首先保证它们的绘制顺序,再尝试对它们进行批处理。这意味着,当绘制顺序无法满足时,批处理无法在这些物体上被成功应用。
减少需要处理的顶点数目 3个常用的顶点优化策略。 1)优化几何体 在很多建模软件中宏,都有相应的优化选项,可以自动优化网格结构。 2)模型的LOD技术 原理是,当一个物体离摄像机很远时,模型的很多细节我无法察觉到的。因此,LOD允许当对象逐渐原理摄像机时,减少模型上的面片数量,从而提高性能。 在Unity 中,我们可以使用LOD Group组件来为一个物体构建一个LOD。我们需要为同一个对象准备多个包含不同细节程序的模型,然后把它们赋给LOD Group 组件中的不同等级,Unity 就会自动判断当前位置上需要使用哪个等级的模型。 3)遮挡剔除技术 遮挡剔除可以用来消除那些在其他物体后面看不到的物体,这意味着资源不会浪费在计算那些看不到的顶点上,进行提升性能。 我们需要把遮挡剔除和摄像机的视锥体剔除区分开来。视锥体剔除只会剔除掉那些不在摄像机的视野范围内的对象,但不会判断视野中是否有物体被其他物体挡住。而遮挡剔除会使用一个虚拟的射线机来遍历场景,从而构建一个潜在可见的对象几何的层级结构。在运行时刻,每个摄像机将会使用这个数据来识别哪些物体是可见的,而那些物体被其他物体挡住不可见。使用遮挡剔除,不仅可以减少处理的顶点数目,还可以减少overdraw。 要在Unity中使用遮挡剔除技术,我们需要进行一系列额外的处理工作。
减少需要处理的片元数目 另一个造成GPU瓶颈的是需要处理过多的片元。这部分优化重点在于减少overdraw。简单来说,overdraw 值得是同一个像素被绘制了多次。 1)控制绘制顺序 由于深度测试的存在,如果我们可以保证物体都是从前往后绘制的,那么就可以很大程度上减少overdraw。这是因为,在后面绘制的物体由于无法通过深度测试,因此,就不会再进行后面的渲染处理。 在Unity中,那些渲染队列数目小于2500(如"Background" "Geometry" 和 "AlphaTest")的对象都被认为是不透明物体,这些物体总体上是从前往后绘制的,而使用其他的队列(如"Transparent" "Overlay")的物体,则是从后往前绘制的。这意味着,我们可以尽可能地把物体的队列设置为不透明物体的渲染队列,而尽量避免使用半透明队列。 而且,我们还可以充分利用Unity的渲染队列来控制绘制顺序。 例如将队列设置"Geometry+1"这样。 2)时刻警惕透明物体 对于半透明物体对象来说,由于它们没有开启深度写入,因此,如果要得到正确的渲染效果,就必须从后往前渲染。这意味着,半透明物体几乎一定会造成overdraw。例如GUI对象来说,它们大多数被设置为半透明物体,如果屏幕中GUI占据的比例太多。而主摄像机又没有进行调整而是投影整个屏幕,那么GUI就会造成大量的overdraw。 因此,我们尽量减少窗口中GUI所占的面积。也可以把GUI的绘制和三维场景的绘制交给不同的摄像机,而其中负责三维场景的摄像机的视角范围尽量不要和GUI的相互重叠。 在移动平台上,透明度测试也会影响游戏性能。虽然透明度测试没有关闭深度测试,但由于它的实现使用了discard或clip操作,而这些操作会导致一些硬件的优化策略失效。 3)减少实时光照和阴影 实时光照对于移动平台是一种非常昂贵的操作。如果场景中包含了过多的点光源,并且使用了多个Pass的Shader,那么狠可能就会造成性能下降。我们可以使用烘焙技术,把光照提前烘焙到一张光照纹理中,然后在运行时刻只需要根据纹理采样得到光照结果即可。
节省带宽 之前提到过,使用纹理图集可以帮助我们减少draw call 的数目,而这些纹理的大小同样是一个需要考虑的问题。需要注意的是,所有纹理的长宽比最好是正方形,而且长宽值最好是2的整数幂。这是因为有很多优化策略只有在这种时候才可以发挥最大效果。在Unity5 中,即便我们导入的纹理长宽值并不是2的整数幂,Unity也会自动把长宽装换到离它最近的2的整数幂值。但我们仍然应该在制作美术资源时就把这条规则谨记在心,防止由于缩放而造成不好的影响。 除此之外,我们还应该尽可能使用多级渐远纹理技术和纹理压缩。在Unity中,我们可以通过纹理导入面板来查看纹理的各个导入属性。通过把纹理类型设置为Advanced,就可以自定义许多选项,例如,是否生成多级渐远纹理(mipmaps)。如下图所示,当勾选了Generate Mip Maps 选项后,Unity就会为同一张纹理创建出很多不同大小的小纹理,构成一个纹理金字塔。
纹理压缩同样可以节省带宽。但对于像Android 这样的平台,有很多不同架构的GPU,纹理压缩就变得有点复杂,因为不同的GPU架构有它自己的纹理压缩格式。所幸的是,Unity可以根据不同的设备选择不同的压缩格式,而我们只需要把纹理压缩格式设置为自动压缩即可。但是,GUI类型的纹理同样是个例外,一些时候由于对画质的要求,我们不希望对这些纹理进行压缩。 过高的屏幕分辨率也是造成性能下降的原因之一,尤其是对于很多低端手机,除了分辨率高其他硬件条件并不尽如人意,这恰恰是游戏性能的两个瓶颈:过大的屏幕分辨率和糟糕的GPU。因此,我们可能需要对于特定及其进行分辨率的缩放。当然,这样可能会造成游戏效果的下降,但性能和画面需要平衡。
减少计算复杂度 Shader 的LOD技术可以控制使用的Shader等级。它的原理是,只有Shader 的LOD 值小于某个设定的值,这个Shader才会被使用,而使用了那些超过设定值的Shader 的物体将不会被渲染。 我们通常会在SubShader 中使用类似下面的语句来指明该Shader 的LOD值。
SubShader{我们也可以在Unity Shader的导入面板上看到该Shader 的LOD值。在默认情况下,允许的LOD得你是无限大的。这意味着,任何被当前显卡支持的Shader都可以被使用。但是,在某些情况下,我们可能需要去掉一些使用了复杂计算的Shader渲染。这时,我们可以使用Shader.maximumLOD 或 Shader.globalMaximumLOD 来设置允许的LOD值。
Tags{"RenderType"="Opaque"}
LOD 200
}
Unity 内置的Shader 使用了不同的LOD值,例如,Diffuse 的LOD为 200,而Bumped Specular 的LOD 为400 代码优化方面:首先尽可能使用低精度的浮点值进行运算。还需要注意的是,我们应当尽量避免在不同精度之间的转换。 还有就是尽可能不要使用全屏的屏幕后处理效果。 尽可能不要使用分支语句和循环语句。 尽可能避免使用类似sin、tan、pow、log等较为复杂的数学运算。使用查找表来替代它。 尽可能不使用discard