【转】【Unity】渲染性能优化---经验总结

转自:【Unity】渲染性能优化---经验总结(一) - 哔哩哔哩 (bilibili.com)

1、渲染优化的几大性能点

我们来简单浏览一下渲染的主要过程:

CPU计算和收集渲染所需数据组装描述符和材质--->CPU向GPU传递渲染所需数据--->CPU发起DrawCall--->GPU进行渲染和计算--->在一些情况下GPU会向CPU回传数据(例如一些ComputeShader)

这里首先引出一个关键点:渲染流程中实际上大部分阶段都是需要CPU参与的!!!! 有时程序在定位性能热点时,觉得项目中没有多少GC和复杂运算,那么性能问题就一定是发生在GPU上,实际上不然。就我个人而言,不论是PC、主机还是移动端的GPU,其性能我还是比较自信的,如果没有什么骚操作,GPU中的运算通常不会引发严重的性能问题。

那么根据上面的渲染流程,也就可以得出渲染中的几大性能点:

1、对于 CPU计算和收集渲染所需数据组装描述符和材质 阶段:项目中存在大量零散琐碎的物体;有大量可以共用材质的物体却没有共用材质;有大量复杂的动画运算和蒙皮运算等,这些都会使CPU计算和收集渲染数据的时间延长。

2、对于 CPU向GPU传递渲染所需数据 阶段:该阶段产生的性能问题就是常说的 带宽问题,CPU 与 GPU 用于传递数据的通道,其传输速率有限(而这个传输速率就称为带宽),当一帧内传输的内容大小大于一帧的传输速率时,就会出现传输排队,导致后续渲染延迟。 尤其对于移动端,由于移动端的 CPU 和 GPU 间的带宽本来就较小,且移动端 CPU 和 GPU 都在一块芯片上,共用一整块功率,当出现带宽问题时,使用功率上升,导致手机发热较快。 贴图占用内存过大、网格数据占用内存过大、一些大物体在CPU端视锥体检测无法被筛掉等,这些都会触发带宽问题。

3、对于 CPU发起DrawCall 阶段:这一阶段导致性能问题的就是 DrawCall 的数量 和 DrawCall 本身的复杂度了。DrawCall 就是 CPU 的一种指令,所以其耗时就是本身指令执行的耗时。通常我们认为渲染在GPU上有性能问题时,往往最终是由于 DrawCall 数过多导致GPU开始渲染的时间节点被大幅延迟导致的。该合批的没有合批、该用同一个材质的没有用同种材质、美术资源的制作上导致合批困难、材质有大量的属性和变量等,都会导致 DrawCall 数量的上升 和 DrawCall本身指令的复杂化。

4、对于 GPU进行渲染和计算 阶段:这一阶段才算彻底的走到了GPU的内部,也就是我们觉得要改Shader的时候。但实际上,这一阶段出现的一些问题也不是需要简化Shader运算才能解决的。这一阶段会产生性能问题的主要原因有:渲染顺序的不合理和特效面片的不合理导致过多的Overdraw、Shader中冗余或复杂的计算、贴图或网格数据过大导致运算时加载数据过慢、对于移动端 一些骚操作或者不合理的渲染流程还会触发 TileBase 架构的 GMEM_Load 操作导致渲染过程变慢。

5、对于 在一些情况下GPU会向CPU回传数据 阶段:通常游戏开发中不会遇到这一阶段。对于这一阶段我们只需要留意使用 GPU 做计算功能时,其结果应该尽可能的是留在GPU的内存中,作为GPU后面计算或渲染的输入,如果一定要把结果传给CPU,那就要注意结果的大小,避免产生带宽问题。

2、如何确定渲染性能点是在CPU还是GPU?

通过上面我们知道,对于渲染流程有的过程是在CPU、有的过程是在GPU,因此渲染的性能点也是有的在CPU、有的在GPU。那么究竟怎么确定到底是CPU的问题还是GPU的问题呢?

这里我推荐的是使用 Unity 自带的性能分析器:Profiler (注意:如果游戏目标平台不是PC那么 Profiler 一定要是真机测试!!!)

Profiler 的 Timeline 视图展示了每一帧中 CPU 和 GPU 进行的主要阶段。在一帧中,大致的流程为:CPU执行一系列运算后确定动画、网格和材质信息--->CPU发起DrawCall--->GPU收到DrawCall和数据开始渲染--->在GPU渲染的同时 CPU 可继续向后运行

