第四章 开始Unity Shader学习之旅(1)

1. 一个最简单的顶点/片元着色器

现在,我们正式开始学习如何编写Unity Shader,更准确的说是,学习如何编写顶点/片元着色器

2.顶点/片元着色器的基本结构

我们在以前已经讲过了Unity Shader的基本结构。它包含了Shader、Properties、SubShader、Fallback等语义块。顶点/片元着色器的结构与之大体类似,它的结构如下:

Shader "MyShaderName"{
//属性
}
		SubShader{
		//针对显卡A的SubShader
		Pass{
		 //设置渲染状态和标签
		 //开始Cg代码片段
		 CGPROGRAM
		 //改代码片段的编译指令,例如:
		 #pragma vertex vert
		 #pragma fragment frag
		 //Cg代码写在这里
			ENDCG
			//其他设置
		 }
		 //其它需要的Pass
		SubShader{
		//针对显卡B的Shader
		}
		//上述SubShader都失败后用于回调的UnityShader
		Fallback "VertexLit"
}

其中,最重要的部分是Pass语义块,我们绝大部分代码都是写在这个语义块里的。下面我们就来创建一个最简单的顶点/片元着色器。
(1)新建一个场景,如下图所示:
在这里插入图片描述
可以看到,场景中已经包含了一个摄像机、一个平行光。而且场景的背景不是纯色,而是一个天空盒子(Skybox)。这是因为在Unity5.x中,默认的天空盒子不为空,而是Unity内置的一个天空盒子。为了得到更加原始的效果,我们选择去掉这个天空盒子。做法是,在Unity的菜单中,选择Window->Lighting->Skybox,把该项置空。
(2)新建一个UnityShader
(3)新建一个材质,把新建的UnityShader赋给它
(4)新建一个球体,把刚才的材质赋给它
(5)打开新建的Shader,删除里面的代码,把下面的代码粘进去


保存并返回Unity查看结果。
最后我们得到的结果如图所示:
在这里插入图片描述
这就是我们遇见的第一个真正意义上的顶点/片元着色器,我们有必要来详细的解释一下它。
首先,代码的第一行通过Shader语义定义了这个UnityShader的名字。需要注意的是,在上面的代码里,我们并没有用到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中的语义(semantics),它们是不可省略的,这些语义将告诉系统用户需要哪些输入值,以及用户的输出是什么。例如这里,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中的一个系统语义,它等同于告诉渲染器,把用户的输出颜色存储到一个渲染目标(render target)中,这里将输出到默认的帧缓存中。片元着色器的代码很简单,返回了一个表示白色的fixed4类型的变量。片元着色器输出的颜色的每个分量范围在[0,1],其中红(0,0,0)表示黑色,而(1,1,1)表示白色。
至此,我们已经对第一个顶点/片元着色器进行了详细的解释。但是,现在得到的效果实在是太简单了,如何丰富它呢?下面我们将一步步为它添加更多的内容,以得到一个更加具有实践意义的顶点/片元着色器。

3. 模型数据从哪里来

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

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

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

Shader "Custom/NewSurfaceShader"
{
   SubShader{
   Pass{
   CGPROGRAM
   #pragma vertex vert
   #pragma fragment frag
   //使用一个结构体来定义顶点着色器的输入
   struct a2v{
   //POSITION的语义告诉Unity,用模型空间的顶点坐标填充vertex变量
   float4 vertex:POSITION;
   //NORMAL语义告诉Unity,用模型空间的法线向量填充normal变量
   float4 texcoord:TEXCOORD0;
   };
   float4 vert(a2v v):SV_POSITION{
   //使用v.vertex来访问模型空间的顶点坐标
   return UnityObjectToClipPos(v.vertex);
   }
   fixed 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,TEXCOOR2,TEXCOORD3,COLOR等。
为了创建一个自定义的结构体,我们必须使用如下的格式来定义它:

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

其中,语义是不可以被省略的。很快,我们将给出这些语义的含义和用法。
然后,我们修改了vert函数的输入参数类型,把它设置为我们新定义的结构体a2v。通过这种自定义结构体的方式,我们可以在顶点着色器中访问模型数据。
读者:a2v的名字是什么意思呢?
我们:a表示应用(application),v表示顶点着色器(vertex shader),a2v的意思就是把数据从应用阶段传递到顶点着色器中。
那么填充到POSITION,TANGENT,NORMAL这些语义中的数据究竟是从哪里来的呢?在Unity中,它们是由使用该材质的Mesh Render组件提供的。在每帧调用Draw Call的时候,Mesh Render组件就会把它负责渲染的模型数据发送给Unity Shader。我们知道,一个模型通常包含了一组三角面片,每个三角面片由3个顶点构成,而每个顶点又包含了一些数据,如顶点位置,法线、切线、纹理坐标、顶点颜色等。通过上面方法,我们可以在顶点着色器中访问顶点的这些模型数据。

4. 顶点着色器和片元着色器如何通信

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

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

Shader "Custom/NewSurfaceShader"
{
    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;
	};
	v2f vert(a2v v){
	//声明输出结构
	v2f o;
	o.pos = UnityObjectToClipPos(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;
	}
	fixed4 frag(v2f i):SV_Target{
		//将插值后的i.color显示到屏幕上
		return fixed4(i.color,1.0);
	}
	ENDCG
	}
	}
}

5. 如何使用属性

材质提供给我们一个可以方便调节Unity Shader中参数的方式,通过这些参数,我们可以随时调节材质的效果。而这些参数就要写在Properties语义块中。
现在,我们有了新的需求。我们想要在材质面板显示一个颜色拾取器,从而可以直接控制模型在屏幕上显示的颜色。为此我们需要继续修改上面的代码。


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

有时,读者可能会发现在Cg变量前会有一个uniform关键字,例如:

uniform fixed _Color;

uniform关键字是Cg中修饰变量和参数的一种修饰词,它仅仅用于提供一些关于该变量的初始值是如何指定和存储的相关信息(这和其他一些图像编程接口中的uniform关键词的作用不太一样)。在UnityShader中,uniform关键词是可以省略的。

posted @ 2019-05-08 14:49  御坂御坂001  阅读(445)  评论(0编辑  收藏  举报