16

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

 4 years ago
source link: http://www.luzexi.com/2019/11/20/Unity3D%E9%AB%98%E7%BA%A7%E7%BC%96%E7%A8%8B%E4%B9%8B%E8%BF%9B%E9%98%B6%E4%B8%BB%E7%A8%8B-%E6%B8%B2%E6%9F%93%E7%AE%A1%E7%BA%BF%E4%B8%8E%E5%9B%BE%E5%BD%A2%E5%AD%A68
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

《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)更适合当我们能容忍内存扩大的情况。November 20, 2019 · 书籍著作, Unity3D, 前端技术

感谢您的耐心阅读

Thanks for your reading

版权申明

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

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

Copyright attention

Please don't reprint without authorize.

qrcode_for_gzh.jpg

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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK