[译]Unity3D Shader教程(二)HLSL

2021年6月13日 0 作者 老王

原文地址
Shader Tutorials by Ronja


1 HLSL?

HLSL(High Level Shading Language,高级着色器语言)是Unity用来编写着色器的语言,其用来自定义逻辑并最终决定在屏幕上绘制什么内容。 HLSL是微软设计开发的,供Direct3D API使用的GPU语言。 严格来说,大多数 Unity Shader都是用 CG 编写。Cg是C for Graphics的缩写,Cg与 HLSL 共享大部分语法和功能,并在 2012 年被弃用,因此错误地将Unity Shader中的语言称为HLSL能帮助您在搜索引擎中找到更想要的结果。 理论上Unity 也支持编写GLSL(OpenGL Shading Language)着色器,这是为 OpenGL 设计的语言。因为HLSL有更多示例,并且Unity最终会将Shader将转换为导出平台的对应语言,所以Unity中,我们坚持使用HLSL来编写Shader。
其实上面这段没怎么说清楚为什么咱们用的是HLSL语言来编写Unity Shader。这里来解释一下,编写Shader的语言无非就三种,一是微软平台的HLSL语言,二是OpenGL平台的GLSL语言,三是NVIDA的Cg语言。Cg语言是真正意义上的跨平台着色器语言,即用Cg编写的语言既可以在微软的Direct3D上跑也可以在Open Gl上跑。但是由于Cg和DX9风格的HLSL从写法上说几乎是同一种语言,所以在Unity中HLSL和Cg是等价的。我们一般在CGPROGRAM和ENDCG代码片段中编写。
为了在 Unity 中学习着色器,我们建议您不要从这里开始您的编程之旅。着色器调试起来很麻烦,它们可以做的事情非常有限,而且在许多情况下,您必须朝着与大多数编程上下文略有不同的方向进行处理。 所以我们假设你知道类型、变量、类、方法、循环、if 语句等等的基本编程知识。

2 Builtin Types

内置类型。
fixed、half、float表示浮点数,int表示有符号的整型,uint表示无符号的整型。
在移动端的GPU 上,fixed的范围为-2~2之间的数字,精度为 1/256,half是 16 位浮点数,float是 32 位浮点数。 在PC端GPU 上,所有这些值都是 32 位浮点数,所以为了方便我们会到处使用float,但根据数据的大小选择对应的类型也是一种优化。
整型是只能包含整数值的数字,int 可以包含正值和负值,uint 只能包含正值,使用uint可以带来轻微的性能优势。
此外,还有一个简单的数据类型 bool(布尔值),它保存的值为真或假,因此我们可以将检查结果存储在其中。 如果您将布尔值与其他数字一起使用(例如将它们相加或相乘),它们的行为就像数字 0(如果它为假)或 1(如果它为真)。

3 Vector Values

向量。
我们可以在第2节中的内置类型后面添加一个数字(最大为4)来组成一个多维向量,如fixed4, float2 或 half3。我们向量来表示纹理坐标、颜色和位置。
当我们想访问向量中的某一个值时,如果此向量表示位置,我们依次使用 vector.x、vector.y、vector.z 和 vector.w;当向量表示颜色时 ,我们使用vector.r、vector.g、vector.b 和 vector .a。或者我们也可以这样:vector[2]([]中的2是索引值,是从0开始的,因此4维向量的 4 个值将是 0、1、2 和 3)。
如果我们想从一个向量中获取多个值并将它们放入另一个向量中,我们不必自己构造那个新向量,我们可以使用 Swizzling。 Swizzling 意味着在点之后写入多个向量子值。 如:
vector.xy – 仅获取向量的前 2 个值并将它们放入二维向量中。
vector.zyx – 获取向量的前 3 个值并颠倒它们的顺序。
vector.xxxx – 取向量的第一个值并构建一个全部是该值的 4维向量。

(Swizzling能够提高性能)

4 Matrix Values

矩阵。
像向量在一个方向上扩展基本类型一样,矩阵在两个方向上扩展它们。 它们的语法是将 number x number 附加到标量类型上。 如 float4x4、half3x2 甚至 bool2x4。可以通过像 matrix[3][2] 这样的方括号来访问矩阵的成员,第一个数字表示行,第二个数字表示列。 或者通过像 _m32 这样的访问器。 这钟访问器也可以用于 swizzling,如 matrix._m03_m13_m23 以获取最后一列的前 3 个值并将它们写入 3维 向量。 如果我们使用方括号版本并且只使用一对括号,我们将得到定义该行的向量,如matrix[3]表示第三行。
幸运的是,我们几乎从不需要访问矩阵中的值,所以没必要完全掌握它,如果某天你需要使用到矩阵钟的某个元素,再回过头来看看即可。

5 Textures

纹理。
Hlsl 也有描述纹理的类型。我们后面再讲。

6 Math

