返回
Featured image of post Shader入门精要-Unity中的渲染优化技术

Shader入门精要-Unity中的渲染优化技术

笔记摘自书籍《Shader入门精要》

移动平台的特点

和PC平台相比,移动平台的GPU架构有很大不同。由于处理资源等条件的限制,移动设备上的GPU架构专注于尽可能使用更小的带宽和功能,也由此带来了许多和PC平台完全不同的现象。

例如,为了尽可能移除那些隐藏的表面,介绍overdraw(即一个像素被绘制多次),

  • **PowerVR芯片(通常用于ios设备和某些Android设备)**使用了基于瓦片的延迟渲染(Tiled-based Deffered Rendering,TBDR)架构,把所有的渲染图像装入一个个瓦片(tile)中,再由硬件找到可见的片元,而只有这些可见片元才会执行片元着色器。
  • 另一些基于瓦片的GPU架构,如**Adreno(高通的芯片)和Mali(ARM的芯片)**则会使用Early-Z或相似的技术进行一个低精度的深度检测,来剔除那些不需要渲染的片元。
  • 还有一些GPU,如Tegra(英伟达的芯片),则使用了传统的架构设计,因此在这些设备上,overdraw更可能造成性能的瓶颈。

由于这些芯片架构造成的不同,一些游戏往往需要针对不同的芯片发布不同的版本,以便对每个芯片进行更有针对性的优化。尤其是在安卓平台上,不同设备使用的硬件,如图形芯片、屏幕分辨率等,大相径庭,这对图形优化提出了更高的挑战。相比于Android平台,ios平台的硬件条件则相对统一。

影响性能的因素

我们可以把造成游戏性能的瓶颈的主要原因分成以下几个方面:

  1. CPU:
    • 过多的draw call
    • 复杂的脚本或物理模拟
  2. GPU:
    • 顶点处理
      • 过多的顶点
      • 过多的逐顶点计算
    • 片元处理
      • 过多的片元(即有可能是由于分辨率造成的,也有可能是由于overdraw造成的)
      • 过多的逐片元计算
  3. 带宽:
    • 使用了尺寸很大且未压缩的纹理
    • 分辨率过高的帧缓存

draw call:过多的draw call会造成GPU的性能瓶颈,这是因为每次调用draw call时,CPU往往都需要改变很多渲染状态的设置,而这些操作是非常耗时的。如果一帧中draw call的数目过多的话,就会导致CPU把大部分时间都花费在提交draw call的工作上面了。

本章后续涉及的优化技术有:

  1. CPU优化
    • 批处理技术
    • 共享材质
  2. GPU优化
    • 减少需要处理的顶点数目
      • 优化几何体
      • 使用模型的**LOD(Level of Detail)**技术。
      • 使用**遮挡剔除(Occluision Culling)**技术。
    • 减少需要处理的片元数目
      • 控制绘制顺序
      • 警惕透明物体
      • 减少实时光照
        • 场景烘焙
        • 体积光
        • 查找纹理
        • 实时阴影
    • 减少计算复杂度
      • 使用Shader的**LOD(Level of Detail)**技术。
      • 代码方面的优化
        • 运算对象选择
        • 精度值选择
        • 插值寄存器处理
        • 其他
  3. 节省内存带宽
    • 减小纹理大小
      • 多级渐远技术
      • 纹理压缩
    • 利用分辨率缩放

Unity中的渲染分析工具

在开始优化之前,我们首先需要知道是哪个步骤造成了性能瓶颈。而这可以利用Unity提供的一些渲染分析工具来实现。这些工具包含了渲染统计窗口(Rendering Statistics Window)、性能分析器(Profiler),以及帧调试器(Frame Debugger)。需要注意的是,在不同的目标平台上,这些工具中显示的数据也会发生变化。

渲染统计窗口

Unity 5提供了一个全新的窗口,即渲染统计窗口(Rendering Statistics Window)来显示当前游戏的各个渲染统计变量,我们可以通过Game视图右上方的菜单中单击Stats按钮来打开它,如下图所示:

