1. Unity Shader 的基础: ShaderLab

学习和编写着色器的过程一直是一个学习曲线很陡峭的过程,通常情况下为了自定义渲染效果往往要和很多文件和设置打交道,这些设置很容易消磨初学者的耐心。而且一些细节问题也往往需要开发者花费较多时间去解决。

Unity为了解决上述问题,为我们提供了一层抽象——Unity Shader。而我们在和这层抽象打交道的途径就是使用Unity提供的一种专门为Unity Shader服务的语言——ShaderLab。

Unity Shader是Unity为开发者提供的高层级的渲染抽象层。下图显示了这样的抽象,Unity希望通过这种方式来让开发者更加轻松地控制渲染。

第二章 Unity Shader基础-LMLPHP

在Unity中,所有的Unity Shader都是使用Shader Lab编写的。Shader Lab是Unity提供的编写Unity Shader的一种说明性语言。它使用一些嵌套在花括号内部的语义(syntax)来描述一个Unity Shader文件的结构。这些结构包含了许多渲染所需的数据,例如Properties语义中定义了着色器所需的各种属性,这些属性会出现在材质面板中。从设计上来说,ShaderLab类似于CgFX和Direct3D Effects(.FX)语言,它们都定义了要显示一个材质所需的所有东西,而不仅仅是着色器代码。

一个Unity Shader的基础结构如下所示:

Shader "ShaderName"{
Properties{
//属性
}
SubShader{
//显卡A使用的子着色器
}
SubShader{
//显卡B使用的子着色器
}
Fallback“VertexLit”
}

Unity会在背后根据使用的平台把这些结构编译成真正的代码和Shader文件,而开发者只需要和Unity Shader打交道即可。

2. Unity Shader的结构

2.1 给我们的Shader起名字

每个Unity Shader文件的第一行都需要通过Shader语义来指定该Unity Shader的名字。这个名字由一个字符串来定义,例如“MyShader”,当为材质选择使用的UnityShader时,这些名称就会出现在材质面板的下拉列表里。通过在字符串中添加斜杠(“/”)可以控制Unity Shader在材质面板中出现的位置。例如:

Shader "Custom/MyShader"{}

那么这个Unity Shader在材质面板中的位置是:Shader->Custom->MyShader,如下图所示

第二章 Unity Shader基础-LMLPHP

2.2 材质和Unity Shader的桥梁:Properties

Properties语义块中包含了一些列属性(property),这些属性将会出现在材质面板中。

Properties语义块的定义通常如下:

Properties{
Name ("display name", PropertyType)= DefaultValue
Name ("display name", PropertyType) = DefaultValue
// 更多属性
}

开发者声明这些属性是为了在材质面板中能够方便的调整各材质属性。如果我们需要在Shader中访问它们,就需要使用每个属性的名字(Name)。在Unity中,这些属性的名字通常由一个下滑线开始。显示的名称(display name)则是出现在材质面板上的名字。我们需要为每个属性指定它的类型(Property Type),常见的属性类型,如下表所示。除此之外,我们还需要为每个属性指定一个默认值,在我们第一次把该Unity Shader赋给某个材质时,材质面板上显示的就是这些默认值。

第二章 Unity Shader基础-LMLPHP

对于Int、Float、Range这些数字类型,其默认值就是一个单独数字;对于Color和Vector这类属性。默认值是用圆括号包起来的一个四维向量;对于2D、Cube、3D这三种纹理类型。默认值的定义稍微复杂,它们的默认值是用过一个字符串后跟一个花括号来指定的,其中字符串要么是空的,要么是内置的纹理名称,如“white”“black”“gray”或者“bump”。花括号的用处原本是用于指定一些纹理属性的,例如在Unity5.0以前的版本中,我们可以通过TexGenCubeReflect、TexGen CubeNOrmal

等选项来控制固定管线坐标的生成。但在Unity5.0以后的版本中,这些选项被移除了,如果我们需要类似的功能,就需要自己在顶点着色器中编写计算相应纹理坐标的代码。

下面代码给出了一个展示所有属性类型的例子:

Shader "Custom/ShaderLab"
{
Properties{
//Numbers and Sliders
_Int("Int", Int) = 2
_Float("Float", Float) = 1.5
_Range("Range", Range(0.0,5.0) = 3.0)
// Colors and Vectors
_Color("Color", Color) = (1,1,1,1)
_Vector("Vector",Vector) = (2,3,6,1)
// Textures
_2D("2D", 2D) = ''"{}
_cube("Cube", Cube) = "White" {}
_3D("3D", 3D) = "black" {}
}
FallBack "Diffuse"
}

下图给出了在材质面板显示的结果

第二章 Unity Shader基础-LMLPHP

有时,我们想要在材质面板上显示更多类型的变量,例如使用布尔变量来控制Shader中使用哪种计算。Unity允许我们重载默认的材质编辑面板,以提供更多自定义的数据类型。

为了在Shader中可以访问到这些属性,我们需要在Cg代码片中定义和这些属性相匹配的变量。需要说明的是,即使我们不在Properties语义块中声明这些属性,也可以直接在Cg代码片中定义变量。此时我们可以通过脚本向Shader中传递这些属性。因此,Properties语义块的作用仅仅是为了让这些属性可以出现在材质面板中。

2.3 重量级成员SubShader

每一个UnityShader文件可以包含多个SubShader语义块,但至少要有一个。当Unity需要加载这个UnityShader时,Unity会扫描所有的SubShader语义块,然后选择第一个能够在目标平台上运行的SubShader。如果都不支持的话,Unity就会使用Fallback语义指定的Unity Shader。

Unity提供这种语义的原因在于,不同的显卡有不同的能力。例如,一些旧的显卡仅能支持一定数目的操作指令,而一些更高级的显卡可以支持更多的指令数,那么我们希望在旧的显卡上计算复杂度较低的着色器,在高级的显卡上使用计算复杂度较高的着色器,以便提供更出色的画面。

SubShader语义块包含的定义通常如下:

SubShader{

// 可选的

[Tags]

// 可选的

[RenderSetup]

pass{

}

// other passes

}

SubShader中定义了一系列Pass可选的状态([RenderSetup])和标签([Tags])设置。每个Pass定义了一次完整的渲染流程,但如果Pass的树木过多,往往会造成渲染性能的下降。因此我们应该尽量的使用最小数目的Pass。状态和标签同样可以在Pass中声明。不同的是,SubShader中的一些标签设置是特定的。也就是说,这些标签设置和Pass中使用的标签是不一样的。而对于状态设置来说,其使用的语法是相同的。但是如果我们在SubShader进行了这些设置,那么将会用于所有的Pass。

状态设置

ShaderLab提供了一系列渲染状态的设置指令,而这些指令可以设置显卡的各种状态,例如是否是开启混合/深度测试等,下表给出了ShaderLab中常见的渲染状态设置选项。

第二章 Unity Shader基础-LMLPHP

当在SubShader块中设置了上述渲染状态时,就会应用到所有的Pass。如果我们不想这样(例如在双面渲染中,我们希望在第一个Pass中剔除正面来对背面进行渲染,在第二个Pass中剔除背面来对正面进行渲染),可以在Pass语义块中单独进行上面的设置。

SubShader的标签

SubShader的标签(Tags)是一个键值对(Key/Value Pair),它的键和值都是字符串类型。这些键值对是SubShader和渲染引擎沟通的桥梁。它们用来告诉Unity的渲染引擎:我希望怎样以及何时渲染这个对象。

标签的结构如下:

Tags {"TagName1" = "Value1" "TagName2" = "Value2"}

SubShader的标签块支持的标签类型如下表所示。

第二章 Unity Shader基础-LMLPHP

具体的标签设置我们后面慢慢说,需要注意的是,上述标签仅可以在SubShader中使用,而不可以在Pass块中声明。Pass块中虽然也可以设置标签,但这些标签是不同于SubShader的标签类型。

Pass语义块

Pass语义块包含的语义如下

Pass{
[Name]
[Tags]
[RenderSetup]
// other code
}

首先我们可以在Pass块中定义该Pass的名称,例如:

Name "MyPassName"

