第五章——开始Unity学习之旅

一、一个简单的片元着色器

1、顶点/片元着色器的基本结构

1.1、基本结构

顶点/片元着色器的结构如下:

Shader "MyShaderName"{
	Properties{
		// 属性
	}
	SubShader{
		// 针对显卡A的SubShader
		Pass{
			// 设置渲染状态和标签
			
			// 开始Cg代码片段
			CGPROGRAM
			// 改代码片段的编译指令,例如:
			#pragram vertex vert
			#pragram fragment frag

			// Cg代码写在这里
			
			ENGCG
			
			// 其他设置
		}
		// 其他需要的Pass
	}
	SubShader{
		// 针对显卡B的SubShader
	}

	// 上述SubShader都失败后用于回调的Unity Shader
	Fallback "VertexLit"
}

我们发现,Shader包含两个program。vertex program负责处理网格(mesh)的顶点数据,这包括从object space到display space的转换。fragment program则负责为位于网格三角形内的单个像素着色。
 


Vertex&fragment program.

1.2、例:基本顶点/片元着色器

下面我们新建一个场景,新建一个Shader和材质,并把Shader赋给材质。最后新建一个球体Object,再把材质赋给它。
接下来编写一个基本的顶点/片元着色器。

// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'

Shader "Unity Shaders Book/Chapter 5/Simple Shader " {
    SubShader {
        Pass{
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            float4 vert(float4 v: POSITION) : SV_POSITION {
                return UnityObjectToClipPos(v);
            }

            fixed4 frag() : SV_Target {
                return fixed4(1.0,1.0,1.0,1.0);
            }

            ENDCG
        }
    }
}

或者

Shader"Unity Shaders Book/Chapter 5/Simple Shader" {
	// 定义Unity Shader的名字
	SubShader {
		// 不进行任何渲染设置和标签设置
		// SubShader将使用默认的渲染设置和标签设置
		Pass {
			// 同样没有任何渲染设置和标签设置

			// CGPROGRAM和ENDCG中包含CG代码片段
			CGPROGRAM
		
			// 告诉Unity:vert函数包含顶点着色器代码 frag函数包含片元着色器代码
			// #pragma vertex name
			// #pragma fragment frag
			#pragma vertex vert
			#pragma fragment frag

			// 没有Properties语义块,它不是必须的

			// 顶点着色器代码,它是逐顶点执行的
			// 输入值参数v包含这个顶点的位置,通过POSITION语义指定
			// 返回值是float4类型,它是该顶点在裁剪空间中的位置
			// POSITION和SV_POSITION都是CG/HLSL中的语义(semanties),它们是不可省略的,这些语义将告诉系统用户需要哪些输入值,以及用户的输出是什么。
			// 例如这个函数告诉Unity输入模型的顶点坐标填充到v中,输出裁剪空间中的顶点坐标
			float4 vert(float4 v : POSITION) : SV_POSITION {
				// Unity升级到5.6及以后,所有跟UNITY_MATRIX_MVP运算的矩阵或者向量的mul方法,会被自动转成UnityObjectToClipPos方法
				return UnityObjectToClipPos(v);// 将输入顶点v转到裁剪空间

			}

			// frag函数没有输入,输出是一个fixed4类型,并且使用SV_Target语义进行限定
			// SV_Target 也是HLSL 中的一个系统语义,它等同于告诉渲染器,把用户的输出颜色存储到一个渲染目标(render target) 中,这里将输出到默认的帧缓存中
			// 片元着色器函数返回了一个表示白色的fixed4类型的变量
			fixed4 frag() : SV_TARGET {
				// 片元着色器输出的颜色的每个分量范围在[0,1], 其中(0, 0, 0)表示黑色,而(1, 1, I)表示白色。
				return fixed4(1.0, 1.0, 1.0, 1.0);
			}

			ENDCG

		}
	}
}

最后,我们得到结果如下图:
 


当我们不给着色器指定语义时,编译器会报错,因为编译器发现我们仅仅返回了一个float4的数据,但不知道这个数据代表什么,所以它不知道GPU该如何处理这个数据。所以我们使用SV_POSITIONSV_TARGET分别指定顶点着色器和片元着色器,其中SV代表system value,SV_POSITION代表最终顶点位置,SV_TARGET代表默认的shader目标,也就是frame buffer,它包含我们正在生成的图像。

2、顶点着色器的其他输入数据

在上面的例子中,在顶点着色器中我们使用POSITION语义得到了模型的顶点位置

现在,我们想要得到模型上每个顶点的纹理坐标法线方向。我们需要使用纹理坐标来访问纹理,而法线可用于计算光照。因此,我们需要为顶点着色器定义一个新的输入参数,这个参数不再是一个简单的数据类型,而是一个结构体。修改后的代码如下:

// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'

Shader "Unity Shaders Book/Chapter 5/Simple Shader " {
    SubShader {
        Pass{
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            //使用结构体来定义顶点着色器的输入
            struct a2v {
                //POSITION语义告诉Unity,用模型空间的顶点坐标填充vertex变量
                float4 vertex : POSITION;
                //NORMAL语义告诉Unity,用模型空间的法线方向填充normal变量
                float3 normal : NORMAL;
                //TEXCOORD0语义告诉Unity,用模型的第一套纹理坐标填充texcoord变量
                float4 texcoord : TEXCOORD0;
            };

            float4 vert(a2v v) : SV_POSITION {
                //使用v_vertex来访问模型空间的顶点坐标
                return UnityObjectToClipPos(v_vertex);
            }

            fixed4 frag() : SV_Target {
                return fixed4(1.0,1.0,1.0,1.0);
            }

            ENDCG
        }
    }
}