渲染窗口主要包含了3个方面的信息:音频(Audio)、图像(Graphics)和网络(Network)。我们在这里只关注第二个方面,即图像相关的渲染统计结果。

信息名称 描述
每帧的时间和FPS 在Graphic的右侧显示,给出了处理和渲染一帧所需的时间,以及FPS数目
Batches 一帧中需要进行的批处理数目
Saved by batching 合并的批处理数目,这个数字表明了批处理为我们节省了多少draw call
Tris和Verts 需要绘制的三角面片和顶点数目
Screen 屏幕的大小,以及它占用的内存的大小
SetPass 渲染使用的Pass的数目,每个Pass都需要Unity的runtime来绑定一个新的Shader,这可能造成CPU的瓶颈
Visible Skinned Meshes 渲染的蒙皮网格的数目
Animations 播放的动画数目

性能分析器的渲染区域

如果我们想要查看draw call的数目等其它更加详细的数据,可以通过Unity编辑器的性能分析器来查看。

们可以通过单击Window->Analysis->Profiler来打开Unity的性能分析器(Profiler)。性能分析器中的渲染区域(Rendering Area)提供了更多关于渲染的统计信息,下图给出了对上图场景的渲染分析结果:

一个值得注意的现象是,性能分析器给出的draw call数目和批处理数目、Pass数目并不相等,并且看起来好像要大于我们估算的数目,这是因为Unity在背后需要进行很多工作,例如初始化各个缓存、为阴影更新深度纹理和阴影映射纹理等,因此需要花费比预期更多的draw call。一个好消息是,Unity5引入了一个新的工具来帮助我们查看每一个draw call的工作,这个工具就是帧调试器

帧调试器

们在前面已经多次看到**帧调试器(Frame Debugger)**的作用。我们可以通过Window->Analysis->Frame Debugger来打开它。在这个窗口中,我们可以清楚的看到每一个draw call的工作和结果,如下图所示。

帧调试器的调试面板上显示了渲染这一帧所需要的所有渲染事件,在本例中,事件数目为5,而其中包含了3个draw call事件(其它渲染事件多为清空缓冲等)。通过单击面板上的每个事件,我们可以在Game视图查看该事件的绘制结果,同时渲染统计面板上的数据也会显示成截止到当前事件为止的各个渲染统计数据。

以本例为例,要渲染一帧共需要花费3个draw call,其中1个draw call用于更新深度纹理(对应UpdateDepthTexture)和用于渲染平行光的阴影映射纹理,1个draw call用于绘制球体,1个draw call用于绘制动态批处理后的3个立方体模型。

在Unity的渲染统计窗口、分析器和帧调试器这3个利器的帮助下,我们可以获得很多有用的优化信息。但是,很多诸如渲染时间这样的数据是基于当前的开发平台得到的,而非真机上的结果。

减少draw call数目

批处理的实现原理就是为了减少每一帧需要的draw call数目。为了把一个对象渲染到屏幕上,CPU需要检查哪些光源影响了该物体,绑定Shader并设置它的参数,再把渲染命令发给GPU。当场景中包含了大量的对象时,这些操作就会非常耗时。

一个极端的例子是,如果我们需要渲染1000个三角形,把它们按1000个单独的网格进行渲染花费的时间要远远大于渲染一个包含了一千个三角形的网格。在这两种情况下,GPU的性能消耗其实没有多大的区别,但CPU的draw call数目就会成为性能瓶颈。因此,批处理的思想很简单,就是在每次调用draw call时尽可能多的处理物体。

那么,什么样的物体可以一起处理呢?答案就是使用一个材质的物体。这是因为,对于使用同一个材质的物体,它们之间的不同仅仅在于顶点数据的差别。我们可以把这些顶点数据合并在一起,在一起发送给GPU,就可以完成一次批处理。

两种批处理优缺点

Unity中支持两种批处理方式:一种是动态批处理,另一种是静态批处理

  • 对于动态批处理来说,优点是一切处理都是Unity自动完成的,不需要我们自己进行任何操作,而且物体是可以移动的,但缺点是,限制很多,可能一不小心就破坏了这种机制,导致Unity无动态批处理一些使用了相同材质的物体。
  • 对于静态批处理来说,它的优点是自由度很高,限制很少;但缺点是可能会占用更多的内存,而且经过静态批处理后的物体都不可以再移动了(即便在脚本中尝试改变物体的位置也是无效的)。

