创建时间
Mar 27, 2023 06:27 PM
标签
Tags
技术详解
性能优化
Origin
zhuanlan.zhihu.com
2021.7.15 更新,看到鬼泣的分享,发现之前对 SRP batcher 的理解不够深入,学到了新东西,来更新一下。其实没有源码,想了解的深一点还挺难的,看来下次的工作目标应该是去个买了源码的公司~。主要参考了这里王江荣:【Unity】SRP 底层渲染流程及原理再加上之前的理解。
unity 的优化中,一个很重要的优化就是批处理,但是又没有太详细的解释,只给了开关,以及文档几句简单的说明,然而使用时,却发现并不是简单的开启了选项就能得到很好的效果,所以查找了一些资料,做个总结。

为什么要用批处理

首先 CPU 和 GPU 交互,靠的是一个命令缓冲区。命令缓冲区包含了一个命令队列,由 CPU 向其中添加指令,而由 GPU 从中读取指令。那很明显可以知道,添加和读取指令都是需要时间的,如果添加和读取的速度不同,必然出现一方在等待的情况,也就是说高效交互的目标,就是双方处理速度相同,并达到一个比较大的值。
命令包含两种,一是设置渲染状态,也就是通知 GPU 取读取一些数据,另一个就是渲染。
GPU 渲染能力很强,一个批次数据量多些 GPU 依然可以正常处理,但是 CPU 每次提交都有一定性能消耗,所以一次提交更多的数据,可以充分利用 GPU 的处理能力。
对显卡来说,渲染一个物体需要两个指令,首先是设置渲染状态,就是要渲染的贴图等数据,对应 unity 的 setpass,然后是 drawcall,发送指令。设置渲染状态要比处理 drawcall 慢的多,所以一个优化的方向是减少渲染状态的改变。
批次还有个要注意的地方是带宽的限制,比如手机上延迟渲染支持的不好的原因就是延迟渲染需要的各个缓冲区占用带宽太多。带宽能做的优化基本上是资源上的,不在本文谈论范围之内。

unity 对批次的处理方式

分为两部分,setpass 和 batch。在 profiler 查看的就是这两个值。一般来说,setpass 和 batch 如果很接近,那就有比较大的优化空间了。
SetPassCall
  • 如果一个 batch 和另一个 batch 使用的不是同种材质或者同一个材质的不同 pass,那么就要触发一次 set pass call 来重新设定渲染状态。
  • 复用图片以及用图集可减少。
batch
  • 作用就是把要渲染的数据提交给 GPU。
  • 提交 vbo,提交 ibo,提交 shader,设置好硬件渲染状态,设置光源等
drawcall
  • 实际 unity 用 batch 封装了一次 drawcall,之前看资料说 unity 可以把多个 drawcall 合并成一个 batch 渲染,还疑惑了一下,unity 是怎么做到的,后来想想,batch 就是各种合批方法执行后的结果,最终还是调用图形接口的 drawcall 方法渲染的。
除了 setpass 和 batch,另一个重要的概念是 buffer,GPU instancing 和 SRP Batcher 都是针对 buffer 提升的性能。

SRP batcher

