上一节,我们讲解了 Shader 的功能,并从预处理和注释开始,讲解 GLSL 的语法知识。想要学习和使用一门语言,必须先学习这门语言的语法,语法中除了上一节说到的预处理、注释,还有更加重要的变量定义和使用,函数定义和使用, 以及 GLSL 的一些特殊语法。其中变量相关的知识包含变量类型,变量名,变量的操作等,这一节,我们将介绍变量的数据类型等相关知识。
一个完整的程序,包括预处理、函数、变量等部分组成。这些部分合在一起, 诠释了程序要做什么事情,以及怎么做。在基本的语言中,比如 C、C++,我们对这些已经很熟悉了,而 Shader 其实也就是使用 GLSL 语言编写的完整程序。所以在 Shader 中,除了上一节所说的预处理,还有函数、变量等部分组成。下面, 我们来说一下 Shader 中的变量。
变量在使用之前先要进行定义,变量的定义是由变量类型和变量名组成的。 不存在默认的变量类型,所有的变量定义都必须明确一个变量类型,以及可选的变量类型修饰符。变量是通过特定一个变量类型,后面跟随一个或者多个由逗号隔开的变量名来进行定义的。在许多情况下,我们也可以在定义变量的时候,在变量名后面追加一个=号和初始值来对变量进行初始化。
我们先来说变量类型。首先,先回忆一下那些熟悉的变量类型。
void
代表空。可以把一个变量定义成 void,那么也就是说明这个变量为空,空不等于 0,不能对一个空的变量进行赋值。当 void 类型被用在函数中的时候,能用于函数返回类型,用于表明函数不返回任何值;或者是用来定义函数的传入参数列表,表明函数使用一个空的参数列表,也就是不需要传入任何参数。
bool
任何 bool 类型的变量都只能有 2 种取值,true 或者 false。但是在硬件层面, 其实没有硬件直接支持这种类型的变量,所以,在硬件中,当处理到 bool 类型 的时候,可能会认为比如 1、2 等为 true,0 为 false。true 和 false 这两个关键字被定义为 bool 常量。bool 变量定义的时候可以被初始化,在等号的右边可以赋值任何 bool 类型的表达式,比如 true 或者 false 等。
bool 的初始化也可以使用 bool 类的构造函数。由于下面所有的类型的都会使用到构造函数,所以我们先把构造函数的知识再普及一下。对 C++熟悉的同学都会知道构造函数,在 C++中,构造函数是类被实例化的时候执行的第一个函数, 这个函数的特点就是函数名和类名完全一样。
在 GLSL 中,也存在构造函数。当使用构造函数初始化某个变量的时候,构造函数的函数名就是该变量的类型名,构造函数传入的值也就是初始化的值,当传入值的类型与类型名,也就是构造函数函数名不符的时候,会做一次类型转化。 比如下面这几个例子。
bool(float) // converts a float value to a Boolean
int(bool) // converts a Boolean value to an int
float(int) // converts an integer value to a float
比如可以使用 bool 类型的构造函数,传入 float 类型的值,先将 float 类型转化成 bool 类型。类比一下,肯定也可以从 int 转化成 bool 类型。
同样的,int 类型的构造函数传入 bool 类型的值,先将 bool 类型转化成 int类型。类比一下,肯定也可以从 float 转化成 int 类型。
还有,float 类型的构造函数传入 int 类型的值,先将 int 类型转化成 float 类型。类比一下,肯定也可以从 bool 转化成 float 类型。 关于这些类型转换,还有一点需要注意:比如 int(float),在这里浮点类型的小数部分会被删掉。再比如把 int 或者 float 转换成 bool,那么 0 或者 0.0 会被转 换成 false,其余为 true。反之把 bool 转换成 int 或者 float,那么 false 会被转换为 0 或者 0.0,true 会被转换成 1 或者 1.0。但是,比如 float(float),这样类型不变的语法也合法,但是没有任何意义。
关于 bool 类型,最后再说一下,条件语句中必须要使用到 bool 类型,这个等我们说到条件语句的时候再做说明。
int
int 在硬件层非常重要,比如用于循环之类。但是,GLSL 不要求底层硬件对大数字 int 操作支持的非常好,因为 int 类型的变量可以先转换为 float 类型再进行操作。所以,如果想在 Shader 中使用数字变量,尽可能创建 float 类型的变量。 关于 int,我们还要知道 GLSL 支持 10 进制、8 进制和 16 进制的常量。使用 8 进 制的时候,需要在常量前面加 0 做前缀,而 16 进制,需要以 0x 为前缀,x 大写小写都行。在 int 常量中,不允许存在空格,即使是在 8 进制或者 16 进制的前缀后面。如果用于表示一个负数,前缀加一个负数符号-,该符号不属于常量,int 常量没有字符后缀。
float
float 类型在 Shader 中广泛使用,比如放大缩小某个数值等等。float 常量, 除了我们熟知的 1.5 或 1.或.1 之外,还可以支持科学技术法中的 e,比如 1.5e8 就是 1.5*10 的 8 次方。当使用 e 的时候,e 前面的数值可以不包含小数点,比如 1e-10,我们认为该常量为 float 类型。而如果不使用 e,那么 float 常量中一定要包含小数点。同样的,float 常量中也不可以包含空格,如果用于表示一个负数,前缀的那个负数符号-,不属于 float 常量。
以上这些类型都是属于标量类型。下面,我们来说一些之前接触的不多的变量类型。
vector
vector 在 C++的 stl 语法中也支持,所以大家对 vector 可能还是比较熟悉的。 GLSL 支持一种类似 vector 类型的变量类型。只是,在 GLSL 中分的更细致。 我们知道 vector 和数组有些类似,就是把同一个数据类型的多个值保存在一起。下面我们来一一解释 GLSL 中的这些变量类型。
首先 vec2,就是两个 float 类型的值保存在了一起。刚才我们已经说了 float为 GLSL 最主要的变量类型,所以这种不加任何前缀的 vec 变量类型,就是用于保存 float 值的,对应的 vec3 和 vec4 分别就是三个 float 类型的值保存在一起, 和四个 float 类型的值保存在一起。 这三种变量类型是非常重要的。在 Shader 中,我们用到的坐标值和颜色值, 都是用 vec4 保存,用于保存该坐标点的 xyzw 值或者 rgba 值,而纹理坐标值, 则使用 vec2 值来进行保存,这个等我们说到的时候再进行详细说明。
然后 bvec2,这个以字母 b 为前缀的变量类型,是用于将两个 bool 类型的值保存在了一起。对应的 bvec3 和 bvec4 分别就是将三个 bool 类型的值保存在一起, 和四个 bool 类型的值保存在一起。
bvec 变量类型主要用于 vector 之间进行比较的时候使用的。
最后以字母 i 为前缀的变量类型 ivec2、ivec3、ivec4 分别用于将两个 int 类型的值保存在一起,三个 int 类型的值保存在一起,和四个 int 类型的值保存在一起。
在 GLSL 中定义这种 vec 的变量类型,是因为在 Shader 中存在大量这种多 component 的变量进行各种操作的运算,而如果直接在 GPU 中运行这种 vector 级别的运算,比一个一个进行单值运算要快的多。所以为了提高效率,在 shader 中定义了这种 vec2、3、4 的变量类型,然后将这些变量直接保存到 GPU 中对应硬件上,通过 GPU 的对应模块,一次运算可以得到之前 2 次 3 次 4 次或者更多次运算的结果,这样从带宽、运算效率和功耗上,都会得到大大的优化,所以定义这种变量非常有必要。目前基本上所有 GPU 都已经支持了这种 vec2、3、4 变量类型的运算。
由于 vec 类型中包含了多个成员变量,我们如果想访问 vec 类型变量的每一个成员,只需要在变量名后面加一个点和一个字母即可。而字母也有很多种,比 如(x、y、z、w)是当 vec 类型保存的变量为位置坐标的时候,对应的 4 个成员名,(r、g、b、a)是 vec 类型保存的变量为位置颜色的时候,对应的 4 个成员名,再比如(s、t、p、q)是 vec 类型保存的变量为纹理信息的时候,对应的 4 个成员名,其中第三个成员名 p 本来应该是 r,但是为了与 rgba 的 r 区分,就使用了 p。如果使用了超过所定义 vec 范围的成员,就会出错,比如定义了 vec2 location,location.x 没有问题,但是使用 location.z 就会出错了。一次可以选择多个成员,比如 vec4 color,color.rgba 得到的是一个 vec4 类型变量,color.xyz 得到的是一个 vec3 类型变量。注意一定要使用同一组字母,如果是 color.xyba 就会出错了。最多只能选择 4 个成员,如果写 color.rgbarg,那么会就出错。但是要注意的是针对 vec2 的 location,使用 location.xyxy 则没有问题。在 vec 类型中有一种 语法叫做 swizzle,就是将某个 vec 类型变量的成员调换位置,赋值给新的 vec 中, 比如 vec4 location1 = location.yyxx。
使用这种表达方式还可以对类型中的某些成员进行赋值,比如 location.x= 1.0,location.yx = vec2(1.0, 1.1)。但是需要注意的是比如 location.xx = vec2(1.0, 1.1) 是错的,因为 x 会被使用两次,location.xy = vec3(1.0, 1.1, 1.2)也是错误的,因为将一个 vec3 赋值给了一个 vec2,并且没有使用 vec2 的构造函数做转换。
除了使用这种点加字母的形式,也可以使用[]的形式,比如 location[1]就是 location 的第二个成员,等同于 location.y。而 location[4]就是错误的,因为超出范围了。
需要注意的是,比如 float 等标量并非等同于只包含一个成员的 vector,所以不能使用点或者[]来获取所谓标量中唯一的那个成员,不然会出错。
在定义这种变量的时候,也可以同时进行初始化,vec 的初始化基本上也是使用构造函数,比如下面这几个例子。
vec3(float) // initializes each component of with the float
vec3(float, vec2) // vec3.x = float, vec3.y = vec2.x, vec3.z = vec2.y
在定义这种变量的时候,也可以同时进行初始化,但是由于 GLSL 中使用到的变量的数值大多都是从 OpenGL ES 传入的,所以在这里也就不细讲 vec 这个类 型变量的初始化了。等说 mat 构造函数的时候,再在一起说明。
在标量数据类型的构造函数中,支持传入 vec 类型这种非标量的数据,比如 传入一个 vec3 的变量,那么会把 vec3 变量的第一个值作为输出传给 float 值。
mat
mat 变量类型和 vec 有点类似,只是 vec 保存的是一纬的,比如 vec2、vec3、 vec4 分别就是保存 2 个、3 个或者 4 个 float 类型的变量。但是 mat 变量类型是 二纬的,用于保存类似矩阵。
比如 mat4,用于保存 4*4 个 float 类型的变量,也就是保存了 16 个 float 类型的变量。图形学学习者不可避免的都要接触到矩阵变量,用于进行矩阵变换,比如本地坐标系中的坐标向世界坐标系中进行转换的时候,要用对应的坐标点与转换矩阵进行对应的运算。
而在 Shader 中,我们就要完成类似的运算,将传入的坐标点的值用 vec4 保存,将传入的转换矩阵用 mat4 保存,然后将它们相乘,按照数学中矩阵运算的算法,得到转换后的坐标点值。
所以 mat 这一系列的变量,在 Shader 中的使用也是非常广泛的,除了刚才我们解释的 mat4,还有 mat2 和 mat3,分别是用于保存 2*2 个 float 类型的变量以及 3*3 个 float 类型的变量。Mat 只支持 float 类型,并不支持 bool 和 int,从这也可以看出 shader 中 float 类型是多么的重要。
在硬件中,mat 变量是以列进行保存的,比如 mat2 中有 4 个变量,对应的矩阵位置分别为左上,左下,右上,右下。然而这 4 个变量在硬件中,会按照左上,左下,右上,右下这种顺序进行保存。也就是第一列保存完毕,再保存第二列,mat3 和 mat4 也是这样的保存顺序。无论是写入还是读取都是按照这样的顺序进行。
mat 的成员变量可以通过[]来访问,如果一个 matrix 变量名后面跟一个[],[] 中写入一个 int 常量,比如 mat4 a,获取 a[0],那么就是说把这个 mat 按列分成几个 vector,a[0]就是 a 这个矩阵第一列的 vector,如果 matrix 变量名后面跟两个[],那么第二个[]就是作用在 vector 上的,比如 a[0][1]就是选择了 a 这个矩阵第一列这个 vector 上的第二个元素。当使用 a[0]或者 a[0][1]的时候,就要把其当作 vect4 或者 float 来看。如果[]中的常量超过范围,则报错。
mat 变量主要是通过构造函数来进行初始化。
这里我们将 vec 和 mat 的构造函数放在一起进行讲解,构造函数的传入参数可以是一套标量或者 vectors,甚至可以是 matrix。可以从大类型转成小类型,和小类型转成大类型。
比如,如果将一个标量传入 vector 的构造函数中,那么生成的这个 vector 中所有的值都是这个标量值。比如 vec3(float)。如果将一个标量传入 matrix 的 构造函数中,那么生成的这个 matrix 中对角线上的所有的值都是这个标量值, 其余的将都是 0.0。比如 mat2(float)。 如果一个 vector 的构造函数中传入多个标量、vector、matrix,或者是它们的混合体,vector 的成员会按照从左向右的顺序,从参数中获取值来进行赋值。 每个参数被使用完毕之后,才使用下一个参数进行赋值。比如 vec3(float,vec2)。 如果参数多了,没关系,多的参数会被丢弃,比如 vec3(vec4),那么 vec4 的第四个成员会被丢弃。matrix 的构造函数也一样,matrix 的成员会按照列的方式从参数中读取数据。必须传入足够的参数,来初始化 matrix 的成员。而且也不能传多。比如 mat2(vec2,vec2),mat3(vec3,vec3,vec3),mat4(vec4,vec4, vec4,vec4),再比如可以使用 4 个或者 9 个或者 16 个 float 来构造 mat2,mat3, mat4。但是如果使用 mat2(vec3,vec3,vec3)就是错误的。
如果通过一个 matrix 来对另外一个 matrix 进行赋值,那么传入参数的第 i 行第 j 列,会按照相同的位置传给被赋值的 matrix。其他没有被赋值的地方会从 单位阵的对应位置获取数据。如果通过 matrix 来对 matrix 进行赋值,那么传入参数中只能只有一个 matrix,而不能存在其他参数。比如 mat4(mat2)。
假如使用标量来赋值,但是被构造的类型与传入标量类型不符合,那么会对标量类型进行类型转换。比如 vec4(int)。这样会把 int 先转化成 float,然后将 vec4 的四个成员变量都赋值成这个 float 值。
我们把 vec 和 mat 的操作也放在一起讲。实际上对 vec 类型变量,或者 mat 类型变量做操作,就相当于对这样变量的每个成员一一做操作。比如加法,那么 vec2+vec2 等于 vec2,实际的执行过程也就是把两个 vec2 的 x 相加,得到结果的 vec2 的 x。把两个 vec2 的 y 相加,得到结果 vec2 的 y。
但是有一个是例外的,就是乘法。使用 vec 乘以 mat,或者 mat 乘以 vec, 或者 mat 乘以 mat。
vec3v,u; mat3m; u=m*v;
// u.x = m[0].x * v.x + m[1].x * v.y + m[2].x * v.z;
u.y = m[0].y * v.x + m[1].y * v.y + m[2].y * v.z;
比如 mat 乘以 vec,就是像我们这个例子中这样,得到的结果是一个 vec, 结果中 vec 的 x 是 mat 的第一列的第一个成员乘以乘数 vec 的 x,加上 mat 的第 二列的第一个成员乘以乘数 vec 的 y,再加上 mat 的第三列的第一个成员乘以乘 数 vec 的 z。结果中 vec 的 y 是 mat 的第一列的第二个成员乘以乘数 vec 的 x,加 上 mat 的第二列的第二个成员乘以乘数 vec 的 y,再加上 mat 的第三列的第二个成员乘以乘数 vec 的 z。结果中 vec 的 z,就是 mat 的第一列的第三个成员乘以 乘数 vec 的 x,加上 mat 的第二列的第三个成员乘以乘数 vec 的 y,再加上 mat 的第三列的第三个成员乘以乘数 vec 的 z。而 vec 乘以 mat,得到的结果也是一个 vec,结果中 vec 的 x 是乘数 vec 乘以 mat 的第一列,y 是乘数 vec 乘以 mat 的第二列,z 是乘数 vec 乘以 mat 的第三列;而如果 mat 乘以 mat,那么就是拿第一个 mat 的第 i 行乘以第二个 mat 的第 j 列,得到结果中 mat 的第 i 行 j 列的 一个成员。
下面要说的这两种变量类型,在别的语言中完全没有见过,属于 GLSL 特有的两种变量类型。
sample2D sampleCube
在 OpenGL ES 中有一个名词叫做 texture,中文名是纹理贴图,在游戏中无论多么绚丽的效果,都是由纹理贴图来完成的。之前我们介绍过,如果在一个球上贴上一张地球的纹理贴图,那么这个球就变成了地球仪。所以贴图的主要用处就是给一副画赋予颜色。在 OpenGL ES 的整个 pipeline 中,贴图的使用也占据着一席之地,主要使用方法是在 OpenGL ES 中生成贴图,然后传给 GLSL 使用。而 sample2D 和 sampleCube 就是用于保存从 OpenGL ES 传入,在 Shader 中使用的 2D 贴图或者 CubeMap 贴图的 handle。
当 Shader 拿到贴图的 handle 之后,可以将其用于一些纹理贴图相关的 buildin 函数。
由于 sample 变量是用于保存纹理贴图的,而纹理贴图又是由 OpenGL ES 传入的,所以 sample 变量就不需要考虑其初始化,因为它们的值全部是由 OpenGL ES API 传入。
其实,某种意义上它们属于 int 类型的变种。这个等我们之后在专门介绍纹理的课程中再做具体解释说明。这里只要知道在 Shader 中,有这么一类,共两 种变量类型,用于保存纹理贴图的 handle 的。
如果以上的变量类型都属于简单数据类型,那么下面这两种就属于复杂数据类型。
struct
开发者可以通过 struct 把一系列已知的变量类型封装在一个名字中,创建属于自己的变量类型。
比如我想创建一种变量类型,包含一个 float 变量和一个 vec3 变量,而我给这种自定义的变量类型取名叫做 type1,在创建这种变量类型的时候,我还想定义一个这种变量类型的变量 x,那么可以这么写:
struct type1 {float a; vec3 b;}x;
可以看到左大括号前面,指定的是这种自定义变量类型的类型名 type1,右 大括号后面是我刚定义的这种变量类型的一个变量实例 x。
在这种自定义变量类型定义好之后,如果还想使用这个变量类型定义新的变量实例 x1,那么就和定义别的类型的变量一样,直接写 type1 x1 即可。
需要注意的是,这里我们创建了一个自定义变量类型 type1,假如在此之前, 我们定义了一个变量或者函数或者另外一个自定义变量类型也叫做 type1,那么之前的那个 type1 从这里开始就会失效,从现在开始,在当前 namespace,当前代码块中,type1 指的就是这个新的自定义变量类型。
struct 的结构体主体部分,必须包含至少一个成员,比如我们定义的这个 struct 中就有两个成员。
struct 成员的类型必须是已经定义好的,不支持嵌套定义等。
struct 的成员在声明的时候不能进行初始化。
成员可以是 array,但是在定义的时候需要明确一个大于 0 的 int 常量,表明该 array 的尺寸。
struct 可以是嵌套的,且每一级都是一个独立的 namespace,定义的变量名只需要在当前级是唯一的即可。
C 语言中,我们可以用 struct 来制作位域,可以将一个 32bit 的 int 拆成多个成员,每个成员位数不定,而总位数位为 32bit。这种用法叫做 bit fields,目前 GLSL 中的 struct 还不支持这种用法。
如果在一个自定义变量类型 struct type2 中的成员包含一个 struct type1,那 么在定义 struct type2 的时候,需要在其中声明一个 type1 的变量,而不能直接在 type2 的结构体中写一个 type1。
类似于 vector 中获取成员或者 swizzle 语法,struct 也可以使用点来指定成员, struct 支持.,==,!=,=操作。等于操作符和赋值操作符只支持两个操作数的类型是相同的结构体。只有当两个操作数的所有成员都一样,才认为两个结构体一样。等于操作符和赋值操作符不支持包含 array 或 sample 类型的 struct。
struct 的初始化主要是通过 struct 的构造函数进行。
比如刚才我们定义的 struct 类型 type1,那么构造函数的函数名就是 type1, 传入参数与struct的成员对应,那么type1的构造函数就是type1 name1 = type1 (3.0,(1.0, 1.1, 1.2);
传入参数必须按照 struct 中声明的成员的顺序和类型。
假如 struct 的任何成员变量有任何限制,那么该 struct 也受到相应的限制。 struct 可以被当作函数输入参数,而且如果 struct 中不包含 array,则也可以当作函数输出函数。
array
array 也属于大众数据类型,同样类型的多个变量可以被放入一个 array 中, 只需要定义一个名字后面加[],[]中填写一个数字即可。这个数字代表着 array 的尺寸,必须是一个大于 0 的 int 常量表达式。比如我们定义一个 float 的 array, float a[5]。如果我们使用一个 index 超过或者等于 array 的尺寸,那么会出现错误, 比如我们使用 a[5]或者 a[6]就会出错。如果我们使用一个负的 index 也会 出错,比如 a[-1]。这里的出错导致的结果根据平台的不同而不同,可能会得到未定义数值,也可能直接导致 memory crash。
array 唯一支持的操作符就是[],而我们平时的使用方式都是,使用[]得 到 array 中的某一个元素,这个元素可能是 float,可能是 int,也可能是其他,然后针对这个元素进行操作。
在 GLSL 中只支持一维数组,所有的基本类型或者 struct 都可以组装成 array。 在 shader 中不支持在定义 array 的时候进行初始化。
array 可以被当作函数输入参数,不能当作函数输出函数。 最后还要说一点,GLSL 不支持指针。
GLSL 语言,属于类型安全的语言,不支持类型之间的隐式类型转换。以上就是 GLSL 的全部数据类型。
在这一节的最后,说一下关于变量范围的知识点。变量的范围决定了变量的可见域。GLSL 使用了嵌套式范围系统,允许在一个 Shader 中出现多个相同名字的变量,只是这些名字要定义在不同的 namespace 中。
所谓嵌套的范围系统,我们用变量定义来解释一下:假如变量定义中在所有函数之外,那么它就有了全局定义,可见域就是从它被定义开始,一直到当前 shader 的结束。这个也就是嵌套范围系统中最外面的套。其次,就是定义在一个函数中,或者定义在一个任意语句块中,变量的可见域也就是变量被定义开始, 一直到语句块的结尾处。
变量在被定义之后就开始生效,比如下面这个例子,在外面的范围定义了 x =1,在内部的范围定义了 x=2,然后紧接着定义 y=x。那么由于内部范围中 x =2 已经生效了,那么 y 也就是等于 2 了。
int x=1; {int x = 2, y = x; // y is initialized to '2’ }
再比如下面这个例子,S 是一个结构体,先定义了一个 S 类型的变量 S,然后在这句话结束之后,变量 S 才开始生效,所以在第二行,使用的 S 就是变量 S 了。
struct S { int x;};
{
S S = S(0,0); // 'S' is only visible as a struct and constructor
S; // 'S' is now visible only as a variable
}
我们刚才说了 GLSL 使用嵌套式范围系统,在同一个范围不能定义两个变量名相同的变量,在不同的范围可以。根据作用域的不同,一个变量会覆盖另外一个变量,并且在该作用域中,无法访问被覆盖的变量。
GLSL 中还存在一种范围类型,叫做共享全局,共享全局的变量意思就是变量可以被多个 shader 访问。vertex shader 和 fragment shader 分别拥有一个自己的全局范围,函数定义只能定义在全局范围中,不能定义在语句块中。而共享全局是一块独立的范围。关于 Shader 中存在哪些共享全局,我们将在下一节进行说明。
本节教程就到此结束,希望大家继续阅读我之后的教程。
谢谢大家,再见!
原创技术文章,撰写不易,转载请注明出处:电子设备中的画家|王烁 于 2017 年 7 月 10 日发表,原文链接(http://geekfaner.com/shineengine/blog5_OpenGLESv2_4.html)