通过这个名称,我们可以使用ShaderLab的UsePass命令来直接使用Unity Shader中的其他Pass。例如:

UsePass "MyShader/MYPASSNAME"

这样可以提高代码的复用性。需要注意的是,由于Unity内部会把所有的Pass的名字转换成大写字母的表示,因此,在使用UsePass命令时必须使用大写形式的名字。

其次,我们也可以对Pass设置渲染状态。SunShader的状态设置同样适用于Pass。除了上面提到的状态设置外,在Pass中我们还可以使用固定管线着色器的命令。

Pass同样可以设置标签,但它的标签不同于SubShader标签。这些标签也是用于告诉渲染引擎我们希望怎样来渲染该物体。下图给出了Pass中使用的标签类型。

第二章 Unity Shader基础-LMLPHP

除了上面的普通Pass外,Unity Shader还支持一些特殊的Pass,以便进行代码复用或实现更复杂的效果。

UsePass:如我们之前提到的一样,可以使用该命令来复用其他Unity Shader 中的Pass;

GrabPass :该Pass负责抓取屏幕并将结果存储在一张纹理中,以用于后续的Pass处理。

2.4留一条后路:Fallback

紧跟在个SubShader语义块后面的,可以是一个Fallback指令。它用于告诉Unity,“如果上面所有的SubShader在这块显卡上都不能运行,那么就使用这个最低级的Shader吧” 。

它的语义如下:

Fallback "name"
// 或者
Fallback Off

如上所述,我们可以通过一个字符串来告诉Unity这个最低级的Unity Shader是谁。我们也可以任性的关闭Fallback功能,但一旦你这么做,你的意思大概就是:如果一个显卡跑不了上面的所有SubShader,那就不要管它了。

下面给出了一个使用Fallback语句的例子:

Fallback "VertexLit"

事实上,Fallback还会影响阴影的投射。在渲染阴影纹理时,Unity会在每个UnityShader中寻找一个阴影投射的Pass。通常情况下,我们不需要自己专门实现一个Pass,这是因为Fallback使用的内置Shader中包含了这样一个通用的Pass。因此,为每个UnityShader正确设置Fallback是非常重要的。

2.5 ShaderLab还有其他的语义吗

除了上述语义,还有一些不常用的语义。例如,如果我们不满足Unity内置的属性类型,想要自定义材质面板的编辑面板,就可以使用CustomEditor语义来扩展编辑界面。我们还可以使用Category语义来对Unity Shader中的命令进行分组。

3. Unity Shader的形式

在上面我们讲了Unity Shader文件的结构以及ShaderLab的语法。尽管Unity Shader可以做的事情很多(例如设置渲染状态等),但其最重要的任务还是指定各种着色器所需的代码。这些着色器代码可以写在SubShader语义块中(表面着色器的做法),也可以写在Pass语义块中(顶点/片元着色器和固定函数着色器的做法)。

在Unity中,我们可以使用下面三种形式来编写Unity Shader。而不管使用哪种形式,真正意义上的Shader代码都需要包含在ShaderLab语义块中,如下图所示:

Shader "MyShader"{
Properties{
/所需的各种属性
}
SubShader{
//真正意义上的Shader代码会出现在这里
//表面着色器(Surface Shader)或者
//顶点/片元着色器(Vertex/Fragment Shader)或者
//固定函数着色器(Fixed Function Shader)
}
SubShader{
/和上一个SubShader类似
}
}

3.1 Unity的新宠:表面着色器

表面着色器(Surface Shader)是Unity自己创造的一种着色器代码类型。它需要的代码量很少,Unity在背后做了很多工作,但渲染的代价比较大。它的本质上和下面要讲的顶点/片元着色器是一样的。也就是说,当Unity提供一个表面着色器时,它在背后仍然把它转换成对应的顶点/片元着色器。我们可以理解为,表面着色器时Unity对顶点/片元着色器的更高一层的抽象。它存在的价值在于。Unity为我们处理了很多光照细节,使我们不必再操心这些烦人的事情。

一个非常简单的表面着色器代码如下:

