《Unity3D高级编程之进阶主程》第七章,渲染管线与图形学(三) - 渲染原理与知识3

GPU Instancing 的来龙去脉

GPU Instancing 初次听到这个名词时还有点疑惑,其实翻译过来应该是GPU多实例化渲染,它本身是GPU的一个功能接口,Unity3D将它变得更简单实用。

前面讲过一些关于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 这个变量,这个变量将被用于确定使用多实例数据数组中的索引,它很关键。

接着Shader中把需要用到的多实例变量参数定义起来:

		UNITY_INSTANCING_BUFFER_START(Props)
            UNITY_DEFINE_INSTANCED_PROP(float4, _Color)
        UNITY_INSTANCING_BUFFER_END(Props)

上述中的宏很容易从字面看出它们为”开始多实例宏定义”,”对多实例宏属性定义参数”,以及”结束多实例宏定义”。这三个宏定义我们可以在 UnityInstancing.cginc 中看到,即如下:

		#define UNITY_INSTANCING_BUFFER_START(buf)      UNITY_INSTANCING_CBUFFER_SCOPE_BEGIN(UnityInstancing_##buf)  struct {
        #define UNITY_INSTANCING_BUFFER_END(arr)        } arr##Array[UNITY_INSTANCED_ARRAY_SIZE]; UNITY_INSTANCING_CBUFFER_SCOPE_END
        #define UNITY_DEFINE_INSTANCED_PROP(type, var)  type var;

我们可以从上述的宏定义代码中理解到,这三个宏组合起来可以对GPU Instancing的属性数组进行定义。

        UNITY_INSTANCING_BUFFER_START(Props)
            UNITY_DEFINE_INSTANCED_PROP(float4, _Color)
        UNITY_INSTANCING_BUFFER_END(Props)

就等于

        UNITY_INSTANCING_CBUFFER_SCOPE_BEGIN(UnityInstancing_Props) struct {
            float4 _Color;
        } arrPropsArray[UNITY_INSTANCED_ARRAY_SIZE]; UNITY_INSTANCING_CBUFFER_SCOPE_END

我们看到三个宏加起来组成了一个颜色结构的数组,我们从Unity3D接口传进去的参数就会装入这样的结构中被顶点和片元着色器使用。

在顶点着色器与片元着色中,我们对 InstancingID 进行装配,它们分别为:

	UNITY_SETUP_INSTANCE_ID(v) 和 UNITY_SETUP_INSTANCE_ID(i);

装配过程其实就是从基数偏移的过程 unity_InstanceID = inputInstanceID + unity_BaseInstanceID。最终我们通过 UNITY_SETUP_INSTANCE_ID 装配得到了 unity_InstanceID 即当前渲染的多实例索引ID。

有了多实例的索引ID,我们就可以通过这个变量获取对应的当前实例的属性值,于是就有了以下的宏定义 UNITY_ACCESS_INSTANCED_PROP,通过这个宏定义我们能提取多实例中的我们需要的变量。

#define UNITY_ACCESS_INSTANCED_PROP(arr, var)   arr##Array[unity_InstanceID].var

UNITY_ACCESS_INSTANCED_PROP(Props, _Color); //提取多实例中的当前实例的Color属性变量值

等于

arrPropsArray[unity_InstanceID]._Color
有了类似_Color的多实例属性操作,在模型矩阵变化中也需要具备同样的操作,我们没看到模型矩阵多实例是因为Unity在Shader编写时用宏定义把它们隐藏起来了,它就是 UnityObjectToClipPos。

UnityObjectToClipPos 其实是一个宏定义,当多实例渲染开启时,它被定义成了如下:

#define unity_ObjectToWorld     UNITY_ACCESS_INSTANCED_PROP(unity_Builtins0, unity_ObjectToWorldArray)

inline float4 UnityObjectToClipPosInstanced(in float3 pos)
{
    return mul(UNITY_MATRIX_VP, mul(unity_ObjectToWorld, float4(pos, 1.0)));
}
inline float4 UnityObjectToClipPosInstanced(float4 pos)
{
    return UnityObjectToClipPosInstanced(pos.xyz);
}
#define UnityObjectToClipPos UnityObjectToClipPosInstanced

这个定义也同样可以在 UnityInstancing.cginc 中找到,其中unity_ObjectToWorld是关键,它从多实例数组中取出了当前实例的模型矩阵,再与坐标相乘后计算投影空间的坐标。也就是说当开启 Instancing 多实例渲染时,UnityObjectToClipPos 会从多实例数据数组中取模型矩阵来做模型到投影空间的转换。而当不开启 Instancing 时,UnityObjectToClipPos 则只是用当前独有的模型矩阵来计算顶点坐标投影空间的位置。