在上面的代码中,我们声明了一个新的结构体 a2v,它包含了顶点着色器需要的模型数据。在a2v的定义中,我们用到了更多 Unity支持的语义,如NORMAL 和 TEXCOORD0,当它们作为顶点着色器的输入时都是有特定含义的,因为 Unity会根据这些语义来填充这个结构体。对于顶点着色器的输出,Unity支持的语义有:POSITION,TANGENT,NORMAL,TEXCOORD0,TEXCOORD1,TEXCOORD2,TEXCOORD3,COLOR等。

为了创建一个自定义的结构体,我们必须使用如下格式来定义它:

struct StructName{
	Type Name : Semantic;
	Type Name : Semantic;
}
type FunctionName(StructName name) : Semantic{
	// ...
}

其中,语义是不可以被省略的。在5.4节中,我们将给出这些语义的含义和用法。

然后,我们修改了vert函数的输入参数类型,把它设置为我们新定义的结构体 a2v。通过这种自定义结构体的方式,我们就可以在顶点着色器中访问模型数据。

a表示应用(application),v表示顶点着色器(vertex shader),a2v的意思就是把数据从应用阶段传递到顶点着色器中。

那么,填充到 POSITION, TANGENT,NORMAL 这些语义中的数据究竟是从哪里来的呢?在Unity中,它们是由使用该材质的Mesh Render组件提供的。在每帧调用Draw Call 的时候,Mesh Render组件会把它负责渲染的模型数据发送给Unity Shader。我们知道,一个模型通常包含了一组三角面片,每个三角面片由3个顶点构成,而每个顶点又包含了一些数据,例如顶点位置、法线、切线、纹理坐标、顶点颜色等。通过上面的方法,我们就可以在顶点着色器中访问顶点的这些模型数据。

3、数据传递:顶点到片元

在实践中,我们往往希望从顶点着色器输出一些数据,例如把模型的发现、纹理坐标给片元着色器,这就涉及到顶点着色器和片元着色器之间的通信。

3.1、传递模型的纹理坐标

GPU通过光栅化三角形来组成图像。GPU选出三个处理过的顶点并对它们进行线性插值。对于每个在三角形内的像素,像素调用片元着色器函数,并将线性插值的数据传递给片元着色器函数。
 


对顶点数据进行插值

所以,片元函数不会直接使用顶点函数的输入,而是使用它们的线性插值。在这个过程中,SV_POSITION将会被插值,我们也可以通过语义定义其他的插值。

下面的Shader就是顶点着色器传递纹理坐标给片元着色器着色的例子:

Shader "Unity Shaders Book/Chapter 5/Interpolated"
{
	SubShader{
		Pass{
			CGPROGRAM

			#pragma vertex vert
			#pragma fragment frag

			// 顶点着色器函数
			// 第一个参数语义告诉Unity:把float4类型的输入坐标传递给片元着色器进行插值
			// 第二个参数语义告诉Unity:把float3类型的顶点第一组纹理坐标传递给片元着色器进行插值
			float4 vert(float4 position:POSITION, out float3 localPosition:TEXCOORD0) : SV_POSITION{
				localPosition = position.xyz;
				return UnityObjectToClipPos(position);
			}

			// 片元着色器函数
			// 第一个参数语义告诉Unity:片元着色器使用顶点着色器传递的float4类型的最终顶点坐标进行插值
			// 第二个参数语义告诉Unity:片元着色器使用顶点着色器传递的float3类型的第一组纹理坐标进行插值
			float4 frag(float4 position:SV_POSITION,float3 localPosition : TEXCOORD0) : SV_TARGET{
				return float4(localPosition, 1);
			}

			ENDCG
		}
	}
}

得到最终效果如下:

通过编译器生成的OpenGL代码可以更好理解这一过程:
顶点着色器:

in  vec4 in_POSITION0;
out vec3 vs_TEXCOORD0;
vec4 t0;
void main()
{
    t0 = in_POSITION0.yyyy * glstate_matrix_mvp[1];
    t0 = glstate_matrix_mvp[0] * in_POSITION0.xxxx + t0;
    t0 = glstate_matrix_mvp[2] * in_POSITION0.zzzz + t0;
    gl_Position = glstate_matrix_mvp[3] * in_POSITION0.wwww + t0;
    vs_TEXCOORD0.xyz = in_POSITION0.xyz;
    return;
}

片元着色器:

in  vec3 vs_TEXCOORD0;
layout(location = 0) out vec4 SV_TARGET0;
void main()
{
    SV_TARGET0.xyz = vs_TEXCOORD0.xyz;
    SV_TARGET0.w = 1.0;
    return;
}

虽然着色器是各自独立的小程序,但是它们都是一个整体的一部分,出于这样的原因,我们希望每个着色器都有输入和输出,这样才能进行数据交流和传递。GLSL定义了inout关键字专门来实现这个目的。每个着色器使用这两个关键字设定输入和输出,只要一个输出变量与下一个着色器阶段的输入匹配,它就会传递下去。
如果我们打算从一个着色器向另一个着色器发送数据,我们必须在发送方着色器中声明一个输出,在接收方着色器中声明一个类似的输入。当类型和名字都一样的时候,OpenGL就会把两个变量链接到一起,它们之间就能发送数据了(这是在链接程序对象时完成的)。为了展示这是如何工作的,我们会稍微改动一下之前教程里的那个着色器,让顶点着色器为片段着色器决定颜色。