动态批处理

动态批处理的基本原理是,每一帧把可以进行批处理的模型网格进行合并,再把合并后模型数据传递给GPU,然后使用同一个材质对其渲染。

条件限制

虽然Unity的动态批处理不需要我们进行任何额外工作,但只有满足条件的模型和材质才可以被动态批处理。

  • 使用光照纹理(lightmap)的物体需要小心处理。这些物体需要额外的渲染参数,例如在光照纹理上的索引、偏移量和缩放信息等。因此,为了让这些物体可以被动态批处理,我们需要保证他们指向光照纹理中的同一位置。
  • 多Pass的Shader会中断批处理,在前向渲染中,我们有时需要额外的Pass来为模型添加更多的光照效果,但这样一来模型就不会被动态批处理了。

从上图可以看出,要渲染这样一个包含了4个物体的场景共需要两个批处理。其中一个批处理用于绘制经过动态批处理合并后的3个立方体网格,另一个批处理用于绘制球体。我们可以从Save by batching看出批处理帮我们节省了两个draw call。

多光源的影响

现在,我们再向场景中添加一个点光源,并调整它的位置使它可以照亮场景中的4个物体。由于场景中的物体都使用了多个Pass的Shader,因此点光源会对他们产生光照影响。下图给很出了添加点光源后的渲染统计数据:

从上图可以看出,渲染一帧所需要的批处理数目增大到了8,而Save by batching的数目也变成了0。这是因为,使用了多个Pass的Shader在需要应用多个光照的情况下,破坏了动态批处理的机制,导致Unity不能对这些物体进行动态批处理。

而由于平行光和点光源需要对4个物体分别产生影响,因此需要2×4个批处理操作。需要注意的是,只有物体在点光源的影响范围内,Unity才会调用额外的Pass来处理它。因此,如果场景中点光源距离物体很远,那么它们仍然会被动态批处理的。

静态批处理

Unity提供了另一种批处理方式,即静态批处理。相比于动态批处理来说,静态批处理适用于任何大小的几何模型。它的实现原理是,只在运行开始阶段,把需要进行静态批处理的模型合并到一个新的网格结构中,这意味着这些模型不可以在运行时刻被移动。但由于他只需要进行一次合并操作,因此比动态批处理更加高效。

静态批处理的另一个缺点在于,它往往需要占用更多的内存来存储合并后的几何结构。这是因为,如果在静态批处理前一些物体共享了共同的网格,那么在内存中每一个物体都会对应一个该网格的复制品,即一个网格会变成多个网格再发送给GPU。如果这类使用使用同一网格的对象很多,那么这就会称为1个性能的瓶颈了。

例如在使用了1000个相同树模型的森林中使用静态批处理,那么就会多使用1000倍的内存,这会造成严重的内存影响。这种时候,解决方法要么忍受这种牺牲内存换取性能的方法,要么不使用静态批处理,而使用动态批处理技术(但要小心控制模型的顶点属性数目),或者自己编写批处理的方法。

操作

在本节的场景中,我们给出了一个测试静态批处理的场景。场景中包含了3个Teapot模型,它们使用同一个材质,同时还包含了一个使用不同材质的立方体。场景中还包含了一个平行光,但我们关闭了它的阴影效果,以避免阴影计算对批处理数目的影响。在运行前,这样一个场景的渲染统计数据如下图所示:

从上图我们可以看出,尽管3个Teapot模型使用了相同的材质,但它们仍然没有被动态批处理。这是因为,Teapot模型包含的顶点数目是393,而它们使用的Shader中需要使用4个顶点属性(顶点位置、法线方向、切线方向和纹理坐标),超过动态批处理中的900限制。此时要想减少draw call就需要使用静态批处理。

静态批处理的实现非常简单,只需要把物体面板上的Static复选框勾选即可(实际上我们只需要勾选Batching Static即可),如下图所示。

