顶点-片段着色器基础(Vertex-Fragment Shader)
编码工具
推荐使用Vistual Studio
创建和使用Shader
在Unity中,Shader就是包含了CG/HLSL和ShaderLab的文本文件。
实际上,无论是创建 Standard Surface Shader 还是 Image Effect Shader,都可以把它编写成想要的Shader。
不同之处在于:Unity 会在不同类型的Shader里预先插入一部分代码,从而便于最开始的编写。
在Unity里Shader一般有两种用途:
- 指定给材质,用于物体渲染,这是最常用的。
- 指定给剧本,用于图像处理,例如:后期处理(Post Processing)
Shader 的编写方式
Unity 渲染管线中的Shader可以通过以下的方式编写;
- 顶点-片段着色器
- 表面着色器
- 或者固定函数着色器
固定函数着色器主要用于在老的图形设备上运行,目前以及逐渐被抛弃。
CG 语法基础
在Unity Shader中,ShaderLab语言只是起到组织代码结构的作用,而真正实现渲染效果的部分是用CG语言编写的。
编译指令
CG程序片段通过指令嵌入在Pass中,夹在指令 CGPROGRAM 和 ENDCG 之间,通常看起来是这样的:
Pass
{
// ...设置渲染状态...
CGPROGRAM
//编译指令
#pragma vertex vert
#pragma fragment frag
// CG 代码
ENDCG
}
在编译程序之前,通常需要先使用#pragma声明编译指令
CG中常用的编译指令
编译指令 | 作用 |
#pragma vertex name | 定义顶点着色器的名称,通常会使用vert |
#pragma fragment name | 定义片段着色器的名称,通常会使用frag |
#pragma target name | 定义 Shader 要编译的目标级别,默认2.5 |
1.编译目标等级
当编写玩Shader程序之后,其中的Shader代码可以被编译到不同的Shader Models(简称SM)中,为了能够使用更高级的GPU功能,需要对应使用更高级的编译目标。
但是有一点需要注意的是,高等级的编译目标可能会导致Shader无法在旧的GPU上运行,因此编译目标等级并不是越高越好。
声明编译目标的级别可以使用 #pragma target name 指令,或者也可以使用#pragma require feature 指令直接声明某个特定的功能,例如:
#pragma target 3.5 //目标等级
#pragma require geometry tessellation //需要几何体细分功能
2.渲染平台
最简单的 Shader
Shader "Custom/Simplest Shader"
{
SubShader
{
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
void vert(in float4 vertex : POSITION, out float4 position : SV_POSITION)
{
position = UnityObjectToClipPos(vertex);
}
void frag(in float4 vertex : SV_POSITION, out float4 color : SV_TARGET)
{
color = fixed(1,0,0,1);
}
ENDCG
}
}
}
1.无返回值的函数
函数分为有返回值和无返回值。
无返回值就是函数不会返回任何变量,而是通过out关键词将变量输出。
无返回值函数的语法结构如下所示:
void name (in 参数, out 参数)
{
//函数体
}
关键词解释:
- void:函数以void开头,表示返回值为空
- name:定义函数的名称,后续可以通过这个名称调用函数
- in:输入参数,语法为:in+数据类型+名称,一个函数可以有多个输入,关键词in可以省略
- out:输出参数,语法为:out+数据类型+名称,一个函数可以有多个输出。
2.有返回值的函数
既然有无返回值的函数,那么反过来肯定会存在有返回值的函数。
有返回值的函数不再使用 out 关键词输出参数,而是会在最后通过return关键词返回一个变量,语法结构如下所示:
type name(in 参数)
{
//函数体
return返回值;
}
顶点函数和片段函数中支持的数据类型
数据类型 | 描述 |
fixed,fixed2,fixed3,fixed4 | 低精度浮点值,使用11位精度进行存储,数值区间为[-2.0, 2.0],用于存储颜色、标准化后的向量等。 |
half,half2,half3,half4 | 中精度浮点值,使用16位精度进行存储,数值区间为[-60000,60000] |
float,float2,float3,float4 | 高精度浮点值,使用32位精度进行存储,用于存储顶点坐标、未标准化的向量、纹理坐标等 |
struct | 结构体,可以将多个变量整体进行打包 |
即
void vert(in float4 vertex : POSITION,out float4 position : SV_POSITION)
{
position = UnityObjectToClipPos(vertex);
}
等价于
float4 vert(in float4 vertex: POSITION : SV_POSITION)
{
//返回裁切空间顶点坐标
return UnityObjectToClipPos(vertex);
}
语义
当使用CG语言编写着色器的时候,函数的输入参数和输出参数都需要填充一个语义(Semantic)来表示它们要传递的数据信息。
语义可以执行大量烦琐的操作,使用户能够避免直接与GPU底层进行交流。参数后被冒号隔开并且全部大写的关键词就是语义。
1.顶点着色器的输入语义
语义 | 描述 |
POSITION | 顶点的坐标信息,通常为float3 或 float4 类型 |
NORMAL | 顶点的法线信息,通常为float3类型 |
TEXCOORD0 | 模型的第一套UV坐标,通常为float2、float3 或 float4 类型, TEXCOORD0到 TEXCOORD3分别对应第一到第四套UV坐标。 |
TANGENT | 顶点的切向量,通常为float4类型 |
COLOR | 顶点的颜色信息,通常为float3类型 |
当顶点信息包含的元素少于顶点着色器输入所需要的元素时,缺少的部分会被0填充,而w分量会被1填充。
例如:顶点的UV坐标通常是二维向量,只包含x和y元素。
如果输入的语义 TEXCOORD0 被声明为 flaot4 类型,那么顶点着色器最终获得的数据将变为(x,y,0,1)
2.顶点着色器输出和片段着色器输入语义
在整个渲染流水线中,顶点着色器最重要的一项任务就是需要输出顶点在裁切空间中的坐标。
这样GPU就可以知道顶点在屏幕上
顶点着色器产生的输出值将会在三角形遍历阶段经过插值计算,最终作为像素值输入到片段着色器。
换句话说,顶点着色器的输入即为片段着色器的输入。
语义 | 描述 |
SV_POSITION | 顶点在裁切空间中的坐标,float4类型 |
TEXCOORD0、TEXCOORD1等 | 用于声明任意高精度的数据,例如纹理坐标、向量等 |
COLOR0、COLO1等 | 用于声明任意低精度的数据,例如顶点颜色、数值区间[0,1]的变量 |
片段着色器会自动获取顶点着色器输出的裁切空间顶点坐标,所以片段函数输入的SV_POSITION可以省略。这也解释了为什么有些Shader的片段函数中只有输出参数没有输入参数。
需要特别注意的是,与顶点函数的输入语义不同,TEXCOORDn 不再特质模型的UV坐标,COLORn也不再特指顶点颜色。它们的适用范围更广,可以用于声明任何符合要求的数据,所以在使用过程中不要被语义的名称欺骗了。
3.片段着色器输出语义
片段着色器只会输出一个 fixed4 类型的颜色信息,输出的值会存储到渲染目标(Render Target)中,输出参数使用SV_TARGET语义进行填充。
在CG中调用属性变量
在CG代码块中如何调用Properties中开放出来的属性
1.CG中声明属性变量
Shader通过Properties代码块声明开放出来的属性,如果想要在Shader程序中访问这些属性,则需要在CG代码块中再次进行声明,它的语法为:type name;
type 为变量的类型,name为属性变量的名称。
需要注意的是,必须在函数调用属性之前对其进行声明,否则编译会失败。
Shader "Custom/Properties"
{
Properties
{
_MyFloat("Float Property", Float) = 1 //浮点类型
_MyRange("Range Property", Range(0,1)) = 0.1 //范围类型
_MyColor("Color Property", Color) = (1,1,1,1) //颜色类型
_MyVector("Vector Property",Vector) = (0,1,0,0) //向量类型
_MyTex("Texture Property",2D) = "white" {} //2D贴图类型
_MyCube("Cube Property",Cube) = ""{} //立方体贴图类型
_My3D("3D Property",3D) = ""{} //3D贴图类型
}
SubShader
{
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
//在CG中声明属性变量
float _MyFloat; //浮点类型
float _MyRange; //范围类型
float4 _MyColor; //颜色类型
float4 _MyVector; //向量类型
sampler2D _MyTex; //2D贴图类型
samplerCUBE _MyCube; //立方体贴图类型
sampler3D _My3D; //3D贴图类型
void vert()
{
}
void frag()
{
}
ENDCG
}
}
FallBack "Diffuse"
}
开放属性与CG属性变量的对应关系
开放属性的类型 | 开放属性与CG属性变量的对应关系 |
Float,Range | 浮点和范围类型的属性,根据精度可以使用float, half 或 fixed 声明 |
Color, Vector | 颜色和向量类的属性,可以使用float4,half4 或 fixed4 声明,其中颜色使用低精度的fixed4声明可以减少性能消耗 |
2D | 2D纹理贴图属性,使用sample2D声明 |
Cube | 立方体贴图属性,使用samplerCube声明 |
3D | 3D纹理贴图属性,使用sampler3D声明 |
2.在Shader中使用颜色
将Simplest Shader进行修改,以实现随意更改颜色的功能。
Shader "Custom/Simplest Shader"
{
Properties
{
//开放属性颜色
_MainColor("MainColor",Color) = (1,1,1,1)
}
SubShader
{
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
//声明颜色变量 _MainColor
fixed4 _MainColor;
void vert(in float4 vertex : POSITION,
out float4 position : SV_POSITION)
{
position = UnityObjectToClipPos(vertex);
}
void frag(in float4 vertex : SV_POSITION,
out float4 color : SV_TARGET)
{
//调用颜色变量 _MainColor
color = _MainColor;
}
ENDCG
}
}
}
3.在Shader中使用贴图
纹理贴图作为3D制作中最常用的美术资源,在Shader中的使用是相当频繁的。
纹理贴图在Properties代码块中被定义之后,还需要在CG代码块中再次声明,但是与其他属性不同的是,CG还需要声明一个变量用于存储贴图的其他信息。
在CG中,声明一个纹理变量的 Tiling 和 Offset 的语法结构如下所示:float {TextureName} _ST;
TextureName: 纹理属性的名称
ST: Scale 和 Transform 的首字母,表示 UV(什么是UV贴图和展开?游戏建模纯干货,UV的详细解释,不懂得赶紧看过来:https://blog.csdn.net/shooopw/article/details/111169660)的缩放和平移。
在CG所声明的变量为 float4 类型, 其中 x 和 y 分量分别为 Tiling 的X值 和 Y 值, z 和 w 分量分别为 Offset 的 X 值和 Y 值。
纹理坐标的计算公式为:
texcoord = uv · {TextureName}.xy + {TextureName}.zw
在计算纹理的时候,一定要先乘以平铺值再加偏移值 。
先放大、再平移。如果顺序错误,会出现问题。 矩阵一般不能颠倒顺序运算
增加对纹理资源的支持
_MainTex("MainTex",2D) = "white" { }
Properties 代码块中开放了名称为 _MainTex 的纹理属性,默认值为白色。
sampler2D_MainTex;
float4 _MainTex_ST;
在CG中重新声明了_MainTex 属性变量以及它的平铺偏移变量 _MainTex_ST
void vert(in float4 vertex :POSITION, in float2 uv : TEXCOORD0,
out float4 position : SV_POSITION, out float2 texcoord : TEXCOORD0)
{
position = UnityObjectToClipPos(vertex);
//使用公式计算纹理坐标
texcoord = uv * _MainTex_ST.xy + _MainTex_ST.zw;
}
在顶点函数中,添加了一个名称为 uv 的输入参数用于获取顶点顶点的UV数据之后,又添加了一个名称为texcoord的输出参数用于保存在顶点函数中计算出的纹理坐标。
按照公式,把顶点的UV乘以平铺值然后加上偏移值得到了纹理坐标texcoord,然后输出到片段着色器中。
void frag (in float4 position : SV_POSITION, in float2 texcoord : TEXCOORD0,
out fixed4 color : SV_TARGET)
{
color = tex2D(_MainTex,texcoord) * _MainColor;
}
在片段着色器中添加了一个名称为 Texcoord 的输入参数用于接收从顶点着色器传递过来的纹理变量。
然后调用tex2D()函数,使用纹理坐标texcoord对纹理_MainTex进行采样,最后将采样结果乘上 _MainColor 进行输出。
改写之后的代码如下所示:
Shader "Custom/Simplest Shader"
{
Properties
{
_MainColor ("MainColor",Color) = (1,1,1,1)
//开放纹理属性
_MainTex("MainTex",2D) = "white" {}
}
SubShader
{
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
fixed4 _MainColor;
//声明纹理属性变量以及ST变量
sampler2D _MainTex;
float4 _MainTex_ST;
void vert(in float4 vertex : POSITION,
in float2 uv : TEXCOORD0,
out float4 position : SV_POSITION
out float2 texcoord : TEXCOORD0)
{
position = UnityObjectToClipPos(vertex);
//使用公式 计算纹理坐标
texcoord = uv * _MainTex_ST.xy + _MainTex_ST.zw;
}
void frag(in float4 vertex : SV_POSITION,
in float2 texcoord : TEXCOORD,
out float4 color : SV_TARGET)
{
color = tex2D(_MainTex,texcoord) * _MainColor;
}
ENDCG
}
}
}
通常情况下,纹理资源都需要按照这种流程进行使用,除非能够确定某个纹理资源永远不会用到Tiling 和 Offset , 则可省略对该纹理ST变量的声明,同时不再计算其纹理坐标,于是顶点函数可以做以下删减:
void vert (in float4 vertex : POSITION, int float2 uv : TEXCOORD0,
out float4 position : SV_POSITION, out float2 texcoord : TEXCOORD0)
{
position = UnityObjectToClipPos(vertex);
texcoord = uv;
}
UV 坐标输入到顶点之后无需计算直接输出,片段函数获取到UV之后直接对纹理进行采样。
4.在Shader中使用立方体贴图
Cubemap 是一个六个方向组成的立方体盒子,也可以在Unity中使用全景图(Panorama)转换得到,通常被用来作为环境的反射。
对于立方体贴图采样所使用的函数为:texCUBE(Cube,r);
函数中的Cube表示立方体贴图,r表示视线方向在物体表面上的反射方向。
Cube可以直接在CG中声明这个属性变量,然后直接获取。r通过以下办法获得:
r = 2[(-v) · n]n + v ,其中, n是单位向量(物体表面的法线向量),v可以通过顶点坐标减去摄像机坐标得到(视线向量),上述所有向量均已标准化处理过。
添加对立方体贴图的支持:
//添加 Cubemap 属性和反射强度
_Cubemap("Cubemap",Cube) = "" { }
_Reflection("Reflection",Range(0,1)) = 0
Properties代码块中新开放了Cubemap属性,名称为_Cubemap, 又开放了控制反射强度的属性,名称为_Reflection。
//声明Cubemap和反射属性变量
samplerCUBE _Cubemap;
fixed _Reflection;
然后在CG中分别使用samplerCUBE 和 fixed 对新增的属性变量再次声明。
void vert(in float4 vertex : POSITION, in float3 normal : NORMAL,
in float4 uv : TEXCOORD0, out float4 position : SV_POSITION,
out float4 worldPos : TEXCOORD0, out float3 worldNormal : TEXCOORD1,
out float2 texcoord : TEXCOORD2)
{
position = UnityObjectToClipPos(vertex);
//将顶点坐标变换到世界空间
worldPos = mul(unity_ObjectToWorld, vertex);
//将法向量变换到世界空间
worldNormal = mul(normal, (float3x3)unity_WorldToObject);
worldNormal = normalize(worldNormal);
texcoord = uv * _MainTex_ST.xy + _MainTex_ST.zy;
}
在顶点函数中,使用Unity提供的变换矩阵 unity_ObjectToWorld将顶点坐标从模型空间变换到世界空间。
为了避免非统一缩放(例如,x,y,z轴的Scale分别为1,2,3)的物体法线方向偏移,使用法线向量右乘逆矩阵的方法对其进行空间变换。
void frag(in float4 position : SV_POSITION, in float4 worldPos : TEXCOORD0,
in float3 worldNormal : TEXCOORD1, in float2 texcoord : TEXCOORD2,
out fixed4 color : SV_Target)
{
fixed4 main = tex2D(_MainTex, texcoord) * _MainColor;
//计算世界空间中从摄像机指向顶点的方向向量
float3 viewDir = worldPos.xyz - _WorldSpaceCameraPos;
viewDir = normalize(viewDir);
//套用公式计算反射向量
float3 refDir = 2 * dot(- viewDir, worldNormal)
* worldNormal + viewDir;
refDir = normalize(refDir);
//对Cubemap采样
fixed4 reflection = texCUBE(_Cubemap, refDir);
//使用_Reflection对颜色和反射进行线性计算
color = lerp(main,reflection,_Reflection);
}
将世界空间顶点坐标 worldPos 和世界空间法向量 worldNormal 传入到片段函数之后,使用顶点坐标 worldPos.xyz 减去摄像机坐标 _WorldSpaceCameraPos(Unity提供的可以直接使用的变量)得到从摄像机指向顶点的方向向量 viewDir 比规范化。 然后套用反射向量的计算公式计算反射向量 refDir ,再使用 texCUBE( ) 函数对其进行采样,得到反射颜色 reflection。
最后在lerp( ) 函数中使用 _Reflection 属性对颜色和反射进行线性插值,当 _Reflection 为0物体只显示原本颜色,为1则只显示反射颜色,中间值时按照比例进行显示。
修改之后的Shader如下所示:
Shader "Custom/TestShader"
{
Properties
{
_MainColor("MainColor",Color) = (1,1,1,1)
//开放纹理属性
_MainTex("MainTex",2D) = "white" { }
//添加 Cubemap 属性和反射强度
_Cubemap("Cubemap",Cube) = "" { }
_Reflection("Reflection",Range(0,1)) = 0
}
SubShader
{
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
fixed4 _MainColor;
//声明纹理属性变量以及ST变量
sampler2D _MainTex;
float4 _MainTex_ST;
//声明Cubemap和反射属性变量
samplerCUBE _Cubemap;
fixed _Reflection;
void vert(in float4 vertex : POSITION, in float3 normal : NORMAL,
in float4 uv : TEXCOORD0, out float4 position : SV_POSITION,
out float4 worldPos : TEXCOORD0, out float3 worldNormal : TEXCOORD1,
out float2 texcoord : TEXCOORD2)
{
position = UnityObjectToClipPos(vertex);
//将顶点坐标变换到世界空间
worldPos = mul(unity_ObjectToWorld, vertex);
//将法向量变换到世界空间
worldNormal = mul(normal, (float3x3)unity_WorldToObject);
worldNormal = normalize(worldNormal);
texcoord = uv * _MainTex_ST.xy + _MainTex_ST.zy;
}
void frag(in float4 position : SV_POSITION, in float4 worldPos : TEXCOORD0,
in float3 worldNormal : TEXCOORD1, in float2 texcoord : TEXCOORD2,
out fixed4 color : SV_Target)
{
fixed4 main = tex2D(_MainTex, texcoord) * _MainColor;
//计算世界空间中从摄像机指向顶点的方向向量
float3 viewDir = worldPos.xyz - _WorldSpaceCameraPos;
viewDir = normalize(viewDir);
//套用公式计算反射向量
float3 refDir = 2 * dot(-viewDir, worldNormal)
* worldNormal + viewDir;
refDir = normalize(refDir);
//对Cubemap采样
fixed4 reflection = texCUBE(_Cubemap, refDir);
//使用_Reflection对颜色和反射进行线性计算
color = lerp(main, reflection, _Reflection);
}
ENDCG
}
}
}
HDRI,全景图,Cubemap资源下载网站:https://3dmodelhaven.com/
注意,需要将贴图的属性面板中将Texture Shape从2D改为Cube,确认之后即可转换为立方体贴图。
结构体
在实际编写过程中,着色器通常需要输入输出多个参数。例如上一小节中讲解的 Cubemap Property,需要同时将顶点坐标、法线向量和第一套UV传入到顶点着色器。然后再同时输出裁切空间的顶点坐标、世界空间顶点坐标、世界空间法线向量和纹理坐标到片段着色器。
由于函数有多个输入和输出,为了使代码编写更加方便,并且看起来更加美观,本小节引入一个新的数据类型——结构体(Structure)。
1.结构体语法
结构体允许存储多个不同类型的变量,并将多个变量包装成为一个整体进行输入或者输出。
结构体的语法如下:
struct Type
{
//变量_!;
//变量_2;
//变量_3;
//变量_n;
};
struct: 定义结构体的关键词。
Type: 给当前结构体定义一种类型,着色器函数定义输入和输出数据类型时会用到。结构体内包含的变量仍然需要定义数据类型和名称,然后填充对应的语义。最后通过结构体名称],[变量名称]的语法访问,例如:v.vertex,表示访问名称为v的结构体内的vertex变量。
Texture Property Shader 改写为以结构体作为输入和输出的Shader,代码如下所示:
Shader "Custom/In Out Struct"
{
Properties
{
_MainColor("MainColor",Color) = (1,1,1,1)
_MainTex("MainTex",2D) = "white" { }
}
SubShader
{
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
//定义顶点着色器的输入结构体
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
//定义顶点着色器的输出结构体
struct v2f
{
float4 position : SV_POSITION;
float2 texcoord : TEXCOORD0;
};
fixed4 _MainColor;
sampler2D _MainTex;
float4 _MainTex_ST;
//使用结构体传入和传出参数
void vert(in appdata v, out v2f o)
{
o.position = UnityObjectToClipPos(v.vertex);
o.texcoord = v.uv * _MainTex_ST.xy + _MainTex_ST.zw;
}
void frag(in v2f i, out fixed4 color : SV_TARGET)
{
color = tex2D(_MainTex, i.texcoord) * _MainColor;
}
ENDCG
}
}
}
appdata 是 ApplicationData 的缩写,表示从3D应用获取道德数据。然后又定义了一个类型为 v2f 的结构体,并把之前顶点着色器的输出参数写在结构体内。 v2f 是 vertex to fragment 的缩写,表示从顶点着色器传递到片段着色器的数据。
2.返回结构体的函数
函数可以写成有返回和无返回的形式。
函数可以返回结构体。
Shader "Custom/Return Struct"
{
Properties
{
_MainColor("MainColor",Color) = (1,1,1,1)
_MainTex("MainTex",2D) = "white" { }
}
SubShader
{
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
//定义顶点着色器的输入结构体
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
//定义顶点着色器的输出结构体
struct v2f
{
float4 position : SV_POSITION;
float2 texcoord : TEXCOORD0;
};
fixed4 _MainColor;
sampler2D _MainTex;
float4 _MainTex_ST;
//使用结构体传入和传出参数
v2f vert(appdata v)
{
//声明结构体名称
v2f o;
o.position = UnityObjectToClipPos(v.vertex);
o.texcoord = v.uv * _MainTex_ST.xy + _MainTex_ST.zw;
return o;
}
fixed4 frag (v2f i) : SV_TARGET
{
return tex2D(_MainTex, i.texcoord) * _MainColor;
}
ENDCG
}
}
}
上述Shader中定义了appdata 和 v2f这两个结构体
在Shader中引入了结构体之后,代码更加简洁了。