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

最简单的顶点/片元着色器

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Shader "MyShaderName"{
Properties{
// 属性
}
SubShader{
// 针对显卡A的SubShader
Pass{
// 设置渲染状态和标签

// 开始Cg代码片段
CGPROGRAM

// 该代码片段的编译指令
#pragma vertex vert
#pragma fragment frag

// Cg代码写在这里

// 结束Cg代码段
ENDCG

// 其他设置
}

// 其他需要的Pass
}

SubShader{
// 针对显卡B的SubShader
}

// 上面的SubShader都失败后用于回调的Unity Shader
Fallback "VertexLit"
}

其中最重要的是Pass语义块.我们绝大多数的代码都是写在Pass语义块中的.
下面是一个实际的最简单的顶点/片段着色器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Shader "Custom/myShader"{
SubShader{
Pass{
CGPROGRAM

// 告诉unity,vert函数包含了顶点着色器代码,frag函数包含了片段着色器代码.
#pragma vertex vert
#pragma fragment frag

float4 vert(float4 v : POSITION) : SV_POSITION {
return mul(UNITY_MATRIX_MVP, v);
// MVP矩阵是: 当前的模型矩阵·观察矩阵·投影矩阵,用于将顶点/方向矢量从模型空间变换到剪裁空间
}

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

ENDCG
}
}
}

vert函数里的POSITION和SV_POSITION都是Cg/HLSL中的语义(semantics),是不可省略的,它们告诉系统用户需要哪些输入值,以及用户的输出是什么.例如这里:
POSITION告诉Unity,把模型顶点坐标填充到参数v
SV_POSITION告诉Unity,顶点着色器的输出是剪裁空间中的顶点坐标
如果没有这些语义来限定输入和输出参数的话,渲染器就完全不知道用户的输入和输出是什么,因此会得到错误的结果.

本例中的frag函数没有任何输入,它的输出是一个fixed4类型的变量,并且使用了SV_Target语音进行限定.
SV_Targrt也是HLSL中的一个系统语义,它等同于告诉渲染器,把用户的输出颜色存储到一个渲染目标(render target)中,这里将输出到默认的帧缓存中.

模型数据从哪来

如想要得到更多的模型数据(如顶点的纹理坐标和法线方向),我们需要为顶点着色器定义一个结构体作为输入参数.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
Shader "Custom/MyShader"{
SubShader{
Pass{
CGPROGRAM

#pragma vertex vert
#pragma fragment frag

// 使用一个结构体来定义顶点着色器的输入
struct a2v{

// POSITION语义告诉Unity, 用模型空间的顶点坐标填充vertex变量
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,它包含了顶点着色器需要的模型数据.
对于顶点着色器的输入,Unity支持的语义有: POSITION, TANGENT, NORMAL, TEXCOORD0, TEXCOORD1, TEXCOORD2, TEXCOORD3, COLOR 等.

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

1
2
3
4
5
struct StructName{
Type Name : Semantic;
Type Name : Semantic;
......
}

然后,我们又修改了vert函数的输入类型为a2v.

a表示应用(application),v表示顶点着色器(vertex shader),a2v的意思就是把数据从应用阶段传递到顶点着色器中.

顶点着色器和片元着色器的通信

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
Shader "Custom/Myshader"{
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 = mul(UNITY_MATRIX_MVP, v.vertex);

// v.normal包含了顶点的法线方向, 其分量在[-1.0, 1.0]
// 下面的代码将分量范围映射到了[0.0, 1.0]
// 存储到o.color中传递给片元着色器
o.color = v.normal * 0.5 + float3(0.5, 0.5, 0.5);

return o;
}

fixed4 frag(v2f i) : SV_Target{

// 将插值后的i.color显示到屏幕上
return fixed4(i.color, 1.0);
}

ENDCG
}
}
}

在上面代码中,我们定义了一个v2f结构体在顶点着色器和片元着色器之间传递信息.
顶点着色器的输出结构中,必须包含一个语义为SV_POSITION的变量,否则渲染器会无法得到剪裁空间中的顶点坐标,也就无法将颜色渲染到屏幕上.

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

如何使用属性

通过材质,我们可以方便地调节Unity Shader中的参数,从而随时调整材质的效果.
这些参数需要卸载Properties语义块中.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
Shader "Custom/Myshader"{
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_MVP, v.vertex);
o.color = v.normal * 0.5 + float3(0.5, 0.5, 0.5);
return o;
}

