前面讲过一些关于Unity3D的动态合批(Dynamic batching)与静态合批(Static batching)的功能,GPU Instancing 实际上与他们一样都是为了减少Drawcall而存在。那么有了动态合批和静态合批为什么还需要 GPU Instancing 呢,究竟他们之间有什么区别呢,我们不妨来简单回顾一下Unity3D动态合批(Dynamic batching)与静态合批(Static batching)。

开启动态合批(Dynamic batching)时,Unity3D引擎会检测视野范围内的非动画模型(通过遍历所有渲染模型,计算包围盒在视锥体中的位置,如果完全不在视锥体中则抛弃),筛选符合条件的模型进行合批操作,将他们的网格合并后与材质球一并传给GPU去绘制。
动态合批需要符合什么条件呢:

        1,900个顶点以下的模型。

        2,如果我们使用了顶点坐标,法线,UV,那么就只能最多300个顶点。

        3,如果我们使用了UV0,UV1,和切线,又更少了,只能最多150个顶点。

        4,如果两个模型缩放大小不同,不能被合批的,即模型之间的缩放必须一致。

        5,合并网格的材质球的实例必须相同。即材质球属性不能被区分对待,材质球对象实例必须是同一个。

        6,如果他们有lightmap的数据,必须是相同的才有机会合批。

        7,多个pass的Shader是绝对不会被合批。

        8,延迟渲染是无法被合批。
动态合批的条件比较苛刻,很多模型都无法达到合并的条件。为什么它要使用这么苛刻的条件呢,我们来了解下设计动态合批这个功能的意图。

动态合批(Dynamic batching)这个功能的目标是以最小的代价合并小型网格模型,减少Drawcall。
很多人会想既然合并了为什么不把所有的模型都合并呢,这样不是更减少Drawcall的开销。其实如果把各种情况的大小的网格都合并进来,就会消耗巨大的CPU算力,而且它不只一帧中的计算量,而是在摄像机移动过程中每帧都会进行合并网格的消耗算力,这使得CPU算力消耗太大,相比减少的Drawcall数量,得不偿失。因此Unity3D才对这种极其消耗CPU算力的功能做了如此多的的限制,就是为了让它在运作时性价比更高。

与动态合批不同,静态合批(Static batching)并不实时合并网格,而是在离线状态下生成合并的网格,并将它以文件形式存储合并后的数据,这样在当场景被加载时,这些合并的网格数据也一同被加载进内存中,当渲染时提交给GPU。因此场景中所有被标记为静态物体的模型,只要拥有相同实例的材质球都会被一并合并成网格。

静态合批能降低不少Drawcall,但也存在不少弊端。被合批的模型必须是静态的物体,它们是不能被移动旋转和缩放的,也只有这样我们在离线状态下生成的网格才是有效的(离线的网格数据不需要重新计算),因为合并后的网格,内部是不能动的,它也必须与原模型吻合,因此静态是必须的条件。其生成的离线数据被放在Vertex buffer和Index buffer中。

静态合批生成的离线网格将导致存放在内存的网格数据量剧增,因为在静态合批中每个模型都会独立生成一份网格数据,无论他们所使用的网格是否相同,也就是说场景中有多少个静态模型就有多少个网格,与原本只需要一个网格就能渲染所有相同模型的情况不一样了。
其好处是静态合批后同一材质球实例(材质球实例必须相同,因为材质球的参数要一致)调用Drawcall的数量合并了,合批也不会额外消耗实时运行中的CPU算力,因为它们在离线时就生成的合批数据(也就是网格数据),在实时渲染时如果该模型在视锥体范围内,三角形索引将被部分提取出来简单的合并后提交,而那些早就被生成的网格将被整体提交,当整体网格过大时则会导致CPU和GPU的带宽消耗过大,整个数据必须从系统内存拷贝到GPU显存或缓存,最后由GPU处理渲染。

简而言之,动态合批为了平衡CPU消耗和GPU性能优化,将实时合批条件限制在比较狭窄的范围内。静态合批则牺牲了大量的内存和带宽,以使得合批工作能够快速有效的进行。
GPU Instancing 没有动态合批那样对网格数量的限制,也没有静态网格那样需要这么大的内存,它很好的弥补了这两者的缺陷,但也有存在着一些限制,我们下面来逐一阐述。

