OpenGL学习 (一)

OpenGL学习 (一)

太长时间没有接触过OpenGL了!由于完成SSAO要阅读比较多的OpenGL代码,在此对前期OpenGL的内容稍作回顾!

本节涉及知识:VAO,VBO,EBO等

VAO,VBO,EBO

VAO,VBO

1
2
3
4
5
6
7
8
9
10
11
12
unsigned int VAO;
//这里创建了一个VAO
//&符号:因为这里其实是应该放一个数组的,由于可以返还多个VAO因此这里应该放一个数组变量名,我们知道数组变量名
//是一个地址,因此如果这里填写的是一个变量的话就要用取址符号拿到变量地址
glGenVertexArrays(1, &VAO);//该条方法可以产生多个VAO,在这里只产生了1个,之后会返还一个VAO的ID返还到那个无符号变量中
glBindVertexArray(VAO);

unsigned int VBO;
glGenBuffers(1, &VBO);//创建了一个VBO;同样这里也可以创建多个VBO
glBindBuffer(GL_ARRAY_BUFFER, VBO);//VAO可以持有两种类型的Buffer,这里是ArrayBuffer的方式绑定
//给Buffer塞数据
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

glBufferData是一个专门用来把用户定义的数据复制到当前绑定缓冲的函数。它的第一个参数是目标缓冲的类型:顶点缓冲对象(Vertex Buffer Objects, VBO)当前绑定到GL_ARRAY_BUFFER目标上。第二个参数指定传输数据的大小(以字节为单位);用一个简单的sizeof计算出顶点数据大小就行。第三个参数是我们希望发送的实际数据。
第四个参数指定了我们希望显卡如何管理给定的数据。它有三种形式

  • GL_STATIC_DRAW :数据不会或几乎不会改变。
  • GL_DYNAMIC_DRAW:数据会被改变很多。
  • GL_STREAM_DRAW :数据每次绘制时都会改变。

如果缓冲中的数据每次渲染调用时都保持原样,那么它使用类型最好是GL_STATIC_DRAW。如果一个缓冲中的数据将频繁被改变,那么使用的类型就是GL_DYNAMIC_DRAWGL_STREAM_DRAW,这样就能确保显卡把数据放在能够高速写入的内存部分。


EBO

假设我们要绘制一个矩形。我们可以绘制两个三角形来组成一个矩形(OpenGL主要处理三角形)。这会生成下面的顶点的集合:

1
2
3
4
5
6
7
8
9
10
float vertices[] = {
// 第一个三角形
0.5f, 0.5f, 0.0f, // 右上角
0.5f, -0.5f, 0.0f, // 右下角
-0.5f, 0.5f, 0.0f, // 左上角
// 第二个三角形
0.5f, -0.5f, 0.0f, // 右下角
-0.5f, -0.5f, 0.0f, // 左下角
-0.5f, 0.5f, 0.0f // 左上角
};

可以看到,有几个顶点叠加了。我们指定了右下角和左上角两次!一个矩形只有4个而不是6个顶点,这样就产生50%的额外开销。当我们有包括上千个三角形的模型之后这个问题会更糟糕,这会产生一大堆浪费。更好的解决方案是只储存不同的顶点,并设定绘制这些顶点的顺序。这样子我们只要储存4个顶点就能绘制矩形了,之后只要指定绘制的顺序就行了。如果OpenGL提供这个功能就好了,对吧?
很幸运,索引缓冲对象(Element Buffer Object,EBO,也叫Index Buffer Object,IBO)的工作方式正是这样的。和顶点缓冲对象一样,EBO也是一个缓冲,它专门储存索引,OpenGL调用这些顶点的索引来决定该绘制哪个顶点。所谓的索引绘制(Indexed Drawing)正是我们问题的解决方案。首先,我们先要定义(不重复的)顶点,和绘制出矩形所需的索引:

1
2
3
4
5
6
7
8
9
10
11
float vertices[] = {
0.5f, 0.5f, 0.0f, // 右上角
0.5f, -0.5f, 0.0f, // 右下角
-0.5f, -0.5f, 0.0f, // 左下角
-0.5f, 0.5f, 0.0f // 左上角
};
//定义索引数组
unsigned int indices[] = { // 注意索引从0开始!
0, 1, 3, // 第一个三角形
1, 2, 3 // 第二个三角形
};

数据组织好之后,来使用它们吧!

1
2
3
4
5
6
7
8
//创建EBO
unsigned int EBO;
//产生一个buffer
glGenBuffers(1, &EBO);
//绑定EBO
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
//填充buffer的数据
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

对于VAO,VBO,EBO的理解

OpenGL状态机