3.2、传递模型的纹理坐标和法线

例如:

Shader"Unity Shaders Book/Chapter 5/Simple Shader 2" {
	SubShader {
		Pass {
			CGPROGRAM

			#pragma vertex vert
			#pragma fragment frag

			// 使用一个结构体来定义顶点着色器的输入
			struct a2v {
				// POSITION语义告诉Unity,用模型空间的顶点坐标填充vertex变量
				float4 vertex : POSITION;
				// NORMAL语义告诉Unity,用模型空间的法线方向填充normal变量
				float3 normal : NORMAL;
				// TEXTCOORD0语义告诉Unity,用模型空间的第一套纹理填充textcoord变量
				float4 texcoord : TEXCOORD0;
			};

			// 使用一个结构体来定义顶点着色器的输出
			struct v2f {
				// SV_POSITION语义告诉Unity,pos例包含了顶点在裁剪空间中的位置信息
				float4 pos : SV_POSITION;
				// COLOR0语义可以用于存储颜色信息
				fixed3 color : COLOR0;
			};

			v2f vert(a2v v) {
				// 声明输出结构
				v2f o;
				o.pos = UnityObjectToClipPos(v.vertex);
				// v.normal包含了顶点法线方向,其分量范围在[-1.0, 1.0]
				// 把法线映射到o.color中传递给片元着色器
				o.color = v.normal * 0.5 + fixed3(0.5, 0.5, 0.5);
				return o;
			}
			fixed4 frag(v2f i) : SV_TARGET {
				// 将插值后的i.color显示到屏幕上
				return fixed4(i.color, 1.0);
			}

			ENDCG
		}
	}
}

得到如下效果:

在上面的代码中,我们声明了一个新的结构体v2f。v2f用于在顶点着色器和片元着色器之间传递信息。同样的,v2f中也需要指定每个变量的语义。在本例中,我们使用了SV_POSITIONCOLOR0语义。可以发现,参数列表后的SV_POSITION不在了,因为返回的是一个结构体而不是float4。所以顶点着色器的输出结构中,必须包含一个变量,它的语义是SV_POSITION。否则,渲染器将无法得到裁剪空间中的顶点坐标,也就无法把顶点渲染到屏幕上。

同样的,给出生成的OpenGL代码:
顶点着色器:

in  vec4 in_POSITION0;
in  vec3 in_NORMAL0;
out vec3 vs_COLOR0;
vec4 u_xlat0;
vec4 u_xlat1;
void main()
{
    u_xlat0 = in_POSITION0.yyyy * hlslcc_mtx4x4unity_ObjectToWorld[1];
    u_xlat0 = hlslcc_mtx4x4unity_ObjectToWorld[0] * in_POSITION0.xxxx + u_xlat0;
    u_xlat0 = hlslcc_mtx4x4unity_ObjectToWorld[2] * in_POSITION0.zzzz + u_xlat0;
    u_xlat0 = u_xlat0 + hlslcc_mtx4x4unity_ObjectToWorld[3];
    u_xlat1 = u_xlat0.yyyy * hlslcc_mtx4x4unity_MatrixVP[1];
    u_xlat1 = hlslcc_mtx4x4unity_MatrixVP[0] * u_xlat0.xxxx + u_xlat1;
    u_xlat1 = hlslcc_mtx4x4unity_MatrixVP[2] * u_xlat0.zzzz + u_xlat1;
    gl_Position = hlslcc_mtx4x4unity_MatrixVP[3] * u_xlat0.wwww + u_xlat1;
    vs_COLOR0.xyz = in_NORMAL0.xyz * vec3(0.5, 0.5, 0.5) + vec3(0.5, 0.5, 0.5);
    return;
}

片元着色器:

in  vec3 vs_COLOR0;
layout(location = 0) out vec4 SV_TARGET0;
void main()
{
    SV_TARGET0.xyz = vs_COLOR0.xyz;
    SV_TARGET0.w = 1.0;
    return;
}

顶点着色器的out vec3 vs_COLOR0和片元着色器的in vec3 vs_COLOR0变量名相同,所以它会从顶点着色器传递给片元着色器。注意,这个vs_COLOR0的变量名是根据两个结构体中的COLOR0语义来自动命名的。

4、如何使用属性

我们知道,材质和Unity Shader之间有紧密联系。材质给我们提供了一个可以方便地调节Unity Shader参数的方式,通过这些参数,我们可以随时调整材质的效果。而这些参数就需要写在Properties语义块中。

Properties{
	Name ("display name", PropertyType) = DefaultValue
	Name ("display name", PropertyType) = DefaultValue
	// 更多属性
}

其中,Name为每个属性的名字;display name为出现在材质面板上的名字;PropertyType为属性的类型;DefaultValue为属性的默认值,第一次把Unity Shader赋值给某个材质时,材质面板上显示的就是这个默认值。
其中,Properties语义块支持的属性类型如下:
 


为了在Cg代码中能够访问Properties中声明的属性,我们还需要在CG代码片段中提前定义一个新的变量,变量的名称和类型必须与Properties语义块中的属性定义相匹配。
ShaderLab中属性的类型和CG中变揽的类型之间的匹配关系如下表:

Shaderlab属性类型和CG变量类型的匹配关系

例如,我们想要在材质面板中显示一个颜色拾取器,从而可以直接控制模型在屏幕上显示的颜色,因此,我们可以修改代码:

Shader"Unity Shaders Book/Chapter 5/Simple Shader 3" {
	Properties{
		// 声明一个Color类型的属性
		_Color ("Color Tint", Color) = (1.0, 1.0, 1.0, 1.0)
	}
	SubShader {
		Pass {

			CGPROGRAM

			#pragma vertex vert
			#pragma fragment frag

			// 在Cg代码中,我们需要定义一个与属性名称和类型都匹配的变量
			fixed4 _Color;

			struct a2v {
				float4 vertex : POSITION;
				float3 normal : NORMAL;
				float4 texcoord : TEXCOORD0;
			};

			struct v2f {
				float4 pos : SV_POSITION;
				fixed3 color : COLOR0;
			};

			v2f vert(a2v v) {
				v2f o;
				o.pos = UnityObjectToClipPos(v.vertex);
				o.color = v.normal * 0.5 + fixed3(0.5, 0.5, 0.5);
				return o;
			}
			fixed4 frag(v2f i) : SV_TARGET {
				fixed3 c = i.color;
				// 使用_Color属性来控制输出颜色
				c *= _Color.rgb;
				return fixed4(c, 1.0);
			}

			ENDCG

		}
	}
}

得到效果如下:

二、Unity提供的内置文件和变量

1、内置的包含文件

为了方便开发者的编码过程, Unity 提供了很多内置文件,这些文件包含了很多提前定
义的函数、变量和宏等。

