Unity Shader入门精要学习笔记 - 第5章 开始 Unity Shader 学习之旅

转载自 冯乐乐 《Unity Shader 入门精要》

一个顶点/片元 着色器的结构大概如下:

 

Shader "MyShaderName"
{
	Properties
	{
		//属性
	}
	SubShader
	{
		//针对显卡A的SubShader
		Pass
		{
			//设置渲染状态和标签
			//开始CG代码片段
			CGPROGRAM
			//该代码的预编译指令,例如:
			#pragma vertex vert
			#pragma fragment frag
			//CG代码写在这儿
			ENDCG
			//其他设置
		}
	}
	SubShader
	{
		//针对显卡B的SubShader
	}
}


其中,最重要的部分是Pass语义块,我们绝大部分的代码都是写在这个语义块里面的。下面我们来创建一个最简单的顶点/片元着色器。

 

 

 

1)新建一个场景,命名为Scene_5_2,如下:

可以看到,场景中已经包含了一个摄像机、一个平行光。而且场景的背景不是纯色的,而是一个天空盒子。我们可以Window->Lighting->SkyBox,把该项设置为空,去掉天空盒子。

2)新建一个Unity Shader,把它命名为Chapter5-SimpleShader。

3)新建一个材质球,把它命名为SimpleShaderMat。把第2步中新建的Unity Shader赋给它。

4)新建一个球体,拖拽它的位置以便在Game视图中更可以合适地显示出来。把第3步中新建的材质拖拽给它。

5)双击打开第2步中创建的Unity Shader。删除里面所有的代码。把下面的代码粘贴进去。

 

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 mul(UNITY_MATRIX_MVP, v);
			}
			
			fixed4 frag() : SV_Target
			{
				return fixed4(1.0,1.0,1.0,1.0);
			}
			ENDCG
		}
	}
}


保存并返回Unity查看结果,如下:

 

首先,代码的第一行通过Shader语义定义了这个Unity Shader的名字——"Unity Shaders Book/Chapter 5/Simple Shader"。

需要注意的是,我们没有用到Properties 语义块。Properties 语义并不是必须的,我们可以选择不声明任何材质属性。

然后,我们声明了SubShader 和 Pass 语义块。在本例中,我们不需要进行任何渲染设置和标签设置,因此SubShader 将使用默认的渲染设置和标签设置。在SubShader语义块中,我们定义了一个Pass,在这个Pass中,我们同样没有进行任何自定义的渲染设置和标签设置。

接着,就是由CGPROGRAM 和 ENDCG 所包围的CG 代码段。这是我们的重点。首先,我们遇到了两行非常重要的编译指令:

 

#pragma vertex vert
#pragma fragment frag

 

它们告诉Unity,哪个函数包含了顶点着色器的代码,哪个函数包含了片元着色器的代码。

 

更通用的编译指令表示如下:

 

#pragma vertex name
#pragma fragment name

其中 name 就是我们指定的函数名,这两个函数的名字不一定是vert 和 frag,它们可以是任何自定义的合法的函数名,但我们一般使用vert 和 frag 来定义这两个函数,因为它们很直观。

 

 

接下来,我们看一下vert函数的定义:

 

float4 vert(float4 v : POSITION) : SV_POSITION
{
    return mul(UNITY_MATRIX_MVP, v);
}

 

这就是本例使用的顶点着色器代码,它是逐顶点执行的。vert函数的输入v包含了这个顶点的位置,这是通过POSITION 语义指定的。它的返回值是一个float4 类型的变量,它是该顶点在裁剪空间中的位置,POSITION 和 SV_POSITION 都是CG/HLSL中的语义,它们是不可省略的,这些语义将告诉系统用户需要哪些输入值,以及用户的输出是什么。例如这里,POSITION 将告诉Unity
,把模型的顶点坐标填充到参数v中,SV_POSITION 将告诉Unity,顶点着色器的输出是裁剪空间中的顶点坐标。如果没有这些语义来限定输入和输出的参数的话,渲染器就完全不知道用户的输入输出是什么,因此就会得到错误的效果。本例中的这一步,就是把顶点坐标从模型空间转换到裁剪空间中。UNITY_MATRIX_MVP 矩阵是unity 内置的模型·观察·投影矩阵。

 

然后,我们来看下frag函数:

 

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

 

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

 

 

