Unity Optimizing graphics performance——CPU部分(四)

接上一篇优化前言优化前言,在接下来的本文中,将详细的介绍相关CPU优化。此部分包含光照优化以及drawcall等CPU优化细节

首先,为了渲染物体,CPU往往很多事情要去处理,比如:

  1. 计算光照及光照影响的物体
  2. 加载 shader以及加载shader所需的数据与参数
  3. 向GPU驱动程序发送指令,然后准备指令发送给GPU进行渲染,此过程俗称DrawCall(DC这个概念很重要,在整个优化过程中站很大的比例)
    因此,CPU优化将从这三个方面入手优化。

针对于上面三种情况,每一种对CPU来说都是一种非常负荷大的操作。比如有非常多的可见物体需要渲染,最好是集中在一起,分成一个批次进行渲染,也就是一个Batch。比如,假如现有1000个三角面片,CPU在处理时,相对于处理1000个独立的三角面片,那么CPU处理一个有1000个三角面片组成的大网格要来的轻松许多。
同样这些情况在GPU工作时也是有类似,但是CPU来渲染1000个独立的面片相对于渲染1个由1000个三角面片组成的大网格,消耗显而易见的要大的多。这个是因为CPU与GPU的体系架构所决定的。

下面简单介绍一下CPU与GPU的架构
Unity Optimizing graphics performance——CPU部分(四)
图1 CPU架构
Unity Optimizing graphics performance——CPU部分(四)
图2 GPU架构
图1、2中绿色的是计算单元,橙红色的是存储单元,橙黄色的是控制单元。

GPU采用了数量众多的计算单元和超长的流水线,但只有非常简单的控制逻辑并省去了Cache。
CPU不仅被Cache占据了大量空间,而且还有有复杂的控制逻辑和诸多优化电路,相比之下计算能力只是CPU很小的一部分。简单来说GPU具有非常强的高并发的数据计算处理能力,而CPU相对于GPU强大之处在其的逻辑计算能力。所以有一种优化策略就是前面那种CPU将1000个面片合并为一个大的Mesh交给GPU处理,而不是分1000次,将1000个面片独立发给GPU处理。

因此可以从以下几种方式来减少CPU的工作量:
1. 合并网格,使用Unity的动态或者静态批处理来合并网格
2. 合并图集,比如将不同纹理合并打包成一张大的图集,这样在渲染时,可以多个物体共享一个材质
3. 尽可能的减少需要多次渲染的物体,比如反射、阴影、逐像素计算的光照
4. 减少可见物体,可以通过调整相机的视椎体
5. 使用遮挡剔除

将这些物体合并,并且针对于整个网格而言,使得每个网格至少合并了几百个三角面片并且只使用一个材质。
但是,有一点要注意,如果两个物体不是共享一个材质,那么合并这两个物体并不能带来性能的优化。最常见的情况是两个物体所需要的纹理不同,这样就可能导致需要多个不同的材质。
因此我们在使用合并网格优化CPU性能时,一定要确认所要合并的对象是否共享相同的材质。

另外,当我们使用前向渲染路径( Forward rendering path)来实现逐像素点光照计算时,在这种情况时,合并网格并不能带来性能的提升,下面将对光照性能(Lighting Performance)如何来进行管理。

光照性能(Lighting Performance)
为了达到性能和效果的最佳结合,最好的方式是去创建一种不需要实时计算的光照,比如可以采取烘焙(bake)一张光照贴图(Lighting Mapping)形式的静态光而不是每帧去实时计算的光照。虽然烘焙一张Lighting Map的时间仅仅只比在场景中布置一个实时光源的时间的长一丢丢,但是好处就很明显,比如:
1. 静态光照的处理速度往往比实时光照的速度要快很多
2. 视觉效果更好,因为可以烘焙出全局的环境光,并且使用这种光照映射可以使得效果更加平滑柔顺更加好

在很多情况中,我们可以复杂问题简单化去取代在场景中布置很多实时光源。比如,当我们需要实现一个边缘照明效果的时,我们可以不布置一个直接照射相机的光源,我们可以在我们自定义的着色器(Shader)并且在里面实现这种效果的计算(具体如何实现,将来学习Shader的时候再来补充链接)。

光照——关于前向渲染(Forward Rendering):
动态的逐像素点计算光照(Per-Pixel-Dynamic-Lighting)因为是基于每一个受到光照影响的像素的处理,所以这种方式的效果非常好但是计算有很繁重。因此我们要避免在哪些处理能力比较差的低端设备上使用逐像素点计算的光照计算,比如移动设备或者低端GPU的PC设备上,我们可以使用烘焙的静态的光源(Lighting Map)去实现光照,而不是使用每帧实时计算光照。
同样动态逐顶点计算光照(Per-Vertex-Dynamic-Lighting)可以根据每个顶点变换来实现一个非常好的光照效果,但是同样也需要注意避免多个光照影响一个顶点的情形,这样会使计算负载增加。

