第5章 开始Unity Shader 学习之旅

欢迎来到本书的第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 内置函数把法线从模型空间变换到世界空间中,但这个函数是在Unity5 中被引入的,因此如果读者使用的是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 等语义块。顶点/片元着色器的结构与之大体类似,它的结构如下:

  
    
    
    
    
  1. Shader ” MyShaderName” {
  2. Properties {
  3. //属性
  4. }
  5. SubShader {
  6. // 针对显卡A 的SubShader
  7. Pass {
  8. //设置渲染状态和标签
  9. // 开始CG 代码片段
  10. CGPROGRAM
  11. //该代码片段的编译指令,例如:
  12. #pragma vertex vert
  13. #pragma fragment frag
  14. //CG代码写在这里
  15. ENDCG
  16. //其他设置
  17. }
  18. //其他需要的Pass
  19. }
  20. SubShader {
  21. //针对显卡B 的SubShader
  22. }
  23. //上述SubShader 都失败后用于回调的Unity Shader
  24. Fallback ” VertexLit”
  25. }
其中,最重要的部分是Pass 语义块,我们绝大部分的代码都是写在这个语义块里面的。下面我们就来创建一个最简单的顶点/片元着色器。
(1 )新建一个场景,把它命名为Scene_5_2 。在Unity 5 中可以得到图5.1 中的效果。
第5章 开始Unity Shader 学习之旅_第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。删除里面的所有代码,把下面的代码粘贴进去:

  
    
    
    
    
  1. Shader "Unity Shaders Book/Chapter 5/Simple Shader" {
  2. SubShader {
  3. Pass {
  4. CGPROGRAM
  5. #pragma vertex vert
  6. #pragma fragment frag
  7. float4 vert(float4 v : POSITION) : SV_POSITION {
  8. return mul(UNITY_MATRIX_MVP, v);
  9. }
  10. fixed4 frag() : SV_Target {
  11. return fixed4( 1.0, 1.0, 1.0, 1.0);
  12. }
  13. ENDCG
  14. }
  15. }
  16. }
保存并返回Unity 查看结果。
最后,我们得到的结果如图5.2 所示。
第5章 开始Unity Shader 学习之旅_第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 代码片段。这是我们的重点。首先,我们遇到了两行非常重要的编译指令:

  
    
    
    
    
  1. #pragma vertex vert
  2. #pragma fragment frag
它们将告诉Unity,哪个函数包含了顶点着色器的代码,哪个函数包含了片元着色器的代码。更通用的编译指令表示如下:

  
    
    
    
    
  1. #pragma vertex name
  2. #pragma fragment name
其中 name 就是我们指定的函数名,这两个函数的名字不一定是 vert 和 frag,它们可以是任意自定义的合法函数名,但我们一般使用vert 和 frag 来定义这两个函数,因为它们很直观。
接下来,我们具体看一下vert 函数的定义:

  
    
    
    
    
  1. float4 vert(float4 v : POSITION) : SV_POSITION {
  2. return mul(UNITY_MATRIX_MVP, v);
  3. }
这就是本例使用的顶点着色器代码,它是逐顶点执行的。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 函数:

  
    
    
    
    
  1. fixed4 frag() : SV_Target {
  2. return fixed4( 1.0, 1.0, 1.0, 1.0);
  3. }
在本例中,frag 函数没有任何输入。它的输出是一个fixed4 类型的变量,并且使用了SV_Target语义进行限定。SV_Target 也是HLSL 中的一个系统语义,它等同于告诉渲染器,把用户的输出颜色存储到一个渲染目标(render target)中,这里将输出到默认的帧缓存中。片元着色器中的代码很简单,返回了一个表示白色的fixed4 类型的变量。片元着色器输出的颜色的每个分量范围在
[0, 1],其中(0, 0, 0)表示黑色,而( 1, 1, 1 )表示白色。
至此,我们已经对第一个顶点/片元着色器进行了详细的解释。但是,现在得到的效果实在是太简单了,如何丰富它呢?下面我们将一步步为它添加更多的内容,以得到一个更加具有实践意义的顶点/片元着色器。

5.2.2 模型数据从哪里来