包含文件((include file),是类似于C++中头文件的一种文件。在Unity中,它们的文件后缀是.cginc。在编写Shader时,我们可以使用#include指令把这些文件包含进来,这样我们就可以使用Unity 为我们提供的一些非常有用的变量和帮助函数。例如:

CGPROGRAM
// ...
#include "UnityCG.cginc"
// ...
ENDCG

我们可以通过以下路径查看相关文件:Unity 的安装路径/Data/CGIncludes
下面给出一些常用的包含文件、结构体和帮助函数。


Unity中一些常用的包含文件

可以看出,有一些文件是即便我们没有使用#include 指令,它们也是会被自动包含进来的,例如UnityShaderVariables.cginc。因此,在前面的例子中,我们可以直接使用 UNITY_MATRIXMVP变量来进行顶点变换。除了表5.2中列出的包含文件外,Unity 5引入了许多新的重要的包含文件,如 UnityStandardBRDF.cginc、UnityStandardCore.cginc等,这些包含文件用于实现基于物理的渲染,我们会在第18章中再次遇到它们。


UnityCG.cginc中一些常用的结构体

UnityCGcginc是我们最常接触的一个包含文件。在后面的学习中,我们将使用很多该文件提供的结构体和函数,为我们的编写提供方便。例如,我们可以直接使用UnityCGcginc中预定义的结构体作为顶点着色器的输入和输出。


UnityCG.cginc中一些常用的帮助函数

2、内置的变量

我们在4.8节给出了一些用于坐标变换和摄像机参数的内置变量。除此之外,Unity还提供了用于访问时间、光照、雾效和环境光等目的的变量。这些内置变量大多位于UnityShaderVariables.cginc中,与光照有关的内置变量还会位于Lighting.cginc、AutoLight.cginc等文件中。当我们在后面的学习中遇到这些变量时,再进行详细的讲解。

三、Unity提供的CG/HLSL语义

1、什么是语义

实际上,这些是CG/HLSL 提供的语义(semantics)。读者可以在微软的关于DirectX的文档(Microsoft Learn: Build skills that open doors in your career ).aspx#VS)中找到关于语义的详细说明页面。

1.1、语义的含义

语义实际上就是一个赋给Shader输入和输出的字符串,这个字符串表达了这个参数的含义。通俗地讲,这些语义可以让Shader 知道从哪里读取数据,并把数据输出到哪里,它们在CG/HLSL的Shader流水线中是不可或缺的。需要注意的是,Unity并没有支持所有的语义。

通常情况下,这些输入输出变量并不需要有特别的意义,也就是说,我们可以自行决定这些变量的用途。例如在上面的代码中,顶点着色器的输出结构体中我们用COLORO语义去描述color变量。color变量本身存储了什么,Shader流水线并不关心。

而Unity为了方便对模型数据的传输,对一些语义进行了特别的含义规定。例如,在顶点着色器的输入结构体a2v用TEXCOORDO来描述texcoord,Unity会识别TEXCOORD0语义,以把模型的第一组纹理坐标填充到texcoord中。需要注意的是,即便语义的名称一样,如果出现的位置不同,含义也不同。例如,TEXCOORDO既可以用于描述顶点着色器的输入结构体a2f,也可用于描述输出结构体v2f。但在输入结构体a2f中,TEXCOORD0有特别的含义,即把模型的第一组纹理坐标存储在该变量中,而在输出结构体v2f中,TEXCOORD0修饰的变量含义就可以由我们来决定。

1.2、系统数值语义:SV_

在DirectX 10以后,有了一种新的语义类型,就是系统数值语义(system-value semantics)。这类语义是以SV开头的,SV代表的含义就是系统数值(system-value)。这些语义在渲染流水线中有特殊的含义。例如在上面的代码中,我们使用SV_POSITION语义去修饰顶点着色器的输出变量pos,那么就表示pos包含了可用于光栅化的变换后的顶点坐标(即齐次裁剪空间中的坐标)。用这些语义描述的变量是不可以随便赋值的,因为流水线需要使用它们来完成特定的目的,例如渲染引擎会把用SV_POSITION修饰的变量经过光栅化后显示在屏幕上。读者有时可能会看到同一个变量在不同的Shader里面使用了不同的语义修饰。例如,一些Shader 会使用 POSITION而非sV_POSITION来修饰顶点着色器的输出。SV_POSITION是 DirectX 10中引入的系统数值语义,

在绝大多数平台上,它和POSITION语义是等价的,但在某些平台(例如索尼 PS4)上必须使用SV_POSITION来修饰顶点着色器的输出,否则无法让 Shader 正常工作。同样的例子还有 COLOR和SV_Target。因此,为了让我们的 Shader有更好的跨平台性,对于这些有特殊含义的变量我们最好使用以SV开头的语义进行修饰。我们在5.6节中会总结更多这种因为平台差异而造成的问题。

2、Unity支持的语义

下面总结一些Unity中常用的语义,注意每个语义对应的传递是不同的。

2.1、应用阶段到顶点着色器

表1:从应用阶段传递模型数据给顶点着色器时Unity 支持的常用语义

其中 TEXCOORDn中n的数目是和 Shader Model有关的,例如一般在Shader Model 2(即Unity默认编译到的Shader Model版本)和Shader Model 3中,n等于8,而在Shader Model 4和Shader Model 5中,n等于16。通常情况下,一个模型的纹理坐标组数一般不超过2,即我们往往只使用 TEXCOORDO和TEXCOORD1。在Unity内置的数据结构体appdata_full 中,它最多使用了6个坐标纹理组。

2.2、顶点着色器到片元着色器

表2:从顶点着色器传递数据给片元着色器时Unity 使用的常用语义

上面的语义中,除了SV POSITION是有特别含义外,其他语义对变量的含义没有明确要求,也就是说,我们可以存储任意值到这些语义描述变量中。通常,如果我们需要把一些自定义的数据从顶点着色器传递给片元着色器,一般选用TEXCOORD0等。

2.3、片元着色器的输出

表3:片元着色器输出时Unity 支持的常用语义

在生成的OpenGL最终代码中,顶点着色器函数表1语义转换成in xxx,表2语义转换成out xxx;片元着色器函数表2转换成in xxx,表2语义转换成out xxx。其中xxx语义有关而与变量名无关。当一个阶段的out与下一个阶段的in的语义相同,它们就会传递下去。

3、如何定义复杂的变量类型

上面提到的语义绝大部分用于描述标量或矢量类型的变量,例如 fixed2、float、float4、fixed4等。下面的代码给出了一个使用语义来修饰不同类型变量的例子:

struct v2f {
    float4 pos : SV POSITION;
    fixed3 color0 : COLORO;
    fixed4 color1 : COLOR1;
    half value0 :TEXCOORDO;
    float2 valuel : TEXCOORD1;
};

关于何时使用哪种变量类型,我们会在5.7.1节给出一些建议。但需要注意的是,一个语义可以使用的寄存器只能处理4个浮点值(float)。因此,如果我们想要定义矩阵类型,如float3×4、float4×4 等变量就需要使用更多的空间。一种方法是,把这些变量拆分成多个变量,例如对于float4×4的矩阵类型,我们可以拆分成4个float4类型的变量,每个变量存储了矩阵中的一行数据。

四、程序员的烦恼:Debug

Debug方式:

注意渲染平台的差距

1、使用假彩色图像

假彩色图像(false-color image)指的是用假彩色技术生成的一种图像。与假彩色图像对应的是照片这种真彩色图像(true-color image)。一张假彩色图像可以用于可视化一些数据。

主要思想是,我们可以把需要调试的变量映射到[0,1]之间,把它们作为颜色输出到屏幕上,然后通过屏幕上显示的像素颜色来判断这个值是否正确。这种方法得到的调试信息很模糊,能够得到的信息很有限,但在很长一段时间内,这种方法的确是唯一的可选方法。

需要注意的是,由于颜色的分量范围在[0,1],因此我们需要小心处理需要调试的变量的范围。如果我们已知它的值域范围,可以先把它映射到[0,1]之间再进行输出。如果你不知道一个变量的范围(这往往说明你对这个 Shader 中的运算并不了解),我们就只能不停地实验。一个提示是,颜色分量中任何大于1的数值将会被设置为1,而任何小于0的数值会被设置为0。因此,我们可以尝试使用不同的映射,直到发现颜色发生了变化(这意味着得到了0~1的值)。

如果我们要调试的数据是一个一维数据,那么可以选择一个单独的颜色分量(如R分量)进行输出,而把其他颜色分量置为0。如果是多维数据,可以选择对它的每一个分量单独调试,或者选择多个颜色分量进行输出。

作为实例,下面我们会使用假彩色图像的方式来可视化一些模型数据,如法线、切线、纹理坐标、顶点颜色,以及它们之间的运算结果等。我们使用的代码如下:

shader "Unity Shaders Book/Chapter 5/False Color" {
    Subshader {
        Pass {
            cGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            #include "UnitycG.cginc"

            struct v2f{
                float4 pos : sv_POSITION;
                fixed4 color : cOLORO;
            };

            v2f vert(appdata_full v){
                v2f o;
                o. pos=mul (UNITY_MATRIX_MVP,v.vertex);

                //可视化法线方向
                o.color = fixed4 (v.normal * 0.5 + fixed3(0.5,0.5,0.5),1.0);

                //可视化切线方向
                o.color = fixed4(v.tangent * 0.5 + fixed3(0.5,0.5,0.5),1.0) ;

                //可视化副切线方向
                fixed3 binormal = cross(v.normal, v,tangent.xyz)* v.tangent.w;o.color = fixed4(binormal * 0.5 + fixed3(0.5,0.5,0.5),1.0);

                //可视化第一组纹理坐标
                o .color =fixed4(v.texcoord.xy,0.0,1.0);

                //可视化第二组纹理坐标
                o.color =fixed4 (v.texcoordl.xy, 0.0, 1.0);

                //可视化第—组纹理坐标的小数部分
                o.color = frac(v.texcoord);
                if (any(saturate(v.texcoord)- v.texcoord)){
                    o.color.b =0. 5;
                }
                o.color.a= 1.0;
                //可视化第二组纹理坐标的小数部分
                o.color-frac (v.texcoordl);
                if (any(saturate(v.texcoord1) - v.texcoordl)){
                    o.color.b =0.5;
                }
                o.color.a=1.0;

                //可视化顶点颜色
                //o.color = v.color;

                return o;
            }
                fixed4 frag(v2f i): sV_Target{
                    return i.color;
                }
                ENDCG 
        }
    }
}

在上面的代码中,我们使用了Unity内置的一个结构体——appdata_full。我们在5.3节讲过该结构体的构成。我们可以在 UnityCGcginc里找到它的定义:

struct appdata_full {
    float4 vertex : POSITION;
    float 4 tangent :TANGENT;
    float3 normal :NORMAL;
    float4 texcoord :TExCOORDO;
    float4 texcoordl : TEXCOORD1;
    float4 texcoord2:TEXCOORD2;
    float4 texcoord3 :TEXCOORD3;
#if defined ( SHADER_API_XBOX360)
    half4 texcoord4 :TEXCOORD4;
    half4 texcoord5 :TExCOORD5;
#endif
    fixed4 color : COLOR;);

可以看出,appdata_full几乎包含了所有的模型数据。

我们把计算得到的假彩色存储到了顶点着色器的输出结构体——v2f中的color变量里,并且在片元着色器中输出了这个颜色。读者可以对其中的代码添加或取消注释,观察不同运算和数据得到的效果。图5.4给出了这些代码得到的显示效果。读者可以先自己想一想代码和这些效果之间的对应关系,然后再在Unity中进行验证。

为了可以得到某点的颜色值,我们可以使用类似颜色拾取器的脚本得到屏幕上某点的RGBA值,从而推断出该点的调试信息。在本书的附带工程中,读者可以找到这样一个简单的实例脚本:Assets -> Scripts -> Chapter5 -> ColorPicker.cs。把该脚本拖曳到一个摄像机上,单击运行后,可以用鼠标单击屏幕,以得到该点的颜色值,如图5.5所示。

2、利用神器:Visual Studio

Visual Studio作为Windows系统下的开发利器,在Visual Studio 2012版本中也提供了对Unity Shader的调试功能———Graphics Debugger.

通过 Graphics Debugger,我们不仅可以查看每个像素的最终颜色、位置等信息,还可以对顶点着色器和片元着色器进行单步调试。具体的安装和使用方法可以参见 Unity官网文档中使用Visual Studio对 DirectX 11 的Shader进行调试一文(http:/docs.unity3d.com/Manual/SL-DebuggingD3D11ShadersWithVS.html)。

当然,本方法也有一些限制。例如,我们需要保证 Unity运行在 DirectX 11 平台上,而且 GraphicsDebugger本身存在一些bug。

3、最新利器:帧调试器

要使用帧调试器,我们首先需要在Window -> Frame Debugger 中打开帧调试器窗口,如图5.6所示。

帧调试器可以用于查看渲染该帧时进行的各种渲染事件(event),这些事件包含了Draw Call序列,也包括了类似清空帧缓存等操作。

帧调试器窗口大致可分为3个部分:最上面的区域可以开启/关闭(单击 Enable按钮)帧调试功能,当开启了帧调试时,通过移动窗口最上方的滑动条(或单击前进和后退按钮),我们可以重放这些渲染事件;左侧的区域显示了所有事件的树状图,在这个树状图中,每个叶子节点就是一个事件,而每个父节点的右侧显示了该节点下的事件数目。我们可以从事件的名字了解这个事件的操作,例如以 Draw开头的事件通常就是一个 Draw Call;当单击了某个事件时,在右侧的窗口中就会显示出该事件的细节,例如几何图形的细节以及使用了哪个Shader等。同时在Game视图中我们也可以看到它的效果。如果该事件是一个 Draw Call并且对应了场景中的一个GameObject,那么这个GameObject 也会在 Hierarchy视图中被高亮显示出来,图5.7显示了单击渲染某个对象的深度图事件的结果。

如果被选中的 Draw Call 是对一个渲染纹理(RenderTexture)的渲染操作,那么这个渲染纹理就会显示在 Game视图中。而且,此时右侧面板上方的工具栏中也会出现更多的选项,例如在Game视图中单独显示R、G、B和A通道。

Unity 5提供的帧调试器实际上并没有实现一个真正的帧拾取(frame capture)的功能,而是仅仅使用停止渲染的方法来查看渲染事件的结果。例如,如果我们想要查看第4个 Draw Call 的结果,那么帧调试器就会在第4个 Draw Call调用完毕后停止渲染。这种方法虽然简单,但得到的信息也很有限。如果读者想要获取更多的信息,还是需要使用外部工具,例如5.5.2节中的VisualStudio插件,或者Intel GPA、RenderDoc、NVIDIA NSight、AMD GPU PerfStudio等工具。

五、小心:渲染平台的差异

1、渲染纹理的坐标差异

2、Shader的语法差异

3、Shader的语义差异

我们在5.4节讲到了Shader中的语义是什么,其中我们讲到了一些语义在某些平台下是等价的,例如 SV_POSITION和 POSITION。但在另一些平台上,这些语义是不等价的。为了让Shader能够在所有平台上正常工作,我们应该尽可能使用下面的语义来描述Shader的输入输出变量。

·使用SV_POSITION来描述顶点着色器输出的顶点位置。一些Shader使用了POSITION语

义,但这些Shader无法在索尼 PS4平台上或使用了细分着色器的情况下正常工作。

使用SV_Target来描述片元着色器的输出颜色。一些Shader使用了COLOR或者COLORO语义,同样的,这些Shader无法在索尼PS4上正常工作。

4、其他平台差异

本书只给出了一些最常见的平台差异造成的问题,还有一些差异不再列举。如果读者发现-些Shader在平台A下工作良好,而在平台B下出现了问题,可以去 Unity官方文档(Unity - Manual: Writing shaders for different graphics APIs)中寻找更多的资料。

六、Shader整洁之道

优化:

  • 尽可能使用精度低的类型。(float(32 bit)>half(16 bit)>fixed(11 bit))
  • 慎用分支和循环语句
  • 不要除以0

1、float、half还是fixed

在本书中,我们使用CG/HLSL来编写Unity Shader 中的代码。而在 CG/HLSL 中,有3种精度的数值类型:float,half和 fixed。这些精度将决定计算结果的数值范围。

表5.8给出了这3种精度在通常情况下的数值范围。

上面的精度范围并不是绝对正确的,尤其是在不同平台和 GPU上,它们实际的精度可能和上面给出的范围不一致。通常来讲。

· 大多数现代的桌面GPU会把所有计算都按最高的浮点精度进行计算,也就是说,float、

half、fixed在这些平台上实际是等价的。这意味着,我们在PC上很难看出因为half和 fixed精度而带来的不同。

但在移动平台的GPU上,它们的确会有不同的精度范围,而且不同精度的浮点值的运算速度也会有所差异。因此,我们应该确保在真正的移动平台上验证我们的Shader 。

fixed精度实际上只在一些较旧的移动平台上有用,在大多数现代的GPU上,它们内部把fixed和 half当成同等精度来对待。

尽管有上面的不同,但一个基本建议是,尽可能使用精度较低的类型,因为这可以优化 Shader的性能,这一点在移动平台上尤其重要。从它们大体的值域范围来看,我们可以使用fixed类型来存储颜色和单位矢量,如果要存储更大范围的数据可以选择half类型,最差情况下再选择使用float。如果我们的目标平台是移动平台,一定要确保在真实的手机上测试我们的 Shader,这一点非常重要。关于移动平台的优化技术,读者可以在第16章中找到更多内容。

2、规范语法

在5.6.2节,我们提到DirectX平台对Shader 的语义有更加严格的要求。这意味着,如果我们要发布到 DirectX平台上就需要使用更严格的语法。例如,使用和变量类型相匹配的参数数目来对变量进行初始化。

3、避免不必要的计算

如果我们毫无节制地在Shader(尤其是片元着色器)中进行了大量计算,那么我们可能很快就会收到Unity的错误提示:

temporary register limit of 8 exceeded

Arithmetic instruction limit of 64 exceeded; 65 arithmetic instructions needed to compileprogram

出现这些错误信息大多是因为我们在Shader 中进行了过多的运算,使得需要的临时寄存器数目或指令数目超过了当前可支持的数目。

读者需要知道,不同的Shader Target、不同的着色器阶段,我们可使用的临时寄存器和指令数目都是不同的。

通常,我们可以通过指定更高等级的Shader Target来消除这些错误。

表5.9给出了Unity目前支持的Shader Target。

需要注意的是,所有类似OpenGL的平台(包括移动平台)被当成是支持到Shader Model 3.0的。而WP8/WinRT平台则只支持到Shader Model 2.0。

Shader Model是由微软提出的一套规范,通俗地理解就是它们决定了Shader中各个特性(feature)的能力(capability)。这些特性和能力体现在Shader能使用的运算指令数目、寄存器个数等各个方面。Shader Model等级越高,Shader的能力就越大。具体的细节读者可以参见本章的扩展阅读部分。

虽然更高等级的Shader Target可以让我们使用更多的临时寄存器和运算指令,但一个更好的方法是尽可能减少Shader 中的运算,或者通过预计算的方式来提供更多的数据。

4、慎用分支和循环语句

在我们学习第一门语言的课上,类似分支、循环语句这样的流程控制语句是最基本的语法之一。但在编写Shader的时候,我们要对它们格外小心。

大体来说,GPU使用了不同于CPU 的技术来实现分支语句,在最坏的情况下,我们花在一个分支语句的时间相当于运行了所有分支语句的时间。因此,我们不鼓励在Shader中使用流程控制语句,因为它们会降低GPU的并行处理操作(尽管在现代的GPU上已经有了改进)。

如果我们在Shader中使用了大量的流程控制语句,那么这个Shader的性能可能会成倍下降。一个解决方法是,我们应该尽量把计算向流水线上端移动,例如把放在片元着色器中的计算放到顶点着色器中,或者直接在CPU中进行预计算,再把结果传递给Shader。当然,有时我们不可避免地要使用分支语句来进行运算,那么一些建议是:

·分支判断语句中使用的条件变量最好是常数,即在 Shader运行过程中不会发生变化;

·每个分支中包含的操作指令数尽可能少;

·分支的嵌套层数尽可能少。

5、不要除以0

虽然在用类似C#等高级语言进行编程的时候,我们会谨记不要除以О这个基本常识(就算你没这么做,编辑器可能也会报错),但有时在编写Shader的时候我们会忽略这个问题。

例如,我们在 Shader里写下如下代码:

fixed4 frag(v2f i) : SV_Target
{
    return fixed4(0.0/0.0,o.0/0.0,0.0/0.0,1.0);
}

这样代码的结果往往是不可预测的。在某些渲染平台上,上面的代码不会造成Shader的崩溃,但即便不会崩溃得到的结果也是不确定的,有些会得到白色(由无限大截取到1.0),有些会得到黑色,但在另一些平台上,我们的Shader可能就会直接崩溃。因此,即便在开发游戏的平台上,我们看到的结果可能是符合预期的,但在目标平台上可能就会出现问题。

一个解决方法是,对那些除数可能为0的情况,强制截取到非0范围。在一些资料中,读者可能也会看到使用if语句来判断除数是否为0的例子。

七、扩展阅读

Shader的编译

Unity Shader编译器会将我们的代码抓换到我们的目标平台,不同平台有不同的解决方案。 例如,Windows下的Direct3D,Macs下的OpenGL,手机上的OpenGL ESd等等。我们处理的是多个平台而非单个。
我们可以在inspector窗口中查看Shader的信息以及编译的报错。窗口还有一个Complied and show code按钮,点击它可以就可以查看Unity生成的最终代码。通过点击按钮旁边的下拉菜单,我们可以选择Shader的平台。
 


Shader inspector, with errors for all platforms.

例如,对于一个Shader代码,编译器生成的OpenGLCore代码如下:

#ifdef VERTEX
void main()
{
    gl_Position = vec4(0.0, 0.0, 0.0, 0.0);
    return;
}
#endif
#ifdef FRAGMENT
layout(location = 0) out vec4 SV_TARGET0;
void main()
{
    SV_TARGET0 = vec4(0.0, 0.0, 0.0, 0.0);
    return;
}
#endif

生成的D3D11代码如下:

Program "vp" {
SubProgram "d3d11 " {
      vs_4_0
      dcl_output_siv o0.xyzw, position
   0: mov o0.xyzw, l(0,0,0,0)
   1: ret 
}
}
Program "fp" {
SubProgram "d3d11 " {
      ps_4_0
      dcl_output o0.xyzw
   0: mov o0.xyzw, l(0,0,0,0)
   1: ret 
}
}

又例如,对于Unity Shader中的一个矩阵乘法:

float4 MyVertexProgram (float4 position : POSITION) : SV_POSITION {
	return mul(UNITY_MATRIX_MVP, position);
}

编译器生成的OpenGLCore代码如下:

uniform 	vec4 _Time;
uniform 	vec4 _SinTime;
uniform 	vec4 _CosTime;
uniform 	vec4 unity_DeltaTime;
uniform 	vec3 _WorldSpaceCameraPos;
…
in  vec4 in_POSITION0;
vec4 t0;
void main()
{
    t0 = in_POSITION0.yyyy * glstate_matrix_mvp[1];
    t0 = glstate_matrix_mvp[0] * in_POSITION0.xxxx + t0;
    t0 = glstate_matrix_mvp[2] * in_POSITION0.zzzz + t0;
    gl_Position = glstate_matrix_mvp[3] * in_POSITION0.wwww + t0;
    return;
}

定义了一些uniform的变量,uniform意味着变量对于所有的顶点和片元具有相同的值。
我们也可在Shader中定义自己的uniform变量,但这不是必须的。

生成的D3D11代码如下:

Bind "vertex" Vertex
ConstBuffer "UnityPerDraw" 352
Matrix 0 [glstate_matrix_mvp]
BindCB  "UnityPerDraw" 0
      vs_4_0
      dcl_constantbuffer cb0[4], immediateIndexed
      dcl_input v0.xyzw
      dcl_output_siv o0.xyzw, position
      dcl_temps 1
   0: mul r0.xyzw, v0.yyyy, cb0[1].xyzw
   1: mad r0.xyzw, cb0[0].xyzw, v0.xxxx, r0.xyzw
   2: mad r0.xyzw, cb0[2].xyzw, v0.zzzz, r0.xyzw
   3: mad o0.xyzw, cb0[3].xyzw, v0.wwww, r0.xyzw
   4: ret

八、参考

读者可以在《GPU精粹2》中的GPU流程控制一章中更加深入地了解为什么流程控制语句在GPU上会影响性能。在5.7.3节我们提到了Shader中临时寄存器数目和运算指令都有限制,实际上 Shader Model对顶点着色器和片元着色器中使用的指令数、临时寄存器、常量寄存器、输入/输出寄存器、纹理等数目都进行了规定。读者可以在Wiki的相关资料P和HLSL的手册3中找到更多的内容。

[1] Mark Harris, Ian Buck. "GPU Flow-Control Idioms." In GPU Gems 2.中译本:GPU精粹2:高性能图形芯片和通用计算编程技巧,法尔译,清华大学出版社,2007年。

[2] High-Level Shading Language,Wiki ( https:/en.wikipedia.org/wiki/High-Level_Shading_Language)。

[3] Shader Models vs Shader Profiles,HLSL手册(Shader Models vs Shader Profiles - Win32 apps | Microsoft Learn

《Unity Shader 入门精要》第五章
catlikecoding rendering 2
learnopengl-cn 着色器

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值