光照——合并网格(Combine Mesh)
在合并网格时,避免合并那些因为距离远且受到不同逐像素点光照影响的Mesh。当我们在使用逐像素点计算的光照的时候,需要经过多次渲染处理这些像素点才能达到我们所需要的光照效果。如果我们将两个距离较远且受到不同关照的网格合并在一起,这样会增加网格的实际大小。并且在渲染过程中,在这个大Mesh中所有影响到像素点的像素光都需要被考虑在内,因此在渲染通道的数量又增加了,在计算量和内存上都增大了开销。通常,渲染这种合并网格的渲染通道数是渲染每个独立的网格的所需渲染通道数的总和,所以这种合并网格的方式实际效果不可取。

在我们渲染期间,Unity会找出一个网格周围的所有光照,然后对这些光照进行计算并找出哪个光照对这个网格影响最大。Unity中的QualitySetting往往被用于更新最终有多少光照是像素光,有多少光照是顶点光。对于每一个光照计算来说,光源离网格有多远,光照强度有多大,这些指标都很重要,甚至对于某类光照来说,这些指标甚至比其他的因素更重要(此处并不明白是什么因素有待以后慢慢补充)。因为每一个光照都可以根据质量来设置一种渲染模式(每个Light中的Render Mode),一些设置为不重要的光照,以此一些不重要的光照他的渲染开销可以降低。
比如:现在有一款驾驶游戏,玩家在夜间开着头灯驾驶车辆。这个头灯是夜间最重要的光源,所以我们将他设置为最重要(Important)的光照,那么其他的比如汽车尾灯或者其他微弱影响的光源,这类光源并不需要通过像素光来提升实际效果,所以我们可以将其Render Mode设置为NotImportant从而避免在一些没有实际用处的地方浪费宝贵的渲染资源。

最重要的,优化像素光不仅仅是可以给CPU减负同样也可以给GPU减负:
1. 可以降低CPU的DrawCall
2. 可以让GPU减少顶点和像素光栅化的处
与逐点计算的像素光相同的情况还有实时阴影(Realtime Shadows),这个跟上面情况类似。

Draw Call Batching(drawcall的批处理)
在我们需要在屏幕上绘制一个物体的时候,引擎必须要向图形处理API(比如OpenGL或者Direct3D)发送一个draw call 指令。
从CPU角度来说,Draw calls需要做很多准备来支持图形处理API来处理每一次图形调用(也可以理解为CPU为GPU每一帧渲染需要准备很多数据已经发送指令修改GPU渲染状态),这个可以导致CPU的消耗。主要是由于在不同的drawcall之间需要进行状态的切换(比如渲染不同的材质时),因为在这个切换的过程中我们需要对CPU进行数据的收集和验证并且通知并改变GPU来改变渲染状态。

Unity引擎为此提出了两种解决方案来解决DrawCall的问题:
1. Dynamic Batching(动态批处理): 对于在那些小的零散的Mesh,CPU将会对Mesh的顶点进行转换,动态的将他们组合在一起,并且通知GPU将他们一次绘制出来。
2. Static Batching(静态批处理):将一些不会移动并且不会发生改变的物体通过Static合并成一张大的网格,这样可以使他们渲染起来更快。

与手动的合并GameObjects比起来,使用Unity内置的合并方式好处更多。但是值得注意的是,GameObjects还是可以单独进行裁剪的。
但是Unity内置的合并方式也有缺点
1. 静态批处理,可能导致更多的内存消耗,因为需要在内存中组成一个大的网格镜像
2. 动态批处理,会导致更多CPU的开销,因为需要去重新计算每个网格的顶点位置并重新计算生成一张大的网格数据

注意:动态批处理与graphics job设置不兼容(Player Settings中的graphics jobs决定Unity是利用主进程、渲染进程或者工作进程进行渲染任务。在可以设置的平台上,graphics jobs可以带来较为可观的性能改进)。如果设置了graphics jobs,在Standalone平台上,动态批处理是被禁用的,将不支持。

Material set-up for batching(为了批处理关于材质的设置)
1. 只有当物体之间共享一个相同的材质,物体才能被批处理。因此如果我们想获得好的批处理效果,我们可以在不同物体之间尽可能多的使用共享材质
2. 如果有两个一模一样的材质,但是他们使用的是不同纹理,我们可以讲两个不同的纹理合并一个大的纹理,通常称作纹理集(这里引入了纹理集的概念,有兴趣可以查一下相关的资料),如果这些所需都在一个图集中,那么我们可以将这两个材质使用一个来代替。
3. 如果我们需要在脚本中访问共享材质属性,这里非常要注意一点,我们如果通过Renderer.Material来更改的话,会在内存中重新创建一个Material的副本,会带来内存的消耗。但是我们可以通过修改Renderer.sharedMaterial来修改,这样依然可以保持两个物体共享一个材质(不过这种情况貌似会将动态修改的保存下来,暂时不确定)。
4. 阴影投射(Shadow Caster)经常会被批处理合并在一起渲染,即使他们使用的材质不同也同样会进行批处理。在Unity中阴影投射哪怕是使用了不同的材质都会进行动态批处理,只需要在阴影通道处理时这些材质所需要的值是一样的就可以进行批处理。比如:我们创建很多条板箱会使用很多不同的材质和纹理,但是我们渲染的投射的阴影所需要的纹理前面的材质并不相关,所以这种情况我们可以进行批处理。(官方对这一块的描述比较复杂,简单来说,就是我们在渲染阴影时,如果阴影效果相同,同一个材质,也可以使用同一个材质然后搭配一张合并了不同纹理的纹理集)

