GLSL 语法与内建函数
GLSL 的修饰符与数据类型
GLSL 中变量的修饰符
- const:修饰不可被外界改变的常量
- attribute:修饰经常更改的变量,只可以在顶点着色器中使用
- uniform:修饰不经常更改的变量,可用于顶点着色器和片段着色器
- varying:修饰在顶点着色器计算,然后传递到片元着色器中使用的变量
GLSL 的基本数据类型
- int
- float
- bool
float 是可以再加一个修饰符的,这个修饰符用来指定精度。 - highp:32bit,一般用于顶点坐标(vertex Coordinate)
- medium:16bit,一般用于纹理坐标(texture Coordinate)
- lowp:8bit,一般用于颜色表示(color)
GLSL 中就是向量类型(vec)
向量类型是 Shader 中最常用的一个数据类型,因为在做数据传递的时候经常要传递多个参数,相较于写多个基本数据类型,使用向量类型更加简单。
比如,通过 OpenGL 接口把物体坐标和纹理坐标传递到顶点着色器中,用的就是向量类型。每个顶点都是一个四维向量,在顶点着色器中利用这两个四维向量就能去做自己的运算。
attribute vec4 position;
矩阵类型(matrix)
矩阵类型在 GLSL 中同样也是一个非常重要的数据类型,在某些效果器的开发中,需要开发者自己传入一些矩阵类型的数据,用于像素计算。
uniform lowp mat4 colorMatrix;
上面的代码表示的是一个 44 的浮点矩阵,如果是 mat2 的声明,代表的就是 22 的浮点矩阵,而 mat3 代表的就是 3*3 的浮点矩阵。
OpenGL 为开发者提供了以下接口,把内存中的数据(mColorMatrixLocation)传递给着色器。
glUniformMatrix4fv(mColorMatrixLocation, 1, false, mColorMatrix);
其中,mColorMatrix 是这个变量在接口程序中的句柄。这里一定要注意,上边的这个函数不属于 GLSL 部分,而是属于客户端代码,也就是说,我们调用这个函数来和着色器进行交互。
纹理类型
一般只在片元着色器中使用,下面 GLSL 代码是二维纹理类型的声明方式。
uniform sampler2D texSampler;
首先我们需要拿到这个变量的句柄,定义为 mGLUniformTexture,然后就可以给它绑定一个纹理,接口程序的代码如下:
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texId);
glUniform1i(mGLUniformTexture, 0);
注意,上述接口程序中的第一行代码激活的是哪个纹理句柄,在第三行代码中的第二个参数就需要传递对应的 Index,就比如说代码中激活的纹理句柄是 GL_TEXTURE0,对应的第三行代码中的第二个参数 Index 就是 0,如果激活的纹理句柄是 GL_TEXTURE1,那对应的 Index 就是 1,句柄的个数在不同的平台不一样,但是一般都会在 32 个以上。
传递类型
在 GLSL 中有一个特殊的修饰符就是 varying,这个修饰符修饰的变量都是用来在顶点着色器和片元着色器之间传递参数的。
最常见的使用场景就是在顶点着色器中修饰纹理坐标,顶点着色器会改变这个纹理坐标,然后把这个坐标传递到片元着色器,代码如下:
attribute vec2 texcoord;
varying vec2 v_texcoord;
void main(void)
{
//计算顶点坐标
v_texcoord = texcoord;
}
接着在片元着色器中也要声明同名的变量,然后使用 texture2D 方法来取出二维纹理中这个纹理坐标点上的纹理像素值,代码如下:
varying vec2 v_texcoord;
vec4 texel = texture2D(texSampler, v_texcoord);
取出了这个坐标点上的像素值,就可以进行像素变化操作了,比如说去提高对比度,最终将改变的像素值赋值给 gl_FragColor。
GLSL 的内置变量与内嵌函数
内置变量
常见的是两个 Shader 的输出变量,一个是顶点着色器的内置变量 gl_position,它用来设置顶点转换到屏幕坐标的位置。
vec4 gl_posotion;
另外一个内置变量用来设置每一个粒子矩形大小,一般是在粒子效果的场景下,需要为粒子设置绘制的半径大小时使用。
float gl_pointSize;
其次是片元着色器的内置变量 gl_FragColor,用来指定当前纹理坐标所代表的像素点的最终颜色值。
vec4 gl_FragColor;
然后是 GLSL 内嵌函数部分,我们在这里只介绍常用的几个常用函数。
内嵌函数
内嵌函数 | 说明 |
---|---|
abs(genType x) | 绝对值函数 |
floor(genType x) | 向下取整函数 |
ceil(genType x) | 向上取整函数 |
mod(genType x, genType y) | 取模函数 |
min(genType x, genType y) | 取得最小值函数 |
max(genType x, genType y) | 取得最大值函数 |
clamp(genType x, genType y, genType z) | 取得中间值函数 |
step(genType edge, genType x) | 如果x<edge,返回0.0,否则返回1.0 |
smoothstep(genType edge0, genType edge1, genType x) | 如果x<=edge0,返回0.0,如果x>=dege1,返回1.0,如果edge0<x<edge1,则执行0~1之间的平滑差值 |
mix(genType x, genType y, genType a) | 返回线性混合的x和y,用公式表示为x*(1-a)+y*a,这个函数在mix两个纹理图像的时候非常有用 |
对于一种语言的语法来讲,剩下的就是控制流的部分了。 | |
GLSL 的控制流与 C 语言非常类似,既可以使用 for、while 以及 do-while 实现循环,也可以使用 if 和 if-else 进行条件分支的操作。 |
OpenGL ES 的纹理
OpenGL 中的纹理用 GLUint 类型来表示,通常我们称之为 Texture 或者 TextureID,可以用来表示图像、视频画面等数据。
对于二维的纹理,每个二维纹理都由许多小的片元组成,每一个片元我们可以理解为一个像素点。
大多数的渲染过程,都是基于纹理进行操作的,最简单的一种方式就是从一个图像文件加载数据,然后上传到显存中构造成一个纹理。
纹理坐标系
为了访问到纹理中的每一个片元(像素点),OpenGL ES 构造了纹理坐标空间,坐标空间的定义是从左下角的(0,0)到右上角的(1,1)。
横轴维度称为 S 轴,左边是 0,右边是 1,纵轴维度称为 T 轴,下面是 0,上面是 1。
按照这个规则就构成了左图所示的坐标系,可以看到上下左右四个顶点的坐标位置,而中间的位置就是(0.5,0.5)。
另外在这里不得不提的是计算机系统里的坐标空间,通常 X 轴称之为横轴,从左到右是 0~1,Y 轴称之为纵轴,是从上到下是 0~1,如图所示:
无论是计算机还是手机的屏幕坐标,X 轴是从左到右是 0~1,Y 轴是从上到下是 0~1,这种存储方式是和图片的存储是一致的。
我们这里假设图片(Bitmap)的存储是把所有像素点存储到一个大数组中,数组的第一个像素点表示的就是图片左上角的像素点(即第一排第一列的像素点),数组中的第二个元素表示的是第一排第二列的第二个像素点,依此类推。
这样你会发现这种坐标其实是和 OpenGL 中的纹理坐标做了一个旋转 180 度。 因此从本地图片中加载一张纹理并且渲染到界面上的时候,就会用到纹理坐标和计算机系统的坐标的转换。
纹理创建与绑定
创建
加载一张图片作为 OpenGL 中的纹理。首先要在显卡中创建一个纹理对象,OpenGL ES 提供了方法原型如下:
void glGenTextures (GLsizei n, GLuint* textures)
这个方法中的第一个参数是需要创建几个纹理对象,第二个参数是一个数组(指针)的形式,函数执行之后会将创建好的纹理句柄放入到这个数组中。
如果仅仅需要创建一个纹理对象的话,只需要声明一个 GLuint 类型的 texId,然后将这个纹理 ID 取地址作为第二个参数,就可以创建出这个纹理对象,代码如下:
glGenTextures(1, &texId);
执行完上面这个指令之后,OpenGL 引擎就会在显卡中创建出一个纹理对象,并且把这个纹理对象的句柄存储到 texId 这个变量中。
绑定
那接下来我们要对这个纹理对象进行操作,OpenGL ES 提供的都是类似于状态机的调用方式,也就是说在对某个 OpenGL ES 对象操作之前,先进行绑定操作,然后接下来所有操作的目标都是针对这个绑定的对象进行的。对于纹理 ID 的绑定调用代码如下:
glBindTexture(GL_TEXTURE_2D, texId);
执行完上面这个指令之后,OpenGL ES 引擎认为这个纹理对象已经处于绑定状态,那么接下来所有对于纹理的操作都是针对这个纹理对象的了,当我们操作完毕之后可以调用如下代码进行解绑:
glBindTexture(GL_TEXTURE_2D, 0);
上面这行指令执行完毕之后,就代表我们不会对 texId 这个纹理对象做任何操作了,所以上面这行代码一般在一个 GLProgram 执行完成之后调用。
过滤
首先就是纹理的过滤方式,当纹理对象被渲染到物体表面上的时候,纹理的过滤方式指定纹理的放大和缩小规则。
实际上,是 OpenGL ES 的绘制管线中将纹理的元素映射到片元这一过程中的映射规则,因为纹理(可以理解为一张图片)大小和物体(可以理解为手机屏幕的渲染区域)大小不太可能一致,所以要指定放大和缩小的时候应该具体确定每个片元(像素)是如何被填充的。
放大(magnification)规则的设置:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
缩小(minification)规则的设置:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
上述两个指令设置的过滤方式都是 GL_LINEAR,这种过滤方式叫做双线性过滤,底层使用双线性插值算法来平滑像素之间的过渡部分,OpenGL 的具体实现会使用四个邻接的纹理元素,并在它们之间用一个线性插值算法做插值,这种过滤方式是最常用的。
OpenGL 还提供了 GL_NEAREST 的过滤方式,GL_NEAREST 被称为最邻近过滤,底层为每个片段选择最近的纹理元素进行填充,缺点就是当放大的时候会丢失掉一些细节,会有很严重的锯齿效果。因为是原始的直接放大,相当于降采样。而当缩小的时候,因为没有足够的片段来绘制所有的纹理单元,也会丢失很多细节,是真正的降采样。
其实 OpenGL 还提供了另外一种技术,叫做 MIP 贴图,但是这种技术会占用更多的内存,优点是渲染也会更快。当缩小和放大到一定程度之后效果也比双线性过滤的方式更好,但是它对纹理的尺寸以及内存的占用是有一定限制的。不过,在处理以及渲染视频的时候不需要放大或者缩小这么多倍,所以在这种场景下 MIP 贴图并不适用。
综合对比这几种过滤方式,在使用纹理的过滤方式时我们一般都会选用双线性过滤的过滤方式(GL_LINEAR)。
在纹理坐标系中的 s 轴和 t 轴超出范围的纹理处理规则,常见的代码设置如下:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
上述代码表示的含义就是,给这个纹理的 s 轴和 t 轴的坐标设置为 GL_CLAMP_TO_EDGE 类型,代表所有大于 1 的像素值都按照 1 这个点的像素值来绘制,所有小于 0 的值都按照 0 这个点的像素值来绘制。
除此之外,OpenGL ES 还提供了 GL_REPEAT 和 GL_MIRRORED_REPEAT 的处理规则,从名字也可以看得出来,GL_REPEAT 代表超过 1 的会从 0 再重复一遍,也就是再平铺一遍,而 GL_MIRRORED_REPEAT 就是完全镜像地平铺一遍。
纹理的上传与下载
假设我们有一张 PNG 类型的图片,我们需要将它解码为内存中 RGBA 裸数据,所以首先我们需要解码。可以采用跨平台(C++ 层)的方式,引用 libpng 这个库来进行解码操作,当然也可以采用各自平台的 API 进行解码。无论哪一种方式,最终都可以得到 RGBA 的数据。等拿到 RGBA 的数据之后,记为 uint8_t 数组类型的 pixels。
接下来,就是要将 PNG 素材的内容放到这个纹理对象上面去
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA,
GL_UNSIGNED_BYTE, pixels);
执行上述指令的前提是我们已经绑定了某个纹理,OpenGL 的大部分纹理一般只接受 RGBA 类型的数据。当然在视频场景下,考虑性能问题也会使用到 GL_LUMINANCE 类型,不过需要在片元着色器中,把 YUV420P 格式转换成 RGBA 格式。
上述指令正确执行之后,RGBA 的数组表示的像素内容会上传到显卡里面 texId 所代表的纹理对象中,以后要使用这个图片,直接使用这个纹理 ID 就可以了。
既然有内存数据上传到显存的操作,那么一定也会有显存的数据回传回内存的操作
glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
执行上述指令的前提是我们已经绑定了某个纹理,然后将绑定的这个纹理对象代表的内容拷贝回 pixels 这个数组中,这个拷贝会比较耗时,并且拷贝时间会和分辨率(width\height)大小成正比。
一般在实际的开发工作中要尽量避免这种内存和显存之间的数据拷贝与传输,而是使用各个平台提供的快速映射 API 去完成内存与显存的拷贝工作。
物体坐标与纹理绘制
物体坐标系
OpenGL 规定物体坐标系中 X 轴从左到右是从 -1 到 1 变化的,Y 轴从下到上是从 -1 到 1 变化的,物体的中心点是 (0, 0) 的位置。
接下来的任务就是将这个纹理绘制到物体(屏幕)上,首先要搭建好各自平台的 OpenGL ES 的环境,包括上下文与窗口管理,然后创建显卡可执行程序,最终让程序跑起来。
纹理的绘制
先来看一个最简单的顶点着色器(Vertex Shader),代码如下:
static char* COMMON_VERTEX_SHADER =
"attribute vec4 position; \n"
"attribute vec2 texcoord; \n"
"varying vec2 v_texcoord; \n"
" \n"
"void main(void) \n"
"{ \n"
" gl_Position = position; \n"
" v_texcoord = texcoord; \n"
"} \n";
片元着色器(Fragment Shader),代码如下:
static char* COMMON_FRAG_SHADER =
"precision highp float; \n"
"varying highp vec2 v_texcoord; \n"
"uniform sampler2D texSampler; \n"
" \n"
"void main() { \n"
" gl_FragColor = texture2D(texSampler, v_texcoord); \n"
"} \n";
利用上面两个 Shader 创建好的这个 Program,我们记为 mGLProgId。
接下来我们需要将这个 Program 中的重点属性以及常量的句柄寻找出来,以备后续渲染过程中向顶点着色器和片元着色器传递数据。
mGLVertexCoords = glGetAttribLocation(mGLProgId, "position");
mGLTextureCoords = glGetAttribLocation(mGLProgId, "texcoord");
mGLUniformTexture = glGetUniformLocation(mGLProgId, "texSampler");
在这个例子里,我们要从 Program 的顶点着色器中读取两个 attribute,并放置到全局变量的 mGLVertexCoords 与 mGLTextureCoords 中,从 Program 的片元着色器中读取出来的 uniform 会放置到 mGLUniformTexture 这个变量里。
所有准备工作都做好了之后,接下来进行真正的绘制操作。
首先,规定窗口大小:
glViewport(0, 0, screenWidth, screenHeight);
函数中的参数 screenWidth 表示绘制 View 或者目标 FBO 的宽度,screenHeight 表示绘制 View 或者目标 FBO 的高度。
然后使用显卡绘制程序:
glUseProgram(mGLProgId);
设置物体坐标与纹理坐标:
GLfloat vertices[] = { -1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f };
glVertexAttribPointer(mGLVertexCoords, 2, GL_FLOAT, 0, 0, vertices);
glEnableVertexAttribArray(mGLVertexCoords);
设置纹理坐标:
GLfloat texCoords1[] = { 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f };
GLfloat texCoords2[] = { 0.0f, 1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f };
glVertexAttribPointer(mGLTextureCoords, 2, GL_FLOAT, 0, 0, texCoords2);
glEnableVertexAttribArray(mGLTextureCoords);
代码中有两个纹理坐标数组,分别是 texCoords1 与 texCoords2,最终我们使用的是 texCoords2 这个纹理坐标。
因为我们的纹理对象是将一个 RGBA 格式的 PNG 图片上传到显卡上,其实上传上来本身就需要转换坐标系,这两个纹理坐标恰好就是做了一个上下的翻转,从而将计算机坐标系和 OpenGL 坐标系进行转换。
对于第一次上传内存数据的场景纹理坐标一般都会选用 texCoords2。
但是如果这个纹理对象是 OpenGL 中的一个普通纹理对象的话,则需要使用 texCoords1。
接下来,指定我们要绘制的纹理对象,并且将纹理句柄传递给片元着色器中的 uniform 常量:
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texId);
glUniform1i(mGLUniformTexture, 0);
执行绘制操作:
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
上述这行指令执行成功之后,就相当于将最初内存中的 PNG 图片绘制到默认的 FBO 上去了,最终再通过各平台的窗口管理操作(Android 平台的 swapBuffer、iOS 平台的 renderBuffer),就可以让用户在屏幕上看到了。
当确定这个纹理对象不再使用了,则需要删掉它,执行代码是:
glDeleteTextures(1, &texId);
除此之外,关于纹理的绘制我们还要额外注意一点:我们提交给 OpenGL 的绘图指令并不会马上送给图形硬件执行,而是会放到一个指令缓冲区中。
考虑性能的问题,等缓冲区满了以后,这些指令会被一次性地送给图形硬件执行,指令比较少或比较简单的时候,是没办法填满缓冲区的,所以这些指令不能马上执行,也就达不到我们想要的效果。
因此每次写完绘图代码,想让它立即完成效果的时候,就需要我们自己手动调用 glFlush() 或 gLFinish() 函数。
- glFlush:将缓冲区中的指令(无论是否为满)立刻送给图形硬件执行,发送完立即返回;
- glFinish:将缓冲区中的指令(无论是否为满)立刻送给图形硬件执行,但是要等待图形硬件执行完后这些指令才返回。