《shader入门精要》笔记-第16章-Unity中的渲染优化

影响性能的因素

CPU负责保证帧率,GPU负责分辨率先关的一些处理

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

对于CPU来说,限制它的主要原因是每一帧中draw call的数目.
CPU每次通知GPU渲染之前,都要提前准备好顶点数据(如位置,法线,颜色,纹理坐标等),然后调用一系列API将它们放到GPU的指定位置,最后,调用一个指令,通知GPU开始渲染.
而调用绘制命令时,就会产生一个drawcall.过多的drawcall会造成CPU的性能瓶颈,这是因为每次调用drawcall是,CPU往往都需要 改变很多渲染状态的设置,而这些操作是非常耗时的.如果一帧中需要的drawcall过多的话,就会导致CPU把大部分时间都花费在提交drawcall上面.
当然,其他操作也可能造成CPU瓶颈,例如物理,布料模拟,蒙皮,粒子模拟等,这些都是计算量很大的操作.

而对于GPU来说,它负责整个渲染流水线.他从处理CPU传递过来的模型数据开始,进行顶点着色器,片元着色器等一系列工作,最后输出到屏幕上的每个像素.因此,GPU性能瓶颈和需要处理的定点数,屏幕分辨率,显存等因素有关.
而相关的优化策略可以从减少处理的数据规模(包括顶点数目和片源数目),减少运算复杂度等方面入手.

了解了上面的基本内容后,本章节后续会涉及的优化技术有:

  1. GPU优化
    • 使用批处理技术减少drawcall数
  2. GPU优化
    • 减少需要处理的顶点数目
      • 优化几何体
      • 使用模型的LOD(Level of Detail)技术
      • 使用遮挡剔除(Occlusion Culling)技术
    • 减少需要处理的片元数目
      • 控制绘制顺序
      • 警惕透明物体
      • 减少实时光照
    • 减少计算复杂度
      • 使用Shader的LOD技术
      • 代码方面的优化
  3. 节省内存带宽
    • 减少纹理大小
    • 利用分辨率缩放

减少drawcall数目

批处理(batching)是经常看到的优化技术.批处理的实现原理就是为了减少每一帧出现的drawcall数目.
为了把一个对象渲染到屏幕上,CPU需要检查哪些光源影响了该物体,绑定shader并设置它的参数,再把渲染命令发送给GPU.
当场景包含大量对象时,这些操作就会非常耗时.一个极端的例子是,如果我们需要渲染1000个三角形,把它们按1000个单独的网格进行渲染所花费的时间要远大于渲染一个包含了1000个三角形的网格.在这两种情况下,GPU性能消耗其实并没有多大的区别,但CPU的drawcall数目会成为性能瓶颈.
因此,批处理的思想很简单,就是在每次调用drawcall时尽可能多的处理多个物体.

使用同一材质的物体可以一起批处理.这是因为,对于使用同一材质的物体,他们之间的不同仅仅在于顶点数据上的差距.
我们可以把这些顶点合并在一起发给GPU,就可以完成一次批处理.

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

对于动态批处理来说,优点是一切处理都是由Unity自动完成的,不需要我们做任何操作,而且物体是可以移动的,但缺点是,限制很多,可能一不小心就会破坏了这种机制,导致Unity无法动态批处理一些使用了相同材质的物体.

而对于静态批处理来说,它的优点是自由度很高,限制很少;但缺点是可能会占用更多的内存,而且经过静态批处理后的所有物体都不可以再移动了(即便在脚本中尝试改变物体的位置也是无效的).

动态批处理

如果场景中有一些模型共享了同一个材质并满足一些条件,Unity就会自动把它们进行批处理,从而只花费一个drawcall就可以渲染所有模型.
动态批处理的基本原理是,每一帧都可以进行批处理的模型网格进行合并,再把合并后的模型数据传递给GPU,然后使用同一材质对其渲染.
除了实现方便,动态批处理的另一好处是,经过批处理的物体仍然可以移动,这是由于在处理每帧时Unity都会重新合并一次网格.

虽然Unity动态批处理不需要我们进行额外的工作,但只有满足条件的模型和材质才可以被动态批处理.需要注意的是,随着Unity版本的变化,这些条件也有一些改变.
在本节中,我们给出一些主要的条件限制:

  • 能够进行动态批处理的网格的顶点属性规模要小于900.
    例如,如果shader中需要使用顶点位置,发信和纹理坐标这三个顶点属性,那么要想让模型能够被动态批处理,它的顶点数目不能超过300.需要注意的是,这个数字在未来有可能会发生变化,因此不需要依赖这个数据.
  • 一般来说,所有对象都需要使用同一个缩放尺度
    一个例外情况是,如果所有物体都是用了不同的非统一缩放,那么它们也是可以被动态批处理的.但在Unity5中,这种对模型缩放的限制已经不存在了.
  • 使用光照纹理(lightmap)的物体需要小心处理.
    这些物体需要额外的渲染参数,例如,在光照纹理上的索引,偏移量和缩放信息等.因此,为了让这些物体可以被动态批处理,我们需要保证它们指向光照纹理中的同一个位置(?)
  • 多Pass的Shader会中断批处理.
    在前向渲染中,我们有时需要使用额外的pass来为模型添加更多的光照效果,但这样一来模型就不会被动态批处理了.