OpenGL是一个状态机,OpenGL在任何时间都只会有一个状态是处于运行中的,我们把这个运行中的状态称为context,如上图,虚线代表着这个上下文。context只会认得当下正在操作的VAO,同时context也只能操作一个VBO。因此要对VAO和VBO进行操作要分别bind它们,而对于其他的VAO和VBO当前context是无法操作的。

bind VAO是为了选择一个VAO到context中来(显存中可能存在多个VAO),然后bind VBO选择一个VBO为当前的VAO服务(通过一个“界面”Array-Buffer)。VBO中存储着从CPU传递过来的数据,VAO帮助我们理清楚VBO中这些数据的含义,VAO中有着大概0~15的栏位,每个栏位可以代表着相关意义比如位置,法线,纹理贴图等等。另外假如要操作别的VBO那么就直接把别的VBO bind过来即可,未来VAO需要哪些数据的时候就去哪个VBO找。

如果要使用索引功能,这个功能是走的另外一个“界面”Element-Buffer。传进来的索引数据不会占有之前的0~15的属性栏位,它们会在一个固定的地方。
glDrawElements函数从当前绑定到GL_ELEMENT_ARRAY_BUFFER目标的EBO中获取索引。这意味着我们必须在每次要用索引渲染一个物体时绑定相应的EBO,这还是有点麻烦。

当然context除了可以使用这些之外,还可以设置一些功能,如glEnabel打开的剔除功能等等。

1
2
3
//背面剔除
glEnable(GL_CULL_FACE);
glCullFace(GL_BACK);

数据的准备与传送

vertex Shader之前准备数据的过程是: 读入.obj文件,用读取文件的相应方法把所有的数据读入到一个数组里面,此后经历CPU和GPU之前狭长的传送带到达GPU,把之前的数据存放在VBO中,但是VBO中可能存储了各种各样的数据,如何去理解这些数据代表什么意思呢?此时就需要用到VAO,VAO上有很多的栏位,VAO从VBO中挖出各种各样的数据到各个栏位中。(比如第0个栏位存放顶点数据,第1个存放法线数据。。。)


shader创建及编译

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*compile shader*/
//创建顶点和片元着色器的ID,用ID来引用它们
unsigned int vertex, fragment;
//创建着色器对象(这里是vertex shader)
vertex = glCreateShader(GL_VERTEX_SHADER);
//把着色器源码附着到着色器对象身上
glShaderSource(vertex, 1, &vertexSource, NULL);
glCompileShader(vertex);//编译着色器,从源代码转成二进制代码
//检查是编译成功(自写函数)
checkCompileErrors(vertex, "VERTEX");

fragment = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragment, 1, &fragmentSource, NULL);
glCompileShader(fragment);
checkCompileErrors(fragment, "FRAGMENT");

glShaderSource函数把要编译的着色器对象作为第一个参数。第二参数指定了传递的源码字符串数量,上面代码中只有一个。第三个参数是顶点着色器真正的源码,第四个参数我们先设置为NULL。


shader链接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*link shader*/
//这里依旧是使用ID来引用程序对象
unsigned int ID = glCreateProgram();
//把顶点着色器附加到程序对象上
glAttachShader(ID, vertex);
//把片元着色器附加到程序对象上
glAttachShader(ID, fragment);
//Link将几个shader的代码紧紧沾合在一起,这样它们交互的时候才知道对方声明的函数等信息在哪里
glLinkProgram(ID);
//还是这个自写函数
checkCompileErrors(ID, "PROGRAM");

//在把程序链接之后,之前的shader就不需要了,可以把它删除了
glDeleteShader(vertex);
glDeleteShader(fragment);

着色器程序对象(Shader Program Object)是多个着色器合并之后并最终链接完成的版本。如果要使用刚才编译的着色器我们必须把它们链接(Link)为一个着色器程序对象,然后在渲染对象的时候激活这个着色器程序。已激活着色器程序的着色器将在我们发送渲染调用的时候被使用。

当链接着色器至一个程序的时候,它会把每个着色器的输出链接到下个着色器的输入。当输出和输入不匹配的时候,你会得到一个连接错误。

经过shader的编写,编译,链接,debug之后,我们可以调用glUseProgram函数,用刚创建的程序对象作为它的参数,以激活这个程序对象:

1
glUseProgram(ID);

在glUseProgram函数调用之后,每个着色器调用和渲染调用都会使用这个程序对象(也就是之前写的着色器)了。


链接顶点属性(Linking Vertex Attributes)

仅仅经过以上的这些设置还不够,OpenGL还不知道它该如何解释内存中的顶点数据,以及它该如何将顶点数据链接到顶点着色器的属性上。我们需要告诉OpenGL怎么做。