这时,我们再观察渲染统计窗口中的批处理数目,还是没有变化,但是不要着急,运行程序后,变化就出现了,如下图所示:

合并后的网格

从上图可以看出,现在的批处理数目变成了2,而Save by batching数目也显示2。此时,如果我们在运行时查看每个模型使用的网格,会发现它们都变成了一个名为Combined Mesh(roo:scene)的东西,如下图所示。

这个网格是Unity合并了所有被标识为“Static”的物体的结果,在我们的例子里,就是3个Teapot和一个立方体。读者可能会有一个疑问,这4个对象明明都不是使用的同一种材质,为什么可以合并成一个呢?如果仔细观察上图的结果,会发现在图的右下方标明了“4 submeshes”,也就是说,这个合并后的网格其实包含了4个子网格,即场景中的4个对象。对于合并后的网格,Unity会判断其中使用同一个材质的子网格,然后对它们进行批处理。

在内部实现上,Unity首先把这些静态物体变换到世界空间下,然后为它们构建一个更大的顶点和索引缓存。对于使用了同一材质的物体,Unity只需要调用一个draw call就可以绘制全部的物体。而对于使用了不同材质的物体,静态批处理同样可以提升渲染性能。尽管这些物体仍然需要调用多个draw call,但静态批处理可以减少这些draw call之间的状态切换,而这些切换往往是费时的操作。从合并后的网络结构中我们还可以发现,尽管3个Teapot对象使用了同一个网格,但合并后却变成了3个独立网格。而且,我们可以从Unity的分析器中观察到在应用静态批处理前后VBO total的变化。从下图所示中我们可以看出:

更多的内存

VBO(Vertex Buffer Object,顶点缓冲对象)的数目变大了。这正是因为静态批处理会占用更多内存的缘故,正如本节一开头所讲,静态批处理需要占用更多的内存来存储合并后的几何结构,如果一些物体共享了相同的网格,那么在内存中每一个物体都会对应一个该网格的复制品。

多光源的影响

如果场景中包含了除了平行光以外的其它光源,并且在Shader中定义了额外的Pass来处理它们,这些额外的Pass部分是不会被批处理的。下图显示了在场景中添加了一个会影响4个物体的点光源之后,渲染统计窗口的数据变化。

但是,处理平行光的Base Pass部分仍然会被静态批处理,因此我们让然可以节省两个draw call。

共享材质

从之前的内容可以看出,无论是动态批处理还是静态批处理,都要求模型之间需要共享同一个材质。

纹理不同

如果两个材质之间只有使用纹理的不同,我们可以把这些纹理合并到一张更大的纹理中,这张更大的纹理被称为是一张图集(atlas)。一旦使用了同一张纹理,我们就可以使用同一个材质,再使用不同的采样坐标对纹理采样即可。

材质参数不同

但有时,除了纹理不同外,不同的物质在材质上还有一些微小的参数变化,例如颜色不同、某些浮点属性不同。但是不管是动态批处理还是静态批处理,它们的前提都是要使用同一个材质。是同一个,而不是使用了同一个Shader的材质,也就是说它们指向的材质必须是同一个实体。这意味着,只要我们调整了参数,就会影响到所有使用这个材质的对象。那么想要微小的调整怎么办呢?一种常用的方法就是使用网格的顶点数据(最常见的就是顶点颜色数据)来存储这些参数。

前面说过,经过批处理后的物体会被处理成更大的VBO发送给GPU,VBO中的数据可以作为输入传递给顶点着色器,因此我们可以巧妙地对VBO中的数据进行控制,从而达到不同效果的目的。

一个例子是,森林场景中所有的树使用了同一种材质,我们希望它们通过批处理来较少draw call,但不同树的颜色可能不同。这时,我们可以利用网格的顶点的颜色数据来调整。

需要注意的是,如果我们需要在脚本中访问共享材质,应该使用Renderer.sharedMaterial来保证修改的是和其它物体共享的材质,但这会意味着修改会应用到所有使用该材质的物体上。另一个类似的API是Render.material,如果使用Render.material来修改材质,Unity会创建一个该材质的复制品,从而破坏批处理在该物体上的应用,这可能并不是我们希望看到的。