在上面的例子中,在顶点着色器我们使用了POSITION 语义得到了模型的顶点位置 。那么,如果我们想要得到更多模型数据怎么办呢?

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

 

Shader "Unity Shaders Book/Chapter 5/Simple Shader"
{
	SubShader
	{
		Pass
		{
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			//使用一个结构体来定义顶点着色器的输入
			struct a2v
			{
				//POSITION 语义告诉Unity,用模型空间的顶点坐标填充vertext变量
				float4 vertex : POSITION;
				//NORMAL 语义告诉Unity,用模型空间的法线方向填充normal变量
				float3 normal : NORMAL;
				//TEXCOORD0 语义告诉Unity,用模型的第一套纹理坐标填充texcoord变量
				float4 texcoord : TEXCOORD0;
			};
			
			float4 vert(a2v v) : SV_POSITION
			{
				//使用v.vertex来访问模型空间的顶点坐标
				return mul(UNITY_MATRIX_MVP, v.vertex);
			}
			
			fixed4 frag() : SV_Target
			{
				return fixed4(1.0,1.0,1.0,1.0);
			}
			ENDCG
		}
	}
}

 

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

 

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

 

struct StructName
{
	Type Name : Semantic
	Type Name : Semantic
	......
};

 

其中,语义是不可以被省略的。

 

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

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

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

在实践中,我们往往希望从顶点着色去输出一些数据。例如把模型的法线、纹理坐标等传递给片元着色器。这就涉及顶点着色器和片元着色器之间的通信。
为此,我们需要再定义一个新的结构体。修改后的代码如下:

 

Shader "Unity Shaders Book/Chapter 5/Simple Shader"
{
	SubShader
	{
		Pass
		{
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			struct a2v
			{
				float4 vertex : POSITION;
				float3 normal : NORMAL;
				float4 texcoord : TEXCOORD0;
			};
			
			struct v2f
			{
				//SV_POSITION语义告诉Unity,pos 里包含了顶点在裁剪空间中的位置信息
				float4 pos : SV_POSITION;
				//COLOR0 语义可以用于存储颜色信息
				fixed3 color : COLOR0;
			};
			
			/*
			//原有的SV_POSITION的作用是将输出的pos转换到裁剪空间
			Unity5.3.6以下可用
			v2f vert(a2v v) : SV_POSITION
			{
				//声明输出结构
				v2f o;
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				//v.normal 包含了顶点的法线方向,其分量范围在[-1.0, 1.0]
				//下面的代码把分量范围映射到了[0.0, 1.0]
				//存储到o.color 中传递给片元着色器
				o.color = v.normal * 0.5 + fixed3(0.5, 0.5, 0.5);
				return o;
			}
			*/

			float4 UnityObjectToClipPos(in float3 pos){  
				//先通过mul(_Object2World, float4(pos, 1.0)) 将顶点从模型空间转换到世界空间
				//然后再通过UNITY_MATRIX_VP进行观察和投影变换,转换到裁剪空间
				return mul(UNITY_MATRIX_VP, mul(_Object2World, float4(pos, 1.0)));
			}
			//Unity5.3.6及以上可用
			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
			{
				//将插值后的i.color显示到屏幕
				return fixed4(i.color,1.0);
			}

			ENDCG
		}
	}
}

 

在上面的代码中,我们声明了一个新的结构体v2f。v2f用于在顶点着色器和片元着色器之间传递信息。同样的,v2f中也需要制定每个变量的语义。在本例中,我们使用了SV_POSITION 和 COLOR0 语义。顶点着色器的输出结构中,必须包含一个变量,它的语义是 SV_POSITION 。否则,渲染器将无法得到裁剪空间中的顶点坐标,也就无法把顶点渲染到屏幕上。COLOR0  语义中的数据可以由用户自行定义,但一般都是存储颜色,例如逐顶点的漫反射颜色或逐顶点的高光反射颜色,类似的语义还有COLOR1等。

至此,我们就完成了顶点着色器和片元着色器之间的通信。需要注意的是,顶点着色器是逐顶点调用的,而片元着色器是逐片元调用的。片元着色器中的输入实际上是把顶点着色器的输出进行插值后得到的结果。


现在,我们有了新的需求,我们想要在材质面板显示一个颜色拾取器,从而可以直接控制模型在屏幕上显示的颜色。为此,我们继续修改上面的代码。