那么如果在 CPU 发起 DrawCall 之前,GPU 没有正在运行的渲染任务,那么GPU就会处在等待状态。而如果 CPU 在发起 DrawCall 时,GPU还有渲染任务没有处理完,那么 CPU 就会处在等待状态,等待GPU将当前任务完成后再发起DrawCall。(这里是一种简单的说法,对于 Vulkan 和 DX12 这些现代图形API情况会有些不同,但其实也是大同小异)

对于等待的阶段,Profiler 会用 灰色块 表示:

图2.1 灰色块 Gfx.WaitForGfxCommandsFromMainThread 表示当前GPU正在等待CPU

图2.2 灰色块 Semaphore.WaitForSignal 表示当前 CPU 正在等待 GPU

那么如果 GPU 等待 CPU 的 灰色块(如上图2.1 渲染线程的 Gfx.WaitForGfxCommandsFormMainThread),所占时间过长,那就说明 CPU 在渲染阶段运行时间过长,渲染性能点出现在 CPU 端。

而如果 当前帧内 渲染线程(RenderThread) 的开头出现了灰绿色块(如图2.2 渲染线程开头的灰绿色块) 且自身的绿色块也很长一直到排到最后;或者出现了CPU等待GPU的灰色块(如图2.2 的 Semaphore.WaitForSignal) 这就说明 GPU 在渲染时花费了大量时间,甚至在一帧的时间内都没有处理完任务,一直到下一帧还在处理。 也就是说渲染性能点出现在了 GPU 端。

这里也引出了 Profiler 的一个迷惑人的点:当 CPU 等待 GPU 时,Profiler 会一直拉长 CPU 当前所处阶段的时间。例如图2.2,Semaphore.WaitForSingle 上面的 MeshSkinning.Update ,其显示执行了 7.33ms 但实际上它的执行可能只花了 0.2ms,而之后都是在等待 GPU 任务的完成。因此当我们发现 CPU 某些任务执行时间莫名过长时,要检查一下其下面是否有 Semaphore.WaitForSignal 这个灰色块,如果有就说明不是CPU执行这项任务过慢,而是GPU出现了渲染性能问题。

3、CPU端渲染性能热点确定

知道了是CPU还是GPU的问题后我们就来进一步的确定问题。

对于CPU端的渲染性能点,其实只要资源规范设定好,资源创作流水线定制好,渲染流水线设定好,那么就不会有太大的问题。

主要是多大的贴图会产生带宽问题?多少个DrawCall会造成明显卡顿?多少个多少帧的动画或者多少个多少骨骼的角色会造成明显卡顿?这些定量的问题,我还都没有具体的去研究过,平常也没有去记录相关的数据。所以就算拿来一份指标报告,你让我看着这些数据也很难直接的确定出问题 X...X

所以对于这一块我的想法就是做好资源管理和规范,尽量避免出现问题,真出现问题了就用排除法,其它地方没问题,那一定就是这里有问题喽。 所以在后面的资源管理章节我会再细说一这部分。

当然,对于这一块性能热点的确定我也不是完全没有办法,这里我还是推荐使用 Untiy 的 Profiler,其 Timeline 把每个阶段的耗时都标出来了,我们只需要结合经验看看那一块的用时过高就可以确定问题所在啦。

Profiler 显示的 动画计算用时

4、GPU端渲染性能热点确定

这一部分首先根据自身经验和直觉,有些东西是可以直接定位出来的。 如当我们看到项目里有大量特效叠加,且特效面片很大时,就会知道 GPU 渲染时可能产生了大量的 Overdraw,之后在用 Unity 自身的 Overdraw 窗口检查一下,就知道当前 Overdraw 是否需要优化了。

在Unity内检测Overdraw情况,越红表示Overdraw越高。图中的其实不算太高,因为项目这里我已经优化过一波了

而对于渲染顺序引起的Overdraw,如果能和美术制定好流程规范,让美术能够理解 渲染队列、Early-Z、Overdraw 这些规范那就会非常Nice,不然的话就是在美术制作好场景后自己检测一遍然后做修改咯。

而对于其它直观上难以确定的 GPU 性能点,这里如果是移动端我推荐使用 SnapdragonProfiler 进行抓帧分析,其它平台推荐 RenderDoc。SnapdragonProfiler 是高通出的性能分析器,因此其只有搭配使用高通芯片的手机才能完全的发挥它的作用,这里推荐小米和三星的手机。

SnapdragonProfiler

RenderDoc