顶点着色器允许我们指定任何以顶点属性(vertex attributes)为形式的输入。这使其具有很强的灵活性的同时,它还意味着我们必须手动指定输入数据的哪一个部分对应顶点着色器的哪一个顶点属性。所以,我们必须在渲染前指定OpenGL该如何解释顶点数据—使用glVertexAttribPointer函数告诉OpenGL该如何解析顶点数据(应用到逐个顶点属性上)

1
2
3
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
//顶点属性位置值作为参数,启用顶点属性;顶点属性默认是禁用的
glEnableVertexAttribArray(0);

以上面代码块作为列子对glVertexAttribPointer函数参数稍作讲解。

  • a)第一个参数指定我们要配置的顶点属性。即我们通过指定顶点属性位置值(0~15号),来指定我们要把数据解析到哪一个顶点属性中。b)该项和vertex shader的关系通过一个例子来说明:在vertex shader中我们可能见过这样的一句话layout(location = 0) in vec3 aPos;,其含义就是aPos将从位置0把数据取出来然后使用。
  • 第二个参数指定顶点属性的大小。顶点属性是一个vec3,它由3个值组成,所以大小是3。
  • 第三个参数指定数据的类型,这里是GL_FLOAT(GLSL中vec*都是由浮点数值组成的)。
  • 下个参数定义我们是否希望数据被标准化(Normalize)。如果我们设置为GL_TRUE,所有数据都会被映射到0(对于有符号型signed数据是-1)到1之间。GL_FALSE则表示你不需要标准化。
  • 第五个参数叫做步长(Stride),它告诉我们在连续的顶点属性组之间的间隔。由于下个组位置数据在3个float之后,我们把步长设置为3 * sizeof(float)
  • 最后一个参数的类型是void*,所以需要我们进行这个奇怪的强制类型转换。它表示位置数据在缓冲中起始位置的偏移量(Offset)。void*是一个什么样的类型呢?void*能包容地接受各种类型的指针。也就是说,如果你期望接口能够接受任何类型的参数,你可以使用void*类型。

上述代码块描述的是这样一份顶点缓冲数据:

  • 位置数据被储存为32位(4字节)浮点值。
  • 每个位置包含3个这样的值。
  • 在这3个值之间没有空隙(或其他值)。这几个值在数组中紧密排列(Tightly Packed)。
  • 数据中第一个值在缓冲开始的位置。

当然这只是一种比较简单的情况,以后可能会把uv坐标,法线坐标,位置坐标等等统统放在一个顶点缓冲中,那个时候解析起来可能就比较复杂了。


使用VAO绘制物体的流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ..:: 初始化代码 :: ..

// 1. 绑定顶点数组对象
glBindVertexArray(VAO);
// 2. 把我们的顶点数组复制到一个顶点缓冲中,供OpenGL使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. 复制我们的索引数组到一个索引缓冲中,供OpenGL使用
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
// 4. 设定顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

[...]

// ..:: 绘制代码(渲染循环中) :: ..
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
//glDrawArrays(GL_TRIANGLES, 0, 3);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0)//当前使用索引来绘制三角形
//解绑VAO
glBindVertexArray(0);

就这么多了!前面做的一切都是等待这一刻,一个储存了我们顶点属性配置和应使用的VBO的顶点数组对象。一般当你打算绘制多个物体时,你首先要生成/配置所有的VAO(和必须的VBO及属性指针),然后储存它们供后面使用。当我们打算绘制物体的时候就拿出相应的VAO,绑定它,绘制完物体后,再解绑VAO

1
2
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);//这句话可以不写
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

使用glDrawElements时,我们会使用当前绑定的索引缓冲对象中的索引进行绘制。第一个参数指定了我们绘制的模式,这个和glDrawArrays的一样。第二个参数是我们打算绘制顶点的个数,这里填6,也就是说我们一共需要绘制6个顶点。第三个参数是索引的类型,这里是GL_UNSIGNED_INT。最后一个参数里我们可以指定EBO中的偏移量(或者传递一个索引数组,但是这是当你不在使用索引缓冲对象的时候),但是我们会在这里填写0。

glDrawElements函数从当前绑定到GL_ELEMENT_ARRAY_BUFFER目标的EBO中获取索引。这意味着我们必须在每次要用索引渲染一个物体时绑定相应的EBO,这还是有点麻烦。不过顶点数组对象同样可以保存索引缓冲对象的绑定状态。VAO绑定时正在绑定的索引缓冲对象会被保存为VAO的元素缓冲对象。绑定VAO的同时也会自动绑定EBO,因此glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO)可以不写。

当目标是 GL_ELEMENT_ARRAY_BUFFER 的时候,VAO会储存glBindBuffer的函数调用。这也意味着它也会储存解绑调用,所以确保你没有在解绑VAO之前解绑索引数组缓冲,否则它就没有这个EBO配置了。准确来说不要在VAO解绑之前解绑了EBO,否则绘制会出错。


参考资料
LearnOpenGL CN

评论