第5章 开始 Unity Shader 学习之旅
5.2 一个最简单的顶点/片元着色器
顶点/片元着色器的基本结构
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'
// 定义 shader 的名字
Shader "Chapter 5/Simple Shader"
{
SubShader
{
Pass {
// 生命 CG 代码块
CGPROGRAM
// 告诉 Unity 哪个函数包含了着色器的代码
#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
}
}
FallBack "Diffuse"
}
首先的重点代码片段是两行非常重要的编译指令:
#pragma vertex vert
#pragma fragment frag
更通用的指令如下:
#pragma vertex name
#pragma fragment name
其中 name 就是我们指定的函数名,它们可以是任意自定义的合法函数名,但我们一般用 vert
、frag
来定义这两个函数,因为它们很直观。
接下来是顶点着色器代码片段:
float4 vert(float4 v : POSITION) : SV_POSITION {
return mul(UNITY_MATRIX_MVP,v);
}
代码中的 POSITION
、SV_POSITION
都是 Cg/HLSL 中的语义(semantics)
,它们是不可忽略的,这些语义将告诉系统用户需要哪些输入值,以及用户的输出是什么。例如这里,POSITION 将告诉 Unity,把模型的顶点坐标填充到输入参数v中,SV_POSITION 将告诉 Unity,顶点着色器的输出是裁剪空间中的顶点坐标。如果没有这些语义来限定输入和输出参数的话,渲染器就完全不知道用户的输入输出是什么,因此就会得到错误的效果。
最后,我们来看看片元着色器的代码:
fixed4 frag() : SV_Target {
return fixed4(1.0, 1.0, 1.0, 1.0);
}
frag 函数返回一个颜色值,SV_Target
语义告诉渲染器,把用户的输出颜色存储到一个渲染目标中,这里将输出到默认的帧缓存中。
在unity中新建一个球体,并把带有该 shader 的材质附在球体上,可以看到一个白色的球体:
模型数据从哪来
如果我们想要获得更多的模型数据应该怎么办?为此我们需要定义一个新的结构体,并在结构体中包含我们需要的模型数据:
// 定义 shader 的名字
Shader "Chapter 5/Simple Shader"
{
SubShader
{
Pass {
// 生命 CG 代码块
CGPROGRAM
// 告诉 Unity 哪个函数包含了着色器的代码
#pragma vertex vert
#pragma fragment frag
struct a2v {
// 顶点坐标
float4 vertex : POSITION;
// 法线向量
float3 normal : NORMAL;
// 模型的第一套纹理坐标
float3 texcoord : TEXCOORD0;
};
// 顶点着色器,将顶点坐标转换为裁剪空间的左边
float4 vert(a2v i) : SV_POSITION {
return UnityObjectToClipPos(i.vertex);
}
// 片元着色器,返回白色
fixed4 frag() : SV_Target {
return fixed4(1.0, 1.0, 1.0, 1.0);
}
ENDCG
}
}
FallBack "Diffuse"
}
我们声明了一个新的结构体 a2v, 结构体内通过语义 POSITION
、NORMAL
、TEXCOORD0
等语义使其包含了新的模型数据。
那么,填充到POSITION,TEXCOORD0,NORMAL 这些语义中的数据究竟是从哪里来的呢?在Unity中,它们是由使用该材质的 Mesh Render
组件提供的。在每帧调用 Draw Call 的时候,Mesh Render 组件会把它负责渲染的模型数据发送给 Unity Shader。我们知道,一个模型通常包含了一组三角面片,每个三角面片由3个顶点构成,而每个顶点又包含了一些数据,例如顶点位置、法线、切线、纹理坐标、顶点颜色等。通过上面的方法,我们就可以在顶点着色器中访问顶点的这些模型数据。
顶点着色器和片元着色器之间如何通信
实践中我们往往希望从顶点着色器输出一些数据到片元着色器,为此,我们需要再定义一个新的结构体:
// 定义 shader 的名字
Shader "Chapter 5/Simple Shader"
{
SubShader
{
Pass {
// 生命 CG 代码块
CGPROGRAM
// 告诉 Unity 哪个函数包含了着色器的代码
#pragma vertex vert
#pragma fragment frag
struct a2v {
// 顶点坐标
float4 vertex : POSITION;
// 法线向量
float3 normal : NORMAL;
// 模型的第一套纹理坐标
float3 texcoord : TEXCOORD0;
};
struct v2f {
float4 position : SV_POSITION;
fixed3 color : COLOR0;
};
// 顶点着色器,将顶点坐标转换为裁剪空间的左边
v2f vert(a2v i) {
v2f o;
o.position = UnityObjectToClipPos(i.vertex);
// v.normal包含了顶点的法线方向,其分量范围在[-1.0, 1.0]
// 下面的代码把分量范围映射到了[0.0, 1.0]
// 存储到o.color中传递给片元着色器
o.color = i.normal * 0.5 + fixed3(0.5, 0.5, 0.5);
return o;
}
// 片元着色器,返回白色
fixed4 frag(v2f i) : SV_Target {
return fixed4(i.color, 1.0);
}
ENDCG
}
}
FallBack "Diffuse"
}
上面的代码中,我们定义了一个新的结构体 v2f 用于在顶点着色器和片元着色器之间传递信息。需要注意的是,顶点着色器是逐顶点调用的,二片元着色器是逐片元调用的,片元着色器中的输入实际是把顶点着色器的输出进行插值后得到的结果。修改后的 shader 效果如下:
如何使用属性
// 定义 shader 的名字
Shader "Chapter 5/Simple Shader"
{
// 定义属性
Properties {
_Color ("Color Tint", Color) = (1.0, 1.0, 1.0, 1.0)
}
SubShader
{
Pass {
// 生命 CG 代码块
CGPROGRAM
// 告诉 Unity 哪个函数包含了着色器的代码
#pragma vertex vert
#pragma fragment frag
// 在 Cg 代码中我们需要定义一个匹配的同名变量
fixed4 _Color;
struct a2v {
// 顶点坐标
float4 vertex : POSITION;
// 法线向量
float3 normal : NORMAL;
// 模型的第一套纹理坐标
float3 texcoord : TEXCOORD0;
};
struct v2f {
float4 position : SV_POSITION;
fixed3 color : COLOR0;
};
// 顶点着色器,将顶点坐标转换为裁剪空间的左边
v2f vert(a2v i) {
v2f o;
o.position = UnityObjectToClipPos(i.vertex);
// v.normal包含了顶点的法线方向,其分量范围在[-1.0, 1.0]
// 下面的代码把分量范围映射到了[0.0, 1.0]
// 存储到o.color中传递给片元着色器
o.color = i.normal * 0.5 + fixed3(0.5, 0.5, 0.5);
return o;
}
// 片元着色器,返回白色
fixed4 frag(v2f i) : SV_Target {
return fixed4(i.color * _Color.rgb, 1.0);
}
ENDCG
}
}
FallBack "Diffuse"
}
在上面的代码中,我们首先添加了 Properties 语义块
中,并在其中声明了一个属性 _Color,它的类型是 Color,初始值是 (1.0,1.0,1.0,1.0),对应白色。为了在 Cg 代码中可以访问它,我们还需要在Cg代码片段中提前定义一个新的变量,这个变量的名称和类型必须与 Properties 语义块中的属性定义相匹配。ShaderLab 和 Cg 的类型匹配关系如下:
有时,读者可能会发现在Cg变量前会有一个uniform
关键字,例如:
uniform fixed4 _Color;
uniform
关键词是 Cg 中修饰变量和参数的一种修饰词,它仅仅用于提供一些关于该变量的初始值是如何指定和存储的相关信息。在Unity Shader中,uniform关键词是可以省略的。
5.3 Unity 内置文件和变量
内置的包含文件
包含文件(include file)
,是类似于C++中头文件的一种文件。在 Unity 中,它们的文件后缀是 .cginc
。在编写Shader 时,我们可以使用 #include
指令把这些文件包含进来,这样我们就可以使用 Unity 为我们提供的一些非常有用的变量和帮助函数:
#include "UnityCG.cginc"
Unity 将它的内置包含文件放在安装目录的 Contents/CGIncludes
目录下,MAC 的地址为 /Applications/Unity/Hub/Editor/2021.3.9f1c1/Unity.app/Contents/CGIncludes
,其中版本随 Unity 版本变化:
其中一些常见的包含文件作用如下:
可以看出,有一些文件是即便我们没有使用 #include 指令,它们也是会被自动包含进来的,例如UnityShaderVariables.cginc
。
UnityCG.cginc
是我们最常接触的一个包含文件,在后面的学习中,我们将使用很多该文件提供的结构体和函数,为我们的编写提供方便,强烈建议好好看看它的源码。
5.4 Unity 提供的 Cg/HLSL 语义
什么是语义
语义实际是就是一个赋给 Shader 输入和输出的字符串,这个字符串表达了这个参数的含义,通俗的说,这些语义可以让 Shader 知道从哪里读取数据,并把数据输出到哪里,他们在 Cg/HLSL 的 Shader 流水线中是不可或缺的。需要注意的是,Unity 并没有支持所有语义。
需要注意的是,即便语义的名称一样,如果出现的位置不同,含义也不同。例如,TEXCOORD0 既可以用于描述顶点着色器的输入结构体 a2v,也可用于描述输出结构体 v2f。但在输入结构体a2f中,TEXCOORD0 有特别的含义,即把模型的第一组纹理坐标存储在该变量中,而在输出结构体v2f中,TEXCOORD0 修饰的变量含义就可以由我们来决定。
DirectX 10后,有了一种新的语义类型,叫做系统数值语义(system-value semantics)
,这类语义是以 SV 开头的,这些语义在渲染管线中有特殊的含义。
读者有时可能会看到同一个变量在不同的 Shader 里面使用了不同的语义修饰。例如,一些Shader会使用POSITION 而非 SV_POSITION来修饰顶点着色器的输出。SV_POSITION 是 DirectX 10中引入的系统数值语义,在绝大多数平台上,它和 POSITION 语义是等价的,但在某些平台(例如索尼 PS4)上必须使用SV_POSITION 来修饰顶点着色器的输出,否则无法让Shader正常工作。同样的例子还有 COLOR 和 SV_Target。因此,为了让我们的 Shader 有更好的跨平台性,对于这些有特殊含义的变量我们最好使用以 SV 开头的语义进行修饰。
5.5 程序员的烦恼: Debug
Shader中可以选择的调试方法非常有限,甚至连简单的输出都不行。本节旨在给出 Unity 中对 Unity Shader 的调试方法,这主要包含了两种方法。
使用假彩色图像
假彩色图像(false-color image)
指的是用假彩色技术生成的一种图像。与假彩色图像对应的是照片这种真彩色图像(true-color image)
。一张假彩色图像可以用于可视化一些数据,主要思想是,我们可以把需要调试的变量映射到[0, 1]之间,把它们作为颜色输出到屏幕上,然后通过屏幕上显示的像素颜色来判断这个值是否正确。之前 shader 中的彩球就是以假彩色图像的形式将法线向量数据展示在场景中。
需要注意的是,由于颜色的分量范围在[0, 1],因此我们需要小心处理需要调试的变量的范围。如果我们已知它的值域范围,可以先把它映射到[0, 1]之间再进行输出。如果你不知道一个变量的范围(这往往说明你对这个Shader中的运算并不了解),我们就只能不停地实验。一个提示是,颜色分量中任何大于1的数值将会被设置为1,而任何小于0的数值会被设置为0。因此,我们可以尝试使用不同的映射,直到发现颜色发生了变化(这意味着得到了0~1的值)。
作为示例,下面我们可以使用假彩色图像可视化一些模型数据,如法线、切线、纹理坐标、顶点颜色等:
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'
// 定义 shader 的名字
Shader "Chapter 5/Simple Shader"
{
SubShader
{
Pass {
// 生命 CG 代码块
CGPROGRAM
// 告诉 Unity 哪个函数包含了着色器的代码
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct v2f {
float4 pos : SV_POSITION;
fixed4 color : COLOR0;
};
v2f vert(appdata_full v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
// // 可视化法线方向
o.color = fixed4(v.normal * 0.5 + fixed3(0.5, 0.5, 0.5), 1.0);
// // 可视化切线方向
// o.color = fixed4(v.tangent.xyz * 0.5 + fixed3(0.5, 0.5, 0.5), 1.0);
// // 可视化副切线方向
// fixed3 binormal = cross(v.normal, v.tangent.xyz) * v.tangent.w;
// o.color = fixed4(binormal * 0.5 + fixed3(0.5, 0.5, 0.5), 1.0);
// // 可视化第一组纹理坐标
// o.color = fixed4(v.texcoord.xy, 0.0, 1.0);
// // 可视化第二组纹理坐标
// o.color = fixed4(v.texcoord1.xy, 0.0, 1.0);
// // 可视化第一组纹理坐标的小数部分
// o.color = frac(v.texcoord);
// if (any(saturate(v.texcoord) - v.texcoord)) {
// o.color.b = 0.5;
// }
// o.color.a = 1.0;
// // 可视化第二组纹理坐标的小数部分
// o.color = frac(v.texcoord1);
// if (any(saturate(v.texcoord1) - v.texcoord1)) {
// o.color.b = 0.5;
// }
// o.color.a = 1.0;
// // 可视化顶点颜色
// o.color = v.color;
return o;
}
fixed4 frag(v2f i) : SV_Target {
return i.color;
}
ENDCG
}
}
FallBack "Diffuse"
}
利用 Visual Studio
Visual Studio作为Windows系统下的开发利器,在Visual Studio 2012版本中也提供了对Unity Shader的调试功能——Graphics Debugger。通过Graphics Debugger,我们不仅可以查看每个像素的最终颜色、位置等信息,还可以对顶点着色器和片元着色器进行单步调试。但是必须保证系统运行在 DirectX 11 平台上。
Frame Debugger
尽管 Mac 用户无法体验 Visual Studio 的强大功能,但幸运的是,Unity 5带来了一个新的针对渲染的调试器——帧调试器(Frame Debugger)。与其他调试工具的复杂性相比,Unity原生的帧调试器非常简单快捷。我们可以使用它来看到游戏图像的某一帧是如何一步步渲染出来的。
帧调试器可以用于查看渲染该帧时进行的各种渲染事件(event),这些事件包含了 Draw Call 序列,也包括了类似清空帧缓存等操作。
Unity 提供的帧调试器实际上并没有实现一个真正的帧拾取(frame capture)
的功能,而是仅仅使用停止渲染
的方法来查看渲染事件的结果。例如,如果我们想要查看第4个Draw Call的结果,那么帧调试器就会在第4个Draw Call调用完毕后停止渲染。这种方法虽然简单,但得到的信息也很有限。如果读者想要获取更多的信息,还是需要使用外部工具,例如 Visual Studio 插件,或者 Intel GPA、RenderDoc、NVIDIA NSight、AMD GPU PerfStudio 等工具。
5.6 小心渲染平台的差异
渲染纹理的坐标差异
OpenGL 和 DirectX 在屏幕空间坐标系上存在差异,它们在 y 轴的方向是相反的:
大多数情况下,这样的差异对我们并不会对我们造成任何影响,因为 Unity 内部做了相应的跨平台处理。
但需要注意的是,我们不仅可以把渲染结果输出到屏幕里,还可以输出到不同的渲染目标中,这时,我们需要使用渲染纹理(render texture)来保存这些渲染结果。当我们要渲染到纹理的时候,如果不采取任何措施的话,就会出现纹理翻转的情况。幸运的是,Unity 在背后为我们处理了这种翻转问题–当在 DirectX 平台上使用渲染纹理技术时,Unity 会为我们翻转屏幕图像纹理,以达到平台一致性。但是如果我们开启了抗锯齿
选项,此时 Unity 不会对纹理进行翻转,此时我们需要自己在顶点着色器中翻转这些纹理:
#if UNITY_UV_STARTS_AT_TOP
if(_MainTex_TexelSize.y < 0)
uv.y = 1-uv.y;
#endif
其中,UNITY_UV_STARTS_AT_TOP 用于判断当前平台是否是 DirectX 类型的平台,而当在这样的平台下开启了抗锯齿后,主纹理的纹素大小在竖直方向上会变成负值,以方便我们对主纹理进行正确的采样。因此,我们可以通过判断 _MainTex_TexelSize.y 是否小于0来检验是否开启了抗锯齿。如果是,我们就需要对除主纹理外的其他纹理的采样坐标进行竖直方向上的翻转。
Shader 语法差异
DirectX 对 Shader 的语法较 OpenGL 更为严格,比如下面的代码:
float4 v = float4(0.0);
在 OpenGL 编译成功,在 DirectX 则会报错,在 DirectX 必须写成:
float4 v = float4(0.0, 0.0, 0.0, 0.0);
Shader 语义差异
一些语义在某些平台是等价的,但是在另一些平台,这些语义是不等价的。比如 POSITON 和 SV_POSITION,如果使用了 POSITION,则 Shader 无法在 PS4 平台正常工作。
其他差异
以上只举例了几项平台差异,若想更全面的了解,可以直接查看 Unity 文档:https://docs.unity3d.com/Manual/SL-PlatformDifferences.html
5.7 Shader 整洁之道
在本章的最后,我们给出一些关于如何规范 Shader 代码的建议。当然,这些建议并不是绝对正确的,读者可以根据实际情况做出权衡。写出规范的代码不仅是让代码变得漂亮易懂而已,更重要的是,养成这些习惯有助于我们写出高效的代码。
float、half、fixed
在 Cg/HLSL 中,有三种精度的数值类型:float、half、fixed
上面的精度范围在不同平台和 GPU上是不同的:
- 大多数现代桌面 GPU 会把所有计算都按最高的浮点精度进行计算,这意味着在 PC 上,我们很难看出因为 half 和 fixed 精度不同而带来的不同。
- 在移动平台上,它们的确会有不同的精度范围,而且不同精度的浮点值的运算速度也会有所差异,因此我们应该确保在真正的移动平台上验证我们的 shader。
- fixed 精度实际上只在一些较旧的移动平台上有用,在大多数现代的 GPU 上,它们内部把 fixed 和 half 当成同等精度来对待。
- 一个基本的建议是,尽可能使用精度较低的类型,因为这可以优化 shader 的性能,这一点在移动平台尤为重要。如果我们的目标平台是移动平台,一定要确保在真实的机器上测试我们的 shader,这一点非常重要。
避免不必要的计算
如果我们毫无节制地在 Shader 中进行大量计算,可能会收到 Unity 的错误提示:
temporary register limit of 8 exceeded
Arithmetic instruction limit of 64 exceeded; 65 arithmetic instructions needed to compile program
出现这些错误信息大多是因为我们在 Shader 中进行了过多的运算,使得需要的临时寄存器数目或指令数目超过了当前可支持的数目。读者需要知道,不同的 Shader Target、不同的着色器阶段,我们可使用的临时寄存器和指令数目都是不同的。
通常,我们可以通过指定更高等级的 Shader Target 来消除这些错误:
Shader Model
是由微软提出的一套规范,通俗地理解就是它们决定了 Shader 中各个特性的能力。这些特性和能力体现在 Shader 能使用的运算指令数目、寄存器个数等各个方面。Shader Model 等级越高,Shader 的能力就越大。
虽然更高等级的 Shader Target 可以让我们使用更多的临时寄存器和运算指令,但一个更好的方法是尽可能减少 Shader 中的运算,或者通过预计算的方式来提供更多的数据。
慎用分支和循环
在最开始,GPU 是不支持在顶点着色器和片元着色器中使用流程控制语句的。随着GPU的发展,我们现在已经可以使用 if-else
、for
和while
这种流程控制指令了。但是,它们在 GPU 上的实现和在 CPU 上有很大的不同。大体来说,GPU 使用了不同于 CPU 的技术来实现分支语句,在最坏的情况下,我们花在一个分支语句的时间相当于运行了所有分支语句的时间。因此,我们不鼓励在 Shader 中使用流程控制语句,因为它们会降低 GPU 的并行处理操作(尽管在现代的 GPU 上已经有了改进)。
如果我们在 Shader 中使用了大量的流程控制语句,那么这个 Shader 的性能可能会成倍下降。一个解决方法是,我们应该尽量把计算向流水线上端移动,例如把放在片元着色器中的计算放到顶点着色器中,或者直接在 CPU 中进行预计算,再把结果传递给Shader。当然,有时我们不可避免地要使用分支语句来进行运算,那么一些建议是:
- 分支判断语句中使用的条件变量最好是常数,即在Shader运行过程中不会发生变化
- 每个分支中包含的操作指令数尽可能少
- 分支的嵌套层数尽可能少。
不要除以0
return fixed4(0.0/0.0, 0.0, 0.0, 0.0);
这样代码的结果往往是不可预测的。在某些渲染平台上,上面的代码不会造成 Shader的崩溃,但即便不会崩溃得到的结果也是不确定的,有些会得到白色,有些会得到黑色,但在另一些平台上,我们的Shader可能就会直接崩溃。因此,即便在开发游戏的平台上,我们看到的结果可能是符合预期的,但在目标平台上可能就会出现问题。
一个解决方法是,对那些除数可能为0的情况,强制截取到非0范围。在一些资料中,读者可能也会看到使用 if 语句来判断除数是否为0的例子。另一个方法是,使用一个很小的浮点值,例如0.000001来保证分母大于0。
5.8 推荐阅读
- 读者可以在
《GPU精粹2》
中的 GPU 流程控制一章中更加深入地了解为什么流程控制语句在GPU上会影响性能。 - Shader Models vs Shader Profiles,HLSL 手册