Unity系列文章目录

前言

欢迎来到本书的第 2 篇—初级篇。在基础篇中,我们学习了渲染流水线,并给出了Unity
Shader 的基本概况,同时还打下了一定的数学基础。从本章开始,我们将真正开始学习如何在Unity
中编写Unity Shader。
本章的结构如下:在5.1 节,我们将给出编写本书时使用的软件,包括Unity 的版本等。这
是为了让读者可以在实践时不会出现因版本不同而造成困扰。在5.2 节,我们将看到一个最简单
的顶点/片元着色器,并详细地解释这个顶点/片元着色器的组成结构。5.3 节将介绍Unity 内置的
Unity Shader 文件,以及提供给用户的一些包含文件、内置变量和函数等。5.4 节则向读者阐述
Unity Shader 中使用的Cg 语义,这是很多初学者容易困惑的地方。在5.5 节中,我们会介绍如何
对Unity Shader 进行调试。5.6 节将介绍平台差异对Unity Shader 的影响。最后,5.7 节将给出一
些在编写Unity Shader 时很容易实现的优化技巧。为了让读者养成良好的编程习惯,我们在这节
也给出了一些建议。

一、5.1 本书使用的软件和环境

本书使用的Unity 版本是Unity 5.2.1 免费版。使用更高版本的Unity 通常不会有什么影响。
但如果你打算使用更低版本的Unity,那么在学习本书时可能就会遇到一些问题。例如,你发现
有些菜单或变量在你安装的Unity 中找不到,可能就是因为Unity 版本不同造成的。绝大多数情
况下,本书的代码和指令仍然可以工作良好,但在一些特殊情况下,Unity 可能会更改底层的实
现细节,造成同样的代码得到不一样的效果(例如,在非统一缩放时对法线进行变换,详见19.3
节)。还有一些问题是Unity 提供的内置变量、宏和函数,例如我们在书中经常会使用
UnityObjectToWorldNormal 内置函数把法线从模型空间变换到世界空间中,但这个函数是在Unity
5 中被引入的,因此如果读者使用的是Unity 5 之前的版本就会报错。类似的情况还有和阴影相关
的宏和变量等。和Unity 4.x 版本相比,Unity 5.x 最大的变化之一就是很多以前只有在专业版才支
持的功能,在免费版也同样提供了。因此,如果读者使用的是Unity 4.x 免费版,可能会发现本书
中的某些示例无法实现。
本书工程编写的系统环境是Mac OS X 10.9.5。如果读者使用的是其他系统,绝大部分情况也
不会有任何问题。但有时会由于图像编程接口的种类和版本不同而有一些差别,这是因为Mac 使
用的图像编程接口是基于OpenGL 的,而其他平台如Windows,可能使用的是DirectX。例如,在
OpenGL 中,渲染纹理(Render Texture)的(0, 0)点是在左下角,而在DirectX 中,(0, 0)点是在左
上角。在5.6 节,我们将总结一些由于平台而造成的差异问题

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

现在,我们正式开始学习如何编写Unity Shader,更准确地说是,学习如何编写顶点/片元着色器。

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

我们在3.3 节已经看到了Unity Shader 的基本结构。它包含了Shader、Properties、SubShader、
Fallback 等语义块。顶点/片元着色器的结构与之大体类似,它的结构如下:

Shader "MyShaderName" {
Properties {
// 属性
}
SubShader {
// 针对显卡A 的SubShader
Pass {
// 设置渲染状态和标签
// 开始Cg 代码片段
CGPROGRAM
// 该代码片段的编译指令,例如:
#pragma vertex vert
#pragma fragment frag
// Cg 代码写在这里
ENDCG
// 其他设置
}
// 其他需要的Pass
}
SubShader {
// 针对显卡B 的SubShader
}
// 上述SubShader 都失败后用于回调的Unity Shader
Fallback "VertexLit"
}

其中,最重要的部分是Pass 语义块,我们绝大部分的代码都是写在这个语义块里面的。下面
我们就来创建一个最简单的顶点/片元着色器。
(1)新建一个场景,把它命名为Scene_5_2。在Unity 5 中可以得到图5.1 中的效果。

在这里插入图片描述
可以看到,场景中已经包含了一个摄像机、一个平行光。而且,场景的背景不是纯色,而是
一个天空盒子(Skybox)。这是因为在Unity 5.x 版本中,默认的天空盒子不为空,而是Unity 内
置的一个天空盒子。为了得到更加原始的效果,我们选择去掉这个天空盒子。做法是,在Unity
的菜单中,选择Window -> Lighting -> Skybox,把该项置为空。注意,在Unity 4.x 版本中,设置
天空盒子的位置与这里并不一样。
(2)新建一个Unity Shader,把它命名为Chapter5-SimpleShader。
(3)新建一个材质,把它命名为SimpleShaderMat。把第2 步中新建的Unity Shader 赋给它。
(4)新建一个球体,拖曳它的位置以便在Game 视图中可以合适地显示出来。把第3 步中新
建的材质拖曳给它。
(5)双击打开第2 步中创建的Unity Shader。删除里面的所有代码,把下面的代码粘贴进去:

Shader "Unity Shaders Book/Chapter 5/Simple 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 fixed4(1.0, 1.0, 1.0, 1.0);
}
ENDCG
}
}
}

保存并返回Unity 查看结果。
最后,我们得到的结果如图5.2 所示。

在这里插入图片描述

这是我们遇见的第一个真正意义上的顶点/片元着色器,我们有必要来详细地解释一下它。
首先,代码的第一行通过Shader 语义定义了这个Unity Shader 的名字—“Unity Shaders
Book/Chapter 5/Simple Shader”。保持良好的命名习惯有助于我们在为材质球选择Shader 时快速找
到自定义的Unity Shader。需要注意的是,在上面的代码里我们并没有用到Properties 语义块。
Properties 语义并不是必需的,我们可以选择不声明任何材质属性。
然后,我们声明了SubShader 和Pass 语义块。在本例中,我们不需要进行任何渲染设置和标
签设置,因此SubShader 将使用默认的渲染设置和标签设置。在SubShader 语义块中,我们定义
了一个Pass,在这个Pass 中我们同样没有进行任何自定义的渲染设置和标签设置。
接着,就是由CGPROGRAM 和ENDCG 所包围的CG 代码片段。这是我们的重点。首先,我
们遇到了两行非常重要的编译指令:

#pragma vertex vert
#pragma fragment frag
它们将告诉Unity,哪个函数包含了顶点着色器的代码,哪个函数包含了片元着色器的代码。
更通用的编译指令表示如下:
#pragma vertex name
#pragma fragment name
其中name 就是我们指定的函数名,这两个函数的名字不一定是vert 和frag,它们可以是任
意自定义的合法函数名,但我们一般使用vert 和frag 来定义这两个函数,因为它们很直观。
接下来,我们具体看一下vert 函数的定义:
float4 vert(float4 v : POSITION) : SV_POSITION {
return mul (UNITY_MATRIX_MVP, v);
}
这就是本例使用的顶点着色器代码,它是逐顶点执行的。vert 函数的输入v 包含了这个顶点
的位置,这是通过POSITION 语义指定的。它的返回值是一个float4 类型的变量,它是该顶点在
裁剪空间中的位置,POSITION 和SV_POSITION 都是Cg/HLSL 中的语义(semantics),它们是
不可省略的,这些语义将告诉系统用户需要哪些输入值,以及用户的输出是什么。例如这里,
POSITION 将告诉Unity,把模型的顶点坐标填充到输入参数v 中,SV_POSITION 将告诉Unity,
顶点着色器的输出是裁剪空间中的顶点坐标。如果没有这些语义来限定输入和输出参数的话,渲
染器就完全不知道用户的输入输出是什么,因此就会得到错误的效果。在5.4 节中,我们将总结
这些语义。在本例中,顶点着色器只包含了一行代码,这行代码读者应该已经很熟悉了(起码对
这个数学操作应该很熟悉了),这一步就是把顶点坐标从模型空间转换到裁剪空间中。UNITY_
MATRIX_MVP 矩阵是Unity 内置的模型·观察·投影矩阵,我们在4.8 节已经见过它了。
然后,我们再来看一下frag 函数:
fixed4 frag() : SV_Target {
return fixed4(1.0, 1.0, 1.0, 1.0);
}
在本例中,frag 函数没有任何输入。它的输出是一个fixed4 类型的变量,并且使用了SV_Target
语义进行限定。SV_Target 也是HLSL 中的一个系统语义,它等同于告诉渲染器,把用户的输出颜
色存储到一个渲染目标(render target)中,这里将输出到默认的帧缓存中。片元着色器中的代码
很简单,返回了一个表示白色的fixed4 类型的变量。片元着色器输出的颜色的每个分量范围在[0,
1],其中(0, 0, 0)表示黑色,而(1, 1, 1)表示白色。
至此,我们已经对第一个顶点/片元着色器进行了详细的解释。但是,现在得到的效果实在是
太简单了,如何丰富它呢?下面我们将一步步为它添加更多的内容,以得到一个更加具有实践意
义的顶点/片元着色器。
5.2.2 模型数据从哪里来
在上面的例子中,在顶点着色器中我们使用POSITION 语义得到了模型的顶点位置。那么,
如果我们想要得到更多模型数据怎么办呢?
现在,我们想要得到模型上每个顶点的纹理坐标和法线方向。这个需求是很常见的,我们需
要使用纹理坐标来访问纹理,而法线可用于计算光照。因此,我们需要为顶点着色器定义一个新
的输入参数,这个参数不再是一个简单的数据类型,而是一个结构体。修改后的代码如下:

Shader "Unity Shaders Book/Chapter 5/Simple Shader" {
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,它包含了顶点着色器需要的模型数据。
在a2v 的定义中,我们用到了更多Unity 支持的语义,如NORMAL 和TEXCOORD0,当它们作为
顶点着色器的输入时都是有特定含义的,因为Unity 会根据这些语义来填充这个结构体。对于顶
点着色器的输入,Unity 支持的语义有:POSITION, TANGENT,NORMAL,TEXCOORD0,
TEXCOORD1,TEXCOORD2,TEXCOORD3,COLOR 等。
为了创建一个自定义的结构体,我们必须使用如下格式来定义它:
struct StructName {
Type Name : Semantic;
Type Name : Semantic;
.......
};
其中,语义是不可以被省略的。在5.4 节中,我们将给出这些语义的含义和用法。
然后,我们修改了vert 函数的输入参数类型,把它设置为我们新定义的结构体a2v。通过这
种自定义结构体的方式,我们就可以在顶点着色器中访问模型数据。
读者:a2v 的名字是什么意思呢?
我们:a 表示应用(application),v 表示顶点着色器(vertex shader),a2v 的意思就是把数据
从应用阶段传递到顶点着色器中。
那么,填充到POSITION,TANGENT,NORMAL 这些语义中的数据究竟是从哪里来的呢?在Unity
中,它们是由使用该材质的Mesh Render 组件提供的。在每帧调用Draw Call 的时候,Mesh Render 组
件会把它负责渲染的模型数据发送给Unity Shader。我们知道,一个模型通常包含了一组三角面片,
每个三角面片由3 个顶点构成,而每个顶点又包含了一些数据,例如顶点位置、法线、切线、纹理坐
标、顶点颜色等。通过上面的方法,我们就可以在顶点着色器中访问顶点的这些模型数据。
5.2.3 顶点着色器和片元着色器之间如何通信
在实践中,我们往往希望从顶点着色器输出一些数据,例如把模型的法线、纹理坐标等传递

给片元着色器。这就涉及顶点着色器和片元着色器之间的通信。
为此,我们需要再定义一个新的结构体。修改后的代码如下:

```cpp
Shader "Unity Shaders Book/Chapter 5/Simple Shader" {
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 + fixed3(0.5, 0.5, 0.5);
return o;
}
fixed4 frag(v2f i) : SV_Target {
// 将插值后的i.color 显示到屏幕上
return fixed4(i.color, 1.0);
}
ENDCG
}
}
}

在上面的代码中,我们声明了一个新的结构体v2f。v2f 用于在顶点着色器和片元着色器之间
传递信息。同样的,v2f 中也需要指定每个变量的语义。在本例中,我们使用了SV_POSITION 和
COLOR0 语义。顶点着色器的输出结构中,必须包含一个变量,它的语义是SV_POSITION。否则,
渲染器将无法得到裁剪空间中的顶点坐标,也就无法把顶点渲染到屏幕上。COLOR0 语义中的数
据则可以由用户自行定义,但一般都是存储颜色,例如逐顶点的漫反射颜色或逐顶点的高光反射
颜色。类似的语义还有COLOR1 等,具体可以详见5.4 节。
至此,我们就完成了顶点着色器和片元着色器之间的通信。需要注意的是,顶点着色器是逐
顶点调用的,而片元着色器是逐片元调用的。片元着色器中的输入实际上是把顶点着色器的输出
进行插值后得到的结果。
5.2.4 如何使用属性
在3.1.1 节中,我们就提到了材质和Unity Shader 之间的紧密联系。材质提供给我们一个可以
方便地调节Unity Shader 中参数的方式,通过这些参数,我们可以随时调整材质的效果。而这些
参数就需要写在Properties 语义块中。
现在,我们有了新的需求。我们想要在材质面板显示一个颜色拾取器,从而可以直接控制模
型在屏幕上显示的颜色。为此,我们继续修改上面的代码。

Shader "Unity Shaders Book/Chapter 5/Simple Shader" {
Properties {
// 声明一个Color 类型的属性
_Color ("Color Tint", Color) = (1.0,1.0,1.0,1.0)
}
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 + fixed3(0.5, 0.5, 0.5);
return o;
}
fixed4 frag(v2f i) : SV_Target {
fixed3 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 中变量的类型之间的匹配关系如表5.1 所示。

在这里插入图片描述

有时,读者可能会发现在Cg 变量前会有一个uniform 关键字,例如:
uniform fixed4 _Color;
uniform 关键词是Cg 中修饰变量和参数的一种修饰词,它仅仅用于提供一些关于该变量的初
始值是如何指定和存储的相关信息(这和其他一些图像编程接口中的uniform 关键词的作用不太
一样)。在Unity Shader 中,uniform 关键词是可以省略的。

参考

Unity Shader入门精要
作者:冯乐乐