算数运算。
除了简单数学中的+ – / 运算符和 逻辑运算符> < == != ! >= <= && 、|| ,hlsl 还内置了许多数学函数,如 abs、dot、lerp、pow、min、atan2 (支持的函数可以见这里)。
还有一些简写,如 +=
= -= 和 /= ,它们修改一个变量,然后再次赋值给它自己,还有var++ 和 var- -,其从给定的变量中加或减 1。
标量类型和向量类型的乘法返回一个向量类型,它们的维数都与数字相乘。 所以 float2(2, 7) * 3 等于 float2(6, 21)。
另外,我们我们使用mul函数来实现矩阵与向量相乘以变换向量。矩阵和向量是具体是怎么计算的,这里不细说。

7 Custom Types

自定义类型。
除了内置类型,我们还可以添加我们自己的类型。 添加你自定义类型的语法是这样的(最后的分号很重要!):

struct typeName{
  float variable;
  float2 otherVariable;
};

理论上我们也可以使用 class 关键字,使用继承、成员函数甚至接口,但我从未在任何着色器中遇到过它们,所以我不会在这里解释,等它们出现时再说。 如果您确实想使用这些功能,则语法类似于 C++/C#
与向量类型一样,我们使用来访问自定义类型的成员变量,例如 instance.variable 或 instance.otherVariable.x。

8 Variables

变量。
hlsl 中的所有数据类型都是值类型。 这意味着一旦我们拥有一个值,我们就可以更改它,而无需通过 new 或类似的方式创建它。
如果我们想创建向量类型,我们可以通过调用函数之类的类型来实现。 在这些情况下,参数类型中的子值数量之和必须与目标类型中的值数量相等。比如我们可以通过传入 4 个float或2 个float 2或一个float、一个float2 和另一个float或者仅一个float4来构建一个新的float4。 如我们可以这样构建我们自定义类型的变量:

typeName instance;
instance.variable = 3.14;
instance.otherVariable = float2(3, 1.4);

我们声明的变量可以在函数内部,在这种情况下,它们只能被该函数内部的其他部分访问,这些部分需晚于变量声明(就像在大多数编程语言中一样);也可以在函数之外,在这种情况下,它们可以被 从着色器中的所有函数访问,无论变量声明在哪儿(但通常在顶部声明它们,在函数上方以轻松找到它们)。

9 Functions

函数。(C#中称为方法)
hlsl 中的大多数函数都在全局范围内。 这意味着它们不属于任何数据类型,我们可以从任何地方调用它们。 它们可以接受多个(或没有)参数并返回一个值。 如果您的函数没有返回值,则必须将返回类型声明为 void。 典型的函数语法如下所示:

returnType functionName(argType arg1, otherArgType arg2){
  //do some stuff and calculate returnValue

  return returnValue;
}

要调用一个函数,我们只需写下函数名,后跟方括号和后缀中的参数。 如果有多个具有相同名称但参数类型不同的函数,hlsl 将自动找到适合我们调用它的参数的函数。如functionName(arg1, arg2)。

10 Control Flow

流程控制。
对于许多着色器来说,一个接一个执行一个命令就足够了,而不会遗漏或重复任何一个命令。 对于他们中的很多人来说,在两种代码路径之间进行选择也很重要。 众所周知,在着色器中使用控制流是有害的,尤其是在移动 GPU 上会影响您的性能,您应该使用诸如 step 之类的函数。 但是这种方法是完全错误的,因为step这些函数在它们内部其实也是使用了分支,使用它们反而会使你的代码更复杂和阅读。如果使用if能使你的代码更整洁,那么就可以使用它。
(“step不能提高效率”这观点我持保留意见,等考证后再说)

10.1 if statements

if语句。
如下面的例子,如果condition为真,则执行 do thing 块,否则执行 do other thing 块,两个代码块永远不会同时执行。

if(condition){
  //do thing
} else {
  //do other thing
}

其中, else是可选的。 条件周围的括号是强制性的。 如果你不使用花括号,该语句只会影响下一行(直到下一个分号,而不是下一个换行符),而不是之后的行。 条件可以是布尔值、数字(在这种情况下 0 为假,任何其他值包括负数为真)或返回两者之一的操作。 如果你有一个与你想要的相反的值(当你想执行一些代码时为假,否则为真),只需在它前面加上一个感叹号,就会翻转:真值变为假,假值变为真。如!flag。

10.2 Loops

循环。
控制哪些代码被执行,哪些不执行的另一种方式是循环。 While 循环是一种更简单的循环。 只要定义它们的条件不再为真,它们就会永久执行。 如果一开始就不是真的,它们根本不会被执行。 它们看起来像这样:

while(condition){
  //do things
}

重要的是,在 do things 中的某个地方您要讲条件改为true,否则循环while将永远运行,这很糟糕,甚至可能使您的整个编辑器崩溃。
另一种循环是 for 循环,它添加了一些语法糖来迭代和计数,它们是这样定义的。

for(beforeLoopLogic; condition; inLoopLogic){
  //do things
}

我们偶尔会遍历某个数组,变量索引从 0 到 maxValue-1 ,不那么令人困惑的版本如下:

for(uint index=0;index

此代码等效于使用 while 循环的代码:

uint index = 0;
while(index < maxValue){
  //do things

  index++;
}

两个循环也支持关键字 break 和 continue。
在您的代码中使用 break 语句会使代码跳转到下一个循环的末尾。 这也将跳出无限的 while 循环。
使用 continue 语句会使循环跳转到循环的下一次迭代的开始。