与动态和静态合批不同的是,GPU Instancing 并不通过对网格的合并操作来减少Drawcall,GPU Instancing 的处理过程是只提交一个模型网格让GPU绘制很多个地方,这些不同地方绘制的网格可以对缩放大小,旋转角度和坐标有不一样的操作,材质球虽然相同但材质球属性可以各自有各自的区别。

从图形调用接口上来说 GPU Instancing 调用的是 OpenGL 和 DirectX 里的多实例渲染接口。我们拿 OpenGL 来说:

void glDrawArraysInstanced(GLenum mode, GLint first, GLsizei count, Glsizei primCount);
 void glDrawElementsInstanced(GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei primCount);
 void glDrawElementsInstancedBaseVertex(GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instanceCount, GLuint baseVertex);

这三个接口正是 GPU Instancing 调用OpenGL多实例渲染的接口,第一个是无索引的顶点网格集多实例渲染,第二个是索引网格的多实例渲染,第三个是索引基于偏移的网格多实例渲染。这三个接口都会向GPU传入渲染数据并开启渲染,与平时渲染多次要多次执行整个渲染管线不同的是,这三个接口会分别将模型渲染多次,并且是在同一个渲染管线中。

如果只是一个坐标上渲染多次模型是没有意义的,我们需要将一个模型渲染到不同的多个地方,并且需要有不同的缩放大小和旋转角度,以及不同的材质球参数,这才是我们真正需要的。GPU Instancing 正为我们提供这个功能,上面三个渲染接口告知Shader着色器开启一个叫 InstancingID 的变量,这个变量可以确定当前着色计算的是第几个实例。

有了这个 InstancingID 就能使得我们在多实例渲染中,辨识当前渲染的模型到底使用哪个属性参数。Shader的顶点着色器和片元着色器可以通过这个变量来获取模型矩阵、颜色等不同变化的参数。我们来看看在Unity3D的Shader中我们应该做些什么:

Shader "SimplestInstancedShader"
 {
     Properties
     {
         _Color ("Color", Color) = (1, 1, 1, 1)
     }

     SubShader
     {
         Tags { "RenderType"="Opaque" }
         LOD 100

         Pass
         {
             CGPROGRAM
             #pragma vertex vert
             #pragma fragment frag
             #pragma multi_compile_instancing // 开启多实例的变量编译
             #include "UnityCG.cginc"

             struct appdata
             {
                 float4 vertex : POSITION;
                 UNITY_VERTEX_INPUT_INSTANCE_ID //顶点着色器的 InstancingID定义
             };

             struct v2f
             {
                 float4 vertex : SV_POSITION;
                 UNITY_VERTEX_INPUT_INSTANCE_ID //片元着色器的 InstancingID定义
             };

             UNITY_INSTANCING_BUFFER_START(Props) // 定义多实例变量数组
                 UNITY_DEFINE_INSTANCED_PROP(float4, _Color)
             UNITY_INSTANCING_BUFFER_END(Props)
            
             v2f vert(appdata v)
             {
                 v2f o;

                 UNITY_SETUP_INSTANCE_ID(v); //装配 InstancingID
                 UNITY_TRANSFER_INSTANCE_ID(v, o); //输入到结构中传给片元着色器

                 o.vertex = UnityObjectToClipPos(v.vertex);
                 return o;
             }
            
             fixed4 frag(v2f i) : SV_Target
             {
                 UNITY_SETUP_INSTANCE_ID(i); //装配 InstancingID
                 return UNITY_ACCESS_INSTANCED_PROP(Props, _Color); //提取多实例中的当前实例的Color属性变量值
             }
             ENDCG
         }
     }
 }

上述的Shader是一个很常见的GPU Instancing 写法,使用 Instancing 在Shader作为选取参数的依据。Shader中_Color 和 unity_ObjectToWorld (模型矩阵)是多实例化的,它们通过 InstancingID 作为索引来确定取数组中的变量。

InstancingID被包含在了宏定义中我们无法看到,我们来看看上述Shader中包含有 INSTANCE 字样的宏定义是怎样的,以此来剖析GPU Instancing是怎样用InstancingID来区分不同实例的变量的。

首先编译命令 multi_compile_instancing 会告知着色器我们将会使用多实例变量。

其次在顶点着色器和片元着色的输入输出结构中,加入 UNITY_VERTEX_INPUT_INSTANCE_ID 告知结构中多一个变量即:

        uint instanceID : SV_InstanceID;
这么看来我们就知道了每个顶点和片元数据结构中都定义了 instanceID 这个变量,这个变量将被用于确定使用多实例数据数组中的索引,它很关键。