Shader "Unity Shaders Book/Chapter 5/Simple Shader"
{
	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 = mul(UNITY_MATRIX_VP, mul(_Object2World, 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;
				c *= _Color.rgb;
				return fixed4(c,1.0);
			}
			ENDCG
		}
	}
}

在上面的代码中,我们首先添加了Properties语义块中,并在其中声明了一个属性_Color,它的类型是Color,初始值是(1.0,1.0,1.0,1.0),对应白色。为了在CG代码中可以访问它,我们还需要再CG代码片段中提前定义一个新的变量,这个变量的名称和类型必须与Properties语义块中的属性定义相匹配。

 

ShaderLab中属性的类型和CG变量的类型之间的匹配关系如下表:

Unity Shader入门精要学习笔记 - 第5章 开始 Unity Shader 学习之旅_第1张图片
uniform 关键词是CG中修饰变量和参数的一种修饰词,它仅仅用于提供一些关于该变量的初始值是如何指定和存储的相关信息。在Unity中,uniform关键词是可以省略的。
 

内置文件

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

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

 

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

 

我们可以在官网(https://unity3d.com/cn/get-unity/download/archive)上选择下载->内置着色器来直接下载这些文件,下图显示了由官网压缩包得到的文件。

 



从上图可以看出,从官网上下载的文件中包含了许多文件夹。其中,CGIncludes 文件夹中包含了所有的内置包含文件;DefaultResouces 文件夹中包含了一些内置组件或功能所需要的Unity Shader,例如一些GUI元素使用的Shader;DefaultResourcesExtra 则包含了所有Unity中内置的Unity Shader;Editor 文件夹目前只包含了一个脚本文件,它用于定义Unity5 引入的 Standard Shader 所用的材质面板。这些文件都是非常好的参考资料,在我们想要学习内置着色器的实现或是寻找内置函数的实现时,都可以在这里找到内部实现。但在这里,我们只关注CGIncludes文件夹下的相关文件。

我们也可以从Unity应用程序中直接找到CGIncludes文件夹。在Windows上,它的位置是:

Unity的安装路径/Data/CGIncludes。

下表给出了CGIncludes 中主要包含文件以及它们的用处。

可以看出,有一些文件即使我们没有包含进来,Unity也会帮我们自己包含。

UnityCG.cgnic文件是我们最常接触的一个文件。下表给出了一些结构体的名称和包含的变量。

强烈建议读者找到UnityCG.cgnic文件并查看上述结构体的声明,这样的过程可以帮助我们更快理解Unity中一些内置变量的工作原理。

除了上述结构体外,UnityCG.cgnic也提供了一些常用的帮助函数。下表给出了一些函数名和它们的描述

 

Unity提供的CG/HLSL语义

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

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

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

在DirectX以后,有了一种新的语义类型,就是系统数值语义。这类语义是以SV开头的,SV代表的意思就是系统数值。这些语义在渲染流水线中有特殊的含义。例如在上面的代码中,我们使用SV_POSITION语义去修饰顶点着色器的输出变量pos,那么就表示pos包含了可用于光栅化的变换后的顶点坐标(即齐次裁剪空间中的坐标)。用这些语义描述的变量是不可以随便赋值的,因为流水线需要使用它们完成特定的目的,例如渲染引擎会把用SV_POSITION修饰的变量经光栅化后显示在屏幕上。读者有时可能会看到同一个变量在不同的Shader里面使用了不同的语义修饰。例如,一些Shader会使用POSITION而不是SV_POSITION来修饰顶点着色器的输出。SV_POSTION是DirectX10中引入的系统数值语义,在绝大多数平台上,它和POSITON是等价的。但在某些平台,如PS4上,必须使用SV_POSTION来修饰顶点着色器的输出,否则无法让着色器正常工作。同样的例子还有COLOR和SV_Target。因此,为了让我们的Shader有更好的跨平台特性,对于这些有特殊含义的变量我们最好以SV开头进行修饰。

下表总结了从应用阶段传递模型数据给顶点着色器时Unity使用的常用语义。这些语义虽然没有使用SV开头,但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,即我们往往只使用 TEXCOORD0 和 TEXCOORD1。在 Unity内置的数据结构体appdata_full中,它最多使用的6个坐标纹理组。

下表总结了从顶点着色器阶段到片元着色器阶段Unity支持的常用语义。

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

下表给出了Unity中支持的片元着色器的输出语义。

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

 

struct v2f
{
	float4 pos : SV_POSITION;
	fixed3 color0 : COLOR0;
	fixed4 color1 : COLOR1;
	half value0 : TEXCOORD0;
	float2 value1 : TEXCOORD1;
};

 

 

 

需要注意的是,一个语义可以使用的寄存器只能处理4个浮点值。因此,如果我们想要定义矩阵类型,如 float3×4、float4×4 等变量就需要使用更多的空间。一种方式是,把这些变量拆分成多个变量,例如对于float4×4 的矩阵类型,我们就可以拆分成4个float4类型的变量,每个变量存储了矩阵中的一行数据。

 

 

 

Unity中对 Unity Shader 的两种调式方法

假彩色图像指的是用假彩色技术生成的一种图像。与假彩色图像对应的是照片这种真彩色。一张假彩色图像可以用于可视化一些数据,那么如何用它对Shader进行调试呢?

主要思想是,我们可以把需要调试的变量映射到[0,1]之间,把它们作为颜色输出到屏幕上,然后通过屏幕上显示的像素颜色来判断这个值是否正确。

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

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

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

 

Shader "Unity Shader Book/Chapter 5/False Color"
{
	SubShader
	{
		Pass
		{
			CGPROGRAM
			
			#pragma vertex vert
			#pragma frament frag
			
			#include "UnityCG.cgnic"
			
			struct v2f
			{
				float4 pos : SV_POSITION;
				fixed4 color : COLOR0;
			};
			
			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.tangen.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.texcoord1.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.texcoord1);
				if(any(saturate(v.texcoord1) - v.texcoord1))
				{
					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。我们可以在UnityCG.cgnic文件中找到它的定义:

 

struct appdata_full {
	float4 vertex : POSITION;
	float4 tangent : TANGENT;
	float3 normal : NORMAL;
	float4 texcoord : TEXCOORD0;
	float4 texcoord1 : 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变量里,并且在片元着色器中输出了这个颜色。读者可以对其中的代码添加或取消注释,观察不同运算和数据得到的效果。下图给出了这些代码运行的效果
Unity Shader入门精要学习笔记 - 第5章 开始 Unity Shader 学习之旅_第2张图片
为了可以得到某点的颜色值,我们可以使用类似颜色拾取器的脚本得到屏幕上某点的RGBA值,从而推断出改点的调试信息。

 

Visual Sutio 2012版本以上也提供了对Unity Shader 的调试功能——Graphics Debugger。

通过Graphics Debugger,我们不仅可以查看每个像素的最终颜色、位置信息等,还可以对顶点着色器和片元着色器进行单步调试。具体的安装步骤参照Unity官网中的链接 https://docs.unity3d.com/Manual/SL-DebuggingD3D11ShadersWithVS.html

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

Unity 5 中 还可以使用帧调试器。

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

Unity Shader入门精要学习笔记 - 第5章 开始 Unity Shader 学习之旅_第3张图片

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

Unity Shader入门精要学习笔记 - 第5章 开始 Unity Shader 学习之旅_第4张图片

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

Unity 5 提供的帧调试器实际上并没有实现一个真正的帧拾取的功能,而是仅仅使用停止渲染的方法来查看渲染事件的结果。例如,如果我们想要查看第4个 Draw Call 的结果,那么帧调试器就会在第4个Draw Call 调用完毕后停止渲染。这种方法虽然简单,但得到的信心也有限。如果要获取更多的信息,还是要使用外部工具。

 

Unity 在渲染平台的差异

OpenGL 和 DirectX 在屏幕空间坐标存在差异,如下图

需要注意的是,我们不仅可以把渲染结果输出到屏幕上,还可以输出到不同的渲染目标中。这时,我们需要使用渲染纹理来保存这些渲染结果。

大多数情况下,这样的差异并不会对我们造成任何影响。但当我们要使用渲染到纹理技术,把屏幕图像渲染到一张渲染纹理中时,如果不采取任何措施的话,就会出现纹理翻转的情况。幸运的是,Unity 在背后为我们处理了这种翻转问题—— 当在DirectX平台上使用渲染到纹理技术时,Unity 会为我们翻转屏幕图像纹理,以便在不同平台上达到一致性。

在一种特殊情况下Unity不会为我们进行这个翻转操作,这种情况就是我们开启了抗锯齿(在Edit->Project Setting->Quality->Anti Aliasing 中开启)并在此时使用了渲染到纹理技术。在这种情况下,Unity 首先渲染得到屏幕图像,再由硬件进行抗锯齿处理后,得到一张渲染纹理来供我们进行后续处理。此时,在DirectX 平台下,我们得到的输入屏幕图像并不会被Unity 翻转,也就是说,此时对屏幕图像的采样坐标是需要符合DirectX 平台规定的。如果我们的屏幕特效只需要处理一张渲染图像,我们仍然不需要在意纹理的翻转问题,这是因为我们调用Graphics.Blit 函数时,Unity 已经为我们队屏幕图像的采样坐标进行了处理,我们只需要按正常的采样过程处理屏幕图像即可。但如果我们需要同时处理多张渲染图像(前提是开启了抗锯齿),例如需要同时处理屏幕图像和法线纹理,这些图像再竖直方向的朝向就可能是不同的(只有在DirectX这样的平台上才有这样的问题)。这种时候,我们就需要自己在顶点着色器中翻转某些渲染纹理(例如深度纹理或由其他脚本传递过来的纹理)的纵坐标,使之都符合DirectX 平台的规则。例如:

 

#if UNITY_UV_STARTS_AT_TOP
	if (_MainTex_TexelSize.y < 0)
		uv.y = 1 - uv.y;
#endif	

 

 

其中UNITY_UV_STARTS_AT_TOP 用于判断当前平台是否是DirectX 类型的平台,而当在这样的平台下卡其了抗锯齿后,主纹理的纹素大小在竖直方向上会变成负值,以便我们对主纹理进行正确的采样。因此,我们可以通过判断_MainTex_TexelSize.y 是否小于 0 来校验是否开启了抗锯齿。如果是,我们就需要对除主纹理外的其他纹理的采样坐标进行竖直方向上的翻转。

 

 

在DirectX 和 OpenGL 平台上,存在一些Shader 的语法差异,例如

 

float4 v = float4(0.0);

 

这段代码在OpenGL 上是合法的,但是在DirectX 11 平台上,是会报错。应该写成

 

float4 v = float4(0.0, 0.0, 0.0, 0.0);

 

其他语法上也有一些差异,DirectX 9 / 11 平台也不支持在顶点着色器中使用tex2D 函数,需要使用tex2Dlod 代替。而且我们还需要添加#pragma target 3.0 ,因为tex2Dlod 是 Shader Model 3.0 中的特性。

 

 

Shader 整洁之道

在CG/HLSL中,有3种精度的数值类型:float,half和fixed。这些精度将决定计算结果的数值范围。下表给出了3种精度在通常情况下的数值范围。

上面的精度范围并不是绝地正确的,尤其是在不同平台和GPU上,它们实际的精度可能和上面给出的范围不一致。通常来讲,大多数桌面GPU会把所有计算都按最高的浮点精度进行计算,也就是说,float、half、fixed 在这些平台上实际是等价的。但在移动平台上,它们的确会有不同的精度范围,而且不同精度的浮点值的运算速度也会有所差异。因此,我们应该确保在真正的移动平台上验证我们的Shader。fixed精度实际上只在一些较旧的移动平台上有用,在大多数现代GPU上,它们内部把fixed 和 half 当成同等精度来对待。

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

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

temporary register limit of 8 exceeded

Arithmetic instruction limit of 64 exceeded; 65 arithmetic instruction needed to compile program

出现这些错误信息大多是因为我们再Shader中进行了过多的运算,使得需要的临时寄存器数目或指令数目超过了当前可支持的数目。通常,我们可以通过制定更高级的Shader Target 来消除这些错误。下表给出了Unity 目前支持的Shader Target。

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

Shader Model 是由微软提出的一套规范,通俗地理解就是它们决定了Shader 中各个特性的能力。这些特性和能力体现在Shader 能使用的运算指令数目、寄存器个数等各个方面。Shader Model 等级越高,Shader 的能力就越大。

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

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

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

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

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

另外在Shader编程过程中,不要除以0。


 

2018年10月9日 重新修正:

1、感谢le12380的提醒,许多地方漏掉了分号。这边进行了补充

2、发现在Unity5.3.6中,使用SV_POSITION语义会发出invalid output semantic 'SV_POSITION': Legal indices are in [0,0]的错误,而在低一些的版本中没有出现此错误。可使用o.pos = mul(UNITY_MATRIX_VP, mul(_Object2World, v.vertex));代替SV_POSITION语义。

 

你可能感兴趣的:(游戏编程,Unity-Shader)