关于怎么使用 SnapdragonProfiler 来确定 GPU 端 具体的性能点,我会放在后面的抓帧工具篇细说。而至于 RenderDoc 由于我实际上用的不多,所以就先不谈了。

造成渲染性能点的原因以及如何解决

1、CPU计算和收集渲染所需数据组装描述符和材质 阶段的性能点

物体的合批。如果场景中存在大量的琐碎的物体,那么 CPU 在做视锥体剔除 和 收集这些物体的渲染信息时的耗时就会增加。对于合批通常使用这三种方法:建模时就手动合并网格、静态合批、动态合批。

对于建模合批:这里不建议在建模时就把各种物体的网格合并为一个,因为这样做会产生一个巨大的且有很多顶点数的物体,由于其体积过大,CPU无法在视锥体剔除阶段将其剔除,那么就会有过多的网格信息需要从CPU传到GPU,此时容易引发带宽问题。这里推荐的做法是建模时要思考个体与整体的可见性问题,例如:对于书架里的书,当里面一本书可见时,通常一组书都可见,那么就可以把一组书的网格合并为一个,甚至当一本书可见时通常整个书架都可见,所以书和书架的网格都可以在建模时就合并在一起。

对于静态合批:对于静态不会动的物体,在Unity中我们可以将其标记为 Static,即静态物体,那么在游戏运行时,Unity就会对使用相同材质的静态物体的网格进行合并。注意:Unity并不是简单的把所有静态物体都合并为一个网格。 Unity在静态合批时也会考虑到视锥体剔除问题,参与静态合批的物体自身会有一个索引,反过来根据索引我们也可以找到具体的物体对象,那么就可以动态的决定该对象是否隐藏(参与渲染)。

对于动态合批:一些顶点数较小的网格,在运行时Unity会动态的对它们的网格进行合并,因此即使这些物体是动态的也没有关系。 注意:要使用静态合批和动态合批,首先要在设置中开启:

在 PlayerSetting 中开启静合批和动态合批

动态合批的顶点数限制的具体定义:

一个网格, 如果 Shader 中使用了 Vertex Position, Normal 和 单个 UV,那么只有在顶点数不超过 300 个时,该网格才能参与动态合批。

一个网格,如果 Shader 中 使用了 Vertex Position, Normal, UV0, UV1和Tangent,那么只有在顶点数不超过 180 个时,该网格才能参与动态合批。

还有一些情况会导致无法进行动态合批,如 Shader 有多个Pass、材质不同的物体无法合批在一起等等,github.com/Unity-Technologies/BatchBreakingCause 这篇官方文档介绍了所有导致合批失败的原因。通过 Unity的 FrameDebuger 我们也可以知道一些合批失败的原因:

对于原本能合批却合批失败的物体,FrameDebuger会给出原因

动态合批通常的应用场景有两个:粒子系统 和 UI。 粒子系统没什么好说的,要注意的是 UI 的动态合批:

1、不同 Canvas 下的 UI 无法动态合批

2、不同层级下的UI无法动态合批(这里的层级可以理解为 一个 UI 其下面垫了几层的UI,这一块可以根据 FrameDebuger 的合批结果来进行调整)

3、alpha = 0,depth = -1 的 UI 无法进行合批

4、depth = x 的 UI,只能与 depth ≤ x 的 UI 进行合批

5、动态合批本身也是有代价的。 当调用 Canvas.BuildBatch 或者,UI 元素产生变化时 就会重新进行 动态合批。 因此我们应该尽量把经常产生变化的 UI 和 不怎么产生变化的静态UI 分别放在两个 Canvas 下,这样可以降低 UI 动态合批的复杂度,加快合批运行的时间。

过多的材质。过多的材质与上面的合批基本原因和处理方法是一样的。场景中有大量的材质就会导致收集材质信息时变得复杂。那么对于能够合批的物体,合批后它们使用的就是同一个材质。 而对于材质使用的Shader和属性都一样,但贴图不一样的情况,我们可以试着将贴图进行合并.

复杂的动画。Unity是可以对动画文件进行压缩的,对于一些动画我们不需要太高的精度那么就可以进行压缩,来加快动画运算的速度和减少动画文件的体积。并且对于动画文件中存储的一些数值的精度我们可以通过代码工具来修改,从而进行动画压缩,例如对于动画文件里 1.123456789 的数值,我们可以修改为 1.1234。并且有的动画文件中,某一帧和它的前一帧和后一帧是没有变化的,那么该帧就可以删除,这个也可以用代码工具来检测和解决。

