原文:
HLSL
HLSL?
HLSL是Unity着色器中所使用的一种语言,可以通过HLSL来实现渲染逻辑。HLSL语言是微软设计的、面向D3D的GPU编程语言。严格的说,网络上现有的大多数Unity着色器脚本是通过CG语言编写的,CG是C for Graphics的简写,也就是为图形编辑而设计的C语言。但是CG语法和HLSL有很多共性,同时CG语言在2012年便不再维护,而我们很容易把两者混淆,不过这并不影响我们进一步的学习。理论上,Unity也支持GLSL语言,GLSL是为OpenGL设计的编程语言,也是类C的编程语言。由于HLSL的例子在网络上随处可见,同时Unity会根据平台不同,自动会将着色器脚本翻译为对应的语言,所以我们不必纠结使用哪种语言。为了方便,这里建议直接使用HLSL语言。
因为着色器编程难度较大,所以建议初学者在系统学习过基本的编程,再考虑学习着色器编程。因为着色器脚本是在GPU中运行的,而一般的编辑软件是在CPU中运行,所以在着色器编程过程中,很难进行异常分析。因此这也导致我们开发过程中有很多限制,另外,和普通的软件开发相比,我们需要从不同的角度来思考问题。如果你已经具备了基本的编程素养,知道什么是数据类型、类、函数、循环、条件语句等,也大概知道CPU和GPU的区别、串行与并行的区别,那么欢迎你阅读下面的内容。
Builting Types
首先,我们需要知道在着色器编写过程中,有哪些可以使用的内置数据类型。
Scalar Values
标量。在Unity的hlsl语言中,小数的类型有fixed
、half
、以及float
,整形的类型有int
、uint
。需要指出的是,在最新的URP渲染管线中,小数的类型只支持half
、和float
。
在移动端的GPU中,fixed
的数据范围为[-2, 2]
,其精度为1/256
。而half
和float
分别是16位和32位的浮点数。在PC端的GPU中,这三类都是32位浮点数。所以在后面的内容中,你将会看到我基本上都是使用float
来表示小数,当然,后面有工具可以对此进行优化。
整形,也就是我们所知的整数,其中int
可以是正数也可以是负数,而uint
只能是正数,这两者之间合理的选择,也能达到细微的优化作用。
另外,还支持bool
类型,布尔类型数据用于表示是与否的两种状态。如果我们强行将布尔和数值进行加减乘除运算时,那么实际的布尔值的是就变成1、而否就变成0参与计算。
Vector Values
向量。向量在空间上表示的是一个方向,由各个维度的投影分量构成。上提到的标量,从广义上来说是一维向量。向量在HLSL中的表示很简单,只要在上面对应标量的后面加上维度。例如上面的fixed
、half
、float
的四维向量分别是fixed4
、half4
、float4
。向量的使用很常见,例如记录纹理坐标、颜色、位置等信息。
当然,我们也可以访问向量中特定维度的分量。以4维向量v为例,向量数据是一个长度为4的数组,起始索引为0。也就是说v[0]实际上就是访问的第一个维度的值。另外,向量在着色器中主要用于表示空间、和颜色。所以为了方便,可以直接通过维度名称来索引,例如获取x轴的分量可以表示为v.x,获取红色通道的值可以表示为v.r。这些维度、通道的数据就是存储在前面所说的数组中,其顺序为xyzw、和rgba。换句话说,v[0]、v.x、v.r访问的是同一个值。
另外,在实际使用中,我们可能经常遇到需要从一个向量中选取部分值,来重新构成一个新的向量。因此HLSL在语言设计时就引入了这种通过维度、或通道混合的方式来实现向量重构。举个例子:
- v.xy : 选取原向量中的前两个维度,构成一个新的二维向量;
- v.zyx : 选取原向量中的前三个维度,并且调换顺序,构成一个新的三维向量;
- v.xxxx: 选取原向量中的第一个维度,构成一个四维向量,新构成的四维向量的各个维度的值都等于原向量中的第一个维度的值;
Matrix Values
矩阵。如果说前面的向量是在标量的基础上,朝着一个方向扩展。那么矩阵就是朝着两个方向扩展,分别是横向r、和纵向c。因此矩阵的数据结构是一个二维数组,与标量对应的表示有fixedrxc
、halfrxc
、floatrxc
,其中的r和c表示的是行数和列数。例如float4x4
、half3x2
、bool2x4
。和向量类型,矩阵也可以使用二维数组的方式进行访问,例如matrix[3][2]
,访问的是矩阵中第3行、第2列的元素,注意,这里的行号和列号都是从0开始的。除了二维数组的访问方式,还可以使用元素名称进行访问,例如_m32是第3行、第2列的名称。还可以使用名称混合的方式,实现向量重构。例如matrix._m03_m13_m23,是选取矩阵最后一列的前三个元素构成一个三维向量。如果我们采用二维数组的访问方式,但是只传入一个索引号,那么这个索引号表示的是行号,得到的是该行的向量。例如matrix[0]表示的是获取第0行的向量。
矩阵处理非常繁琐,庆幸的是我们很少会需要去单独处理其中的元素,因此可以直接调用现有的辅助函数来实现矩阵运算。
Textures
HLSL同时也定义了纹理类型。纹理类型比较特殊,这里不做过多讲述,只需要知道,我们可以通过tex2D(texture, coordinate)
来对纹理进行采样。当然,在后面的内容我们会了解跟多,I Promise!
Math
在数学计算方面,HLSL提供基本的操作方法,例如+
、-
、*
、/
,可以执行基本的数值计算,而<
、>
、==
、!=
、!
、>=
、<=
、&&
、||
可以用于条件比较。除此之外,HLSL还集成了像abs
、dot
、lerp
、pow
、min
、atan2
等常用函数,详情可参考这里。
同时,HLSL也提供一些快捷操作符,例如+=
、*=
、-=
、/=
,这些在将操作符左右两边的数据除了后,复值给左边的数据。还有++
、--
可以用于自增、自减一个单位。
需要注意的是,标量和向量的乘法,是标量乘以向量中的每一个元素,然后生成一个同维度的向量。例如float2(2,7) * 3 == float2(6, 21)
。
而矩阵与向量的乘积相对复杂点,在矩阵分析中有介绍,不过直观的理解就是矩阵表示空间坐标系之间的关系,而向量表示空间坐标系中的点、或方向,两者乘积的结果表示向量从一个空间变换到另一个空间。在应用过程中,我们并不太关系该操作的具体实现。初学者可以基于这种直观理解,然后参考模仿现有的应用案例,或者直接从中复制过来,久而久之就知道怎么用了。矩阵向量的乘积使用的函数是mul
,具体可以参考这里。
Custom Types
除了内置类型,我们还可以添加一些自定义类型。自定义类型的语法和类C语言的结构体很像,但是需要注意的是,必须在自定义类型后面加上分号;
。如下所示:
1 | struct typeName{ |
理论上来说,我们也可以使用class
关键字、继承、成员函数、甚至是接口类型。但是,目前为止,我只看到使用struct
的情况,所以我们依照祖传习惯就好。如果你想做第一个吃螃蟹的人,那么你可以试着将这些类C的语法应用上去,能不能正常使用,就全看天意了:->!
和向量类似,如果我们想访问自定义结构中的成员变量,同样是采用.
连接符。例如,instance.variable,或者可以访问成员的成员,如,instance.otherVariable.x。
Variables
变量。在HLSL中,所有的数据都是值类型,这意味着所有操作,都是直接作用在变量上,而不是变量的引用。同时,我们也不需要使用new
之类的关键字来创建变量。
当我们希望创建一个向量时,只需要把向量类型当做普通函数调用就可以。例如创建四维向量float4()
、float4(1,1,1,1)
。在这种情况下,创建向量的所有参数的维度总和,必须等于目标向量的维度。例如创建一个四维向量,可以传入四个标量,也可以传入两个二维向量,还可以传入两个标量加一个二维向量,或者直接传入一个四维向量。如下,以上面的自定义结构为例:
1 | typeName instance; |
另外,变量可以声明在函数内、和函数外。如果定义在函数内,那么能在函数内可以使用,并且使用的位置必须在定义之后。如果定义在函数外面,那么所有的函数都可以使用这个变量,不受顺序的影响,但是习惯上我们会将变量统一定义在函数之前。
Functions
在HLSL中,大多是函数都是全局函数。这意味着它们不属于任何数据结构,并且我们可以在任何位置调用它们。这些函数可以传入一个或多个参数,还可以返回计算结果。如果你不希望函数返回任何值,那么你可以在前面声明为viod
。如下是一个函数范例:
1 | returnType functionName(argType arg1, otherArgType arg2){ |
当我们调用函数的时候,只需要写下函数名,以及后面跟的括号。如果函数接受传参的话,直接将所需参数以逗号分隔,依次写在括号内。这里的函数支持重载,函数和其参数共同构成了该函数的唯一标识,所以我们可以定义多个同名、但不同参的函数体。在调用时,程序会自动根据传入参数来判断所调用的函数。
Control Flow
对于大多是着色器,我们只需要一行接一行的执行相关逻辑,就可以实现我们想要的功能。但是有些复杂点的需求,我们可能需要重复执行某些命令,或者需要丢弃某些命令,这就涉及到代码执行路径选择的问题。在程序上这叫分支语句、或者叫条件语句,还有循环语句。很多人认为,使用分支语句会影响着色器的执行效率,尤其是移动端,所以应该使用step
来代替分支语句。这中说法显然不对,因为在step
这类函数中本身就是基于分支语句实现的。而且使用step
这些函数会使我们程序在逻辑上变得复杂,不利于阅读。当然,上面关于执行效率的说法并不是完全没有依据,假如我们使用分支语句,刚好GPU会执行所有路径,然后丢弃其中一条路径的计算结果,这时候确实存着资源浪费。但是这并不能通过其他技术来避免,毕竟有些逻辑本身就存在分支,所以不要下意识的排斥使用条件语句,而应该多考虑代码的整体结构,是否美观、是否易于阅读。
if statements
如下所示的if
语句,如果条件为true
,那么执行上半部分的逻辑,否则,执行下半部分的逻辑。
1 | if(condition){ |
上面的else{...}
部分是可选的。花括号中的逻辑相当于一个整体。如果我们不使用花括号,那么上面的条件语句只会将其后的一行逻辑代码当成它的分支。上面的condition
值可以是bool
、或int
,也可以是一条语句,但是这条语句的结果必须是前面两者之一。另外,!
可以用于逻辑取反,假设我们希望条件为false
时执行if
后的分支,那么只需要在条件之前加!
。
Loops
除了分支语句,还有另外一种常见的控制流-循环语句。While
循环是最简单的循环语句,如果条件为ture
时,它将会一直循环下去,直到条件为false
为止。如下:
1 | while(condition){ |
有一点要记住,在while
循环语句中,必须要有某一段可执行代码来将条件置为false
,如果没有这种代码,那么该循环将会一直执行下去,这是非常严重的问题。
另一种循环叫做for
循环,在for
循环中可以假如控制循环次数的变量。如下所示:
1 | for(beforeLoopLogic; condition; inLoopLogic){ |
for
循环下循环次数控制方便面,显得更加简单明了。比如从0开始到最大次数:
1 | for(uint index=0;index<maxValue;index++){ |
当然,我们也可以使while
循环来实现同样的功能,但是看起来就没有for
循环那么整洁了:
1 | uint index = 0; |
所有的循环语句中都支持break
和continue
关键字。其中break
用于中断循环语句,直接跳出循环。而continue
是跳过本次循环,但是会继续执行后续的循环。
好了,本篇终结!希望你能喜欢我的教程。如果你想支持我,可以关注我的推特,或者通过ko-fi、或patreon给两小钱。总之,各位大爷,走过路过不要错过,有钱的捧个钱场,没钱的捧个人场:-)!!!