
文中内容主要参考书籍《unity shader入门精要》,作者为冯乐乐。
代码太长看得人眼花,公式太难看得人犯傻,抓掉了头发一把又一把,谁让我从小就是学渣。翻开书本时英姿勃发,收起卷牍后泫然泣下,学习就是这么味如嚼蜡,其实,最好的学习方法是把知识图表化。
本文中,上半段先把一个最简单的顶点片元着色器实现出来,实现的这个无光材质可以控制颜色。下半段则是对每一个关键字进行解释,最终要用流程图来展示Shader每个结构之间的关系。
Shader "Unity Shaders Book/Chapter 5/Simple Shader For Article 11"
{
Properties
{
_Color ("Color Control", Color) = (1.0, 1.0, 1.0, 1.0)
}
SubShader
{
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
fixed4 _Color;
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};
struct v2f
{
float4 pos : SV_POSITION;
fixed3 color : COLOR;
};
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;
c *= _Color.rgb;
return fixed4(c, 1.0);
}
ENDCG
}
}
}
以上是无光材质的代码,简单而且朴实无华,复制进你的文件里就可以看到材质的变化,接下来,再逐字逐句解一解每个片段包含了什么想法。
现在可以在Unity中新建一个名为”SimpleMaterial”的材质,把刚刚的Shader拖到材质上,再在场景里新建一个球体把材质拖上去,就可以看到下面这样的效果。
出现这种彩色效果的原因是我们取了法线的数值转成颜色输出出来了。在材质的控制面板有一个颜色值可以和球体的基础颜色进行混合:
可以随意调整一下看看变化。
现在我们有了一个Shader和一个材质,我们知道Shader和材质之间是有联系的,Shader提供了属性接口,材质可以通过接口控制显示效果。那么我们可以构建起一个粗略的图表。
再回到代码部分。
Shader "Unity Shaders Book/Chapter 5/Simple Shader For Article 11"
第一句,”Shader”代表这是一个着色器文件,后面引号里的路径代表你可以在Shader下拉列表的哪个位置找到它,在最后一个”/”号后面的是这个Shader的名字。
Properties
{
_Color ("Color Control", Color) = (1.0, 1.0, 1.0, 1.0)
}
紧接着,就是属性声明部分,”Properties”部分定义一个用来声明属性的代码块。”_Color”代表了这个属性在UnityShader内部的名称,一般会在前面加一个下划线,不过这不是必须的,只是一些代码规范。”Color Control”是这个属性在材质中的显示名称。第三个词”Color”则是代表了一种数据类型。等于号后面的(1.0, 1.0, 1.0, 1.0)则是给这个属性赋予初始值。
Properties语义块中的定义方法一般都是这样的:
Name (“display name”, PropertyType) = DefaultValue
根据这句代码再结合上面的解释,相信很快就会理解。
根据这些内容再完善一下我们的图表。
可以看到材质的控制面板里的ColorControl向Properties中的_Color传入Color类型的数据。
再回到代码,接下来就是Shader的主要部分SubShader ,所有有关着色器运算的部分都在这里。而Pass字段代表的是一个完整的渲染流程,它的内部会有一个顶点着色器和一个片元着色器以及各种标签和设置,其中一些东西可以放在SubShader下面,也可以放在Pass内部。
SubShader
{
Pass
{
......
}
}
CGPROGRAM和ENDCG中间是我们最主要的代码段。
#pragma vertex vert
#pragma fragment frag
#pragma的意思是编译指令,它会指定后面的函数将要编译成什么着色器。Vertex和fragment分别表示要把该函数编译为顶点着色器和片元着色器。vert和frag代表函数的名称。
fixed4 _Color;
在CG代码块中还需要再次声明属性的类型才能正常使用属性。
根据上面的内容我们就得到了接下来的图表。
Shader下面包含一个SubShader区块,SubShader区块中又包含有一个Pass块,Pass块中包含了一个名为”vert”的顶点着色器和一个名为”frag”的片元着色器。还可以看到_Color属性在进入到Pass块中的时候经过了一次声明。要注意的是UnityShader中不只是可以包含一个SubShader块,SubShader中也不只是可以包含一个Pass块。
继续我们的代码解读。
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};
这是使用了一个结构体用来定义顶点着色器的输入。”struct”的意思就是结构体,”a2v”是这个结构体的名字。这里把结构体的名字命名为其他名称也可以,但是我们的这个名字并不是无意义的,”a”代表的是应用(application),”v”代表的是顶点着色器,”a2v”就是代表把数据从应用阶段输入到顶点着色器(此处可以回忆之前的文章中关于渲染流水线的部分)。
顶点着色器其实就是我们渲染流程的最开始阶段了,那么要输入顶点着色器的数据从哪里来的?就是从一些已经保存在模型上的数据,或者是由应用程序提供的数据上读取的。
顶点着色器是输入结构体中,一般都是如下的形式:
Type Name : Semantic;
冒号前面的两个词声明了一个数据,冒号后面的词代表从应用阶段获取的数据,用来给已经定义的这个数据赋值。
float 4 vertex :POSITION;
这里声明了一个float4类型的变量vertex。然后用模型空间的顶点坐标给该变量赋值。
第二句代表使用模型空间的法线方向给normal变量赋值。
第三句代表用模型的第一套纹理坐标给texcoord变量赋值。
需要注意的是,上面的”POSITION”、”NORMAL” 、”TEXCOORD0”本身并不是一个数据,它其实相当于一个包装出厂时贴的标签,比如说数据代表了一个饮料,你拿到手之后并不知道它是什么味道的,此时就需要查看饮料瓶上的标签说明,这些标签就是“Semantic”,它告诉我们这个数据从哪来到哪去。
struct v2f
{
float4 pos : SV_POSITION;
fixed3 color : COLOR;
};
顶点着色器的输入部分有了,接下来是顶点着色器的输出部分。这个结构体的名称”v2f”代表的就是把数据从顶点着色器(vertex)输入到片元着色器(fragment)。
第一行的意思是从顶点着色器中输出的这个变量”pos”中的信息是顶点在裁剪空间中的的位置。需要注意的是,在定义了顶点着色器的输出结构体的时候,结构体中是必须有这个数据的,如果没有的话,片元着色器就无法正确的读取顶点在裁剪空间中的位置信息。
第二行的意思是告诉Unity变量”color”中的信息是颜色信息。
然后我们可以根据以上的代码继续完善图表。
从数据的流向来看,结构体就是处于顶点着色器和片元着色器中间的一个承接点,就像是数据传输中的桥梁。
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;
}
接下来就是顶点着色器。
第一个字段”v2f”代表的就是我们上面声明的输出结构体,它表示顶点着色器中的计算结果要由它来储存。
其实不仅仅可以使用结构体来放在上面的位置,也可以用一个变量来放在上面的位置,比如写成:
float4 vert (a2v v):SV_POSITION{…}
但是这样的话就只能承载一个变量,如果顶点着色器想要向片元着色器传输多个变量,就不能再使用类似float4这样的数据类型了。所以说结构体其实就是一个变量组。还有一个不一样的地方在于后面多了一个”SV_POSITION”,为什么要加这个字段呢?顶点着色器最为基本的作用就是把顶点坐标从模型空间转换到裁剪空间,如果顶点着色器的输出只剩下一个变量了,那这个变量一定是就是顶点在裁剪空间的位置信息。在有结构体的时候,这个字段被写在结构体中相应的变量后面了,但是现在没有定义结构体,就只能把这个字段写在现在的位置,来告诉Unity顶点着色器输出的这个变量的是什么。
第二个词”vert”就是我们定义的顶点着色器的名称,无需多言。
括号中的”(a2v v)”代表我们的顶点着色器的输入。就像是要用float的时候必须要给这个变量一个名字一样,要用结构体这个变量组的时候也要给结构体一个名字,”v”就是这个结构体的名字,当然也可以不叫”v”而起一个其他的名字。
再看大括号中的第一句”v2f o;” 这个的含义和上一句话是一个意思,不必多言。
o.pos则是取出结构体中的单个变量来操作。”UnityObjectToClipPos(v.vertex);”代表的是调用Unity内置的一个变换矩阵,把输入结构体v中的变量”vertex”从模型空间转换到裁剪空间。
“o.color” 是取出输出结构体中的单个变量”color”来进行操作。”v.normal”是输入结构体中的normal变量,从上文中我们可以知道这个变量中承载的信息是模型空间的顶点法线方向信息。这句代码是把模型的法线方向信息作为颜色信息输出了,在编写shader时首先需要打开的思路就是:不管是颜色还是矢量还是其他什么数据,都只是计算机中的一串数据罢了,它们可以互相转换,你可以把矢量连接到颜色上输出,也可以把颜色连接到矢量中计算,除了数学规律,没有什么规矩是不可改变的。
再接着看这句的计算”v.normal * 0.5 * fixed3(0.5, 0.5, 0.5)” 。法线信息是一个三维变量,它的取值范围是-1到1,颜色信息也是一个三维变量,它的取值范围是0到1,因此,需要把法线的每个值都转到0到1的范围内才能显示为有意义的颜色信息。
接下来最后一句”return o;”代表返回结构体v2f 。
fixed4 frag(v2f i) :SV_Target
{
fixed3 c = i.color;
c *= _Color.rgb;
return fixed4(c, 1.0);
}
再看片元着色器 中的计算。
片元着色器中的计算结果只是一个四维的变量,所以说我们并不需要再定义一个结构体来储存多个数据。”fixed4”就表示片元着色器的计算结果是一个四维变量。”frag”代表我们定义的片元着色器的名称。”(v2f i)”这里又声明了一个名为”i”的结构体v2f,我们不是在顶点着色器输出的时候就已经声明过一个”o”了吗,为什么要在这里再声明一次。当我们从顶点着色器中输出时,这个结构体中是逐顶点的数据,而到了片元着色器中调用这个结构体时,我们需要的是逐片元的数据,因此,数据在被片元着色器调用时进行了插值。
“SV_Target”则是告诉Unity:片元着色器中输出的这个数据要储存到一个帧缓存中。
大括号中首先声明了一个变量”fixed3 c”,然后用结构体”i”中的”color”变量给它赋值。
在大括号中的第二句中终于用到了我们从一开始声明的属性”_Color”,用来和变量”c”进行乘法计算。
最终返回了”c”的颜色值,并且用”1.0”填充了A分量。
在看过上面的解析之后,再来完善示意图。