Unity合批方式浅析
Draw call的优化是Unity性能优化中老生常谈的一环,而合批是CPU端优化Draw Call的主要手段。本文使用RenderDoc对Unity中常用的静态合批/动态合批/GPU Instancing三种合批手段进行实验,分析不同合批方式的原理/代价/适用场景,便于优化时选取合适的合批方案。(项目暂时没有用上SRP,SRP Batcher以后有机会再补充)
为了测试方便,文本使用Unity 2019.4.14f1 和 RenderDoc v1.14在PC上进行测试,耗时/内存等具体数据可能和真机上有较大不同,但也足够看出各合批方式间的差异。
标准测试场景
实验前我们需要搭建一个标准的测试场景用于比较合批开启前后的变化,这里使用了Unity默认的Standard着色器,在场景中一定范围内随机渲染了5000个球体,并在渲染后直接对球体的材质颜色进行修改,使其生成不同的材质实例,让每个球体的材质都不一样。
使用RenerDoc查看发现,每一次CPU发送Draw Call命令前(执行glDrawElements方法),都会有一系列的设置渲染状态、传递顶点/材质/光照信息、绑定顶点/纹理信息等操作。CPU侧的渲染相关开销并不完全来源于 Draw Call命令本身,调用Draw Call命令前的准备工作,尤其是将CPU上的顶点/材质信息传递到显存缓冲区中供GPU使用等一些涉及到CPU Write的步骤,才是CPU渲染方面开销的关键。
由于使用的都是相同的模型网格,所以只在第一次Draw Call需要指定顶点数据的读取方式。接下来我们再看看开启各种合批后,会有怎么样的变化。
Static Batching 静态合批
提前在场景中生产球体模型,在Inspector面板中为所有球体勾选Static选项,Player Setting中勾选启用静态合批,运行场景发现开启静态合批后,由于材质各不相同,每一个球体都生产了只包含自己的Combine Mesh,而Draw Call数量并没有变化,在RenerDoc中观察发现,即便所有的球体用得都是相同的Mesh,但在内存中每个球体的Combine Mesh还是会占用一份的内存,每次渲染前还需要重新指定顶点数据的读取方式,性能不升反降。
我们不再修改材质的颜色,让场景中的球体使用相同材质再进行测试,发现在使用相同材质时,Batches数由几千次下降到了几十次,而Draw Call数仍然是几千,静态合批一个Batches能绘制多个球体,从RenderDoc中查看某个静态合批Batches顶点着色器的输入,输入包含了该Batches下所有SubMesh的顶点信息以及索引数组,绘制时只有第一次调用Draw Call前进行了渲染状态的设置,后续只是不断的调用Draw Call渲染SubMesh,单纯的Draw Call调用并没有太大开销。
这里分批的原因是达到了静态合批64K顶点上限的限制,但是重复的Mesh在内存中存在多份的问题还是存在。
另外,静态合批还可以在运行时通过调用StaticBatchingUtility.Combine方法进行,主要是用于将一些相对静止的物体进行合批,比如模拟经营游戏中的建筑在摆放好了位置之后一般就在场景中固定不动了,除非再次进行编辑。
但运行时静态合批由于需要在运行过程中处理顶点数据,要求模型开启Read/Write选项,Mesh信息除了上传到GPU显存中,还会在CPU中也额外保存一份,会造成更大的内存开销。同时在调用合批方法的那一帧,会有一次较大的CPU开销。
小结
合批方式 | 原理 | 代价 | 适用场景 |
---|---|---|---|
Static Batching | 多个Mesh转换为一个Mesh下的多个SubMesh | 包体大小增加,内存大小增加(重复的Mesh) | 静止的物体,Mesh的重复率低,材质数量少的场景 |
Static Batching(运行时) | 同 Static Batching | 运行时一次较大的CPU开销,CPU上多占用一份内存 | 相对静止的场景 |
Dynamic Batching 动态合批
动态合批和静态合批的最大区别在于,动态合批可以用于时实运动的游戏物体,动态物体的位置等信息可能每帧都在变化,动态合批需要在每帧重新计算处理合批需要的Mesh等信息,所以动态合批的操作本身是会有一定CPU开销的。
仅有当动态合批节省下来的CPU耗时大于合批本身产生的开销时,动态合批才有正向效果,为了保证动态合批在大多数时候产生的影响是正向的,除了相同材质的大前提外,Unity对动态合批还做了诸多限制:
- 顶点属性信息数量不能超900
- Scaler不能镜像(Scaler设置为+1的物体不能和-1的物体合批)
- 相同材质的不同实例不能合批
- Shader不能使用多个Pass
- 使用光照贴图的话,光照贴图的offset等参数需要一致
- …(具体信息请查阅各个版本的Unity官方文档)
我们的测试场景,在Player Setting直接开启动态合批,动态合批是无法生效的,因为球体的顶点属性数量明显超过了动态合批的要求,我们把球体换成立方体再来看看效果。
仅用6个Draw Call就完成了5000个立方体的渲染,这里分批次的原因是超过了动态合批32K顶点的上限。
观察对比静态合批和动态合批Batches和DrawCall数量的不同,可以更加直观的区分两者原理上的差别,静态合批是在打包时将设置为静态的物体合并成一个Mesh下的多个SubMesh,一个Batches中会多次调用DrawCall渲染SubMesh,故Draw Call数远大于Batches数 ,而动态合批是把符合合批条件的模型的顶点信息变换到世界空间中,然后通过一次Draw call绘制多个
模型,故Batches数和Draw Call数相同。
小结
合批方式 | 原理 | 代价 | 适用场景 |
---|---|---|---|
Dynamic Batching | 把符合合批条件的模型的顶点信息变换到世界空间中,然后通过一次Draw call绘制多个模型 | 动态合批每帧会产生一定的CPU开销 | 常用于UI/粒子特效的合批 |
GPU Instancing
GPU Instancing用于相同Mesh物体的大量渲染,弥补了静态合批下重复材质会大量增加内存的缺陷, 同时也没有动态合批那么多的规则限制。高版本Unity的Standard Shader是支持GPU Instancing的,在Render Doc中我们可以看到GPU Instancing与其他合批方式最大的不同是GPU Instancing在最后发出Draw Call命令的时候用的是glDrawElementsInstanced接口。
GPU Instancing 还有一个强大的功能是不同的材质属性不会打断合批,我们就可以在一次提交Mesh后,绘制多个Transform/Color属性不同的物体,GPU Instancing默认支持不同的Transform,其他属性需要在Shader中添加相应声明。
UNITY_INSTANCING_BUFFER_START(Props)
UNITY_DEFINE_INSTANCED_PROP(fixed4, _Color)
UNITY_INSTANCING_BUFFER_END(Props)
小结
合批方式 | 原理 | 代价 | 适用场景 |
---|---|---|---|
GPU Instancing | 提交一次Mesh在多个地方绘制,要求材质球相同但材质的属性可以不同 | Shader需要支持、要求相对较高图形API版本(Android OpenGL ES 3.0+ / IOS Metal) | 大量相同网格的物体渲染、GPU Skinning |
最佳实践
最后汇总一下三种合批方式的特点
合批方式 | 原理 | 代价 | 适用场景 |
---|---|---|---|
Static Batching | 多个Mesh转换为一个Mesh下的多个SubMesh | 包体大小增加,内存大小增加(重复的Mesh) | 静止的物体,Mesh的重复率低,材质数量少的场景 |
Static Batching(运行时) | 同 Static Batching | 运行时一次较大的CPU开销,CPU上多占用一份内存 | 相对静止的场景 |
Dynamic Batching | 把符合合批条件的模型的顶点信息变换到世界空间中,然后通过一次Draw call绘制多个模型 | 动态合批每帧会产生一定的CPU开销 | 常用于UI/粒子特效的合批 |
GPU Instancing | 提交一次Mesh在多个地方绘制,要求材质球相同但材质的属性可以不同 | Shader需要支持、要求相对较高图形API版本(Android OpenGL ES 3.0+ / IOS Metal) | 大量相同网格的物体渲染、GPU Skinning |
合批方式的选择这里推荐国外一位专注性能优化的大佬总结的流程图