批处理的注意事项

在选择使用动态批处理还是静态批处理,我们有一些小小的建议。

  1. 尽可能选择静态批处理,但得时刻小心对内存的消耗,并且记住经过静态批处理的物体不可以再被移动
  2. 如果无法进行静态批处理,而要使用动态批处理的话,那么请小心上面提到的各种条件限制。
  3. 对于游戏中的小道具,例如可以捡拾的金币等,可以使用动态批处理。
  4. 对于包含动画的这类物体,我们无法全部使用静态批处理,但其中如果有不动的部分,可以把这部分标识成“Static”。
  5. 由于批处理需要把多个模型变换到世界空间下再合并它们,因此,如果Shader中存在一些基于模型空间下的坐标的运算,那么往往会得到错误的结果。一个解决方法是,在Shader中使用DisableBatching标签来强制使用该Shader的材质不会被批处理。
  6. 使用半透明材质的物体通常需要使用严格的从后往前的绘制顺序来保证透明混合的正确性。对于这些物体,Unity会首先保证它们的绘制顺序,再尝试对它们进行批处理。这意味着,当绘制顺序无法满足时,批处理无法在这些物体上被成功应用。

减少需要处理的顶点数目

优化几何体

在建模时,有一条规则我们需要记住:尽可能减少模型中三角面片的数目,一些对于模型没有影响、或是肉眼非常难察觉到区别的顶点都要尽可能的去掉。 在很多三维建模软件中,都有相应的优化选项,可以自动优化网格结构。

软件顶点数不同

Unity中显示的数目往往要多于建模软件里显示的顶点数,通常Unity中显示的数目要大很多。谁才是对的呢?其实,这是因为在不同的角度上计算的,都有各自的道理,但我们真正应该关心的是Unity里显示的数目。

  • 三维软件更多的是站在我们人类的角度去理解顶点的,即组成几何体的每一个点就是一个单独的点。
  • Unity是站在GPU的角度去理解顶点数的。在GPU看来,有时需要把一个顶点拆分成两个或更多的顶点。这种将顶点一分为多的原因主要有两个:一个是为了分离纹理坐标(uv splits),另一个是为了产生平滑的边界(smoothing splits)。它们的本质,其实都是因为对于GPU来说,顶点的每一个属性和顶点之间必须是一对一的关系。
    • 分离纹理坐标,是因为建模时一个顶点的纹理坐标有多个。例如,对于一个立方体,它的6个面之间虽然使用了一些相同的顶点,但在不同面上,同一个顶点的纹理坐标可能并不相同。对于GPU来说,这是不可理解的,因此它必须把这个顶点拆分成多个具有不同纹理坐标的顶点。
    • 平滑边界也是类似的,不同的是,此时一个顶点可能会对应多个法线信息或切线信息。这通常是因为我们要决定一个边是一条硬边(hard edge)还是一条平滑边(smooth edge)。

对于GPU来说,它本质上只关心有多少个顶点。因此,尽可能减少顶点的数目其实才是我们真正需要关心的事情。因此最后一条几何体优化建议就是:移除不必要的硬边以及纹理衔接,避免边界平滑和纹理分离。

模型的LOD技术

另一个减少顶点数目的方法是使用LOD(Level of Detail)技术

这种技术的原理是,当一个物体里摄像机很远时,模型上的很多细节是无法被察觉到的。因此,LOD允许当对象逐渐远离摄像机时,减少模型上的面片数量,从而提高性能。

在Unity中,我们可以使用LOD Group组件来为一个物体构建一个LOD。我们需要为同一个对象准备多个包含不同细节程度的模型,然后把它们赋给LOD Group组件中的不同等级,Unity就会自动判断当前位置上需要使用哪个等级的模型。

遮挡剔除技术

我们最后要介绍的顶点优化策略就是遮挡剔除(Occlusion culling)技术。遮挡剔除可以用来消除那些在其他物件后面看不到的物体,这意味着资源不会浪费在计算那些看不见的顶点上,进而提升性能。

遮挡剔除&视椎体剔除