在上面的例子中,在顶点着色器中我们使用POSITION 语义得到了模型的顶点位置。那么,如果我们想要得到更多模型数据怎么办呢?
现在,我们想要得到模型上每个顶点的纹理坐标和法线方向。这个需求是很常见的,我们需要使用纹理坐标来访问纹理,而法线可用于计算光照。因此,我们需要为顶点着色器定义一个新的输入参数,这个参数不再是一个简单的数据类型,而是一个结构体。修改后的代码如下:

  
    
    
    
    
  1. Shader "Unity Shaders Book/Chapter 5/Simple Shader" {
  2. SubShader {
  3. Pass {
  4. CGPROGRAM
  5. #pragma vertex vert
  6. #pragma fragment frag
  7. uniform fixed4 _Color;
  8. struct a2v {
  9. float4 vertex : POSITION;
  10. float3 normal : NORMAL;
  11. float4 texcoord : TEXCOORD0;
  12. };
  13. // 使用一个结构体来定义顶点着色器的输入
  14. struct a2v {
  15. // POSITION 语义告诉Unity ,用模型空间的顶点坐标填充vertex 变量
  16. float4 vertex : POSITION;
  17. // NORMAL 语义告诉Unity ,用模型空间的法线方向填充normal 变量
  18. float3 normal : NORMAL;
  19. // TEXCOORDO 语义告诉Unity ,用模型的第一套纹理坐标填充texcoord 变量
  20. float4 texcoord : TEXCOORDO;
  21. };
  22. float4 vert(a2v v) : SV_POSITION {
  23. // 使用v.vertex 来访问在模型空间的顶点坐标
  24. return mul(UNITY—_MATRIX_MVP, v.vertex);
  25. }
  26. fixed4 frag() : SV_Target {
  27. return fixed4( 1.0, 1.0, 1.0, 1.0);
  28. }
  29. ENDCG
  30. }
  31. }
  32. }
在上面的代码中,我们声明了一个新的结构体a2v,它包含了顶点着色器需要的模型数据。
在a2v 的定义中,我们用到了更多Unity 支持的语义,如NORMAL 和TEXCOORDO , 当它们作为顶点着色器的输入时都是有特定含义的, 因为Unity 会根据这些语义来填充这个结构体。对于顶点着色器的输出, Unity 支持的语义有: POSITION, TANGENT, NORMAL, TEXCOORD0, TEXCOORD1, TEXCOORD2, TEXCOORD3, COLOR 等。
为了创建一个自定义的结构体,我们必须使用如下格式来定义它:

  
    
    
    
    
  1. struct StructName {
  2. Type Name : Semantic;
  3. Type Name : Semantic;
  4. ……
  5. };
其中,语义是不可以被省略的。在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 顶点着色器和片元着色器之间如何通信

在实践中,我们往往希望从顶点着色器输出一些数据,例如把模型的法线、纹理坐标等传递给片元着色器。这就涉及顶点着色器和片元着色器之间的通信。
为此,我们需要再定义一个新的结构体。修改后的代码如下:

  
    
    
    
    
  1. Shader "Unity Shaders Book/Chapter 5/Simple Shader" {
  2. SubShader {
  3. Pass {
  4. CGPROGRAM
  5. #pragma vertex vert
  6. #pragma fragment frag
  7. struct a2v {
  8. float4 vertex : POSITION;
  9. float3 normal : NORMAL;
  10. float4 texcoord : TEXCOORD0;
  11. };
  12. // 使用一个结构体来定义顶点着色器的输入
  13. struct a2v {
  14. // POSITION 语义告诉Unity ,用模型空间的顶点坐标填充vertex 变量
  15. float4 vertex : POSITION;
  16. // NORMAL 语义告诉Unity ,用模型空间的法线方向填充normal 变量
  17. float3 normal : NORMAL;
  18. // TEXCOORDO 语义告诉Unity ,用模型的第一套纹理坐标填充texcoord 变量
  19. float4 texcoord : TEXCOORDO;
  20. };
  21. // 使用一个结构体来定义顶点着色器的输出
  22. struct v2f {
  23. // SV_POSITION 语义告诉Unity, pos 里包含了顶点在裁剪空间中的位置信息
  24. float4 pos : SV POSITION ;
  25. // COLORO 语义可以用于存储颜色信息
  26. fixed3 color : COLORO;
  27. };
  28. float4 vert(a2v v) : SV_POSITION {
  29. 声明输出结构
  30. v2f o;
  31. // 使用v.vertex 来访问在模型空间的顶点坐标
  32. o.pos = mul(UNITY—_MATRIX_MVP, v.vertex);
  33. // v.normal 包含了顶点的法线方向,其分量范围在[-1.0, 1.0]
  34. //下面的代码把分量范围映射到了[0.0, 1.0]
  35. // 存储到o.color 中传递给片元着色器
  36. o.color = v.normal * 0.5 + fixed3( 0.5 , 0.5, 0.5);
  37. return o;
  38. }
  39. fixed4 frag(v2f i) : SV_Target {
  40. // 将插值后的i.color 显示到屏幕上
  41. return fixed4(i.color, 1.0);
  42. }
  43. ENDCG
  44. }
  45. }
  46. }