Shader "Custom/Simple Surface Shader"{
SubShader{
Tags {"RenderType" = "Opaque"}
CGPROGRAM
#pragma surface surf Lambert
struct Input{
float 4 color : COLOR;
};
void surf (Input In, inout SurfaceOutput o){
o.Alebo = 1;
}
ENDCG
}
Fallback "Diffuse"
}

从上述程序中可看出,表面着色器被定义在SubShader语义块(而非Pass语义块)中的CGPROGRAM和ENDCG之间。原因是,表面着色器不需要开发者关心使用多少个Pass、每个Pass如何渲染等问题,Unity会在背后为我们做好这些事情。我们要做的就是告诉它使用这些纹理去填充颜色,使用这个法线纹理去填充法线,使用Lambert光照模型,其他的不要来烦我哦。

CGPROGRAM和ENDCG之间的代码是用Cg/HLSL编写的,也就是说。我们需要把Cg/HLSL语言嵌套在ShaderLab语言中。值得注意的是,这里的Cg/HLSL是Unity经封装后提供的,它的语法和标准的Cg/HLSL几乎一样,但还是有细微的不同,例如有些原生的函数和用法Unity没有提供支持。

3.2 最聪明的孩子:顶点/片元着色器

在Unity中我们可以使用Cg/HLSL语言来编写顶点/片元着色器(Vertex/Fragment Shader)。他们更加复杂,但灵活性也更高。

一个非常简单的顶点/片元着色器代码如下:

Shader"Custom/Simple VertexFragment Shader"{
SubShader{
Pass{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
float4 vert(float4 v :POSITION):SV_POSITION{
return mul (UNITY_MATRIX_MVP,v);
}
fixed4 frag() : SV_Target{
return fixed(1.0,0.0,0.0,1.0);
}
ENDCG
}
}
}

和表面着色器类似,顶点/片元着色器的代码也要定义在CGPROGRAM和ENDCG之间,但不同的是,顶点/片元着色器是写在Pass语义块内,而非SubShader内。原因是,我们需要自己定义每个Pass需要使用的Shader代码。虽然我们可能需要编写更多的代码,但带来的好处是灵活性很高。更重要的是。我们可以控制渲染的实现细节。同样,这里的CGPROGRAM和ENDCG之间的代码也是使用Cg/HLSL编写的。

3.3 被抛弃的角落:固定函数着色器

上面两种Unity Shader形式都使用了可编程管线。而对于一些较旧的设备,他们不支持可编程管线着色器,因此,这时候我们就需要使用固定函数着色器(Fixed Function Shader)来完成渲染。这些着色器往往只可以完成一些非常简单的效果。

一个非常简单的固定函数着色器示例代码如下:

Shader"Tutorial/Basic"{
Properties {
_Color ("Main Color", Color) = (1,0.5,0.5,1)
}
SubShader {
Pass{
Material{
Diffuse [_Color]
}
Lighting On
}
}
}

可以看出,固定函数着色器代码被定义在Pass语义块中,这些代码相当于Pass中的一些渲染设置。

对于固定着色器来说。我们需要完全使用ShaderLab的语法(即使用ShaderLab的渲染设置命令)来编写,而非使用Cg/HLSL

由于现在绝大多数GPU都支持可编程的渲染管线,这种固定渲染管线的编程方式已经逐渐被抛弃,实际上,在Unity5.2中,所有固定函数着色器都会在背后被Unity编译成对应的顶点/片元着色器,因此真正意义上的固定函数着色器已经不存在了。

3.4 选用哪种UnityShader形式

那么我们究竟选用哪一种Unity Shader的编写呢?在这里给出一些建议。

1.除非你有非常明确的需求必须要使用固定函数着色器,例如在非常旧的设备上运行你的游戏(这些设备非常少见),否则请使用可编程管线的着色器,即表面着色器或顶点/片元着色器

2.如果你想和各种光源打交道,你可能更喜欢使用表面着色器,但需小心它在移动平台的性能表现。

3.如果你需要使用的光照数目非常少,例如只有一个平行光,那么使用顶点/片元着色器是一个更好的选择。

4.最重要的是,如果你有很多自定义的渲染效果,那么请选择顶点/片元着色器。

04-27 03:42