我们需要把遮挡剔除和摄像机的**视椎体剔除(Frustum Culling)**区分开来。

  • 视椎体剔除只会剔除掉那些不在摄像机视野范围内的对象,但不会判断视野中是否有物体被其他物体挡住。
  • 而遮挡剔除会使用一个虚拟的摄像机来遍历场景,从而构建一个潜在可见的对象几何的层级结构。在运行时刻,每个摄像机将会使用这个数据来识别哪些物体是可见的,而哪些被其他物体挡住不可见。

使用遮挡剔除技术,不仅可以减少要处理的顶点数目,还可以减少overdraw,提高游戏性能。

减少需要处理的片元数目

另一个造成GPU瓶颈的是需要处理过多的片元。这部分优化的重点在于减少overdraw。简单来说,overdraw指的就是同一个像素被绘制了多次。

overdraw视图查看

Unity还提供了查看overdraw的视图,我们可以在Scene视图左上方的下拉菜单中选中Overdraw即可。

实际上,这里的视图只是提供了查看物体相互遮挡的层数,并不是真正的最终屏幕绘制overdraw。也就是说,可以理解为他显示的是,如果没有使用任何深度测试和其它优化策略时的overdraw。这种视图是通过把所有对象都渲染成一个透明的轮廓,通过查看透明颜色的累积程度,来判断物体之间的遮挡。当然,我们可以使用一些措施来防止这种最坏的情况出现。

控制绘制顺序

为了最大限度的表面overdraw,一个重要的优化策略就是控制绘制顺序。由于深度测试的存在,如果我们可以保证物体都是从前往后绘制的,那么可以很大程度上减少overdraw。这是因为,在后面绘制的物体无法通过深度测试,因此就无法再进行后面的渲染处理。

在Unity中,那些渲染队列数目小于2500(如:Background、Geometry和AlphaTest)的对象都被认为是不透明(opaque)的物体,这些物体总体上是从前往后绘制的,而使用其他的队列(如Transparent、Overlay等)的物体,则是从后往前绘制的。这意味着,我们可以尽可能的把物体的队列设置为不透明物体的渲染队列,而尽量避免使用半透明队列。

案例

在第一人称射击游戏中,对于游戏中的主要人物角色来说,他们使用的Shader往往比较复杂,但是,由于他们通常会挡住屏幕的很大一部分区域,因此我们可以先绘制它们(使用更小的渲染队列)。

而对于一些地方角色,他们通常会出现在各种掩体后面,因此,我们可以在所有常规的不透明物体后面渲染它们(使用更大的渲染队列)。

而对于天空盒子来说,它几乎覆盖了所有像素,而且我们知道它永远会出现在所有物体的后面,因此它的队列可以设置为“Geometry+1”。这样,就可以保证不会因为它而造成overdraw。这些排序的思想往往可以节省掉很多渲染时间。

时刻警惕透明物体

对于半透明对象来说,由于它们没有开启深度写入,因此,如果要得到正确的渲染结果,就必须从后往前渲染。这意味着,半透明物体几乎一定会造成overdraw。如果我们不注意这一点,在一些机器上可能会造成严重的性能下降。

例如,对于GUI对象来说,它们大都被设置成了半透明,如果屏幕中GUI占据的比例太多,而主摄像机有没有进行调整而是投影整个屏幕,那么GUI就会造成大量overdraw。

因此,如果场景中包含了大面积的半透明对象,或者有很多层相互覆盖的半透明对象(即便它们每个的面积可能都不大),或者是透明的粒子效果。在移动设备上也会造成大量的overdraw。这是应该避免的。

GUI处理

对于上述GUI的这种情况,我们可以尽量减少窗口中GUI所占的面积。

如果实在无能无力,我们可以把GUI的绘制和三维场景的绘制交给不同的摄像机,而其中负责三维场景的摄像机的视角范围尽量不要和GUI的相互重叠,当然,这样会对游戏的美观度产生一定影响。因此我们可以在代码中对机器的性能进行判断,例如首先关闭一些耗费性能的功能,如果发现这个机器表现良好,在尝试开启一些特效功能。

透明度测试

在移动平台上,透明度测试也会影响游戏性能。虽然透明度测试没有关闭深度写入,但由于它的实现使用了discard或clip操作,而这些操作会导致一些硬件的优化策略失效。

