Batch 和 Draw Call 的区别
一次 draw call 意味着调用一次图形 API(绘制物体,例如绘制一个三角形);一个 batch 是一组 draw call,即多个 draw call 打包交给图形 API(节省性能开销)。
何为 “Draw Call”:
“一个 Draw Call,等于呼叫一次 DrawIndexedPrimitive (DX) or glDrawElements (OGL),等于一个 Batch”
摸过 DirectX 或 OpenGL 的人来说,对 DrawIndexedPrimitive 与 glDrawElements 这 API 一定不陌生。当我们准备好资料 (通常为三角面的顶点资讯) 要 GPU 划出来时,一定得呼叫这个函式。换句话说,如果在画面上有一张 “木 “ 椅子、一张 “铁” 桌子,那理论上就会有两个 Draw Call。
每次对 Shader 的更动或者贴图的更动,基本上就是对 Rendering Pipeline 的设定做修改,所以需要不同的 Draw Call 来完成物件的绘制。现在了解为什么 Unity 官方文件里,老是要你尽量使用同样材质球,以减少 Draw Call 数量了吧!
再来谈到 Batch,其实也是 Draw Call 的另一种称呼。你可以想成每一次的 Draw Call 会产生一个 Batch,而 Batch 里装的是物件顶点资料,Batch 由 CPU 透过 “驱动程式” 将顶点资料送往 GPU,GPU 接手后将物件画在画面上。由此可知,越多 Draw Call,CPU 就越忙碌。这下更清楚知道 Draw Call 数量所影响的是 CPU 效能而非 GPU。
那既然 Batch 是个箱子,里头装着物件的顶点资料,再依据我们上面的描述,那表示同样材质或 Shader 的物件,可以合并成一个 Batch 送往 GPU,这样就是最省事的方法!
批处理就是把渲染时使用相同材质(Shader)、相同贴图的 3D 模型的网格合并在一起,成为一个大网格,然后再调用一次 Draw Call,直接渲染这一个大网格。
静态批处理
静态批处理的原理
静态合批最重要的一件事就是合并网格。
在运行前 或是 发布前,场景中 相同材质,并且标记为 Batching Static(自动静态合批)或者通过代码调用合并函数(StaticBatchingUtility.Combine 手动静态合批)的物体,引擎会把网格信息取出来,之后对网格上的顶点进行空间变换,变换到同一坐标系下,合并成一个新的大网格。
形成新的大 Vertex Buffer Object(VBO 顶点缓冲对象)和 Index Buffer Object(IBO 索引缓冲对象)(这两个缓冲区一旦确定了就不会再被修改了),最终会在一个批次中提交这些顶点信息。(如果之后需要对合并的网格进行空间变换,由于已经在同一坐标系了,所以直接对合并的节点进行矩阵变换即可)。
静态批处理到底会不会减少 DC
这个缓存会记录着每一个 渲染对象的 IBO 的范围,然后在遍历每个渲染对象前,先设置他们同一个渲染状态(也就是材质信息要一致的原因),然后再逐个遍历渲染对象的 IBO,再调用类似 glDrawElement 的 API 来绘制即可,绘制前,要判断这个 渲染对象时是否在视锥体内,如果不在,就不绘制。所以静态合批不是减少 DC,而是减少 DrawState 的设置,在 unity 就是减少 SetPassCall 的设置。
静态合批虽然合并网格,从而在一个批次中提交更多的顶点信息,虽然可以有效减少 Batch 次数和渲染状态 SetPassCall 的改变次数。但是这并不意味着这一个批次中只有一个 DC。
举个例子,在一个合并过的网格中,因为场景中物体的可见性,需要大网格中的某一部分发生剔除或是被隐藏时,其对应 Vertex Buffer 和 Index Buffer 并不会被修改,引擎会选择将整个大网格拆分成若干个小部分来进行分次渲染,每次小渲染都是一个 DC,通过调整每个 DC 来跳过不显示的内容,由于 这些子物体共享 Material,所以渲染状态/绘制命令并没有切换,调用 DC 时会缓存绘制命令到 Command Buffer,还是起到了优化的目的。
所以 静态合批并不减少 Draw call 的数量 (但是在编辑器时由于计算方法区别 Draw call 数量是会显示减少了的),但是由于我们预先把所有的子模型的顶点变换到了世界空间下,并且这些子模型共享材质,所以在 多次 Draw call 调用之间并没有渲染状态的切换,渲染 API 会缓存绘制命令,起到了渲染优化的目的。另外,在运行时所有的顶点位置处理不再需要进行计算,节约了计算资源。
Batch ≠ DrawCall
一次静态合批,并不表示一定只有一次 DrawCall 命令的调用。
合并发生后,每个参与合批的网格信息(顶点、索引等)就会被最终确定,不再被修改。当一个参与合并的单位不显示时,如被设置为隐藏或被视椎体剔除,引擎并不会修改顶点缓冲区和索引缓冲区的内容,而会拆分若干个小的 DrawCall 来分次渲染。通过调整每个 DrawCall 的索引(起始索引、索引个数)来跳过不应该被显示的单位。
通过两次 DrawCall 来跳过隐藏的三角形
由于,这些 DrallCall 之间几乎没有渲染状态的切换,效率较高,所以引擎也将其统计为一次合批(尽管包含若干个 DrawCall)。
与直接使用大网格的不同
静态合批与直接使用大网格(是指直接制作而成,非静态合并产生的网格)的不同,主要体现在两方面。
其一,静态合批可以主动隐藏部分对象。静态合批在运行时,由于每个参与合并的对象可以通过起始索引等彼此区分,因此可以通过上述多次 DrawCall 的策略,实现隐藏指定的对象;而直接使用大网格,则无法做到这一点。
其二,静态合批可以有效参与 CPU 的视锥剔除。当有剔除发生时,被送进渲染管线的顶点数量就会减少(通过参数控制),也就意味着被顶点着色器处理的顶点会减少,提升了 GPU 的效率;而使用大网格渲染时,由于整个网格都会被送进渲染管线,因此每一个顶点都需要被顶点着色器处理,如果摄像机只能照到一点点,那么绝大多数参与计算的顶点最后都会被裁减掉,有一些浪费。
运行时静态合批方法
Unity 还提供了一种灵活度很高的运行时静态合批方法。我们可以在运行时调用 staticBatchingUtility.Combine 实现将一些模型合并成一个完整模型。
这个函数的实现有两个版本:
// StaticBatchingUtility.Combine prepares all children of the staticBatchRoot for static batching.
// Once combined, children cannot change their Transform properties; however, staticBatchRoot can be moved.
public static void Combine(GameObject staticBatchRoot);
// StaticBatchingUtility.Combine prepares all gos for static batching.staticBatchRoot is treated as their parent.
// Once combined, gos cannot change their Transform properties; however, staticBatchRoot can be moved.
// The GameObject in gos must have MeshFilter components attached for this to work.
public static void Combine(GameObject[] gos, GameObject staticBatchRoot);
使用这种方法我们可以避免最终打包的应用体积增大,但是由于在运行时通过 CPU 做模型的合并,会到来一次性的运行时内存和 CPU 开销。
静态批处理的优缺点
静态合批采用了以空间换时间的策略来提升渲染效率。
优点:
- 网格通常在预处理阶段(打包)时合并,运行时顶点、索引信息也不会发生变化,所以无需 CPU 消耗算力维护;
- 若采用相同的材质,则以一次渲染命令,便可以同时渲染出多个本来相对独立的物体,减少了 DrawCall 的次数。
- 在渲染前,可以先进行视锥体剔除,减少了顶点着色器对不可见顶点的处理次数,提高了 GPU 的效率。
缺点:
占用更高的内存,会额外拷贝一份网格至内存。合批后的网格会常驻内存,在有些场景下可能并不适用。比如森林中的每一棵树的网格都相同,如果对它采用静态合批策略,合批后的网格基本等同于:单颗树网格 x 树的数量,这对内存的消耗可能就十分巨大了。
静态批处理的物体不能够移动,即 Transform 组件无效,刚体组件 Rigidbody 也无效。
有动画的模型,设置静态批处理无效,不会降低 Batches 的数值,而且有动画的模型, 即使设置了静态批处理, 也跟没有设置是一样的。
何时使用静态批处理
游戏场景内的“背景元素” , 比如说: 不需要改变位置, 也不需要进行刚体交互的建筑物, 路边的石头, 树木等, 这些“死” 的游戏场景元素。
这些场景元素是在做搭建游戏场景的时候, 就需要事先摆放好它们的位置, 然后设置成静态批处理。
动态合批
动态合批的原理
合批并非是在绘制前“合并网格“
动态合批不会在绘制前创建新的网格,它只是将可以参与合批单位的顶点属性,连续填充到一块顶点和索引缓冲区中,让 GPU 认为它们是一个整体。
在 Unity 中,引擎已自动为每种可以动态合批的渲染器分配了其类型公用的顶点和索引缓冲区,所以动态合批不会频繁的创建顶点和索引缓冲区。
合批前会先处理每个顶点的顶点属性
在向顶点和索引缓冲区内填充数据前,引擎会处理被合批网格的每个顶点信息,将其空间变换到 世界坐标系 下。
这是因为这些对象可能都不属于相同的父节点,因此无法对其进行 统一 的空间转换(本地到世界),需要在送进渲染管线前将每个顶点的坐标转换为世界坐标系下的坐标(所以 Unity 中,合并后对象的顶点着色器内被传入的 M 矩阵,都是单位矩阵)。
Unity 动态合批的条件
相对于上述看起来有点厉害但是本质上无用的知识而言,了解动态合批规则其实更为重要。比如:
- 材质球相同;
- Mesh 顶点数量不能超过 300 以及顶点属性不能超过 900;(目前 Unity 限制能进行 Dynamic batching 的模型最高能有 900 个顶点属性。这里注意不是 900 个顶点,而是 900 个定点属性。如果我们在 Shader 中使用了 Vertex Position, Normal and single UV,那么能够进行 Dynamic batching 的模型最多只能够有 300 个顶点。如果我们在 Shader 中使用了 Vertex Position、Normal、UV0、UV1 and Tangent 那么顶点的数量就减少到 180 个。这个顶点属性数量的要求是 Unity 自己设计的,未来随着硬件性能的提升,这个值也会调整。)
- 缩放不能为负值(x、y、z 向量的乘积不能为负)等;
- 如果两个模型 缩放大小 不同,不能被合批的,即模型之间的缩放必须一致。
- 如果他们有 Lightmap 数据,必须相同 的才有机会合批。
- 使用 Multi-pass Shader 的物体会禁用 Dynamic batching。因为 Multi-pass Shader 通常会导致一个物体要连续绘制多次,并切换渲染状态。这会打破其跟其他物体进行 Dynamic batching 的机会。
- 延迟渲染是无法被合批。
但我个人认为你不需要记住每一个条件,除了上述相对重要些的条件外,其余的可以通过 FrameDebugger 中提示的合批失败原因,来反向了解合批条件。
Dynamic batching 在降低 Draw call 的同时会导致额外的 CPU 性能消耗,所以仅仅在合批操作的性能消耗小于不合批,Dynamic batching 才会有意义。而新一代图形 API( Metal、Vulkan)在批次间的消耗降低了很多,所以在这种情况下使用 Dynamic batching 很可能不能获得性能提升。Dynamic batching 相对于 Static batching 不需要预先复制模型顶点,所以在内存占用和发布的程序体积方面要优于 Static batching。但是 Dynamic batching 会带来一些运行时 CPU 性能消耗,Static batching 在这一点要比 Dynamic batching 更加高效。所以我们在实践中可以根据具体的场景灵活地平衡两种合批技术的使用。
静态合批与动态合批的差别
动态合批与静态合批最大的差别在于:
- 动态合批不会创建常驻内存的“合并后网格”,也就是说它不会在运行时造成内存的显著增长,也不会影响打包时的包体大小;
- 动态合批在绘制前会先将顶点转换到世界坐标系下,然后再填充进顶点、索引缓冲区;静态合批后子网格不接受任何变换操作,仅手动合批后的 Root 节点可被操作,因此静态合批的顶点、索引缓冲区中的信息不会被修改(Root 的变换信息则会通过 Constant Buffer 传入);
- 因为 2 的原因,动态合批的主要开销在于 遍历顶点进行空间变换 时的对 CPU 性能的开销;静态合批没有这个操作,所以也没有这个开销;
- 动态合批使用根据渲染器类型分配的 公共缓冲区,而静态合批使用自己专用的缓冲区。
实例化渲染
简述工作原理
实例化渲染,是通过调用“特殊”的渲染接口,由 GPU 完成的“批处理”。
它与传统的渲染方式相比,最大的差别在于:调用渲染命令时需要告知 GPU 这次渲染的 次数(绘制 N 个)。当 GPU 接到这个命令时,就会连续绘制 N 个物体到我们的屏幕上,其效率远高于连续调用 N 次传统渲染命令的和(一次绘制一个)。
举个例子,假设希望在屏幕上绘制出两个颜色、位置均不同的箱子。如果使用传统的渲染,则需要调用 两次 渲染命令(DrawCall = 2),分别为:画一个红箱子 和 画一个绿箱子。
如果使用实例化渲染,则只需要调用 一次 渲染命令(DrawCall = 1),并且附带一个参数 2(表示绘制两个)即可。
当然,如果只是这样,那 GPU 就会把两个箱子画在相同的位置上。所以我们还需要告诉 GPU 两个箱子各自的 位置(其实是转换矩阵)以及 颜色。
这个位置和颜色我们会按照数组的方式传递给 GPU,大概这个样子吧:
那接下来 GPU 在进行渲染时,就会在渲染每一个箱子的时候,根据当前箱子的索引(第几个),拿到正确的属性(位置、颜色)来进行绘制了。
Unity 中启用实例化渲染
在 Unity 中可以通过自动或手动的方式,启用实例化渲染。
并非所有设备都可以使用实例化渲染。在 Unity 官方文档中,列举了各平台支持实例化渲染的最低要求。也可以通过引擎中 SystemInfo.supportsInstancing 属性来判断环境是否支持实例化渲染。
自动启用实例化渲染
使用支持实例化渲染的 Shader,并勾选材质球上的启用开关,Unity 便会对满足条件的物体,自动开启实例化渲染。
手动实例化渲染
使用 Graphics.DrawMeshInstanced 和 Graphics.DrawMeshInstancedIndirect 来手动执行 GPU 实例化,详见官方文档中的解释。
与静、动态合批的差异
静、动态合批实质上是将可以合批的对象真正的合并成一个大物体后,再通知 GPU 进行渲染,也就是其顶点索引缓冲区中必须包含全部参与合批对象的顶点信息;因此,可以认为是 CPU 完成的批处理。
实例化渲染是对 网格信息的重复利用,无论最终要渲染出几个单位,其顶点和索引缓冲区内都只有 一份数据,可以认为是 GPU 完成的批处理。
其实这么总结也有点问题,本质上讲:动、静态合批解决的是合批问题,也就是先有大量存在的单位,再通过一些手段合并成为批次;而实例化渲染其实是个复制的事儿,是从少量复制为大量,只是利用了它“可以通过传入属性实现差异化”的特点,在某些条件下达到了与合批相同的效果。
静、动态合批与实例化渲染的优先级
Static batching 的优先级要比 GPU Instancing 的优先级高,如果一个 GameObject 被标记为 static 物体并且在 Build 阶段成功地执行了静态合批,那么如果这个物体还要使用 Instancing Shader 渲染的话,Instancing 会失效。
Dynamic batching 的优先级要低于 GPU Instancing。如果一个 GameObject 使用 Instancing 渲染的话,那么对于它的 Dynamic batching 会失效。
优先级:静态合批 > 实例化渲染 > 动态合批
简单总结静、动态合批及实例化渲染
无论是静态合批、动态合批或实例化渲染,本质上并无孰优孰劣,它们都只是提高渲染效率的解决方案,也都有自己适合的场景或擅长解决的问题。
个人以为:
- 如果你的场景中存在多数静止的、使用了不同网格、相同材质的物体,特别是当你的相机通常只能照到一部分物体时(如第一视角),可以优先尝试下静态合批,通过牺牲一些内存来提升渲染效率;
- 针对那些运动的、网格顶点数很少、材质相同的物体,比如飞行的各种箭矢、炮弹等,使用动态合批,通过增加一些 CPU 处理顶点的性能开销,来提升渲染效率,也许是不错的选择;
- 如果有大量模型相同、材质相同、或尽管表现上有一些不同,但仍然可以通过属性来实现这些差异化的物体时,启用实例化渲染通常可以在很大程度上提升渲染效率。