在上面的代码中,我们声明了一个新的结构体v2f。 v2f 用于在顶点着色器和片元着色器之间传递信息。同样的, v2f 中也需要指定每个变量的语义。在本例中,我们使用了SV_POSITION 和COLOR0 语义。顶点着色器的输出结构中,必须包含一个变量, 它的语义是SV_POSITION。否则,渲染器将无法得到裁剪空间中的顶点坐标,也就无法把顶点渲染到屏幕上。COLOR0 语义中的数据则可以由用户自行定义,但一般都是存储颜色,例如逐顶点的漫反射颜色或逐顶点的高光反射颜色。类似的语义还有COLOR1 等,具体可以详见5.4 节。
至此,我们就完成了顶点着色器和片元着色器之间的通信。需要注意的是,顶点着色器是逐顶点调用的,而片元着色器是逐片元调用的。片元着色器中的输入实际上是把顶点着色器的输出进行插值后得到的结果。

5.2.4 如何使用属性

在3.1.1 节中,我们就提到了材质和Unity Shader 之间的紧密联系。材质提供给我们一个可以方便地调节Unity Shader 中参数的方式,通过这些参数,我们可以随时调整材质的效果。而这些参数就需要写在Properties 语义块中。
现在,我们有了新的需求。我们想要在材质面板显示一个颜色拾取器,从而可以直接控制模型在屏幕上显示的颜色。为此,我们继续修改上面的代码。

  
    
    
    
    
  1. Shader "Unity Shaders Book/Chapter 5/Simple Shader" {
  2. Properties {
  3. //声明一个Color 类型的属性
  4. _Color ( "Color Tint", Color) = ( 1, 1, 1, 1)
  5. }
  6. SubShader {
  7. Pass {
  8. CGPROGRAM
  9. #pragma vertex vert
  10. #pragma fragment frag
  11. // 在CG 代码中,我们需要定义-个与属性名称和类型都匹配的变量
  12. uniform fixed4 _Color;
  13. struct a2v {
  14. float4 vertex : POSITION;
  15. float3 normal : NORMAL;
  16. float4 texcoord : TEXCOORD0;
  17. };
  18. struct v2f {
  19. float4 pos : SV_POSITION;
  20. fixed3 color : COLOR0;
  21. };
  22. v2f vert(a2v v) {
  23. v2f o;
  24. o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
  25. o.color = v.normal * 0.5 + fixed3( 0.5, 0.5, 0.5);
  26. return o;
  27. }
  28. fixed4 frag(v2f i) : SV_Target {
  29. fixed3 c = i.color;
  30. // 使用Color 属性来控制输出颜色
  31. c *= _Color.rgb;
  32. return fixed4(c, 1.0);
  33. }
  34. ENDCG
  35. }
  36. }
  37. }
在上面的代码中,我们首先添加了Properties 语义块中,并在其中声明了一个属性_Color,它的类型是_Color,初始值是
( 1.0, 1.0, 1.0, 1.0),对应白色。为了在CG 代码中可以访问它,我们还需要在CG 代码片段中提前定义一个新的变量,这个变量的名称和类型必须与Properties 语义块中的属性定义相匹配。
ShaderLab 中属性的类型和CG 中变量的类型之间的匹配关系如表5.1 所示。
第5章 开始Unity Shader 学习之旅_第3张图片

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

5.3 强大的援手:Unity提供的内置文件和变量

上一节讲述了如何在Unity 中编写一个基本的顶点/片元着色器的过程。顶点/片元着色的复杂之处在于,很多事情都需要我们“亲力亲为”,例如我们需要自己转换法线方向,自己处理光照、阴影等。为了方便开发者的编码过程, Unity 提供了很多内置文件,这些文件包含了很多提前定义的函数、变量和宏等。如果读者在学习他人编写的Unity Shader 代码时,遇到了一些从未见过的变量、函数,而又无法找到对应的声明和定义,那么很有可能就是这些代码使用了Unity 内置文件提供的函数和变量。
本节将给出这些文件和变量的概览。

5.3.1 内置的包含文件

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

  
    
    
    
    
  1. CGPROGRAM
  2. // ……
  3. #include ”UnityCG.cginc”
  4. // ……
  5. ENDCG