例如我们之前讲过的PowerVR使用的基于瓦片的延迟渲染技术,为了减少overdraw它会在调用片元着色器前就判断哪些片元被真正渲染的。但是,由于透明度测试在片元着色器中使用了discard函数改变了片元是否会被渲染的结果,因此GPU就无法使用上述的优化策略了。

也就是说,只要在执行了所有的片元着色器后,GPU才知道哪些片元会被真正渲染到屏幕上,这样,原先那些可以减少overdraw的优化就都无效了。这种时候,使用透明度混合的性能往往比使用透明度测试更好。

减少实时光照和阴影

实时光照对于移动平台是一种非常昂贵的操作。如果场景中包含了过多的点光源,并且使用了多个Pass的Shader,那么很有可能会造成性能的下降。

例如,一个场景里如果包含了3个逐像素的点光源,而且使用了逐像素的Shader,那么很有可能将draw call数目(CPU的瓶颈)提高3倍,同时也会增加overdraw(GPU的瓶颈)。这是因为,对于逐像素的光源来说,被这些光源照亮的物体需要被再渲染一次。

更糟糕的是,无论是静态批处理还是动态批处理,对于这种额外的处理逐像素光源的Pass都无法进行批处理,也就是说,它们会中断批处理。

烘焙技术

一个模拟光源的方法是使用场景烘焙。把光照提前烘焙到一张**光照纹理(lightmap)**中,然后在运行时刻只需要根据纹理采样得到光照结果即可。

体积光

另一个模拟光源的方法是使用体积光God Ray。场景中很多小型光源的效果都是靠这种方法模拟的。它们一般不是真正的光源,很多情况是通过透明纹理模拟得到的。

在移动平台上,一个物体使用的逐像素光源数目应该小于1(不包括平行光)。如果一定要使用更多的实时光,可以使用逐顶点光照来代替。

查找纹理

在游戏《ShadowGun》中,游戏角色看起来使用了非常复杂高级的光照计算,但这实际上是优化后的结果。开发者们把复杂的光照计算存储到一张**查找纹理(lookup texture,也被称为查找表,lookup table,LUT)**中。然后在运行时刻,我们只需要使用光源方向、视角方向、法线方向等参数,对LUT采样得到光照结果即可。

使用这样的查找纹理,不仅可以让我们使用更出色的光照模型,例如更加复杂的BRDF模型,还可以利用查找纹理的大小来进一步优化性能,例如,主要角色可以使用更大分辨率的LUT,而一些NPC就是用较小的LUT。

在ShadowGun这款游戏中,有很多取巧的渲染优化技术,可以去百度看看。

实时阴影

实时阴影同样是一个非常消耗性能的效果。不仅是CPU需要提交更多的draw call,GPU也要进行更多的处理。因此,我们应该尽量减少实时阴影,例如使用烘焙把静态物体的阴影信息存储到光照纹理中,而只对场景中的动态物体使用适当的实时阴影。

节省带宽

大量使用未经压缩的纹理以及使用过大的分辨率都会造成由于带宽而引发的性能瓶颈。

减少纹理大小

之前提到过,使用纹理图集可以帮助我们减少draw call的数目,而这些纹理的大小同样是一个需要考虑的问题。

需要注意的是,所有纹理的长宽比最好是一个正方形,而且长宽值最好是2的整数幂。这是因为有很多优化策略只有在这种时候才可以发挥最大作用。在Unity 5中,即便我们导入的纹理长宽值不是2的整数幂,Unity也会自动把长宽转换到离它最近的2的整数幂值。

除此之外,我们还应该尽可能使用多级渐远纹理技术(mipmapping)纹理压缩。在Unity中,我们可以通过纹理导入面板来查看纹理的各个导入属性。通过把纹理类型设置为Advanced,就可以自定义许多选项。例如,是否生成多级渐远纹理(mipmaps),如下图所示:

多级渐远技术

当勾选了Generate Mip Maps选项后,Unity就会为同一张纹理创建出很多不同大小的小纹理,构成一个纹理金字塔。

