欢迎来到本书的第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 本书使用的软件和环境
本书工程编写的系统环境是Mac OS X 10.9.5 。如果读者使用的是其他系统, 绝大部分情况也不会有任何问题。但有时会由于图像编程接口的种类和版本不同而有一些差别,这是因为Mac 使用的图像编程接口是基于OpenGL 的, 而其他平台如Windows , 可能使用的是DirectX . 例如, 在OpenGL 中,渲染纹理( Render Texture )的(0, 0)点是在左下角,而在DirectX 中,(0, 0)点是在左上角。在5.6 节,我们将总结一些由于平台而造成的差异问题。
5.2 一个最简单的顶点/片元着色器
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 中的效果。
(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 所示。
然后,我们声明了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 类型的变量。片元着色器输出的颜色的每个分量范围在
至此,我们已经对第一个顶点/片元着色器进行了详细的解释。但是,现在得到的效果实在是太简单了,如何丰富它呢?下面我们将一步步为它添加更多的内容,以得到一个更加具有实践意义的顶点/片元着色器。
5.2.2 模型数据从哪里来
现在,我们想要得到模型上每个顶点的纹理坐标和法线方向。这个需求是很常见的,我们需要使用纹理坐标来访问纹理,而法线可用于计算光照。因此,我们需要为顶点着色器定义一个新的输入参数,这个参数不再是一个简单的数据类型,而是一个结构体。修改后的代码如下:
Shader "Unity Shaders Book/Chapter 5/Simple Shader" {
SubShader {
Pass {
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
uniform fixed4 _Color;
struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};
// 使用一个结构体来定义顶点着色器的输入
struct a2v {
// POSITION 语义告诉Unity ,用模型空间的顶点坐标填充vertex 变量
float4 vertex : POSITION;
// NORMAL 语义告诉Unity ,用模型空间的法线方向填充normal 变量
float3 normal : NORMAL;
// TEXCOORDO 语义告诉Unity ,用模型的第一套纹理坐标填充texcoord 变量
float4 texcoord : TEXCOORDO;
};
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 和TEXCOORDO , 当它们作为顶点着色器的输入时都是有特定含义的, 因为Unity 会根据这些语义来填充这个结构体。对于顶点着色器的输出, Unity 支持的语义有: POSITION, TANGENT, NORMAL, TEXCOORD0, TEXCOORD1, TEXCOORD2, TEXCOORD3, COLOR 等。
为了创建一个自定义的结构体,我们必须使用如下格式来定义它:
struct StructName {
Type Name : Semantic;
Type Name : Semantic;
……
};
其中,语义是不可以被省略的。在5.4 节中,我们将给出这些语义的含义和用法。
然后,我们修改了vert 函数的输入参数类型,把它设置为我们新定义的结构体a2f。通过这种自定义结构体的方式,我们就可以在顶点着色器中访问模型数据。
读者: a2v 的名字是什么意思呢?
我们: a 表示应用( application ), v 表示顶点着色器( vertex shader), a2v 的意思就是把数据从应用阶段传递到顶点着色器中。
那么,填充到POSITION, TANGENT, NORMAL 这些语义中的数据究竟是从哪里来的呢?在Unity中,它们是由使用该材质的Mesh Render 组件提供的。在每帧调用Draw Call 的时候, Mesh Render 组件会把它负责渲染的模型数据发送给Unity Shader。我们知道, 一个模型通常包含了一组三角面片,每个三角面片由3 个顶点构成,而每个顶点又包含了一些数据,例如顶点位置、法线、切线、纹理坐标、顶点颜色等。通过上面的方法,我们就可以在顶点着色器中访问顶点的这些模型数据。
5.2.3 顶点着色器和片元着色器之间如何通信
在实践中,我们往往希望从顶点着色器输出一些数据,例如把模型的法线、纹理坐标等传递给片元着色器。这就涉及顶点着色器和片元着色器之间的通信。为此,我们需要再定义一个新的结构体。修改后的代码如下:
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 a2v {
// POSITION 语义告诉Unity ,用模型空间的顶点坐标填充vertex 变量
float4 vertex : POSITION;
// NORMAL 语义告诉Unity ,用模型空间的法线方向填充normal 变量
float3 normal : NORMAL;
// TEXCOORDO 语义告诉Unity ,用模型的第一套纹理坐标填充texcoord 变量
float4 texcoord : TEXCOORDO;
};
// 使用一个结构体来定义顶点着色器的输出
struct v2f {
// SV_POSITION 语义告诉Unity, pos 里包含了顶点在裁剪空间中的位置信息
float4 pos : SV POSITION ;
// COLORO 语义可以用于存储颜色信息
fixed3 color : COLORO;
};
float4 vert(a2v v) : SV_POSITION {
声明输出结构
v2f o;
// 使用v.vertex 来访问在模型空间的顶点坐标
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, 1, 1, 1)
}
SubShader {
Pass {
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// 在CG 代码中,我们需要定义-个与属性名称和类型都匹配的变量
uniform 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,初始值是
ShaderLab 中属性的类型和CG 中变量的类型之间的匹配关系如表5.1 所示。
uniform fixed4 _Color;
unifom 关键词是CG 中修饰变量和参数的一种修饰词,它仅仅用于提供一些关于该变量的初始值是如何指定和存储的相关信息(这和其他一些图像编程接口中的uniform 关键词的作用不太一样)。在Unity Shader 中, uniform 关键词是可以省略的。
5.3 强大的援手:Unity提供的内置文件和变量
本节将给出这些文件和变量的概览。
5.3.1 内置的包含文件
包含文件(include file ), 是类似于C++中头文件的一种文件。在Unity 中,它们的文件后缀是.cginc。在编写Shader 时,我们可以使用#include 指令把这些文件包含进来,这样我们就可以使用Unity 为我们提供的一些非常有用的变量和帮助函数。例如:CGPROGRAM
// ……
#include ”UnityCG.cginc”
// ……
ENDCG
那么,这些文件在哪里呢?我们可以在官方网站( http://unity3d.com/cn/get-unity/download/archive )上选择下载->内置着色器来直接下载这些文件,图5.3 显示了由官网压缩包得到的文件。
我们也可以从Unity 的应用程序中直接找到CGIncludes 文件夹。在Mac 上,它们的位置是:
/Applications/Unity/Unity.app/Contents/CGincludes;在Windows 上,它们的位置是:Unity 的安装路径/Data/CGIncludes。
UnityCG.cginc 是我们最常接触的一个包含文件。在后面的学习中,我们将使用很多该文件提供的结构体和函数,为我们的编写提供方便。例如,我们可以直接使用UnityCG.cginc 中预定义的结构体作为顶点着色器的输入和输出。表5.3 给出了一些结构体的名称和包含的变量。
5.3.2 内置的变量
我们在4.8 节给出了一些用于坐标变换和摄像机参数的内置变量。除此之外, Unity 还提供了用于访问时间、光照、雾效和环境光等目的的变量。这些内置变量大多位于UnityShaderVariables.cginc 中,与光照有关的内置变量还会位于Lighting.cginc、AutoLight.cginc 等文件中。当我们在后面的学习中遇到这些变量时,再进行详细的讲解。5.4 Unity提供的CG/HLSL 语义
5.4.1 什么是语义
实际上,这些是CG/HLSL 提供的语义(semantics )。如果读者从前接触过CG/HLSL 编程的话,可能对这些语义很熟悉。读者可以在微软的关于DirectX 的文档(https://msdn.microsoft.com/en-us/library/windows/desktop/bb509647(v=vs.85).aspx#VS)中找到关于语义的详细说明页面。根据文档我们可以知道,语义实际上就是一个赋给Shader 输入和输出的字符串,这个字符串表达了这个参数的含义。通俗地讲,这些语义可以让Shader 知道从哪里读取数据,并把数据输出到哪里,它们在CG/HLSL 的Shader 流水线中是不可或缺的。需要注意的是, Unity 并没有支持所有的语义。
通常情况下, 这些输入输出变量并不需要有特别的意义,也就是说,我们可以自行决定这些变量的用途。例如在上面的代码中,顶点着色器的输出结构体中我们用COLOR0 语义去描述color变量。color 变量本身存储了什么, Shader 流水线并不关心。
而Unity 为了方便对模型数据的传输, 对一些语义进行了特别的含义规定。例如,在顶点着色器的输入结构体a2f 用TEXCOORD0来描述texcoord, Unity 会识别TEXCOORD0 语义,以把模型的第一组纹理坐标填充到texcoord 中。需要注意的是,即便语义的名称一样,如果出现的位置不同,含义也不同。例如, TEXCOORDO 既可以用于描述顶点着色器的输入结构体a2f,也可用于描述输出结构体 v2f。. 但在输入结构体 a2f 中, TEXCOORD0 有特别的含义,即把模型的第一组纹理坐标存储在该变量中,而在输出结构体v2f 中, TEXCOORD0 修饰的变量含义就可以由我们来决定。
用这些语义描述的变量是不可以随便赋值的, 因为流水线需要使用它们来完成特定的目的,例如渲染引擎会把用SV_POSITION 修饰的变量经过光栅化后显示在屏幕上。读者有时可能会看到同一个变量在不同的Shader 里面使用了不同的语义修饰。例如, 一些Shader 会使用POSITION 而非SV_POSITION 来修饰顶点着色器的输出。SV_POSITION 是DirectX 10 中引入的系统数值语义,
5.4.2 Unity 支持的语义
表5.5 总结了从应用阶段传递模型数据给顶点着色器时Unity 使用的常用语义。这些语义虽然没有使用SV 开头,但Unity 内部赋予了它们特殊的含义。了6 个坐标纹理组。
表5.6 总结了从顶点着色器阶段到片元着色器阶段Unity 支持的常用语义。
5.4.3 如何定义复杂的变量类型
上面提到的语义绝大部分用于描述标量或矢量类型的变量,例如fixed2 、float、float4 、fixed4等。下面的代码给出了一个使用语义来修饰不同类型变量的例子:struct v2f {
float4 pos : SV_POSITION;
fixed3 colorO : COLORO;
fixed4 colorl : COLORl;
half valueO : TEXCOORDO;
float2 valuel : TEXCOORDl;
}
关于何时使用哪种变量类型,我们会在5.7.1 节给出一些建议。但需要注意的是, 一个语义可以使用的寄存器只能处理4 个浮点值( float)。因此,如果我们想要定义矩阵类型,如float3 × 4、float4 × 4 等变量就需要使用更多的空间。一种方法是,把这些变量拆分成多个变量,例如对于float4 × 4 的矩阵类型,我们可以拆分成4 个float4 类型的变量,每个变量存储了矩阵中的一行数据。
5.5 程序员的烦恼: Debug
本节旨在给出Unity 中对Unity Shader 的调试方法, 这主要包含了两种方法。
5.5.1 使用假彩色图像
假彩色图像( false-color image ) 指的是用假彩色技术生成的一种图像。与假彩色图像对应的是照片这种真彩色图像(true-color image )。一张假彩色图像可以用于可视化一些数据,那么如何用它来对Shader 进行调试呢?主要思想是,我们可以把需要调试的变量映射到[0, 1]之间, 把它们作为颜色输出到屏幕上,然后通过屏幕上显示的像素颜色来判断这个值是否正确。读者心里可能已经在咆哮: “什么? ! 这方法也太原始了吧! ”没错,这种方法得到的调试信息很模糊, 能够得到的信息很有限,但在很长一段时间内,这种方法的确是唯一的可选方法。
以尝试使用不同的映射,直到发现颜色发生了变化(这意味着得到了0~ 1 的值〉。
如果我们要调试的数据是一个一维数据,那么可以选择一个单独的颜色分量(如R 分量〉进行输出,而把其他颜色分量置为0 。如果是多维数据,可以选择对它的每一个分量单独调试,或者选择多个颜色分量进行输出。
作为实例,下面我们会使用假彩色图像的方式来可视化一些模型数据,如法线、切线、纹理坐标、顶点颜色,以及它们之间的运算结果等。我们使用的代码如下:
Shader "Unity Shaders Book/Chapter 5/False Color" {
SubShader {
Pass {
CGPROGRAM
#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 = mul(UNITY_MATRIX_MVP, v.vertex);
// Visualize normal
o.color = fixed4(v.normal * 0.5 + fixed3(0.5, 0.5, 0.5), 1.0);
// Visualize tangent
o.color = fixed4(v.tangent.xyz * 0.5 + fixed3(0.5, 0.5, 0.5), 1.0);
// Visualize binormal
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);
// Visualize the first set texcoord
o.color = fixed4(v.texcoord.xy, 0.0, 1.0);
// Visualize the second set texcoord
o.color = fixed4(v.texcoord1.xy, 0.0, 1.0);
// Visualize fractional part of the first set texcoord
o.color = frac(v.texcoord);
if (any(saturate(v.texcoord) - v.texcoord)) {
o.color.b = 0.5;
}
o.color.a = 1.0;
// Visualize fractional part of the second set texcoord
o.color = frac(v.texcoord1);
if (any(saturate(v.texcoord1) - v.texcoord1)) {
o.color.b = 0.5;
}
o.color.a = 1.0;
// Visualize vertex color
// o.color = v.color;
return o;
}
fixed4 frag(v2f i) : SV_Target {
return i.color;
}
ENDCG
}
}
}
在上面的代码中, 我们使用了Unity 内置的一个结构体——appdata_full。我们在5.3 节讲过该结构体的构成。我们可以在UnityCG.cginc 里找到它的定义:
struct appdata_full {
float4 vertex : POSITION;
float4 tangent : TANGENT;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
float4 texcoord1 : TEXCOORD1;
float4 texcoord2 : TEXCOORD2;
float4 texcoord3 : TEXCOORD3;
#if defined(SHADER_API_XBOX360)
half4 texcoord4 : TEXCOORD4;
half4 texcoord5 : TEXCOORD5;
#endif
fixed4 color : COLOR;
};
可以看出, appdata_full 几乎包含了所有的模型数据。
我们把计算得到的假彩色存储到了顶点着色器的输出结构体——v2f 中的color 变量里,并且在片元着色器中输出了这个颜色。读者可以对其中的代码添加或取消注释,观察不同运算和数据得到的效果。图5.4 给出了这些代码得到的显示效果。读者可以先自己想一想代码和这些效果之间的对应关系,然后再在Unity 中进行验证。
5.5.2 利用神器; Visual Studio
通过Graphics Debugger,我们不仅可以查看每个像素的最终颜色、位置等信息,还可以对顶点着色器和片元着色器进行单步调试。具体的安装和使用方法可以参见Unity 官网文档中使用Visual Studio 对DirectX 11 的Shader 进行调试一文(http://docs.unity3d.com/Manual/SL-DebuggingD3D11ShadersWithVS.html )。
当然,本方法也有一些限制。例如,我们需要保证Unity 运行在DirectX 11 平台上,而且Graphics Debugger本身存在一些问。但这无法阻止我们对它的喜爱之情!而Mac 用户可能就只能无奈地眼馋了。
5.5.3 最新利器:帧调试器
尽管Mac 用户无法体验Visual Studio 的强大功能, 但幸运的是, Unity 5 除了带来全新的UI系统外,还给我们带来了一个新的针对渲染的调试器一一帧调试器( Frame Debugger )。与其他调试工具的复杂性相比, Unity 原生的帧调试器非常简单快捷。我们可以使用它来看到游戏图像的某一帧是如何一步步渲染出来的。要使用帧调试器,我们首先需要在Window -> Frame Debugger 中打开帧调试器窗口,如图5.6 所示。
我们可以从事件的名字了解这个事件的操作,例如以Draw 开头的事件通常就是一个Draw Call;当单击了某个事件时,在右侧的窗口中就会显示出该事件的细节,例如几何图形的细节以及使用了哪个Shader 等。同时在Game 视图中我们也可以看到它的效果。如果该事件是一个Draw Call 并且对应了场景中的一个GameObject,那么这个GameObject 也会在Hierarchy 视圈中被高亮显示出来,图5.7 显示了单击渲染某个对象的深度图事件的结果。
Unity 5 提供的帧调试器实际上并没有实现一个真正的帧拾取(frame capture )的功能,而是仅仅使用停止渲染的方法来查看渲染事件的结果。例如,如果我们想要查看第4 个Draw Call 的结果,那么帧调试器就会在第4 个Draw Call 调用完毕后停止渲染。这种方法虽然简单,但得到的信息也很有限。如果读者想要获取更多的信息,还是需要使用外部工具,例如5.5.2 节中的Visual Studio 插件,或者Intel GPA 、RenderDoc、NVIDIA NSight、AMD GPU PerfStudio 等工具。
5.6 小心:渲染平台的差异
5.6.1 渲染纹理的坐标差异
在2.3.4 节和4.2.2 节中,我们都提到过OpenGL 和DirectX 的屏幕空间坐标的差异。在水平方向上,两者的数值变化方向是相同的,但在竖直方向上,两者是相反的。在OpenGL ( OpenGL ES 也是)中,(0, 0)点对应了屏幕的左下角,而在DirectX ( Metal 也是)中,(0, 0)点对应了左上角。图5.8 可以帮助读者回忆它们之间的这种不同。大多数情况下,这样的差异并不会对我们造成任何影响。但当我们要使用渲染到纹理技术,把屏幕图像渲染到一张渲染纹理中时,如果不采取行任何措施的话,就会出现纹理翻转的情况。
幸运的是, Unity 在背后为我们处理了这种翻转问题一一当在DirectX 平台上使用渲染到纹理技术时, Unity 会为我们翻转屏幕图像纹理,以便在不同平台上达到一致性。
在一种特殊情况下Unity 不会为我们进行这个翻转操作,这种情况就是我们开启了抗锯齿(在Edit-> Project Settings -> Quality -> Anti Aliasing 中开启〉并在此时使用了渲染到纹理技术。在这种情况下, Unity 首先渲染得到屏幕图像,再由硬件进行抗锯齿处理后,得到一张渲染纹理来供我们进行后续处理。此时,在DirectX 平台下,我们得到的输入屏幕图像并不会被Unity 翻转,也就是说,此时对屏幕图像的采样坐标是需要符合DirectX 平台规定的。如果我们的屏幕特效只需要处理一张渲染图像,我们仍然不需要在意纹理的翻转问题, 这是因为在我们调用Graphics.Blit函数时, Unity 已经为我们对屏幕图像的采样坐标进行了处理,我们只需要按正常的采样过程处理屏幕图像即可。但如果我们需要同时处理多张渲染图像(前提是开启了抗锯齿),例如需要同时处理屏幕图像和法线纹理,这些图像在竖直方向的朝向就可能是不同的(只有在DirectX 这样的平台上才有这样的问题)。这种时候,我们就需要自己在顶点着色器中翻转某些渲染纹理(例如深度纹理或其他由脚本传递过来的纹理)的纵坐标, 使之都符合DirectX 平台的规则。例如:
#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 来检验是否开启了抗锯齿。如果是,我们就需要对除主纹理外的其他纹理的采样坐标进行竖直方向上的翻转。我们会在第13 章中再次看到上面的代码。
在本书资源的项目中,我们开启了抗锯齿选项。在第12 章中,我们将学习一些基本的屏幕后处理效果。这些效果大多使用了单张屏幕图像进行处理,因此我们不需要考虑平台差异化的问题,因为Unity 己经在背后为我们处理过了。但在12.5 节中,我们需要在一个Pass 中同时处理屏幕图像和提取得到的亮部图像来实现Bloom 效果。由于需要同时处理多张纹理,因此在DirectX 这样的平台下如果开启了抗锯齿,主纹理和亮部纹理在竖直方向上的朝向就是不同的,我们就需要对亮部纹理的采样坐标进行翻转。在第13 章中,我们需要同时处理屏幕图像和深度/法线纹理来实现一些特殊的屏幕效果,在这些处理过程中,我们也需要进行一些平台差异化处理。在15.3 节中,尽管我们也在一个Pass 中同时处理了屏幕图像、深度纹理和一张噪声纹理,但我们只对深度纹理的采样坐标进行了平台差异化处理,而没有对噪声纹理进行处理。这是因为,类似噪声纹理的装饰性纹理,它们在坚直方向上的朝向并不是很重要,即便翻转了效果往往也是正确的,因此我们可以不对这些纹理进行平台差异化处理。
5.6.2 Shader 的语法差异
读者在Windows 平台下编译某些在Mac 平台下工作良好的Shader 时,可能会看到类似下面的报错信息:
-
void vert (inout appdata_full v , out Input o) {
-
//使用Unity 内置的UNITY_INITIALIZE_OUTPUT 宏对输出结构体o进行初始化
-
UNITY_INITIALIZE_OUTPUT (Input, o);
-
// ……
-
}
除了上述两点语法不同外, DirectX9 / 11 也不支持在顶点着色器中使用tex2D 函数。tex2D是一个对纹理进行采样的函数,我们在后面的章节中将会具体讲到。之所以DirectX 9 / 11 不支持顶点阶段中的tex2D 运算,是因为在顶点着色器阶段Shader 无法得到UV 偏导,而tex2D 函数需要这样的偏导信息〈这和纹理采样时使用的数学运算有关)。如果我们的确需要在顶点着色器中访问纹理,需要使用tex2Dlod 函数来替代,如:
tex2Dlod(tex , float4(uv,0,0))
而且我们还需要添加句#pragma target 3.0 ,因为tex2Dlod 是Shader Model 3.0 中的特性。
5.6.3 Shader 的语义差异
我们在5.4 节讲到了Shader 中的语义是什么,其中我们讲到了一些语义在某些平台下是等价的,例如SV_POSITION 和POSITION。但在另一些平台上,这些语义是不等价的。为了让Shader能够在所有平台上正常工作,我们应该尽可能使用下面的语义来描述Shader 的输入输出变量。- 使用SV_POSITION 来描述顶点着色器输出的顶点位置。一些Shader 使用了POSITION 语义,但这些Shader 无法在索尼PS4 平台上或使用了细分着色器的情况下正常工作。
- 使用SV_Target 来描述片元着色器的输出颜色。一些Shader 使用了COLOR 或者COLOR0语义,同样的,这些Shader 无法在索尼PS4 上正常工作。
5.6.4 其他平台差异
本书只给出了一些最常见的平台差异造成的问题, 还有一些差异不再列举。如果读者发现一些Shader 在平台A 下工作良好,而在平台B 下出现了问题, 可以去Unity 官方文档(http://docs.unity3d.com/Manual/SL-PlatforrnDifferences.html)中寻找更多的资料。5.7 Shader整洁之道
5.7.1 float、half 还是fixed
在本书中,我们使用CG/HLSL 来编写Unity Shader 中的代码。而在CG/HLSL 中, 有3 种精度的数值类型: float, half 和fixed。这些精度将决定计算结果的数值范围。表5.8 给出了这3 种精度在通常情况下的数值范围。- 大多数现代的桌面GPU 会把所有计算都按最高的浮点精度进行计算,也就是说, float、half、fixed 在这些平台上实际是等价的。这意味着,我们在PC 上很难看出因为half 和 fixed精度而带来的不同。
- 但在移动平台的GPU 上,它们的确会有不同的精度范围,而且不同精度的浮点值的运算速度也会有所差异。因此,我们应该确保在真正的移动平台上验证我们的Shader .
- fixed 精度实际上只在一些较旧的移动平台上有用,在大多数现代的GPU 上,它们内部把fixed 和half 当成同等精度来对待。
5.7.2 规范语法
在5.6.2 节,我们提到DirectX 平台对Shader 的语义有更加严格的要求。这意味着,如果我们要发布到DirectX 平台上就需要使用更严格的语法。例如,使用和变量类型相匹配的参数数目来对变量进行初始化。5.7.3 避免不必要的计算
如果我们毫无节制地在Shader (尤其是片元着色器)中进行了大量计算,那么我们可能很快就会收到Unity 的错误提示:通常,我们可以通过指定更高等级的Shader Target 来消除这些错误。表5.9 给出了Unity 目前支持的Shader Target。
我们: Shader Model 是由微软提出的一套规范,通俗地理解就是它们决定了Shader 中各个特性( feature )的能力( capability )。这些特性和能力体现在Shader 能使用的运算指令数目、寄存器个数等各个方面。Shader Model 等级越高, Shader 的能力就越大。具体的细节读者可以参见本章的扩展阅读部分。
虽然更高等级的Shader Target 可以让我们使用更多的临时寄存器和运算指令,但一个更好的方法是尽可能减少Shader 中的运算,或者通过预计算的方式来提供更多的数据。
5.7.4 慎用分支和循环语句
在最开始, GPU 是不支持在顶点着色器和片元着色器中使用流程控制语旬的。随着GPU 的发展,我们现在已经可以使用if-else、for 和while 这种流程控制指令了。但是,它们在GPU 上的实现和在CPU 上有很大的不同。探究这些指令的底层实现不在本书的讨论范围内,读者可以在本章的扩展阅读中找到更多的内容。大体来说, GPU 使用了不同于CPU 的技术来实现分支语句,在最坏
的情况下,我们花在一个分支语句的时间相当于运行了所有分支语句的时间。因此,我们不鼓励在Shader 中使用流程控制语句,因为它们会降低GPU 的并行处理操作(尽管在现代的GPU 上已经有了改进〉。
如果我们在Shader 中使用了大量的流程控制语旬,那么这个Shader 的性能可能会成倍下降。一个解决方法是,我们应该尽量把计算向流水线上端移动,例如把放在片元着色器中的计算放到顶点着色器中,或者直接在CPU 中进行预计算,再把结果传递Shader。当然,有时我们不可避免地要使用分支语句来进行运算,那么一些建议是:
- 分支判断语句中使用的条件变量最好是常数,即在Shader 运行过程中不会发生变化;
- 每个分支中包含的操作指令数尽可能少;
- 分支的嵌套层数尽可能少。
5.7.5 不要除以0
虽然在用类似C#等高级语言进行编程的时候,我们会谨记不要除以0这个基本常识(就算你没这么做,编辑器可能也会报错〉,但有时在编写Shader 的时候我们会忽略这个问题。fixed4 frag(v2f i) : SV_Target
{
return fixed4(0.0/0.0, 0.0/0.0, 0.0/0.0, 1.0);
}
这样代码的结果往往是不可预测的。在某些渲染平台上,上面的代码不会造成Shader 的崩溃,但即使不会崩溃得到的结果也是不确定的,有些会得到白色(由无限大截取到1.0 ),有些会得到黑色,但在另一些平台上,我们的Shader 可能就会直接崩溃。因此,即便在开发游戏的平台上,我们看到的结果可能是符合预期的,但在目标平台上可能就会出现问题。
一个解决方法是,对那些除数可能为0 的情况,强制截取到非0 范围。在一些资料中,读者可能也会看到使用if 语句来判断除数是否为0 的例子。
5.8 扩展阅读
读者可以在《GPU 精粹2》中的GPU 流程控制一章【1】中更加深入地了解为什么流程控制语句在GPU 上会影响性能。在5.7.3 节我们提到了Shader 中临时寄存器数目和运算指令都有限制,实际上Shader Model 对顶点着色器和片元着色器中使用的指令数、临时寄存器、常量寄存器、输入/输出寄存器、纹理等数目都进行了规定。读者可以在Wiki 的相关资料[2]和HLSL 的手册[3]中找到更多的内容。[1] Mark Harris, Ian Buck. "GPU Flow-Control Idioms.” In GPU Gems 2. 中译本: GPU 精粹2: 高性能图形芯片和通用计算编程技巧,法尔译,清华大学出版社, 2007 年。
[2] High-Level Shading Language, Wiki ( https://en.wikipedia.org/wiki/High-Level_Shading_Language )。
[3] Shader Models vs Shader Profiles, HLSL 手册