最近学习Opengl,面对繁复的API确实难学,不过跟着教程还是能一步步跑起来,加上之前学的图形学基本知识也能对应上了,还是很有意思的。但是学习过程中对于下面这三个概念以及相关的api一直很模糊,虽然跟着教程总是能跑起来的,但是一旦想自己改一下流程就很难了。查了一些文章加上一些实验大概弄懂了这三个对象。
- 顶点数组对象:Vertex Array Object,VAO
- 顶点缓冲对象:Vertex Buffer Object,VBO
- 索引缓冲对象:Element Buffer Object,EBO或Index Buffer Object,IBO
但是毕竟没有完全通读文档,而且我对于很多计算机系统的基础知识也不是特别熟悉,以下基本是按照我自己的理解来的。
主存与显存
通常写C++程序都是在主存上运行的,所以很少考虑数据究竟存储在哪里。但是使用OpenGl就不行,因为主存和显存之间通信很慢,而且是隔离的(只考虑PC架构),所以要管理显存就必须通过OpenGL的api来,而是像通常程序一样声明变量就行。
当然通过OpenGL管理的内存具体在哪里我也不清楚,但是我觉得可以等效地看成是就是在显存上的,这样方便理解,而且应该和实际差不多。因此后面我就直接当成是主存和显存两块分开的内存了。
就像上面所说,我们无法直接声明显存中的内存,声明方法就是使用OpenGL的glGen系列函数,这些函数都需要一个GLuin类型的变量传入从而保存生成变量的索引(因为我们无法获得显存的指针,但是可以将这个索引看成是对应显存的指针)。
声明了内存,我们还需要将主存的数据传输到显存中去,这样显卡才能调用,对于顶点数据,这需要通过glBuffer系列的函数,也就是调用这个函数会将指定的主存中的数据拷贝到显存中指定的内存中,以供之后使用。
VAO与VBO
因为从主存向显存传输数据很慢,所以我们将图形要绘制的数据直接放在显存上,这样每个渲染循环只需要设置渲染需要的参数就可以了。显存上直接存储这些数据的区域就是VBO,在OpenGl中是一个buffer对象,但是我更愿意将其理解成一块存储数据的内存区域。
但是VBO中的数据是未格式化的,也就是说gpu不知道该如何理解存储在VBO中的数据,这时候就需要VAO了,简单来说VAO就是告诉gpu该如何处理VBO中的数据。我将其理解成VAO中有一个数组,其中存储了指向VBO中数据的指针,以及相应的数据格式。在OpenGL中这个数组最大一般为16(不确定,但是目前来说具体是多大并不重要。)
要生成一个VAO,使用
1 | glGenVertexArrays(1, &VAO); |
当然也可以一次生成多个VAO。
但是生成的VAO并不能直接使用,因为我们只是得到了索引。要使用一个VAO,使用函数
1 | glBindVertexArray(VAO); |
将Opengl的上下文设置为当前的VAO。按照我的理解,就是有这样一个全局变量,它存储着一个指向VAO的指针,执行bind就是将这个指针指向我们传入的VAO。这样之后的操作(设置attribute,绘图等等)就是对这个VAO操作的了。当然在绘图的时候我更愿意将其理解成一个绘图的单元,也就是我们OpenGL是一个VAO一个VAO画的(并不准确,但我觉得更容易理解)。
至于为什么要这么麻烦设置全局变量指针,而不是在每个函数中通过参数传入VAO我就不得而知了。
如果想接下来的操作不再对该VAO对象,就需要解绑,只需要传入0即可。
1 | glBindVertexArray(0); |
生成VBO的方法也差不多,但是因为OpenGL中有很多种buffer对象,我们的VBO也是其中一种,因此在绑定的时候必须指定要绑定哪一种对象,比如
1 | glBindBuffer(GL_ARRAY_BUFFER, VBO); |
我还是愿意将其理解成有这样一个全局指针,姑且叫做GL_ARRAY好了,他存储了我们绑定的VBO对象的指针。这样我们要获取VBO中的数据时只要通过GL_ARRAY即可。
生成了VBO后之后需要将我们主存中的数据传输到显存中,使用
1 | glBufferData(GL_ARRAY_BUFFER, size, data, GL_STATIC_DRAW); |
可以看到,第一个参数是一个buffer对象类型,因为我们上面以及绑定了VBO,所以就是向我们的VBO中写数据,然后是数据大小(Bytes)以及数据的指针,这还是很好理解的。最后一个参数指定了这些数据是不是经常改变之类的,可以帮助OpenGL更好的管理,一般的教程都会说明,也是比较容易理解的。
OpenGL中有好几种buffer对象,我们可以理解成好几个全局的指针(有些除外)。因此不难理解每个buffer指针每一时刻只能绑定一个对象,当然不同的buffer指针绑定不同的对象是可以的。
另外绑定到了GL_ARRAY_BUFFER的buffer对象就是VBO。
现在我们已经有了VAO和VBO,但是他们还没有关联起来,我们使用函数
1 | glVertexAttribPointer( |
第一个参数就是VAO数组的index,我将其简单理解成从0到15取值,这在我们的顶点shader中有对应。当然既然是数组index,取值也可以是不连续取,只要在顶点shader中对应即可。第二个参数是每个变量的大小,因为OpenGL中是有向量的,向量的维数可以为2,3,4,同样的,这里的向量维数要和顶点shader中的对应。第三个参数是type,就是字面意思。第四个参数告诉OpenGL数据是否已经归一化。第五以及第六参数是一起理解的,stride告诉VAO读取一个数据之后间隔多少字节读取下一个数据,offset告诉VAO第一个数据从VBO第一个数据偏移多少。
可以看到我们并没有指定使用哪一个VAO记录VBO的索引。因为是直接用的全局变量GL_ARRAY指向的那个VAO记录GL_ARRAY_BUFFER指向的那个VBO。因此在执行这一步之前我们必须先绑定我们想要使用的VAO和VBO。
因为这样的方式,所以这里有几个比较有意思的问题,也就是这几个问题困扰了我好几天。
- 首先是绑定的顺序并不重要
1 | glBindVertexArray(vao); |
上面这两种绑定顺序效果是一样的,只要理解成全局变量就很容易理解了。
VAO与VBO对应的关系可以很多,具体来说
- 一个VAO可以对应多个VBO(例如我们可以将顶点位置和法线记录在不同的VBO中),只要在关联之前绑定正确的VBO即可。
- 一个VAO可以只对应一个VBO,因为我们可以VAO的每一个attribute指定步长和offset,而且C中结构体数组是连续存储的,所以这很容易做到。
- 一个VBO也可以对应多个VAO,同样的只要我们在关联的时候指定正确的offset和步长即可。
因为VAO存储的是指向VBO中数据的指针,所以关联方式很灵活,至于实际用哪一种更好,我也不太清楚,我见过用第一种的,也见过用第二种的,第三种的倒是没见过。
另外在绘图的时候我们只需要绑定需要的VAO即可,因为VAO中已经存储了指向VBO的指针,所以不要再绑定VBO。
这基本就是我关于VAO和VBO的一些感悟。
VAO与EBO
最后谈谈EBO,这里面也有一个困惑我的点。EBO本身不难理解,只是其中存储的是顶点的索引,这样我们描述一个三角形(三个顶点就可以描述一个三角形)就可以利用那些重复的点。EBO本身也是一个buffer对象,和VBO的创建和传输一样,只是其类型为GL_ELEMENT_ARRAY_BUFFER。
只是要注意的是EBO对应的这个指针实际不是全局的,而是存储在VAO中的,也就是我们在绑定EBO的时候实际是将当前绑定的VAO中的一个指针改成了我们给的EBO的地址。
当时困扰我的就是,EBO绑定对象之后,直接传输就可以使用了,不用再指定EBO到VAO上。因为我一开始认为所有的buffer指针都是全局的,那么不手动对应VAO自然不可能知道应该使用哪一个EBO。
虽然某种程度上来说是很好理解的,因为一个EBO描述的就是一个整体,我们不可能取出其中的一部分构造出一个有意义的实体,所以绑定对象的时候可以直接就绑定到当前的VAO。
显然易见的时,EBO和VAO的绑定顺序是有先后的,我们必须先绑定到正确的VAO,再将EBO绑定到GL_ELEMENT_ARRAY_BUFFER上,就像下面这样
1 | glBindVertexArray(vao); |
什么时候传输数据
最后一个有趣的地方是,我们可以在任何时候将一个VAO对应到一个VBO上,可以是在VBO刚刚将数据从主存传输到显存中的时候,也可以是之后的什么时间。这其实是一件很显然的事情。
最后就是当我们不再需要VBO中的数据的时候(比如切换场景)或者需要更新VBO中的数据的时候,使用相应的函数即可。glDeleteBuffers,glBufferSubData,还有其他操作还要后续学习。
下图是按照我自己理解制作的一张VAO和VBO关系的图。

一些参考
- https://greyishsong.ink/OpenGL%E5%AD%A6%E4%B9%A0%E6%9C%AD%E8%AE%B0%EF%BC%88%E4%B8%80%EF%BC%89%EF%BC%9A%E5%AF%B9VAO%E3%80%81VBO%E3%80%81EBO%E7%9A%84%E5%88%9D%E6%AD%A5%E7%90%86%E8%A7%A3/
- https://stackoverflow.com/questions/69382389/opengl-binding-order-between-ebo-and-vao
- https://zhuanlan.zhihu.com/p/392128223