fixed4 frag(v2f i) : SV_Target{
float3 c = i.color;

// 使用-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变量类型
Color, Vector float4, half4, fixed4
Range, Float float, half, fixed
2D sampler2D
Cube samplerCube
3D sampler3D

有时会遇到uniform

1
uniform fixed4 _Color;

uniform关键词是Cg中修饰变量和参数的一种修饰词,它仅仅用于提供一些关于该变量的初始值是如何指定和存储的相关信息.
在Unity Shader中,uniform关键词是可以省略的
(所以uniform到底是个啥啊)

Unity内置文件和变量

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

内置的包含文件

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

1
2
3
4
CDPROGRAM
// ...
#include "UnityCG.cginc"
// ...

unity\Editor\Data\CGIncludes文件夹包含了一些内置组件或功能需要的UnityShader.

CDIncludes中主要的包含文件及它们的用处:

文件名 描述
UnityCG.cginc 包含了最常使用的帮助函数,宏和结构体等
UnityShaderVriables.cginc 在编译Unity Shader时,会被自动包含进来.包含了许多内置的全局变量,如UNITY_MATRIX_MVP等
Lighting.cginc 包含了各种内置的关照模型,如果包含的是表面着色器的话会自动包含进来
HLSLSupport.cginc 在编译Unity Shader时,会被自动包含进来.声明了许多跨平台编译的宏和定义

除这些之外,Unity5引入了许多新的重要的包含文件,如UnityShaderVariables.cginc, UnityStandardCore.cginc等,这些包含文件用于实现基于物理的渲染,我们会在18章再次遇到它们

UnityCG.cginc是我们最常接触的一个包含文件.它提供了很多结构体和函数方便我们编写Shader.例如,我们可以直接使用UnityCG.cginc中预定义的结构体作为顶点着色器的输入和输出.

名称 描述 包含的变量
appdata_base 可用于顶点着色器的输入 顶点位置,顶点法线,第一组纹理坐标
appdata_tan 可用于顶点着色器的输入 顶点位置,顶点切线,顶点法线,第一组纹理坐标
appdata_full 可用于顶点着色器的输入 顶点位置,顶点切线,顶点法线,四组(或更多)纹理坐标
appdata_img 可用于顶点着色器的输入 顶点位置,第一组纹理坐标
v2f_img 可用于顶点着色器的输出 裁剪空间中的位置

除了结构体外,UnityCG.cginc也提供了一些常用的帮助函数

函数名 描述
float3 WorldSpaceViewDir(float4 v) 输入一个模型空间中的顶点位置,返回世界空间中从该点到摄像机的观察方向
float3 ObjSpaceViewDir(float4 v) 输入一个模型空间中的顶点位置,返回模型空间中该点到摄像机的观察方向
float3 WorldSpaceLightDir(float4 v) 仅可用于前向渲染中.输入一个模型空间中的顶点位置,返回世界空间中从该点到光源的光照方向.没有被归一化
float3 ObjSpaceLightDir(float4 v) 仅可用于前向渲染中.输入一个模型空间中的顶点位置,返回模型空间中从该点到光源的光照方向.没有被归一化
float3 UnityObjectToWorldNormal(float3 norm) 把法线方向从模型空间变换到世界空间中
float3 UnityObjectToWorldDir(float3 dir) 把方向矢量从模型空间变换到世界空间中
float3 UnityWordToObjectDir(float3 dir) 把方向矢量从世界空间变换到模型空间中

内置的变量

Unity还提供了用于访问时间,光照,雾效和环境光等目的的变量.
这些内置变量大多位于UnityShaderVariables.cginc中,与光照有关的内置变量还会位于Lighting.cginc, AutoLighting.cginc等文件中.
后面遇到再详细讲解

Unity提供的Cg/HLSL语义

语义

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

通常情况下,这些输入输出变量并不需要有特别的意义.也就是说,我们可以自行决定这些变量的用途.

在DX 10之后,有一种新的语义类型,就是系统数值语义(system-value semantics).这类语义是以SV开头的,SV代表的含义就是系统数值(system-value).这些语义在渲染流水线中有特殊的含义.例如我们用SV_POSITION语义去修饰顶点着色器的输出变量pos,那么就表示pos包含了可用于光栅化的变换后的顶点坐标.

这些语义修饰的变量时不可以随意赋值的,因为流水线需要使用它们来完成特定的目的.例如渲染引擎会把用SV_POSITION修饰的变量经过光栅化后显示在屏幕上.
有时会看到同一个变量在不同的Shader里面使用了不同的语义修饰.例如,一些Shader会使用POSITION而非SV_POSITION来修饰顶点着色器的输出.SV_POSITION是DirectX 10中新引入的系统数值语义,在绝大多数平台上,它和POSITION是等价的,但在某些平台(例如索尼PS4)上必须使用SV_POSITION来修饰顶点着色器的输出.否则无法让Shader正常工作.

因此,对于这些有特殊含义的变量我们最好使用SV开头的语义进行修饰.

Unity支持的语义

从应用阶段传递模型数据给顶点着色器时Unity支持的常用语义

语义 描述
POSITION 模型空间中的顶点位置,通常是float4类型
NORMAL 顶点法线,通常是float3类型
TANGENT 顶点切线,通常是float4类型
TEXCOORDn 该顶点的纹理坐标.通常是float2或float4类型
COLOR 顶点颜色,通常是fixed4或者float4类型

其中TEXCOORDn是指TEXCOORD0,TEXCOORD1…其中n的数目是和Shader Model有关的,例如一般在Shader Model2(即Unity默认编译到的Shader Model版本)和Shader Model3中,n等于8,而在Shader Model5中,n等于16.
通常情况下,一个模型的纹理坐标数一般不超过2,我们往往只使用TEXCOORD0和TEXCOORD1.
在Unity中内置的数据结构体appdata_full中,它最多使用了6个纹理坐标

从顶点着色器传递数据给片元着色器时Unity使用的常用语义

语义 描述
SV_POSITION 裁剪空间中的顶点坐标,结构体中必须包含一个用该语义修饰的变量.
COLOR0 通常用于输出第一组颜色信息,但不是必须的
COLOR1 通常用于输出第二组颜色信息,但不是必须的
TEXCOORD0~TEXCOORD7 通常用于输出纹理坐标,但不是必须的

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

片元着色器输出时Unity支持的常用语义

语义 描述
SV_Target 输出值将会存储到渲染目标(render target)中

如何定义复杂的变量类型

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

1
2
3
4
5
6
7
struct v2f{
float4 pos : SV_POSITION;
fixed3 color0 : COLOR0;
fixed4 color1 : COLOR1;
half value0 : TEXCOORD0;
float2 value1 : TEXCOORD1;
};

关于何时使用哪些变量类型,我们会在5.7.1节给出一些建议.但需要注意的是,一个语义可以使用的寄存器只能处理4个浮点值.
因此,如果我们想要定义矩阵类型,如float3X4等变量就需要使用更多的空间.一种方法是,将这些变量拆分成多个变量,例如对于float4X4的矩阵类型,我们可以拆分成4个float类型的变量,每个变量存储了矩阵中的一行数据.

Debug

书上P111

渲染平台的差异

书上p115

Shader整洁

float, half还是fixed

类型 精度
float 最高精度浮点值,通常使用32位来存储
half 中等精度浮点值,通常使用16位来存储,精度是-60,000 ~ 60,000
fixed 最低精度浮点值,通常使用11位来存储,精度是-2.0~2.0

上面的精度范围并不是绝对正确的,尤其是在不同平台和GPU上,他们的实际精度可能和上面给出的范围不一致.通常来讲.

  • 大多数现代的桌面GPU会把所有计算都按最高的浮点精度进行计算.也就是说,float,half,fixed在这些平台上实际是等价的.这意味着我们在PC上很难看出因为half和fixed精度不同而带来的不同.
  • 但在移动平台的GPU上,它们的确会有不同的精度范围.而且不用精度的浮点值的运算速度也会有差异.因此,我们应该确保在真正的移动平台上试验我们的Shader
  • fixed精度实际上只在一些较旧的移动平台上有用,大多数现代GPU上,它们内部把fixed和half当成同样精度来对待.

尽管有上面的不同,单一个基本建议是,尽可能使用精度较低的类型,因为这可以优化Shader的性能,这一点在移动平台上尤其重要.从它们的大体的值域范围来看,我们可以使用

  • fixed类型来存储颜色和单位矢量
  • half存储更大的数据
  • 最差的情况再使用float

如果我们的目标是移动平台,一定要确保在真实的手机上测试我们的Shader.
关于移动平台的优化技术,更多内容见16章

规范语法

在5.6.2节,我们提到了DirectX平台对Shader的语义有更加严格的要求.这意味着,如果我们要发布到DirectX平台上就需要使用更严格的语法.
例如,使用和变量类型相匹配的参数数目来对变量进行初始化.

避免不必要的计算

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

temporary register limit of 8 exceeded

Arithmetic instruction limit of 64 exceeded; 65 arithmetic instructions needed to compileprogram

这样的错误太多是因为我们在Shader中进行了大量的运算,使得需要的临时寄存器数目或指令数目超过了当前可支持的数目.
不同的Shader Target,不同的着色器阶段,我们可使用的临时寄存器和指令数目都是不同的.

通常,我们可以通过指定更高级的Shader Target来消除这些错误.

指令 描述
#pragma target2.0 默认的Shader Target等级,相当于Direct3D上的Shader Model2.0,不支持对顶点纹理的采样,不支持显式的LOD纹理采样等
#pragma target3.0 相当于Direct3D 9 上的Shader Model 3.0, 支持对顶点纹理的采样等
#pragma target4.0 相当于Direct3D 10 上的Shader Model 4.0,支持几何着色器等
#pragma target5.0 相当于Direct3D11上的Shader Model 5.0

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

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

慎用分支和循环语句

if-else,for,while这些流程控制指令在GPU上的实现和在CPU上大不相同.在最坏的情况下,我们花在一个分支语句的时间相当于运行所有分支语句的时间.因此不提倡在Shader中使用流程控制语句.

如果我们的Shader中使用了大量的流程控制语句,那么这个Shader的性能可能会成倍下降.
一个解决方法是,我们应尽量把计算向流水线上端移动.例如把片段着色器中的计算放到顶点着色器中,或者直接在CPU中进行预计算,再把结果传递给Shader.

实在要用到分支语句时:

  • 分支判断语句中使用的条件变量最好是常数,即在Shader运行过程中不会发生变化.
  • 每个分支中包含的操作指令尽可能少
  • 分支嵌套层数尽可能少

不要除以0

Shader中,除以0不会报错.注意.