2、 CPU向GPU传递渲染所需数据 阶段的性能点

这一部分就是带宽问题了。除了压缩资源就是压缩资源。当然还有一些不在本篇范畴内的手段(流式加载、VirtualTexture 等)

贴图资源。

压缩格式:PC端压缩格式一般保持默认就好了。移动端贴图建议使用ASTC6x6进行压缩,如果觉得压缩后效果不行 可以换为 ASTC4x4。对于 HDR 贴图 可以采样 ASTC HDR 6x6 或 4x4 的压缩格式。 一些用来保存高精度数据的贴图,如果没有 Alpha 通道,可以使用 RGB9e5 32bit Shared Exponent Float 的压缩格式,有 Alpha 通道的话那就只能用 RGBAHalf 的格式了。

贴图大小:贴图大小就是尽可能的小,比如先做一个 2048x2048 的贴图,然后不断降低尺寸,直到效果上发生明显变化并且无法接受时,就得到了最适合的贴图大小。并且对于一些具有四方连续特性的细节贴图,我们可以只取其中的一小部分,放在一个尺寸很小的贴图中,然后通过 Tilling And Offset 来采样得到完整的内容。

Mipmap:对于始终只出现在很远处的物体,其贴图尺寸我们可以给很小,并且不需要生成 Mipmap。 同样对于始终只出现在很近处的物体,我们可以给个较大的贴图尺寸,并且不需要生成 Mipmap

网格资源

建模时控制好网格的顶点数

网格 LOD 策略

曲面细分着色器配合高度图和区块划分来做到地形LOD

在网格资源设置中,关闭不需要导入的东西

可以尝试开启 Mesh Compression 对网格进行压缩,看得到的效果是否可以接受

非静态物体(不需要参与烘焙光照贴图的物体)取消勾选 GenerateLightmapUV

对于一些顶点数很多,同时体积很大很容易一直出现在视野范围内的物体。可以将其拆分为多个物体。这样在经过视锥体剔除后,就不需要向GPU传入太多的顶点数据。

3、CPU发起DrawCall 阶段的性能点

DrawCall 的数量,要减小 DrawCall 的数量,其实就是减少物体和材质的数量,这些在上面的 CPU计算和收集渲染所需数据组装描述符和材质阶段 部分已经介绍了,这里就不赘述了。另外对于 DrawCall 数量的影响就是 Shader 的 Pass 数,和在前向渲染的管线中点光源的数量,但这两方面属于是那种如果在不降低渲染效果的情况下那么就该是多少就是多少没办法减小的问题,如果非要减小那么就要对渲染管线进行改造,插入一些现代的优化方案,由于这些方案通常都比较复杂,不在本篇范畴类,有机会会专门做一篇进行介绍。

DrawCall 的复杂度。DrawCall 不单单只是一个指令,一个 DrawCall 中通常包含了多个指令。那么其包含的指令数的多少也就决定了其本身的复杂度。而 DrawCall 中最常见的指令就是告诉GPU材质的属性:

一个DrawCall中包含了多条指令

对于设置一个浮点数的值,需要调用 一次 glUniformf 指令(这里以 OpenGL 为例),而设置一个浮点向量的值,同样也只需要调用 一次 glUniform4fv 指令。因此我们就可以把 4个浮点数变量 合并为 1个浮点向量变量。这样 4条指令就可以合并为 1条指令。而要做到这样,我们只需要在编写Shader时,将4个浮点变量的声明改为1个浮点向量的声明。但是浮点向量在材质面板上的显示方式对于美术极不友好,调节起来很变扭也很不直观,这里可以使用 ShaderGUI 和 MaterialPropertyDrawer 类来对材质面板进行定制,以达到美术友好,这一部分我会放在后面的 骚操作篇进行细说。

4、GPU进行渲染和计算 阶段的性能点

Overdraw

对于特效引起的Overdraw,一定要在特效制作时关注 "像素填充比" 这一概念:

像素填充比,即在一个特效面片中 alpha不为0 的像素 占整个面片像素的比例。在特效制作时,要让像素填充比尽可能的大。下面给出例子:

对于上面这个特效面片,像素填充比就过小,会产生大量没有必要的 Overdraw。此时应该改变网格,减低片面网格的高度,或者将网格做成月牙形。

