gl是一套图形编程API,是图形应用开发的工业标准接口,每个平台都有相应的实现。可视化的操作系统都提供了图形编程接口,通常这一系列的接口作为操作系统的一个系统服务,为客户提供窗口渲染。那为什么还用gl呢?系统提供的这些服务并不是针对gpu进行优化的,通常接口是为2d渲染提供服务的,3d没提供直接支持。还有各个平台的接口不一样,移植很困难。而使用opengl除了移植外,它针对gpu提供了高效的操作,为3d开发提供了原生的支持。gl并不作为系统的一个核心服务,它为众多厂商指定标准,由相应的gpu开发商实现驱动。gl通常作为一个驱动程序安装在系统上面,它同样需要与系统的原生态窗口系统进行交互,把最终渲染数据交由窗口系统。任何系统之上的服务都绕不过系统服务这个坎,而跟系统交互,存在一个上下文切换的问题,系统服务运行在内核态,以提高系统的安全稳健。用户程序中的非系统调用都是运行在用户态,它们的崩溃、出错不会影响系统的不稳定,会被系统关闭。所以这里存在着一个上下文切换的开销。那么使用gl也同样存在上下文切换开销,只不过切换大量减少了。对于直接使用系统的GDI,每次调用导致一个切换显然对于专门的图形应用程序来说花销太大,而对于gl驱动来讲,它部分在用户态执行,可以减少一些开销。gl的一个实现mesa3d。
使用gl就是向它发送数据,以及发送命令,命令有对数据的操作命令,也有渲染命令。一开始的时候,gl没有提供在gpu上进行编程的接口,用户不能利用gpu进行编程,无法对将要被渲染的数据进行处理。只能在数据发送给gl之前进行处理,也就改改顶点位置、颜色、纹理坐标等等,要想改变最终的片元颜色,不得而知了。gl1.5后推出了着色器,让gl大显身手,可以对顶点与片元进行编程。代码书写也发生了变化,需要编写着色器代码。下面分析gl的基本接口,以及gl的缓存功能。gl缓存的目的是为了减小数据传输,这个看似一般的功能,在gl中很重要。同时我会张贴一些接口的实现伪代码,方便理解函数实现,接口调用原理。
下面查看如何使用着色器渲染一个四边形,现在写渲染都是要编写着色器,着色器代码并不是一两个接口的问题,涉及的接口有点多,但是着色器的创建形式固定。gl一开始需要跟系统的窗口系统建立联系,对于窗口的建立,表面生成,最终的显示,gl都是没有权利直接去做的,必须调用系统的图像服务去完成。下面使用glut库去完成这个功能,glut为不同平台提供了统一接口,网上也有源码。代码如下:
int main(int argc, const char * argv[]) {
glutInit(&argc, (char **)argv);
glutInitDisplayMode (GLUT_DOUBLE | GLUT_RGBA);
glutInitWindowSize (640, 480);
glutInitWindowPosition (1000, 100);
glutCreateWindow ("graphics with opengl");
glutDisplayFunc(display);
glutMainLoop();
return 0;
}
上面代码很固定,glutInitDisplayMode描述了显示模式,GLUT_DOUBLE表明使用两个表面,其中一个离屏的,这样gl把操作的结果都写入这个离屏的表面,最后更显示表面交换,显示器一下子把数据刷新显示到显示器上。不使用双表面会有个问题,当一帧间隔大于显示器刷新频率时,假如2个hz一次渲染计算,显示器60hz/s,那么渲染一半,过了1hz,这个时候显示器刷新了,还没画完整的图被渲染出来了。再过1hz一帧才被完全计算渲染。所有这里用GLUT_DOUBLE。GLUT_RGBA表示表面4个颜色分类。后面的几个函数是设置窗口的大小、左上角位置、窗口标题。加入要自定义窗口,那么就不能用glut,得自己使用系统的窗口系统api了。glutDisplayFunc的参数函数是窗口接收到重绘消息时,调用的,本人MAC系统建立窗口后发出两次重建命令,你可以在display里打印log看下输出几次。此外每次窗口大小改变也会发生窗口重绘消息,调用display。glutMainLoop是进入消息循环,后面retuen 0等到窗口接到关闭窗口后才会被调用。上面没有加入每隔多少毫秒进行帧刷新,使用glutIdleFunc可以实现空闲时刷新,在里面加个时间判断就OK了。现在窗口建立起来了,还需要在display里面向gl传输数据与命令,让它渲染。
void display(){
glClearColor(0, 0, 0, 0);
glClear(GL_COLOR_BUFFER_BIT);
drawQuad();
glSwapAPPLE();
}
上面代码就是向gl发送一些列的命令,其中drawQuad是客户函数,其它三个gl开头的函数是gl接口。第一个glClearColor设置gl上下文中用于清除几个用于渲染的缓存(合成表面)的颜色为黑色,gl有颜色、深度等等缓存,这些缓存有不同作用,颜色缓存存储片元最终颜色,深度缓存存储每个片元的深度。其中上面代码清除颜色帧缓存为全0,glClear执行清除,glSwapAPPLE交换前后表面。之所以要这样做,因为opengl初始化时产生的这几个缓存并不去赋值,里面的数据不得而知,可能是之前显示的一幅画面,当你给它发送渲染命令后,颜色帧缓存中没被覆盖的像素点,就会显示出来,显然不是你想要的结果。所以这里的操作也是一个定式。drawQuad是用来给gl发送渲染命令的,里面也传送了数据,在渲染前,着色器必须编写好。在渲染的时候得告诉gl使用哪一个着色器,可以不指定,可以用gl1.0方式渲染,但是gl1.0已经淘汰了,要想真正利用gl的优点,必须使用着色器。着色器分顶点与片元着色器,gl管线都是固定的,就是一个顶点加工流水线,一组顶点过来,进行坐标变换、然后把顶点装配为一组图元,再对图元进行插值得到每个片元(图元渲染单元,一个图元包含N个渲染单元,这些渲染单元最终在显示器上呈现,就是一个像素)。顶点着色器在顶点变换前执行,里面可以改变顶点的位置,片面着色器在图元装配裁剪后,可以改变片元颜色。下面是着色器创建的代码:
inline void checkGLError(){
GLenum __error = glGetError();
if(__error) {
printf("OpenGL error 0x%04X in %s %s %d\n", __error, __FILE__, __FUNCTION__, __LINE__);
}
}
GLuint loadShaderFile(const char *vSourceFilename, const char *fSourceFilename, ...){
//读取顶点着色器源文件
FILE *vf = fopen(vSourceFilename, "rb");
fseek(vf, 0, SEEK_END);
long length = ftell(vf);
fseek(vf, 0, SEEK_SET);
char *vbuf = new char[length+1];
fread(vbuf, length, 1, vf);
vbuf[length] = '\0';
fclose(vf);
//创建顶点着色器
GLuint vshader = glCreateShader(GL_VERTEX_SHADER); //创建顶点着色器程序
glShaderSource(vshader, 1, &vbuf, NULL); //把顶点着色器代码传给gl
glCompileShader(vshader); //让gl编译顶点着色器程序
delete [] vbuf; //服务器端已编译,删除内存中数据
//检查编译状态
GLint status;
glGetShaderiv(vshader, GL_COMPILE_STATUS, &status);
if (!status)
{
GLsizei length;
glGetShaderiv(vshader, GL_SHADER_SOURCE_LENGTH, &length); //获得顶点着色器源代码长度
GLchar* src = (GLchar *)malloc(sizeof(GLchar) * length); //申请内存盛放代码
glGetShaderSource(vshader, length, NULL, src); //拷贝着色器顶点代码到src中
printf("ERROR: Failed to compile shader:\n%s", src); //打印log
free(src);
}
//读取片元着色器源码
FILE *ff = fopen(fSourceFilename, "rb");
fseek(ff, 0, SEEK_END);
length = ftell(ff);
fseek(ff, 0, SEEK_SET);
char *fbuf = new char[length+1];
fread(fbuf, length, 1, ff);
fbuf[length] = '\0';
fclose(ff);
//创建片元着色器
GLuint fshader = glCreateShader(GL_FRAGMENT_SHADER); //创建片元着色器
glShaderSource(fshader, 1, &fbuf, NULL); //把片元代码传给gl
glCompileShader(fshader); //编译片元着色器
delete [] fbuf; //删除内存中数据
//检查编译状态
GLint status2;
glGetShaderiv(fshader, GL_COMPILE_STATUS, &status2);
if (! status2)
{
GLsizei length;
glGetShaderiv(fshader, GL_SHADER_SOURCE_LENGTH, &length);
GLchar* src = (GLchar *)malloc(sizeof(GLchar) * length);
glGetShaderSource(fshader, length, NULL, src);
printf("ERROR: Failed to compile shader:\n%s", src);
free(src);
}
//create shader program
GLuint program = glCreateProgram(); //创建顶点着色器程序
checkGLError(); //检查创建是否失败
if (vshader)
{
glAttachShader(program, vshader); //把顶点着色器程序加入进来
}
checkGLError();//检查加入是否失败
if (fshader)
{
glAttachShader(program, fshader); //把片元着色器加入进来
}
checkGLError();//检查加入是否失败
//着色器变量绑定
va_list ap;//char*
va_start(ap, fSourceFilename);//ap+=sizeof(fSourceFilename)
//为attribute变量绑定位置
int attributeCount = va_arg(ap, int);//*((int*)ap) ap+=4
if (attributeCount) {//存在attribute
while (attributeCount) {
const char *attributeName = va_arg(ap, char*);
int position = va_arg(ap, int);
glBindAttribLocation(program, position, attributeName);
attributeCount--;
}
}
//获得uniform变量位置
int uniformCount = va_arg(ap, int);
while (uniformCount) {
const char *uniformName = va_arg(ap, char *);
int *location = va_arg(ap, int*);
*location = glGetUniformLocation(program, uniformName);
uniformCount--;
}
va_end(ap);
//link
glLinkProgram(program); //链接顶点与片元着色器
glDeleteShader(vshader); //现在program包含了整个着色程序,之前顶点与片元着色程序可以删除了
glDeleteShader(fshader);
checkGLError();
return program;
}
上面代码加载外部的着色器程序创建着色器,同时支持为着色器属性变量(用于顶点着色器,存储顶点数据)绑定到指定位置((位置,属性名)作为键值加到映射表中,后面通过位置告诉gl使用了哪个变量,后面将用伪代码描述此函数实现)。支持获得着色器的全局变量位置。后面使用到了对变长参数的解析,所以创建完着色器,就可以对着色器中的变量进行绑定了,绑定的目的是为了使用着色器变量,在外部传数据给它们。着色器现在创建好了。
一般写程序,使用别人的库,通常创建一个对象就是返回指针,获得一个对象同样也是返回的指针,这个指针位置必然是内存位置,可是对gpu进行编程,它的数据存在显存中怎么办,即便映射到cpu的可访问地址上,也存在客户操作不档,导致gl崩了。实际gl提供了一些直接函数返回内存对显存的映射地址,客户可以向这个地址写入,来写显存,但是只有很小部分这样子,绝大部分客户操作的都是一个绑定ID,就像windows窗口系统的GDI句柄一样。那么绑定到底怎么一回事了,像glBindAttribLocation干什么的?gl中glBind开头的函数很多,主要是让gl中的变量与客户提供的变量建立一种联系,客户用自己的变量操作gl变量,gl则通过客户给的客户变量找到gl中用的变量,下面是glBindAttribLocation的一个伪代码表示:
map shaderMap; //创建的着色器 shader存放键值对(着色程序索引, 着色程序地址)
mapmap > shaderVariableMap; // (着色程序地址, (着色程序变量名,着色程序变量地址))
mapmap > userMap; // (着色程序地址, (着色程序变量索引,着色程序变量地址))
void glBindAttribLocation (GLuint program, GLuint index, const GLchar *name){
GLvoid *shader = shaderMap[program];
map variableAdressMap = shaderVariableMap[shader];
GLvoid *variableAdress = variableAdress[name];
userMap[program][index] = variableAdress;
}
上面的伪代码中几个map都是gl的变量,shaderMap元素由创建着色器时生成,shaderVariableMap也会同时生存,userMap则由客户通过glBindAttribLocation添加元素,后面将可以通过客户提供的着色程序索引与变量索引快速找到着色程序地址与变量地址。后面将尽可能的把gl中的函数以伪代码书写出来。
GLSL是opengl shader language,用于编写着色程序,下面是顶点着色器,与片元着色器程序代码:
顶点着色器程序:
attribute vec2 position;
attribute vec3 color;
varying vec3 fragmentColor;
void main(){
gl_Position = vec4(position.xy, 0.0, 1.0);
fragmentColor = color;
}
片元着色器程序:
varying vec3 fragmentColor;
void main(){
gl_FragColor = vec4(fragmentColor, 1.0);
}
上面着色器代码版本是2.x的,3.x不用varying、attribute,后面详解着色器编程相关的内容。可以看出着色器代码跟c语言差不多,里面提供了许多预定义的操作,比如vec4()生成一个4分量向量。
drawQuad()代码如下:
void drawQuad(){
static GLuint program = loadShaderFile("resource/v.vert", "resource/f.frag", 2, "position", 0, "color", 1, 0);
GLfloat position[] = { //顶点坐标
-0.5, -0.5,
0.5, -0.5,
0.5, 0.5,
-0.5, 0.5};
GLfloat color[] = { //顶点颜色
1.0, 0.5, 0.5,
0.5, 1.0, 0.5,
0.5, 0.5, 1.0,
0.5, 1.0, 0.5};
glUseProgram(program); //渲染开始时选择之前使用的着色器
//开启使用索引为0、1的顶点属性数组
glEnableVertexAttribArray(0);//position
glEnableVertexAttribArray(1);//color
//告诉gl0、1的顶点属性数组的数据在position与color处获取,以及每个顶点2个GLfloat表示位置,3个GLfloat表示颜色
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, position);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, color);
//发送绘制命令,它会把绘制需要的数据传给着色器变量,进行处理
glDrawArrays(GL_QUADS, 0, 4);
}
上面最后调用glDrawArrays发送绘图命令,之前的操作就是为着色器里的变量绑定数据源,glEnableVertexAttribArray开启顶点属性数组,gl是一个状态机,会检查所有顶点属性数组,如果某个开启了,那么执行渲染管线操作之前会把数据载入里面,载入到显存后,顶点着色器程序将在执行时根据glVertexAttribPointer指定的大小,一次读多个字节数据进来处理。glVertexAttribPointer指定了gl从哪里载入数据到开启的数组中,以及gl渲染程序如何从数组读取一个顶点或者一个图元的数据。上面的没有用到gl缓存,也就是数据存在内存中,每次渲染都要把数据从内存载入显存,当数据特别多的时候就性能不好了。另外glDrawArrays也只针对顶点不重合的图元渲染,它指定了图元类型,然后后面就是对应的定点数,如果2个四边形有2个顶点重合,你也必须提供8个顶点的数据,然后告诉它有8个顶点,它会把每4个组成一个四边形,显然对于有大量顶点重合的渲染对象,这个函数是低效的,不过对于没重合的点性能是没问题的。下面是渲染输出图像:
glLinkProgram必须在属性绑定位置操作之后执行,原因是如果使用glLinkProgram链接好了着色程序,那么里面的所有变量地址都固定下来了,后面再绑定显然不能再改变这些位置,除非再次调用glLinkProgram重新链接。里面的属性变量位置会通过glBindAttribLocation绑定到一个专门盛放顶点属性数据的数组上,客户在渲染之前需要告诉gl这个数组从哪里读取顶点属性数据,调用的接口是glEnableVertexAttribArray。着色器可以被任何需要的渲染对象使用,每次着色器的使用的属性数组都有可能被新的数据刷新,所以不要觉得之前数据已经传进去了,后面渲染就不再传数据,着色器程序可以看成一个函数,属性则为这个函数的参数,每次渲染前glUseProgram就是调用这个函数,然后下面传参数进去,链接着色程序之前需要把这些参数的位置设置好,后面调用时这个函数才知道这些参数数据到哪里取。
GLSL-3.30.6.PDFGLSL的3.x文档,讲的很详细。
着色器使用的编程语言类似c语言,支持预处理、变量定义、函数定义、分支、循环,同时内部定义了一些结构体:vec2 vec3 mat2等等。下面是新的顶点着色器代码:
attribute vec2 position;
attribute vec3 color;
varying vec3 fragmentColor;
void main(){
float rad = radians(degrees(position.x));
gl_Position = vec4(position.x, position.y * rad, 0.0, 1.0);
fragmentColor = color;
}
与刚才片元着色器组成的着色程序渲染效果如下:
着色器内部定义了一些内置的变量以及内置函数,可以直接使用,通过编写着色器可以实现很多特别的效果,下篇文章计划写VAO与VBO,以及进一步对着色器代码进行封装,还有加载纹理,使用纹理,编写一些特效。