动态批处理的限制比较多,例如很多时候,我们的模型数据往往会超过900的顶点属性限制.这种时候依赖动态批处理来减少drawcall显然已经不能满足我们的需求了.

静态批处理

相对于动态批处理来说,静态批处理适用于任何大小的几何模型.
它的原理是.只在运行开始阶段,把需要进行静态批处理的模型合并到一个新的网格中,这意味着模型不可以在运行时被移动.但由于他只需要进行一次合并操作,比动态批处理更加高效.

静态批处理的一个缺点在于,他往往需要更大的内存来存储合并后的几何结构.
这是因为,如果在静态批处理前一些物体共享了相同的网格,那么在内存中每一个物体都会对应一个该网格的复制品,即一个网格会变成多个网格再发给GPU.如果这类使用同一网格的对象很多,那么这就会成为一个性能瓶颈了.
例如,如果再一个使用了1000个相同树模型的森林中使用了静态批处理,那么就会使用1000倍的内存,这会导致严重的内存影响.
这种时候,解决方法要么忍受这种牺牲内存换取性能的做法,要么不使用静态批处理,而使用动态批处理技术(但要小心控制模型的顶点数和属性数目),或者自己编写批处理方法.

在内部实现上,Unity首先将这些静态物体变换到世界空间下,然后为他们构建一个更大的顶点和索引缓存.
对于使用了同一材质的物体,静态批处理只需要调用一个drawcall就可以绘制全部物体.
而对于使用了不同材质的物体,静态批处理同样可以提升渲染性能,尽管这些物体仍然需要调用多个drawcall,但静态批处理可以减少这些drawcall之间的状态切换,而这些切换往往是费时的操作.

共享材质

从之前的内容可以看出,无论是动态批处理还是静态批处理,都要求模型之间需要共享同一个材质.但不同的模型之间总会需要不同的渲染属性,例如,使用不同的纹理,颜色等.这时,我们需要一些策略 来尽可能地合并材质.

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

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

(这里还有一段较详细的,以后再看)

批处理的注意事项

  • 尽可能选择静态批处理,但时刻小心对内存的消耗,并且记住经过静态批处理的物体不可以再被移动
  • 如果无法进行静态批处理,而要使用动态批处理的话,那么请小心上面提到的各种限制.
    例如,尽可能让这样的物体少并且尽可能让这些物体包含少量的顶点属性和顶点数目.
  • 对于游戏中的小道具,例如捡拾的金币等,可以使用动态批处理
  • 对于包含动画这类物体,我们无法全部使用静态批处理,但其中如果有不动的部分,可以把这部分标识为"Static"

除了上述的提示外,在使用批处理时还有一些需要注意的地方.
由于批处理需要把多个模型变换到世界空间下再合并它们,因此,如果shader中存在一些基于模型空间下的坐标的运算,那么往往会得到错误的结果.一个解决方法是在shader中使用DisableBatching标签强制使用该Shader的材质不被批处理.
另一个需要注意的是,使用半透明材质的物体 通常需要使用严格的从后往前的绘制顺序来保证透明混合的正确性.对于这些物体,Unity会首先保证它们的绘制顺序,再尝试对它们进行批处理.这意味着,当绘制顺序无法满足时,批处理无法在这些物体上被成功应用.

减少需要的顶点数目

优化几何体

Unity中显示的顶点数目往往要多余建模软件里显示的顶点数.通常Unity中显示的数目要大很多.我们真正应该关心的是Unity中显示的数目.

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

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

模型的LOD算法

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

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

遮挡剔除技术

遮挡剔除(Occlusion culling)可以用来消除那些在其他物体后面看不到的物件,这意味着资源不会浪费在那些看不到的顶点上.进而提升性能.

我们需要把遮挡剔除和摄像机的视锥体剔除(Frustum Culling)区分开来.
视锥体剔除只会剔除掉那些不在摄像机的视野范围内的对象,但不会判断视野中是否有物体被其他物体挡住.而遮挡剔除会使用一个虚拟的摄像机来遍历场景,从而构建一个潜在氪金的对象集合层级结构.
在运行时刻,每个摄像机将会使用这个数据来识别哪些物体是可见的,而哪些物体被其他物体挡住不可见.使用遮挡剔除技术,我们需要进行一系列额外的处理工作,具体步骤参见Unity手册相关内容(docs.unity3d/Manual/OcclusionCulling.heml)

模型的LOD技术和遮挡剔除技术可以同时减少CPU和GPU的负荷.CPU可以提交更少的drawcall,而GPU需要处理的顶点和片元数目也减少了.

减少需要处理的片元数目

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

Unity中Scene视图左上方的下拉菜单中选中Overdraw即可查看overdraw.
实际上,这里的视图只是提供了查看物体相互遮挡的层数,并不是真正的最终屏幕绘制的overdraw.也就是说,可以理解为它显示的是,如果没有任何深度测试和其他优化策略时的overdraw.
这种视图通过吧所有游戏对象都渲染成一个透明的轮廓,透过查看透明颜色的累积程度来判断物体之间的遮挡.

控制绘制顺序

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

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

好无聊了这块以后再来看

时刻警惕欧明物体

减少实时光照和阴影