在游戏运行中就可以根据距离物体的远近,来动态的选择使用哪一个纹理。这是因为,在距离物体很远的时候,就算我们使用了非常精细的纹理,但是肉眼也是分辨不出来的。这种时候,我们完全可以使用更小。更模糊的纹理来代替,这可以让GPU使用分辨率更小的纹理,大量节省访问的像素数目。

在某些设备上,关闭多级渐元纹理往往会造成严重的性能问题。因此,除非我们确定该纹理不会发生缩放,例如GUI和2D游戏中使用的纹理等,都应该为纹理生成相应的多级渐远纹理。

纹理压缩

纹理压缩同样可以节省带宽,但对于像Android这样的平台,有很多不同架构的GPU,纹理的压缩就变得有点复杂,因为不同的GPU架构有它自己的纹理压缩格式,例如,PowerVRAM的PVRTC格式,Tegra的DXT格式、Adreno的ATC格式。所幸的是,Unity可以根据不同的设备选择不同的压缩格式,而我们只需要把纹理压缩格式设置为自动压缩即可。

但是GUI类型的纹理同样是一个例外,一些时候由于对画质的要求,我们不希望对这些纹理进行压缩。

利用分辨率缩放

过高的屏幕分辨率也是造成性能下降的原因之一,尤其是对于很多低端的手机,除了分辨率高其它硬件条件并不尽如人意,而这恰恰是游戏性能的两大瓶颈:过大的屏幕分辨率和糟糕的GPU。

因此,我们需要对于特定机器进行分标率的放缩。当然,这样可能会造成游戏效果的下降,但性能和画面之间永远是个需要权衡的话题。在Unity中设置屏幕分标率可以直接使用Screen.SetResolution

减少计算复杂度

Shader的LOD技术

和前面提到的模型的LOD技术类似,Shader的LOD技术可以控制Shader的等级。它的原理是,只有Shader的LOD值小于某个设定的值,这个Shader才会被使用,而使用那些超过设定值的Shader的物体将不会被渲染。

我们通常会在SubShader中使用类似下面的语句来指明shader的LOD值:

SubShader{
	Tags { "RenderType" = "Opaque" }
	LOD 200
}

我们也可以在UnityShader的导入面板上看到该Shader使用的LOD值。在默认情况下,允许的LOD等级是无限大的。这意味着,任何被当前显卡支持的Shader都可以被调用。

但是,在某些情况下我们可能去掉一些使用了复杂计算的Shader渲染。这时我们可以使用Shader.maximumLODShader.globalMaximumLOD来设置允许的最大LOD值。

Unity内置的Shader使用了不同的LOD值,例如,Diffuse的LOD为200,而Bumped Specular的LOD为100

代码方面的优化

运算对象

在实现游戏效果时,我们可以选择在哪里进行特定的运算。通常来讲,游戏需要计算的对象、顶点和像素数目的排序是对象数<顶点数<像素数。因此,我们应该尽可能的把计算放在每个对象或逐顶点上。

精度值

首先,第一点是,尽可能使用低精度的浮点值进行计算。

  • 最高精度的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合并

除此之外,尽量把多个特效合并到一个Shader中。例如,我们可以把颜色校正和添加噪声等屏幕特效在Bloom特效的最后一个Pass中进行合成。还有一个方法就是使用前面介绍的缩放思想,来选择性的开启特效。

其他

  • 尽可能不要使用分支语句和循环语句
  • 尽可能避免使用类似sin、tan、pow、log等较为复杂的数学运算。我们可以使用查找表来作为替代。
  • 尽可能不要使用discard操作,因为这会影响某些硬件的优化。

根据硬件条件进行缩放

我们很容易可以找到一台手机的渲染性能是另一台手机的10倍。那么如何确保游戏可以同时流畅的运行在不同的移动设备上呢?

一个非常简单实用的方式是使用所谓的放缩(scaling)思想。我们首先保证游戏最基本配置可以在所有的平台上运行良好,而对于一些具有更高表现能力的设备,我们可以开启一些更“养眼”的效果,比如使用更高的分辨率,开启屏幕后处理效果,开启粒子效果等。