到此我们就从着色器中获取了多实例的属性变量,根据不同实例的不同索引获取不同属性变量包括模型矩阵,从而渲染到不同的位置,以及不同的旋转角度和不同的缩放大小,更多的比如颜色、反射系数等属性都可以通过传值的方式传入Shader中,用GPU Instancing的方式渲染。

知道了 GPU Instancing 是如何渲染任然还不够,最好我们还知道数据是怎么传进去的。我们拿 OpenGL 接口代码来分析下:

//获取各属性的索引
int position_loc = glGetAttribLocation(prog, "position");
int normal_loc = glGetAttribLocation(prog, "normal");
int color_loc = glGetAttribLocation(prog, "color");
int matrix_loc = glGetAttribLocation(prog, "model_matrix");

//按正常流程配置顶点和法线
glBindBuffer(GL_ARRAY_BUFFER, position_buffer); //绑定顶点数组
glVertexAttribPointer(position_loc, 4, GL_FLOAT, GL_FALSE, 0, NULL); //定义顶点数据规范
glEnableVertexAttribArray(position_loc); //按上述规范,将坐标数组应用到顶点属性中去
glBindBuffer(GL_ARRAY_BUFFER, normal_buffer); //绑定发现数组
glBertexAttribPointer(normal_loc, 3, GL_FLOAT, GL_FALSE, 0, NULL); //定义发现数据规范
glEnableVertexAttribArray(normal_loc); //按上述规范,将法线数组应用到顶点属性中去

//开始多实例化配置
//设置颜色的数组。我们希望几何体的每个实例都有一个不同的颜色,
//将颜色值置入缓存对象中,然后设置一个实例化的顶点属性
glBindBuffer(GL_ARRAY_BUFFER, color_buffer); //绑定颜色数组
glVertexAttribPointer(color_loc, 4, GL_FLOAT, GL_FALSE, 0, NULL); //定义颜色数据在color_loc索引位置的数据规范
glEnableVertexAttribArray(color_loc); //按照上述的规范,将color_loc数据应用到顶点属性上去

glVertexattribDivisor(color_loc, 1); //开启颜色属性的多实例化,1表示每隔1个实例时共用一个数据

glBindBuffer(GL_ARRAY_BUFFER, model_matrix_buffer); //绑定矩阵数组
for(int i = 0 ; i<4 ; i++)
{
	//设置矩阵第一行的数据规范
	glVertexAttribPointer(matrix_loc + i, 4, GL_FLOAT, GL_FALSE, sizeof(mat4), (void *)(sizeof(vec4)*i));

	//将第一行的矩阵数据应用到顶点属性上去
	glEnableVertexAttribArray(matrix_loc + i);

	//开启第一行矩阵数据的多实例化,1表示每隔1个实例时共用一个数据
	glVertexattribDivisor(matrix_loc + i, 1);
}

这个示例很精准的表达了数据是如何从CPU应用层传输到GPU上再进行实例化的过程。我在代码上做了比较详尽的注释,首先获取需要推入顶点属性的数据的索引,再将数组数据与OpenGL 缓存进行绑定,这样才能注入到OpenGL里去,接着告诉 OpenGL 每个数据对应的格式,然后再根据前一步描述的格式应用到顶点属性中去,最后开启多实例化属性接口,让 InstancingID 起效。

总结,我们解析了 GPU Instancing 在 Unity3D 中的工作方式,得知了它能用同一个模型同一个材质球渲染不同的位置、角度、缩放大小、以及不同颜色等属性。GPU Instancing 并没有对模型网格做任何限制,也没有占用大量内存的来换取性能,很好的弥补了动态合批(Dynamic batching)与静态合批(Static batching)的不足。

只是它毕竟是只能围绕一个模型来操作,只有相同网格(Mesh)和相同的材质球实例(参数可以不同,但必须使用API来设置不同参数)的情况下才能启动多个实例在同一个渲染管线中渲染的优化操作,而动态合批和静态合批却只需要材质球实例一致,网格是可以有差别的。

GPU Instancing、动态合批、静态合批三者所擅长的各不相同,有互相弥补的地方,各自本身也存在着不同程度的限制和优缺点。从整体上来看,GPU Instancing 更适合同一个模型渲染多次的情况,而动态合批(Dynamic batching)更适合同一个材质球并且模型面数较少的情况,静态合批(Static batching)更适合当我们能容忍内存扩大的情况。
· 书籍著作, Unity3D, 前端技术

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    《Unity3D高级编程之进阶主程》第七章,渲染管线与图形学(三) - 渲染原理与知识3

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解

    QQ交流群: 777859752 (高级程序书友会)