本文讲解着色语言GL Shader Language(GLSL)的一些基本的概念,如无特殊说明,文中的GLSL均指OpenGL ES的着色语言。本文是基于OpenGL ES 2.0。
GLSL概览
OpenGL ES的渲染管线包含有一个可编程的顶点阶段的一个可编程的片段阶段。其余的阶段则有固定的功能,应用程序对其行为的控制非常有限。每个可编程阶段中编译单元的集合组成了一个着色器。在OpenGL ES 2.0中,每个着色器只支持一个编译单元。着色程序则是一整套编译好并链接在一起的着色器的集合。着色器shader的编写需要使用着色语言GL Shader Language(GLSL),GLSL的语法与C语言很类似。
使用GLSL构建着色器
变量及变量类型
变量类别 | 变量类型 | 描述 |
---|---|---|
空 | void | 用于无返回值的函数或空的参数列表 |
标量 | float, int, bool | 浮点型,整型,布尔型的标量数据类型 |
浮点型向量 | float, vec2, vec3, vec4 | 包含1,2,3,4个元素的浮点型向量 |
整数型向量 | int, ivec2, ivec3, ivec4 | 包含1,2,3,4个元素的整型向量 |
布尔型向量 | bool, bvec2, bvec3, bvec4 | 包含1,2,3,4个元素的布尔型向量 |
矩阵 | mat2, mat3, mat4 | 尺寸为2x2,3x3,4x4的浮点型矩阵 |
纹理句柄 | sampler2D, samplerCube | 表示2D,立方体纹理的句柄 |
除上述之外,着色器中还可以将它们构成数组或结构体,以实现更复杂的数据类型。在GLSL中没有指针类型。
变量构造器及类型转换
对于类型转换,OpenGl ES SL中有非常严格的规则,即只有类型一致时,变量才能完成赋值或其它对应的操作。为了实现类型转换,在GLSL中有多重构造器可以使用。构造器可以用来初始化变量,也可以用来在不同类型之间进行转换。使用构造器对变量的初始化可以在声明时进行,也可以稍后在着色器中完成。每种内置的类型都有一些相对应的构造器。
首先考虑使用构造器对标量的初始化和类型转换,参考如下代码:
1 | float myFloat = 1.0; |
与此相似,构造器也可以用来初始化和转换矢量的数据类型。
当构造矢量时,矢量构造器中的各参数将会被转换成相同的类型(浮点型、整型或布尔型)。向矢量构造器中传递参数有两种形式:
- 如果矢量构造器中只提供了一个标量参数,则矢量中所有值都会设定为该标量值
- 如果提供了多个标量值或提供了矢量参数,则会从左至右使用提供的参数来给矢量赋值,如果使用多个标量来赋值,则需要确保标量的个数要多于矢量构造器中的个数
矢量构造器用法可参考以下示例代码:
1 | vec4 myVec4 = vec4(1.0); // myVec4 = {1.0, 1.0, 1.0, 1.0} |
矩阵的构造方法相对灵活,有以下规则:
- 如果对矩阵构造器只提供了一个标量参数,该值会作为矩阵的对角线上的值。例如
mat4(1.0)
可以构造一个4 × 4的单位矩阵 - 矩阵可以通过多个矢量作为参数来构造,例如一个mat2可以通过两个vec2来构造
- 矩阵可以通过多个标量作为参数来构造,矩阵中每个值对应一个标量,按照从左到右的顺序
除此之外,矩阵的构造方法还可以更灵活,只要有足够的组件来初始化矩阵,其构造器参数可以是标量和矢量的组合。在OpenGL ES中,矩阵的值会以列的顺序来存储。在构造矩阵时,构造器参数会按照列的顺序来填充矩阵,具体可参考以下示例代码:
1 | mat3 myMat3 = mat3(1.0, 0.0, 0.0, // First column |
矢量和矩阵的分量
单独获得矢量中的组件有两种方法:即使用"."
符号或使用数组下标方法。依据构成矢量的组件个数,矢量的组件可以通过{x, y, z, w}
,{r, g, b, a}
或{s, t, r, q}
等swizzle操作来获取。之所以采用这三种不同的命名方法,是因为矢量常常会用来表示数学矢量、颜色、纹理坐标等。其中的x
、r
、s
组件总是表示矢量中的第一个元素,如下表:
分量访问符 | 符号描述 |
---|---|
(x,y,z,w) | 与位置相关的分量 |
(r,g,b,a) | 与颜色相关的分量 |
(s,t,p,q) | 与纹理坐标相关的分量 |
不同的命名约定是为了方便使用,在使用矢量时不能混用不同的命名约定,即不能使用.xgr
这样的方式,每次只能使用同一种命名约定。当使用"."
操作符时,还可以对矢量中的元素重新排序,参考以下示例代码:
1 | vec3 myVec3 = vec3(0.0, 1.0, 2.0); // myVec3 = {0.0, 1.0, 2.0} |
除了使用"."
操作符之外,还可以使用数组下标操作。在使用数组下标操作时,元素[0]
对应的是x
,元素[1]
对应y
,以此类推。值得注意的是,在OpenGL ES 2.0中的某些情况下,数组下标不支持使用非常数的整型表达式(如使用整型变量索引),这是因为对于矢量的动态索引操作,某些硬件设备处理起来很困难。在OpenGL ES 2.0中仅对uniform类型的变量支持这种动态索引。
矩阵可以认为是矢量的组合。例如一个mat2可以认为是两个vec2,一个mat3可以认为是三个vec3等等。对于矩阵来说,可以通过数组下标“[]”
来获取某一列的值,然后获取到的矢量又可以继续使用矢量的操作方法,可参考以下代码:
1 | mat4 myMat4 = mat4(1.0); // Initialize diagonal to 1.0 (identity) |
矢量和矩阵的操作
绝大多数情况下,矢量和矩阵的计算是逐分量进行的(component-wise)。当运算符作用于矢量或矩阵时,该运算独立地作用于矢量或矩阵的每个分量。
以下是一些示例:
1 | vec3 v, u; |
等效于:
1 | v.x = u.x + f; |
再如:
1 | vec3 v, u, w; |
等效于:
1 | w.x = v.x + u.x; |
对于整型和浮点型的矢量和矩阵,绝大多数的计算都同上,但是对于矢量乘以矩阵、矩阵乘以矢量、矩阵乘以矩阵则是不同的计算规则。这三种计算使用线性代数的乘法规则,并且要求参与计算的运算数值有相匹配的尺寸或阶数。
例如:
1 | vec3 v, u; |
等效于:
1 | u.x = dot(v, m[0]); // m[0] is the left column of m |
再如:
1 | u = m * v; |
等效于:
1 | u.x = m[0].x * v.x + m[1].x * v.y + m[2].x * v.z; |
再如:
1 | mat m, n, r; |
等效于:
1 | r[0].x = m[0].x * n[0].x + m[1].x * n[0].y + m[2].x * n[0].z; |
对于2阶和4阶的矢量或矩阵也是相似的规则。
结构体
与C语言相似,除了基本的数据类型之外,还可以将多个变量聚合到一个结构体中,下边的示例代码演示了在GLSL中如何声明结构体:
1 | struct fogStruct |
首先,定义会产生一个新的类型叫做fogStruct
,及一个名为fogVar
的变量。结构体可以用构造器来初始化,在定义了新的结构体之后,还会定义一个与结构体类型名称相同的构造器。构造器与结构体中的数据类型必须一一对应,以下代码演示了之前定义的结构体初始化的构造语法:
1 | fogVar = fogStruct(vec4(0.0, 1.0, 0.0, 0.0), // color |
结构体的构造器是基于类型的名称,以参数的形式来赋值。获取结构体内元素的方法和C语言中一致:
1 | vec4 color = fogVar.color; |
数组
除了结构体制外,GLSL中还支持数组。 语法与C语言相似,创建数组的方式如下代码所示:
1 | float floatArray[4]; |
与C语言不同,在GLSL中,关于数组有两点需要注意:
- 除了uniform变量之外,数组的索引只允许使用常数整型表达式
- 在GLSL中不能在创建的同时给数组初始化,即数组中的元素需要在定义数组之后逐个初始化,且数组不能使用const限定符
存储限定符
在声明变量时,应根据需要使用存储限定符来修饰,GLSL中支持的存储限定符见下表:
| 限定符 | 描述 |
| —————– | ————————– |
| < none: default > | 局部可读写变量,或者函数的参数 |
| const | 编译时常量,或只读的函数参数 |
| attribute | 由应用程序传输给顶点着色器的逐顶点的数据 |
| uniform | 在图元处理过程中其值保持不变,由应用程序传输给着色器 |
| varying | 由顶点着色器传输给片段着色器中的插值数据 |
本地变量和方法参数只能使用const限定符,方法返回值和结构体成员不能使用限定符。
数据不能从一个着色器程序传递给下一个阶段的着色器程序,这样会阻止同一个着色器程序在多个顶点或者片段中进行并行计算。
不包含任何限定符或者包含const限定符的全局变量可以包含初始化器,这种情况下这些变量会在main()函数开始之后第一行代码之前被初始化,这些初始化值必须是常量表达式。没有任何限定符的全局变量如果没有在定义时初始化或者在程序中被初始化,则其值在进入main()函数之后是未定义的。uniform、attribute和varying限定符修饰的变量不能在初始化时被赋值,这些变量的值由OpenGL ES计算提供。
默认限定符
如果一个全局变量没有指定限定符,则该变量与应用程序或者其他正在运行的处理单元没有任何联系。不管是全局变量还是本地变量,它们总是在自己的处理单元被分配内存,因此可以对它们执行读和写操作。
const 限定符
任意基础类型的变量都可以声明为常量。常量表示这些变量中的值在着色器中不会发生变化,声明常量只需要在声明时加上限定符const即可,声明时必须赋初值。
1 | const float zero = 0.0; |
常量声明过的值在代码中不能再改变,这一点和C语言或C++一样。
结构体成员不能被声明为常量,但是结构体变量可以被声明为常量,并且需要在初始化时使用构造器初始化其值。
常量必须被初始化为一个常量表达式。数组或者包含数组的结构体不能被声明为常量(因为数组不能在定义时被初始化)。
attribute 限定符
GLSL中另一种特殊的变量类型是attribute变量。attribute变量只用于顶点着色器中,用来存储顶点着色器中每个顶点的输入(per-vertex inputs)。attribute通常用来存储位置坐标、法向量、纹理坐标和颜色等。注意attribute是用来存储单个顶点的信息。以下是有位置坐标和纹理坐标attribute的顶点着色器用例:
1 | uniform mat4 u_matViewProjection; |
着色器中的两个attribute变量a_position
和a_texCoord0
由应用程序加载数值。应用程序会创建一个顶点数组,其中包含了每个顶点的位置坐标和纹理坐标信息。与uniform相似,可使用的最大attribute数量也是有上限的,可以使用 gl_MaxVertexAttribs
来获取,也可以使用内置函数glGetIntegerv
来询问GL_MAX_VERTEX_ATTRIBS
。OpenGL ES 2.0实现支持的最少attribute个数是8个。
uniform 限定符
Uniform是GLSL中的一种变量类型限定符,用于存储应用程序通过GLSL传递给着色器的只读值。Uniform可以用来存储着色器需要的各种数据,如变换矩阵、光参数和颜色等。传递给着色器的在所有的顶点着色器和片段着色器中保持不变的的任何参数,基本上都应该通过uniform来存储。uniform变量在全局区声明,使用uniform限定符,以下是uniform的一些用例:
1 | uniform mat4 viewProjMatrix; |
需要注意的一点是,顶点着色器和片段着色器共享了uniform变量的命名空间。对于连接于同一个着色程序对象的顶点和片段着色器,它们共用同一组uniform变量,因此,如果在顶点着色器和片段着色器中都声明了uniform变量,二者的声明必须一致。当应用程序通过API加载了uniform变量时,该变量的值在顶点和片段着色器中都能够获取到。
另一点需要注意的是,uniform变量通常是存储在硬件中的“常量区”,这一区域是专门分配用来存储常量的,但是由于这一区域尺寸非常有限,因此着色程序中可以使用的uniform的个数也是有限的。可以通过读取内置变量 gl_MaxVertexUniformVectors
andgl_MaxFragmentUniformVectors
来获得,也可以使用 glGetIntegerv
查询GL_MAX_VERTEX_UNIFORM_VECTORS
or GL_MAX_FRAGMENT_UNIFORM_VECTORS
。OpenGL ES 2.0的实现必须提供至少128个顶点uniform矢量及16片段uniform矢量。
varying 限定符
GLSL中最后一个要说的变量限定符是varying。varying存储的是顶点着色器的输出,同时作为片段着色器的输入,通常顶点着色器都会把需要传递给片段着色器的数据存储在一个或多个varying变量中。这些变量在片段着色器中需要有相对应的声明且数据类型一致,然后在光栅化过程中进行插值计算。以下是一些varying变量的声明:
1 | varying vec2 texCoord; |
顶点着色器和片段着色器中都会有varying变量的声明,由于varying是顶点着色器的输出且是片段着色器的输入,所以两处声明必须一致。与uniform和attribute相同,varying也有数量的限制,可以使用gl_MaxVaryingVectors
获取或使用glGetIntegerv
查询 GL_MAX_VARYING_VECTORS
来获取。OpenGL ES 2.0实现中的varying变量最小支持数为8。
以下代码是顶点着色器和片段着色器中varying变量的声明:
1 | // Vertex shader |
语句
运算符
下表展示了GLSL中支持的运算符:
优先级 | 运算符类别 | 运算符 | 结合方向 |
---|---|---|---|
1 (最高) | 成组操作 | () | NA |
2 | 数组下标,函数调用与构造函数,访问分量或结构体的字段,后置自增和自减 | [] () . ++ – | 从左向右 |
3 | 前置自增和自减,一元正/负数,一元逻辑非 | ++ – + - ! | 从右向左 |
4 | 乘法,除法 | * / | 从左向右 |
5 | 加法,减法 | + - | 从左向右 |
6 | 关系比较操作 | < > <= >= | 从左向右 |
7 | 相等操作 | == != | 从左向右 |
8 | 逻辑与 | && | 从左向右 |
9 | 逻辑异或 | ^^ | 从左向右 |
10 | 逻辑或 | || | 从左向右 |
11 | 三元选择操作(问号表达式) | ?: | 从右向左 |
12 | 赋值与算数赋值 | = += -= *= /= | 从右向左 |
13(最低) | 操作符序列 | , | 从左向右 |
绝大多数的运算符与C语言中一致。与C语言不同的是:GLSL中对于参与运算的数据类型要求比较严格,即运算符两侧的变量必须有相同的基础类型。对于二目运算符(*,/,+,-),操作数必须为浮点型或整型,除此之外,乘法操作却可以放在不同的数据类型之间如浮点型、矢量和矩阵等,参考以下代码:
1 | float myFloat; |
比较运算符仅能作用于标量,对于矢量的比较,GLSL中有内置的函数,稍后会介绍。
流程控制语句
流程控制语句与C语言非常相似,以下示例代码是if-else
的使用:
1 | if(color.a < 0.25) |
判断的内容必须是布尔值或布尔表达式,除了基本的if-else
语句,还可以使用for
循环,在使用for
循环时也有一些约束,如循环变量的值必须是编译时已知。下边是一段使用for
循环的代码:
1 | for(int i = 0; i < 3; i++) |
在GLSL中使用循环时一定要注意:只有一个循环变量,循环变量必须使用简单的语句来增减(如 i++, i–, i+=constant, i-=constant等),循环终止条件也必须是循环变量和常量的简单比较,在循环内部不能改变循环变量的值。
以下代码是GLSL中不支持的循环用法的示例:
1 | float myArr[4]; |
函数
参数限定符
函数的声明与C语言中很相似。如果一个函数在定以前被调用,则需要先声明其原型。
一般而言,函数的使用也与C语言相似,一个重要的的不同点是向函数传入参数的方式。GLSL提供了一种特殊的限定符用来定义某个变量的值是否可以被函数修改,详见下表:
限定符 | 描述 |
---|---|
in | 默认使用的缺省限定符,指明参数传递的是值,并且函数不会修改传入的值(C语言中传递值) |
inout | 指明参数传入的是引用,如果在函数中对参数的值进行了修改,当函数结束后参数的值也会修改(C语言中传递引用) |
out | 参数的值不会传入函数,但是在函数内部修改其值,函数结束后其值会被修改 |
使用的方式如下边的代码:
1 | vec4 myFunc(inout float myFloat, // inout parameter |
以下是一个示例函数,函数定义用来计算基础的漫反射光照:
1 | vec4 diffuse(vec3 normal, |
值得注意的一点是,GLSL中的函数不能够递归调用。
计算的不变性
invariant 限定符
invariant可以作用于顶点着色器输出的任何一个varying变量。
当着色器被编译时,编译器会对其进行优化,这种优化操作可能引起指令重排序(instruction reordering),指令重排序可能引起的结果是当两个着色器进行相同的计算时无法保证得到相同的结果。
例如,在两个顶点着色器中,变量gl_Position
使用相同的表达式赋值,并且当着色程序运行时,在表达式中传入相等的变量值,则两个着色器中gl_Position
的值无法保证相等,这是因为两个着色器是分别单独编译的。这将会引起multi-pass算法的几何不一致问题。
通常情况下,不同着色器之间的这种值的差异是允许存在的。如果要避免这种差异,则可以将变量声明为invariant,可以单独指定某个变量或进行全局设置。
使用invariant限定符可以使输出的变量保持不变。invariant限定符可以作用于之前已声明的变量使其具有不变性,也可以在声明变量时直接作为声明的一部分,可参考以下两段示例代码:
1 | invariant gl_Position; // make existing gl_Position be invariant |
或
1 | invariant varying mediump vec3 Color; |
以上是仅有的使用invariant限定符情境。如果在声明时使用invariant限定符,则必须保证其放在存储限定符(varying)之前。
只有以下变量可以声明为invariant:
- 由顶点着色器输出的内置的特殊变量
- 由顶点着色器输出的varying变量
- 向片段着色器输入的内置的特殊变量
- 向片段着色器输入的varying变量
- 由片段着色器输出的内置的特殊变量
为保证由两个着色器输出的特定变量的不变性,必须遵循以下几点: - 该输出变量在两个着色器中都被声明为invariant
- 影响输出变量的所有表达式、流程控制语句的输入值必须相同
- 对于影响输出值的所有纹理函数,纹理格式、纹理元素值和纹理过滤必须一致
- 对输入值的所有操作都必须一致。表达式及插值计算的所有操作必须一致,相同的运算数顺序,相同的结合性,并且按相同顺序计算。插值变量和插值函数的声明,必须有相同类型,相同的显式或隐式的精度precision限定符。影响输出值的所有控制流程必须相同,影响决定控制流程的表达式也必须遵循不变性的规则。
最基本的一点是:所有的invariant输出量的上游数据流或控制流必须一致。
初始的默认状态下,所有的输出变量不具备不变性,可以在所有的声明之前使用以下pragma
语句强制所有输出变量invariant:
1 |
输出变量的不变性通常会以优化过程的灵活性为代价,所以使用invariant会牺牲整体性能。因此慎用以上的全局设置方法,可以将其用作协助debug的一种方法。
另一点需要说明的是,这里的不变性指的是对于同一GPU的不变性,并不保证不同OpenGL ES实现之间的不变性。
precision 限定符
OpenGL ES与桌面版之间的一个区别就是在GLSL中引入了精度限定符。精度限定符可使着色器的编写者明确定义着色器变量计算时使用的精度,变量可以选择被声明为低、中或高精度。精度限定符可告知编译器使其在计算时缩小变量潜在的精度变化范围,当使用低精度时,OpenGL的实现可以更快速和低功耗地运行着色器,效率的提高来自于精度的舍弃,如果精度选择不合理,着色器运行的结果会很失真。
OpenGL ES 对各硬件并未强制要求多种精度的支持。其实现可以使用高精度完成所有的计算并且忽略掉精度限定符,然而某些情况下使用低精度的实现会更有优势,精度限定符可以指定整型或浮点型变量的精度,如lowp
,mediump
,及highp
,参见下表:
限定符 | 描述 |
---|---|
highp | 满足顶点着色语言的最低要求。对片段着色语言是可选项 |
mediump | 满足片段着色语言的最低要求,其对于范围和精度的要求必须不低于lowp并且不高于highp |
lowp | 范围和精度可低于mediump,但仍可以表示所有颜色通道的所有颜色值 |
具体用法参考以下示例:
1 | highp vec4 position; |
除了精度限定符,还可以指定默认使用的精度。如果某个变量没有使用精度限定符指定使用何种精度,则会使用该变量类型的默认精度。默认精度限定符放在着色器代码起始位置,以下是一些用例:
1 | precision highp float; |
当为float
指定默认精度时,所有基于浮点型的变量都会以此作为默认精度,与此类似,为int
指定默认精度时,所有的基于整型的变量都会以此作为默认精度。在顶点着色器中,如果没有指定默认精度,则int
和float
都使用highp
,即顶点着色器中,未使用精度限定符指明精度的变量都默认使用最高精度。在片段着色器中,float
并没有默认的精度设置,即片段着色器中必须为float
设置默认精度或者为每一个float
变量指明精度。OpenGL ES 2.0并未要求其实现在片段着色器中支持高精度,可用是否定义了宏GL_FRAGMENT_PRECISION_HIGH
来判断是否支持在片段着色器中使用高精度。
在片段着色器中可以使用以下代码:
1 |
|
这么做可以确保无论实现支持中精度还是高精度都可以完成着色器的编译。注意不同实现中精度的定义及精度的范围都不统一而是因实现而异的。
精度限定符指明了其实现在存储这些变量时必须使用的最小的范围和精度,但实现可以使用不低于此要求的更高的范围和精度。各精度限定符要求的最小范围及精度如下:
浮点数范围 | 浮点数大小范围 | 浮点数精度范围 | 整数范围 | |
---|---|---|---|---|
highp | (-2^62 , 2^62) | (2^-62 ,2^62) | 相对:2^-16 | (-2^16 , 2^16) |
mediump | (-2^14 , 2^14) | (2^-14 ,2^14) | 相对:2^-10 | (-2^10 , 2^10) |
lowp | (-2, 2) | (2^-8 ,2) | 绝对:2^-8 | (-2^8 , 2^8) |
在具体实现中,着色器编译器支持的不同着色器类型和数值形式的实际的范围及精度可用以下函数获取:
1 | void GetShaderPrecisionFormat( enum shadertype, enum precisiontype, int *range, int *precision ); |
其中, shadertype
必须是VERTEX_SHADER
或FRAGMENT_SHADER
;precisiontype
必须是LOW_FLOAT
、MEDIUM_FLOAT
、HIGH_FLOAT
、LOW_INT
、MEDIUM_INT
或HIGH_INT
。
range
是指向含有两个整数的数组的指针,这两个整数将会返回数值的范围。如果用min
和max
来代表对应格式的最小和最大值,则range
中返回的整数值可以定义为:
1 | range[0] = log2(|min|) |
precision
是指向一个整数的指针,返回的该整数是对应格式的精度的位数(number of bits)用log2
取对数的值。
限定符的次序
当同时有多个限定符时,各限定符必须按照一定的顺序,具体如下:
invariant-qualifier -> storage-qualifier -> precision-qualifier
storage-qualifier -> parameter-qualifier -> precision-qualifier
REFERENCE
OpenGL ES Common Profile Specification
OpenGL ES 2.0 ProgrammingGuide
The OpenGL ES Shading Language
OpenGL编程指南(原书第8版)