Dynamic Batching(动态批处理)
Unity会自动的进行批处理,将GameObjects自动移动到一个相同的Drawcall来进行渲染,只要他们满足他们使用的相同的材质或者满足其他的条件。动态批处理是Unity自动完成的,并不需要我们额外去做。不过需要注意集中打断动态批处理的情况:
1. 动态批处理网格的时候因为是逐顶点进行处理的,所以在这方面是有一定的消耗。所以Unity限制动态批处理只适用于合并后的网格顶点在900以下(比如两个网格虽然使用了相同材质和纹理,但是两个网格顶点总数超过了900此种情况就不会进行批处理)。但是如果除了顶点坐标以外我们还使用了法线以及UV坐标,那么这个900就要除以3变成300了,如果我们在着色器(Shader)中使用了顶点坐标、法线、UV0、UV1以及切线,那么这个总数就只能是180了。但是随着硬件设备性能的提升未来这个数值肯定会发生改变的。
2. 缩放会打断批处理,比如相同物体才场景中进行了复制,其中一个的scale是(1,1,1),一个的scale是 (-1,-1,-1),这种情况下,Unity是不会进行动态批处理的。
3. 我们在处理使用 lightmap 的物体的时候如果有额外渲染参数设置也会打断批处理:比如在 lightmap 包含了 lightmap index 和 offset/scale。简单的来说使用动态光照的物体,在用lightmap时如果需要批处理,那么必须使用完全一样的lightmap location。(此处暂时不是很理解,回头学习光照的时候会重新补充,暂时标记)
4. 如果Shader含有多个pass(渲染流水线),同样也会打断批处理。
1)在Unity中几乎所有Shader在使用Forward Render Path时都支持多个光照效果,能非常有有效的为他们提供渲染管线来处理。因此这种per-pixel-light逐像素点计算的光照将不会批处理
2)如果使用Deferred Render Path(延时渲染),动态批处理将会被禁用,因为延时渲染会固定产生两次Drawcall,来绘制物体两次。

综上,动态批处理的工作原理是将能合并的Mesh的通过CPU计算然后产生一张全新的Mesh,这种处理所带来的消耗远远小于多次DrawCall。一次Drawcall 所需要的资源数据往往取决很多方面,最主要的是给 graphic api 使用。比如,在控制台或者像Apple Metal这类现代graphicAPI,drawcall对CPU的消耗往往要小得多,所以动态批处理并不能带来多大的性能提升。

Static Batching(静态批处理)
静态批处理允许引擎去为了渲染那些规定使用Share Material和不发生移动的几何体而减少drawcall,这个比动态批处理更有效,静态批处理不需要去动态的计算顶点重新生成新的Mesh,不过会产生多余的内存消耗。因为被静态批处理的对象无论在运行时还是在编辑时都会在内存中创建一个镜像,所以有时为了控制内存消耗,不得不牺牲渲染性能,避免对某些游戏物体进行静态批处理。例如,如果对那种非常密集的树林进行批处理,那样会对内存造成很大的消耗。

静态批处理的内部原理
静态批处理通过将Static标记游戏对象转换到世界空间并为它们构建一个大的顶点和索引缓冲区。然后,对于同一批中可见的GameObjects,进行一系列简单的drawcall,这样中间没有产生过多的且复杂的状态变化

从技术来说,这样做不是减少了 3DAPI的drawcall ,而是减少了渲染过程中状态的变化。不过静态批处理也有限制,在大多数图形API中只能处理 64k的vertex 和 64的 indices(OpenGL ES 是48k,macos上是32k)。

Tips:
一般情况下,Unity只对 Mesh Renderers、Trail Renderers、Line Renderers、Particle Systems和Sprite Renderers这几类进行批处理。对于skinned Meshes、Cloth或者渲染组件不能被批处理

Renderer只会与其他采用相同方式的Renderer进行批处理。

半透明的Shader往往需要物体的渲染顺序是从前到后的方式来渲染,这样可以不影响透明物体的渲染。因为这顺序要求严格,所以如果要进行批处理的话,必须要遵守这个渲染顺序。这种做法的话,相对于批处理不透明的物体,半透明的物体能进行批处理的次数明显减少

另外手动合并一些相邻的网格也是一种不错的draw call batch。比如一个有着很多抽屉的碗橱,通常可以在3D建模的程序中或者使用Mesh.CombineMeshes来合并成一个新网格。

大致关于Unity的CPU优化方案都集中于此,后面还会补充一下Occlusion Culling*强调内容*