前言
经过前面两个章节的铺垫,我们对渲染以及Unity Shaderlab相关的知识已经有了大概的认识,接下来将要学习的就是Shader最重要的部分,SL(Shader Language),着色器语言。目前主流的着色器语言有HLSL,GLSL,Cg。三者在语法上也有诸多共通之处,选择一种学习即可。而在Unity中,主流是选择Cg作为着色器语言。在Shader编写的过程中,我们会经常穿梭在各个空间中,这里不对3D数学部分的前置知识作介绍,相关知识可从前面章节推荐的书籍学习。
在Shaderlab中,有三种着色器的书写方式。一种是Fixed-Function Shader,固定管线着色器。在这个着色器中,我们只能对渲染进行少量的配置,效果也很有限,在Unity 5.x之后的版本,Unity弃用了这种着色器。第二种是Surface Shader,表面着色器,这是Unity为我们提供的一种便于书写的方式,我们可以通过少量的代码,控制光照阴影等繁复的细节由Unity帮我们处理。新建一个Standard Surface Shader,可以看到里面只有50余行代码,但它包含了所有基础实现。最后一种,是Vertex/Fragment Shader,顶点/片元着色器,这是实现各种天马行空想象的最佳场所,当然,它的代码量以及复杂度也是最高的。而前两种shader也会被编译成对应的Vertex/Fragment Shader。这三种书写方式,都是在.shader文件中进行,组织方式上也是极为相似的。
这个系列的重点是Vertex/Fragment Shader。
CGPROGRAM
以之前模板的代码作为例子:
Shader "Blog/Start" {
Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
_Color ("Color", Color) = (1, 1, 1, 1)
}
SubShader {
CGINCLUDE
#include "UnityCG.cginc"
sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _Color;
struct a2v {
float4 vertex : POSITION;
float2 texcoord : TEXCOORD0;
};
struct v2f {
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
};
v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
return o;
}
fixed4 frag(v2f i) : SV_Target {
fixed4 color;
color = tex2D(_MainTex, i.uv);
color *= _Color;
return color;
}
ENDCG
Pass {
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
ENDCG
}
}
FallBack "Diffuse"
}
在这个Shader中,出现了两个不同的代码块。首先第一个是CGINCLUDE代码块,它可以被放置在任何位置,甚至是整个Shader代码块的外部。在这个代码块中,我们可以编写那些需要重用的代码(如顶点着色器或片元着色器)。然后是CGPROGRAM代码块。这个代码块需要放在Pass块内,否则编译器会把这个Shader当成Surface Shader转而去检索surf()
函数进而引起报错。这个代码块也是定义Vertex/Fragment Shader的地方。要保证,每个Pass都有且只有一个Vertex Shader和Fragment Shader。这两个Shader通过#pragma
编译命令指定。接着是两个结构体:
struct a2v {
float4 vertex : POSITION;
float2 texcoord : TEXCOORD0;
};
struct v2f {
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
};
第一个是顶点着色器的输入结构体,a2v即Application To Vertex(应用阶段到顶点着色),在每一个变量的后面都跟了一个冒号说明,冒号后的是这个变量的Semantic(语义),语义是和GPU通信的桥梁,告诉GPU在这个变量中填充什么数据。float4 vertex : POSITION;
告诉GPU,把顶点数据的POSITION(模型空间下的顶点坐标)输入到vertex变量中,float2 texcoord : TEXCOORD0;
的意思是,把纹理坐标集0给texcoord变量使用。在着色器之间的数据传递都是依靠语义实现的,使用结构体只是为了代码组织更有条理。
v2f即Vertex To Fragment,这是顶点着色器的输出结构体,也是片元着色器的输入结构体。float4 pos : SV_POSITION;
:SV指System Value,带有SV前缀的语义在管线中都有特殊的含义,SV_POSITION的含义是裁剪空间下的坐标。
为什么输出裁剪空间下的顶点坐标?
因为这个坐标接下来用于片元着色器,片元着色器需要的是光栅化后的坐标,也就是裁剪空间的坐标。
然后是顶点着色器部分:
v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
return o;
}
这里只是做里简单的空间变换以及纹理映射。既然需要的是裁剪空间的坐标,那直接把输入的顶点坐标变换到裁剪空间即可。UnityObjectToClipPos()
是Unity为我们提供的坐标空间转换函数。
// Tranforms position from object to homogenous space
inline float4 UnityObjectToClipPos(in float3 pos)
{
#if defined(STEREO_CUBEMAP_RENDER_ON)
return UnityObjectToClipPosODS(pos);
#else
// More efficient than computing M*VP matrix product
return mul(UNITY_MATRIX_VP, mul(unity_ObjectToWorld, float4(pos, 1.0)));
#endif
}
inline float4 UnityObjectToClipPos(float4 pos) // overload for float4; avoids "implicit truncation" warning for existing shaders
{
return UnityObjectToClipPos(pos.xyz);
}
可以看到,这个函数处理了一些差别并重载了两个版本,但本质上,都是MVP矩阵右乘顶点坐标的列向量形式。然后是TRANSFORM_TEX
宏。
// Transforms 2D UV by scale/bias property
#define TRANSFORM_TEX(tex,name) (tex.xy * name##_ST.xy + name##_ST.zw)
可以看到这个宏计算了顶点对应的纹理采样位置,计算方式也对应了我们之前说到的_ST(Scale & Tiling,纹理缩放和偏移)相关知识点。然后是片元着色器部分:
fixed4 frag(v2f i) : SV_Target {
fixed4 color;
color = tex2D(_MainTex, i.uv);
color *= _Color;
return color;
}
片元着色器的最终目的是确定片元的像素颜色,即一个RGBA值。首先注意到的是SV_Target,它的语义是:这个着色器只返回一个值,这个值也就是片元的像素颜色值。此外,片元着色器还可以返回多个颜色,这时我们需要用到SV_TargetN
语义,在这个情境下,SV_Target0是对应片元的像素颜色。例:
struct frag_output {
fixed4 color0 : SV_Target0;
fixed4 color1 : SV_Target1;
fixed4 color2 : SV_Target2;
}
frag_output frag(v2f i) {
frag_output output;
// ...
return output;
}
回到模板的片元着色器内部,首先是color = tex2D(_MainTex, i.uv);
,tex2D(sampler2D texture, float2 uv);
是Cg为我们提供的一个纹理采样函数,它将按照输入的uv采样输入的纹理texture,最后返回采样颜色。然后是color *= _Color;
这里只是简单的把采样颜色和shader外部给定的颜色做乘法处理(叠加)。在场景中新建一个sphere,把这个shader添加到一个新的material,再把这个material挂到sphere上,不出意外没有报错的话,即可得到一个纯白色的球(由于没有任何光照阴影计算,也没有给纹理赋值)。这是我们第一个生效的shader。