那么,这些文件在哪里呢?我们可以在官方网站( http://unity3d.com/cn/get-unity/download/archive )上选择下载->内置着色器来直接下载这些文件,图5.3 显示了由官网压缩包得到的文件。
第5章 开始Unity Shader 学习之旅_第4张图片
从图5.3 中可以看出,从官网下载的文件中包含了多个文件夹。其中, 
CGIncludes 文件夹中包含了所有的内置包含文件;
DefaultResources 文件夹中包含了一些内置组件或功能所需要的Unity Shader,例如一些GUI 元素使用的Shader;DefaultResourcesExtra 则包含了所有Unity 中内置的Unity Shader;
Editor 文件夹目前只包含了一个脚本文件,它用于定义Unity 5 引入的StandardShader (详见第18 章〉所用的材质面板。
这些文件都是非常好的参考资料,在我们想要学习内置着色器的实现或是寻找内置函数的实现时,都可以在这里找到内部实现。但在本节中,我们只关注CGincludes 文件夹下的相关文件。
我们也可以从Unity 的应用程序中直接找到CGIncludes 文件夹。在Mac 上,它们的位置是:
/Applications/Unity/Unity.app/Contents/CGincludes;在Windows 上,它们的位置是:Unity 的安装路径/Data/CGIncludes。
第5章 开始Unity Shader 学习之旅_第5张图片

可以看出,有一些文件是即使我们没有使用#include 指令, 它们也是会被自动包含进来的,例如UnityShaderVariables.cginc。
因此, 在前面的例子中,我们可以直接使用UNITY_MATRIX_MVP 变量来进行顶点变换。除了表5.2 中列出的包含文件外, Unity 5 引入了许多新的重要的包含文件,如UnityStandardBRDF.cginc 、UnityStandardCore.cginc等,这些包含文件用于实现基于物理的渲染, 我们会在第18 章中再次遇到它们。
UnityCG.cginc 是我们最常接触的一个包含文件。在后面的学习中,我们将使用很多该文件提供的结构体和函数,为我们的编写提供方便。例如,我们可以直接使用UnityCG.cginc 中预定义的结构体作为顶点着色器的输入和输出。表5.3 给出了一些结构体的名称和包含的变量。
第5章 开始Unity Shader 学习之旅_第6张图片
我们建议读者在UnityCG.cginc 文件找到这些函数的定义,并尝试理解它们。一些函数我们完 全可以自己实现,例如UnityObjectToWorldDir 和UnityWorldToObjectDir,这两个函数实际上就是 对方向矢量进行了一次坐标空间变换。而UnityCGcginc 文件可以帮助我们提高代码的复用率。 UnityCGcginc 还包含了很多宏,在后面的学习中,我们就会遇到它们。

5.3.2 内置的变量

我们在4.8 节给出了一些用于坐标变换和摄像机参数的内置变量。除此之外, Unity 还提供了用于访问时间、光照、雾效和环境光等目的的变量。这些内置变量大多位于UnityShaderVariables.cginc 中,与光照有关的内置变量还会位于Lighting.cginc、AutoLight.cginc 等文件中。当我们在后面的学习中遇到这些变量时,再进行详细的讲解。

5.4 Unity提供的CG/HLSL 语义

读者在平时的Shader 学习中可能经常看到,在顶点着色器和片元着色器的输入输出变量后还有一个冒号以及一个全部大写的名称,例如在5.2 节看到的SV_POSITION、POSITION、COLOR0 。这些大写的名字是什么意思呢?它们有什么用呢?

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 修饰的变量含义就可以由我们来决定。
在DirectX 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 正常工作。同样的例子还有COLOR和SV_Target。因此,为了让我们的Shader 有更好的跨平台性,对于这些有特殊含义的变量我们最好使用以SV 开头的语义进行修饰。我们在5.6 节中会总结更多这种因为平台差异而造成的问题。

5.4.2 Unity 支持的语义

表5.5 总结了从应用阶段传递模型数据给顶点着色器时Unity 使用的常用语义。这些语义虽然没有使用SV 开头,但Unity 内部赋予了它们特殊的含义。
第5章 开始Unity Shader 学习之旅_第7张图片
其中TEXCOORDn 中n 的数目是和Shader Model 有关的, 例如一般在Shader Model 2 (即Unity 默认编译到的Shader Model 版本〉和Shader Model 3 中, n 等于8 , 而在Shader Model 4 和 Shader Model 5 中, n 等于16。通常情况下, 一个模型的纹理坐标组数一般不超过2 , 即在们往往只使用TEXCOORD0 和TEXCOORD1 。在Unity 内置的数据结构体appdata_full 中,它最多使用
了6 个坐标纹理组。
表5.6 总结了从顶点着色器阶段到片元着色器阶段Unity 支持的常用语义。
第5章 开始Unity Shader 学习之旅_第8张图片

5.4.3 如何定义复杂的变量类型

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

  
    
    
    
    
  1. struct v2f {
  2. float4 pos : SV_POSITION;
  3. fixed3 colorO : COLORO;
  4. fixed4 colorl : COLORl;
  5. half valueO : TEXCOORDO;
  6. float2 valuel : TEXCOORDl;
  7. }
关于何时使用哪种变量类型,我们会在5.7.1 节给出一些建议。但需要注意的是, 一个语义可以使用的寄存器只能处理4 个浮点值( float)。因此,如果我们想要定义矩阵类型,如float3 × 4、float4 × 4 等变量就需要使用更多的空间。一种方法是,把这些变量拆分成多个变量,例如对于float4 × 4 的矩阵类型,我们可以拆分成4 个float4 类型的变量,每个变量存储了矩阵中的一行数据。

5.5 程序员的烦恼: Debug

调试(debug ),大概是所有程序员的噩梦。而不幸的是,对一个Shader 进行调试更是噩梦中的噩梦。这也是造成Shader 难写的原因之一一一如果发现得到的效果不对,我们可能要花非常多的时间来找到问题所处。造成这种现状的原因就是在Shader 中可以选择的调试方法非常有限,甚至连简单的输出都不行。
本节旨在给出Unity 中对Unity Shader 的调试方法, 这主要包含了两种方法。

5.5.1 使用假彩色图像

假彩色图像( false-color image ) 指的是用假彩色技术生成的一种图像。与假彩色图像对应的是照片这种 真彩色图像(true-color image )。一张假彩色图像可以用于可视化一些数据,那么如何用它来对Shader 进行调试呢?
主要思想是,我们可以把需要调试的变量映射到[0, 1]之间, 把它们作为颜色输出到屏幕上,然后通过屏幕上显示的像素颜色来判断这个值是否正确。读者心里可能已经在咆哮: “什么? ! 这方法也太原始了吧! ”没错,这种方法得到的调试信息很模糊, 能够得到的信息很有限,但在很长一段时间内,这种方法的确是唯一的可选方法。
需要注意的是,由于颜色的分量范围在[0, 1],因此我们需要小心处理需要调试的变量的范围。如果我们已知它的值域范围, 可以先把它映射到[0, 1]之间再进行输出。如果你不知道一个变量的范围〈这往往说明你对这个Shader 中的运算并不了解〉,我们就只能不停地实验。一个提示是,颜色分量中任何大于1 的数值将会被设置为1 , 而任何小于0 的数值会被设置为0。因此, 我们可
以尝试使用不同的映射,直到发现颜色发生了变化(这意味着得到了0~ 1 的值〉。
如果我们要调试的数据是一个一维数据,那么可以选择一个单独的颜色分量(如R 分量〉进行输出,而把其他颜色分量置为0 。如果是多维数据,可以选择对它的每一个分量单独调试,或者选择多个颜色分量进行输出。
作为实例,下面我们会使用假彩色图像的方式来可视化一些模型数据,如法线、切线、纹理坐标、顶点颜色,以及它们之间的运算结果等。我们使用的代码如下:

  
    
    
    
    
  1. Shader "Unity Shaders Book/Chapter 5/False Color" {
  2. SubShader {
  3. Pass {
  4. CGPROGRAM
  5. #pragma vertex vert
  6. #pragma fragment frag
  7. #include "UnityCG.cginc"
  8. struct v2f {
  9. float4 pos : SV_POSITION;
  10. fixed4 color : COLOR0;
  11. };
  12. v2f vert(appdata_full v) {
  13. v2f o;
  14. o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
  15. // Visualize normal
  16. o.color = fixed4(v.normal * 0.5 + fixed3( 0.5, 0.5, 0.5), 1.0);
  17. // Visualize tangent
  18. o.color = fixed4(v.tangent.xyz * 0.5 + fixed3( 0.5, 0.5, 0.5), 1.0);
  19. // Visualize binormal
  20. fixed3 binormal = cross(v.normal, v.tangent.xyz) * v.tangent.w;
  21. o.color = fixed4(binormal * 0.5 + fixed3( 0.5, 0.5, 0.5), 1.0);
  22. // Visualize the first set texcoord
  23. o.color = fixed4(v.texcoord.xy, 0.0, 1.0);
  24. // Visualize the second set texcoord
  25. o.color = fixed4(v.texcoord1.xy, 0.0, 1.0);
  26. // Visualize fractional part of the first set texcoord
  27. o.color = frac(v.texcoord);
  28. if (any(saturate(v.texcoord) - v.texcoord)) {
  29. o.color.b = 0.5;
  30. }
  31. o.color.a = 1.0;
  32. // Visualize fractional part of the second set texcoord
  33. o.color = frac(v.texcoord1);
  34. if (any(saturate(v.texcoord1) - v.texcoord1)) {
  35. o.color.b = 0.5;
  36. }
  37. o.color.a = 1.0;
  38. // Visualize vertex color
  39. // o.color = v.color;
  40. return o;
  41. }
  42. fixed4 frag(v2f i) : SV_Target {
  43. return i.color;
  44. }
  45. ENDCG
  46. }
  47. }
  48. }
在上面的代码中, 我们使用了Unity 内置的一个结构体——appdata_full。我们在5.3 节讲过该结构体的构成。我们可以在UnityCG.cginc 里找到它的定义:

  
    
    
    
    
  1. struct appdata_full {
  2. float4 vertex : POSITION;
  3. float4 tangent : TANGENT;
  4. float3 normal : NORMAL;
  5. float4 texcoord : TEXCOORD0;
  6. float4 texcoord1 : TEXCOORD1;
  7. float4 texcoord2 : TEXCOORD2;
  8. float4 texcoord3 : TEXCOORD3;
  9. #if defined(SHADER_API_XBOX360)
  10. half4 texcoord4 : TEXCOORD4;
  11. half4 texcoord5 : TEXCOORD5;
  12. #endif
  13. fixed4 color : COLOR;
  14. };
可以看出, appdata_full 几乎包含了所有的模型数据。
我们把计算得到的假彩色存储到了顶点着色器的输出结构体——v2f 中的color 变量里,并且在片元着色器中输出了这个颜色。读者可以对其中的代码添加或取消注释,观察不同运算和数据得到的效果。图5.4 给出了这些代码得到的显示效果。读者可以先自己想一想代码和这些效果之间的对应关系,然后再在Unity 中进行验证。
第5章 开始Unity Shader 学习之旅_第9张图片
为了可以得到某点的颜色值,我们可以使用类似颜色拾取器的脚本得到屏幕上某点的RGBA值,从而推断出该点的调试信息。在本书的附带工程中, 读者可以找到这样一个简单的实例脚本:Assets-> Scripts-> Chapter5 -> ColorPicker.cs。把该脚本拖曳到一个摄像机上,单击运行后, 可以用鼠标单击屏幕,以得到该点的颜色值,如图5.5 所示。
第5章 开始Unity Shader 学习之旅_第10张图片

5.5.2 利用神器; Visual Studio

本节是Windows 用户的福音, Mac 用户的噩耗。Visual Studio 作为Windows 系统下的开发利器,在Visual Studio 2012 版本中也提供了对Unity Shader 的调试功能—— Graphics Debugger
通过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 所示。
第5章 开始Unity Shader 学习之旅_第11张图片
l帧调试器可以用于查看渲染该帧时进行的各种 渲染事件( event ),这些事件包含了Draw Call序列,也包括了类似清空帧缓存等操作。帧调试器窗口大致可分为3 个部分:最上面的区域可以开启/关闭(单击Enable 按钮)帧调试功能,当开启了帧调试时,通过移动窗口最上方的滑动条(或单击前进和后退按钮),我们可以重放这些渲染事件:左侧的区域显示了所有事件的树状图,在这个树状图中,每个叶子节点就是一个事件,而每个父节点的右侧显示了该节点下的事件数目。
我们可以从事件的名字了解这个事件的操作,例如以Draw 开头的事件通常就是一个Draw Call;当单击了某个事件时,在右侧的窗口中就会显示出该事件的细节,例如几何图形的细节以及使用了哪个Shader 等。同时在Game 视图中我们也可以看到它的效果。如果该事件是一个Draw Call 并且对应了场景中的一个GameObject,那么这个GameObject 也会在Hierarchy 视圈中被高亮显示出来,图5.7 显示了单击渲染某个对象的深度图事件的结果。
第5章 开始Unity Shader 学习之旅_第12张图片
如果被选中的Draw Call 是对一个渲染纹理( Render Texture )的渲染操作,那么这个渲染纹理就会显示在Game 视图中。而且,此时右侧面板上方的工具栏中也会出现更多的选项,例如在Game 视图中单独显示R、G 、B 和A 通道。
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 小心:渲染平台的差异

Unity 的优点之一是其强大的跨平台’性一一写一份代码可以运行在很多平台上。绝大多数情况下, Unity 为我们隐藏了这些细节,但有些时候我们需要自己处理它们。本节给出了一些常见的因为平台不同而造成的差异。

5.6.1 渲染纹理的坐标差异

在2.3.4 节和4.2.2 节中,我们都提到过OpenGL 和DirectX 的屏幕空间坐标的差异。在水平方向上,两者的数值变化方向是相同的,但在竖直方向上,两者是相反的。在OpenGL ( OpenGL ES 也是)中,(0, 0)点对应了屏幕的左下角,而在DirectX ( Metal 也是)中,(0, 0)点对应了左上角。图5.8 可以帮助读者回忆它们之间的这种不同。
第5章 开始Unity Shader 学习之旅_第13张图片
需要注意的是,我们不仅可以把渲染结果输出到屏幕上,还可以输出到不同的渲染目标( Render Target)中。这时,我们需要使用渲染纹理(Render Texture )来保存这些渲染结果。我们将在第12 章中学习如何实现这样的目的。
大多数情况下,这样的差异并不会对我们造成任何影响。但当我们要使用渲染到纹理技术,把屏幕图像渲染到一张渲染纹理中时,如果不采取行任何措施的话,就会出现纹理翻转的情况。
幸运的是, Unity 在背后为我们处理了这种翻转问题一一当在DirectX 平台上使用渲染到纹理技术时, Unity 会为我们翻转屏幕图像纹理,以便在不同平台上达到一致性。
在一种特殊情况下Unity 不会为我们进行这个翻转操作,这种情况就是我们开启了抗锯齿(在Edit-> Project Settings -> Quality -> Anti Aliasing 中开启〉并在此时使用了渲染到纹理技术。在这种情况下, Unity 首先渲染得到屏幕图像,再由硬件进行抗锯齿处理后,得到一张渲染纹理来供我们进行后续处理。此时,在DirectX 平台下,我们得到的输入屏幕图像并不会被Unity 翻转,也就是说,此时对屏幕图像的采样坐标是需要符合DirectX 平台规定的。如果我们的屏幕特效只需要处理一张渲染图像,我们仍然不需要在意纹理的翻转问题, 这是因为在我们调用Graphics.Blit函数时, Unity 已经为我们对屏幕图像的采样坐标进行了处理,我们只需要按正常的采样过程处理屏幕图像即可。但如果我们需要同时处理多张渲染图像(前提是开启了抗锯齿),例如需要同时处理屏幕图像和法线纹理,这些图像在竖直方向的朝向就可能是不同的(只有在DirectX 这样的平台上才有这样的问题)。这种时候,我们就需要自己在顶点着色器中翻转某些渲染纹理(例如深度纹理或其他由脚本传递过来的纹理)的纵坐标, 使之都符合DirectX 平台的规则。例如:

  
    
    
    
    
  1. #if UNITY_UV_STARTS_AT_TOP
  2. if (_MainTex_TexelSize.y < 0)
  3. uv.y = 1 - uv.y;
  4. #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 时,可能会看到类似下面的报错信息:
第5章 开始Unity Shader 学习之旅_第14张图片
而对于第二个报错信息,往往是出现在表面着色器中。表面着色器的顶点函数(注意,不是顶点着色器)有一个使用了out 修饰符的参数。如果出现这样的报错信息,可能是因为我们在顶点函数中没有对这个参数的所有成员变量都进行初始化。我们应该使用类似下面的代码来对这些参数进行初始化:

  
    
    
    
    
  1. void vert (inout appdata_full v , out Input o) {
  2. //使用Unity 内置的UNITY_INITIALIZE_OUTPUT 宏对输出结构体o进行初始化
  3. UNITY_INITIALIZE_OUTPUT (Input, o);
  4. // ……
  5. }
除了上述两点语法不同外, DirectX9 / 11 也不支持在顶点着色器中使用tex2D 函数。tex2D是一个对纹理进行采样的函数,我们在后面的章节中将会具体讲到。之所以DirectX 9 / 11 不支持顶点阶段中的tex2D 运算,是因为在顶点着色器阶段Shader 无法得到UV 偏导,而tex2D 函数需要这样的偏导信息〈这和纹理采样时使用的数学运算有关)。如果我们的确需要在顶点着色器中访问纹理,需要使用tex2Dlod 函数来替代,如:
tex2Dlod(tex , float4(uv,00))
  
    
    
    
    
而且我们还需要添加句#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整洁之道

在本章的最后,我们给出一些关于如何规范Shader 代码的建议。当然,这些建议并不是绝对正确的,读者可以根据实际情况做出权衡。写出规范的代码不仅是让代码变得漂亮易懂而己,更重要的是,养成这些习惯有助于我们写出高效的代码。

5.7.1 float、half 还是fixed

在本书中,我们使用CG/HLSL 来编写Unity Shader 中的代码。而在CG/HLSL 中, 有3 种精度的数值类型: float, half 和fixed。这些精度将决定计算结果的数值范围。表5.8 给出了这3 种精度在通常情况下的数值范围。
第5章 开始Unity Shader 学习之旅_第15张图片
上面的精度范围并不是绝对正确的,尤其是在不同平台和GPU 上,它们实际的精度可能和上面给出的范围不一致。通常来讲。
  •  大多数现代的桌面GPU 会把所有计算都按最高的浮点精度进行计算,也就是说, float、half、fixed 在这些平台上实际是等价的。这意味着,我们在PC 上很难看出因为half 和 fixed精度而带来的不同。
  •  但在移动平台的GPU 上,它们的确会有不同的精度范围,而且不同精度的浮点值的运算速度也会有所差异。因此,我们应该确保在真正的移动平台上验证我们的Shader .
  •  fixed 精度实际上只在一些较旧的移动平台上有用,在大多数现代的GPU 上,它们内部把fixed 和half 当成同等精度来对待。
尽管有上面的不同,但一个基本建议是,尽可能使用精度较低的类型,因为这可以优化Shader的性能,这一点在移动平台上尤其重要。从它们大体的值域范围来看,我们可以使用fixed 类型来存储颜色和单位矢量,如果要存储更大范围的数据可以选择half类型,最差情况下再选择使用float。如果我们的目标平台是移动平台, 一定要确保在真实的手机上测试我们的Shader,这一点非常重要。关于移动平台的优化技术,读者可以在第16 章中找到更多内容。

5.7.2 规范语法

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

5.7.3 避免不必要的计算

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

出现这些错误信息大多是因为我们在Shader 中进行了过多的运算,使得需要的临时寄存器数目或指令数目超过了当前可支持的数目。读者需要知道,不同的Shader Target、不同的着色器阶段,我们可使用的临时寄存器和指令数目都是不同的。
通常,我们可以通过指定更高等级的Shader Target 来消除这些错误。表5.9 给出了Unity 目前支持的Shader Target。
第5章 开始Unity Shader 学习之旅_第16张图片
需要注意的是,所有类似OpenGL 的平台(包括移动平台〉被当成是支持到Shader Model 3.0 的。而WP8/WinRT 平台则只支持到Shader Model 2.0.
读者:什么是Shader Model 呢?
我们: Shader Model 是由微软提出的一套规范,通俗地理解就是它们决定了Shader 中各个特性( feature )的能力( capability )。这些特性和能力体现在Shader 能使用的运算指令数目、寄存器个数等各个方面。Shader Model 等级越高, Shader 的能力就越大。具体的细节读者可以参见本章的扩展阅读部分。
虽然更高等级的Shader Target 可以让我们使用更多的临时寄存器和运算指令,但一个更好的方法是尽可能减少Shader 中的运算,或者通过预计算的方式来提供更多的数据。

5.7.4 慎用分支和循环语句

在我们学习第一门语言的课上,类似分支、循环语句这样的流程控制语句是最基本的语法之一。但在编写Shader 的时候,我们要对它们格外小心。
在最开始, 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 的时候我们会忽略这个问题。
例如,我们在 Shader 里写下如下代码:

  
    
    
    
    
  1. fixed4 frag(v2f i) : SV_Target
  2. {
  3. return fixed4( 0.0/ 0.0, 0.0/ 0.0, 0.0/ 0.0, 1.0);
  4. }
这样代码的结果往往是不可预测的。在某些渲染平台上,上面的代码不会造成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 手册
( https://msdn.microsoft.com/en-us/library/windows/desktop/bb509626(v=vs.85).aspx)。




























你可能感兴趣的:(Unity,Shader入门精要(,冯乐乐))