而对于由于渲染顺序不合理导致的 Overdraw,就只有自己根据实际情况去调整渲染队列了。例如对于一个 有河流、草、树木 和 地面的场景,我们应该 先渲染草,然后渲染树木的树叶、之后是树木、接着是地面,最后是河流。这样在渲染地面时,地面就会有大量像素因为被草和树木遮挡而没能通过深度测试,也就没有被光栅化渲染,从而减少了Overdraw。而因为河流是一个半透明物体(我们可以透过河流看到河水下面的地面),因此河流需要在地面渲染之后才渲染,这样才能和地面做半透明混合。

Shader本身的计算复杂度

LUT策略。我们可以把Shader中这类计算:其输入变量的值范围在0~1或者可以转换为0~1,其输出变量的值范围也在0~1或者可以转换为0~1,那么我们就可以把0~1范围的所有输入都看作一个贴图的UV值,而0~1的所有输出都看作一个贴图的颜色值。那么我们就可以提前离线的将所有输入值都进行计算得到输出值,并存储在一个贴图中。那么 Shader 在 实时计算时,我们只需要拿到输入变量去采样贴图即可,从而避免了复杂的运算。

合并运算。对于 GPU 来说,一个浮点数的运算 和 一个浮点向量的运算使用的指令数是相同的。因此如果几个浮点数需要做相同的运算,那么就可以先把这几个浮点数合并为几个浮点向量,然后再运算。并且,Unity在编译着色器时,会在一些地方帮我们做出这种优化。

在移动端还要注意数值变量的精度问题。在移动端GPU上,数值变量有 fixed、half、float 的区分,其数值精度分别为 8位、16位和32位,位数越高,计算过程就越慢。但在一些GPU上(如苹果的GPU 和 部分华为手机使用的GPU),如果运算结果超过了其定义的精度范围,就会出现渲染异常的情况(渲染结果出现黑色块,或渲染结果不正确等)。这里我的建议是,在声明变量时,颜色使用fixed,世界空间下的位置信息使用float,如果采样的贴图会用到很大的Tilling那么uv也是用float,否则使用 half,其它的变量都使用half。

在移动端还要注意GMEM_Load机制问题。GMEM Load 即 Graphic Memory Load 在 移动端 GPU 的 TileBase 架构下, 其触发表明上一帧的 Frame Buffer 在这一帧渲染时被从 GPU 主存加载到了正在渲染的 Tile 内存中。其会引起严重的渲染性能问题!例如在一款手机上屏幕被分为了30个Tile, 如果触发了 GMEM Load 那么在每次渲染一个 Tile 之前都会从 主存 加载 FrameBuffer,而 Frame Buffer占用内存比较大,加载时间会比较慢,并且在加载完毕后 GPU 内部还需要一系列调度才能让渲染开始进行,因此 GMEM Load 会很大程度的降低GPU运行的效率。 在 Unity 中触发 GMEM Load 的操作有:

开启 HDR。在移动端即使开启了 HDR,颜色缓冲仍然是 RGBA8 格式的,Unity 会创建出一个 RT(RGBA Half 或者 R11G11B10 格式,取决于你的 Graphic Setting),渲染结果先输出到这个 RT 上,并做了编码,之后该 RT 通过解码和Tonemapping 输出到颜色缓冲上(相当于一个后处理),但是 Unity 自己的这个后处理会触发 GMEM Load

GrabPass。因为 GrabPass 就是直接复制 Frame Buffer 的颜色缓冲。

相机的 ClearFlag 是 DepthOnly 或者 DontClear (这个在我的记忆中不是那么确定,总之好像确实有可能会导致GMEM Load)

混乱和大量的RT (这里说的混乱和大量是因为通常简单的使用 RT 是不会导致 GMEM Load 的,但某些情况下会触发,具体是哪些情况我也说不上来,因为触发时 RT 的管理真的太乱了...)。 另外对于不需要 深度/模板缓冲的RT,创建时要把它的 depth 设为 0,这样即使因为 RT 触发了 GMEM Load,由于 GMEM Load Color 和 GMEM Load Depth and Stencil 是两个分开的操作,所以这样可以避免 GMEM Load Depth and Stencil,从而做到尽量避免 GMEM Load。

 5、在一些情况下GPU会向CPU回传数据 阶段的性能点

没什么好说的,这里能引起的只有带宽问题。通常游戏开发中不会遇到这一阶段。对于这一阶段我们只需要留意使用 GPU 做计算功能时,其结果应该尽可能的是留在GPU的内存中,作为GPU后面计算或渲染的输入,如果一定要把结果传给CPU,那就要注意结果的大小,避免产生带宽问题。

你可能感兴趣的:(【转】【Unity】渲染性能优化---经验总结)