只有在 SRP 管线支持的一种批处理方法,针对的是 setpass 的消耗,而不减少 drawcall。
这个的优先级没查到明确的说明,在工程中,开启静态和动态批处理后,能看到 SRP batcher,而静态的看不到了,看这效果应该是最高优先级。
最核心的部分是把 batch 里面每个 drawcall 里小的 CBUFFER 组织成一个大的 CBUFFER,然后统一上传到 GPU。组合的前提是 drawcall 使用的 shader 相同,不支持不同变体。
这样做的好处是一个设置渲染状态的指令,发送了更多数据,而且这些数据会在 GPU 有缓存,不改变时可以复用,不用每帧都发。
unity 底层降低了 setpass 的要求,只要 shader 的 feature 一样,贴图不同也不影响。
SRP batcher 后的 drawcall,就变成了先判断 cbuffer 有没有改变,如果有改变,重新填充数据,然后调用多次 drawcall,依次渲染使用这个 shader 的各种物体。
要注意的是,CBUFFER 保存的只是数据,因为在 API 底层,比如 vulkan,支持的资源是缓冲区和贴图两种,unity 也不能把贴图放到 CBUFFER 里。
内部实现方式是 PerObjectBuffer
当一个 shader 确定的时候,这个 shader 使用了哪些 feature 就已经确定了,没有使用的 feature 不会填充 PerObjectBuffer,也就是 shader 的 feature 越少,一次能合批的数量就会越多。
填充完 PerObjectBuffer 后,就会把他们组成一个大的 CBUFFER(PerObjectLargeBuffer),然后统一传到 GPU 做渲染。
提速效果源于二个方面:一是各种属性值都会一直保留在 GPU 内存中,省去了上传和读取的消耗。二是专用代码会管理大型 “per object” GPU CBUFFER,不过怎样管理的并没有详细说明。
在 Frame Debug 可以看到一个 batcher 处理了多少 drawcall,数量越多越高效。
就像缓存都会遇到的那个问题一样,内存是有限的,如果大量使用,也可能显存不足,所以还是要有些选择,比如大量相似的小物体还是可以用 GPU instance。
关于打断
notion image
条件大多是关于 shader 的,shader 变体会导致重新设置渲染状态,所以官方有个建议是少用 shader 变体,而在 CBuffer 增加更多属性,增加属性并没有多少性能消耗。
还有一个要注意的是 MaterialBufferOverride,应该是超过了 GPU 可读取的贴图上限,这是硬件限制的。一般一个 shader 也不用采样太多贴图,消耗也比较大,可以合并一些通道。

静态批处理

这个使用起来很简单,完全交给美术做场景时选择,效果也很好,就是内存消耗比较大,还有个缺点是加载场景会变慢,因为在合 mesh。
原理是将静态物体集合成一个大的 vbo 提交(不考虑 material 是否相同),但只对要渲染的物体提交其 ibo。代价是顶点数据结构按最复杂的存,组合这个 vbo 对 CPU 和显存有额外开销。
针每个物体可以被单独剔除,通过设置 ibo,也就是实际渲染时只会渲染可见物体,但是 vbo 是一直在内存中的,这也有个好处,就是打断 drawcall 的物体,也不需要上传 vbo,少了上传消耗。

GPU Instancing

优先级比静态低,比动态高。对于一些大型多人的游戏,或是 slg 这种,还是种很实用的技术。
原理理解
  • 针对的是 mesh
  • 主要想法是 GPU 在一次遍历的时候会被告知多次来渲染相同的网格,所以网格只上传一份,每个物体引用这个网格,设置自己的矩阵,不限于小网格,不能合并不同的网格。
  • 只提交一个 mesh,但是将多个使用同种 mesh 和 material 的物体的差异化信息组合成一个 pio(per instanced attribute)提交。
  • 差异化信息保存位置、缩放、旋转、shader 上的参数,不包括纹理
  • 在 GPU,通过读取每个物体的 pio 数据,对同一个 mesh 进行各种变换后绘制
主要限制是缓冲区,缓冲区大小决定了一个渲染批次中可以容纳多少个实例。

动态批处理

比静态批处理省内存,同时有一定的计算消耗。同时限制比较多,具体可以查看 FrameDebug,针对问题进行优化。
原理是将物体动态组装成一个个稍大的 vbo+ibo 提交,mesh 不必相同,material 必须相同。

简单总结一下

SRP batcher 可能是最高效的,但是要占显存。静态批处理同样要占显存,不过不知道内存中合并后的 mesh 和原 mesh 会不会在什么时机释放。GPU instancing 技术是针对网格处理的,结合 GPU skin 可以实现大量模型的高效渲染。动态批处理比较方便,但是限制也很多。
实际项目往往会根据需要综合使用,而不是只用一个,每个技术都有适合的地方。
最后还有个疑问,profiler 里能看到 drawcall 和 batch 值,并且还不完全相同,unity 应该都合成 batch 渲染的,这里的 drawcall 是啥意思,谁给答个疑~~ > 本文由简悦 SimpRead 转码