LearnOpenGL 笔记 -- VAO & VBO
1 前言
VAO和VBO属于我们学习opengl最先接触的几个概念,最开始学习的时候有可能无法直观的理解这个概念的作用和使用方法。笔者也是opengl新手,在此记录学习的相关笔记,便于之后进行查看。本文主要参考learnopengl 教程以及 opengl官网 中的用法和解释,文中的代码实例使用opengl3.3,过早版本可能无法正常运行。
2 Vertex Specification
在解释VAO和VBO之前,我们需要了解其所属的渲染流水线阶段,在opengl wiki中,这一阶段被称为Vertex Specification,在接下来的文章中,我们将其称为顶点规范,以下为官网的原文解释
Vertex Specification is the process of setting up the necessary objects for rendering with a particular shader program, as well as the process of using those objects to render.
顶点规范是指为特定着色器设置其所需要的对象,以及使用这些对象进行渲染的过程
仅从这个解释我们无法很好的理解这一过程究竟做些什么,但我们可以针对这个解释提出几个问题:
- 特定着色器是指哪些着色器
- 所需要的对象是指什么
- 这些对象对于着色器有什么作用
接下来所介绍的概念将终点回答以上的这些问题,在理解了这几个问题之后,我们也能够非常自然的理解VBO和VAO的概念以及作用
2.1 Vertex Stream
为了进行渲染,我们需要使用着色器以及调用这些着色器的渲染管线,在opengl中主要包括顶点着色器(Vertex Shader),几何着色器(Geometry Shader), 片元着色器(Fragment Shader)。其中,顶点着色器包括用户定义的输入变量列表,这些变量代表每个顶点在渲染时所需要的属性
一个常见的顶点着色器输入变量列表
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoord;
这个变量列表表示的是location0输入顶点的坐标属性,location1输入法向量属性,location2输入纹理坐标属性。在定义了这些输入属性之后,我们需要为其“准备”好相应的数据。
对于每个属性,我们需要提供一个数据数组,并且这些数据数组必须有相同的元素个数 (这里的数组类似c语言的数组概念,但是更加灵活),我们把这些数据数组称为顶点数据。
需要注意的是,这些顶点的顺序是十分重要的,这个顺序决定了OpenGL会如何渲染这些顶点组成的Primitive(图元),OpenGL将这种按顺序进行定点数据传输的过程称为Vertex Stream(顶点流)。
OpenGL从顶点数组中产生顶点流的方式有两种,一种是直接按照数组的原始顺序获取顶点数据,另一种则是定义一个索引列表。索引列表定义了获取顶点数据的先后顺序,并且索引列表可以多次访问同一数据元素。
例如,我们的顶点坐标数据数组如下:
{ {1, 1, 1}, {0, 0, 0}, {0, 0, 1} }
如果我们采用第一种顶点流生成方法,那么OpenGL会直接按照顺序从左到右处理这些数据,但当我们使用第二种索引列表的方式时,则可以自定义对于这些数据的处理顺序和次数
例如,这样的索引列表
{2, 1, 0, 2, 1, 2}
那么,按照该索引列表中的顺序去对顶点数据数组进行访问,我们能够获得这样的顶点流
{ {0, 0, 1}, {0, 0, 0}, {1, 1, 1}, {0, 0, 1}, {0, 0, 0}, {0, 0, 1} }
上述例子来源于Opengl wiki ,这种使用索引的顶点流是一种很好的数据压缩手段,对于重复使用的顶点来说,其顶点数据通常会占用32字节左右的内存,而一个索引则通常只有2-4字节。
注意 : 我们可能会想,能不能通过为每个属性赋予独立的索引列表,最大化这种数据压缩的能力?一些3d编辑工具支持这种操作,但是OpenGL(同Direct3D)是不允许的,其要求所有顶点数据数组共享同一个索引列表,因此从其他3d边界工具中导入的顶点数据中如果含有多个索引列表,则需要进行相应的预处理使其能够共享同一个索引列表
2.2 Primitive(图元)
个人认为图元的概念最直接的使用是在几何着色器中,因此这里只会进行一个大概的介绍。
上述的顶点流输出的终究只是顶点,正如我们平时手工绘画一样,并非逐点绘制,而是组合各种线段和几何图形,对于OpenGL而言,这些基本的绘画元素被称为图元。我们需要告诉OpenGL如何处理顶点流输出的顶点数据从而组成各种各样的图元。例如将每三个顶点组成一个三角形,每两个顶点组成一个线段,亦或者将4个顶点组成两个三角形
2.3 总结
通过上述几个概念的介绍,我们应该能够回答之前在Vertex Specification中提出的几个问题
Q1 : 特定着色器是指哪些着色器
A1 : 我们在顶点着色器中定义其所需要的顶点属性,并为每个属性准备其对应的数据数组。并且通过这些数据数组产生的顶点流,来让OpenGL组成图元。所以这里的着色器主要是指顶点着色器,但使用几何着色器时也会涉及到这个概念。
Q2 : 所需要的对象是指什么
A2 : 所需要的对象应该至少包含顶点属性数据,对于使用索引进行顶点流产生的方法还应该准备一个属性之间共享的索引列表。
Q3 : 这些对象对于着色器有什么作用
A3 : 这些对象为顶点着色器的顶点属性提供数据来源,并且通过顶点流提供给OpenGL组成图元的数据处理顺序,后半部分或许在GLSL本身的着色器代码中体现的不是很明显,但是对于光栅化阶段来说则是非常重要的。
3 VAO&VBO
从第二节中的概念我们知道,要进行Vertex Specification, 我们需要能够提供数据数组并产生顶点流的对象, 这也正是VAO和VBO存在的意义。
3.1 VBO(Vertex Buffer Object)
顶点着色器在GPU中创建内存用于存储顶点数据,这些内存在显卡中,因此也被称为显存(vRam)。我们通过配置OpenGL来“解释”这些内存,即不同的数据段对应的是顶点属性中的哪一部分。而OpenGL管理这些内存的方式即为顶点缓冲对象(Vertex Buffer Objects,VBO),我们可以通过创建与配置这些对象来申请显存空间以及解释这些内存。
以下为LearnOpengl中对使用VBO好处的解释
The advantage of using those buffer objects is that we can send large batches of data all at once to the graphics card, and keep it there if there's enough memory left, without having to send data one vertex at a time. Sending data to the graphics card from the CPU is relatively slow, so wherever we can we try to send as much data as possible at once. Once the data is in the graphics card's memory the vertex shader has almost instant access to the vertices making it extremely fast
我们通过顶点缓冲对象(Vertex Buffer Objects, VBO)管理这个内存,它会在GPU内存(通常被称为显存)中储存大量顶点。使用这些缓冲对象的好处是我们可以一次性的发送一大批数据到显卡上,而不是每个顶点发送一次。从CPU把数据发送到显卡相对较慢,所以只要可能我们都要尝试尽量一次性发送尽可能多的数据。当数据发送至显卡的内存中后,顶点着色器几乎能立即访问顶点,这是个非常快的过程。
VBO的好处在于其可以将大量数据一次发送到显存中,这对于图形渲染非常重要,毕竟CPU和GPU之间的通信是昂贵的,我们不希望在每帧之间进行大量的这种数据传输操作。但是,当我们只是想绘制一些简单的图形进行测试的时候,OpenGL也提供了发送单个顶点数据的借口
在Cherno的OpenGL教程中使用的是,其使用的OpenGL3种的接口是
void glVertex2f(GLfloat x,GLfloat y)
而在OpenGL4的reference page中则是这样的接口
void glVertexAttrib2f(GLuint index,GLfloat v0,GLfloat v1)
opengl官网中现在只能找到gl4的reference page,需要gl3以及更老版本的接口查询可以访问docs.gl
虽然接口产生了些许变化,但是这一对象的设计逻辑仍然是共通的,所以我们也通过opengl wiki中对于这一概念的叙述来获得更加全面的认识
A Vertex Buffer Object (VBO) is the common term for a normal Buffer Object when it is used as a source for vertex array data. It is no different from any other buffer object, and a buffer object used for Transform Feedback or asynchronous pixel transfers can be used as source values for vertex arrays.
官网的解释是说,VBO是缓冲对象被用在顶点数组数据是的俗称, 其和一般的缓冲对象没有区别。换句话说,OpenGL所支持的缓冲对象理论上都可以用作顶点缓冲对象,比如Transform Feedback和asychronous pixel transfers的缓冲对象也可以作为顶点缓冲对象使用,具体的操作涉及顶点后处理(Vertex post-processing),纹理(Texture)缓冲等内容,在相应的章节再具体讲解,这里先只需要知道VBO并不是一种特殊的缓冲对象,其余操作的缓冲对象同样能够作为VBO使用。
在OpenGL中我们可以创建VBO,绑定对象,以及设定VBO对顶点数据的解释方式
// 顶点缓冲对象的ID
unsigned int VBO;
// 创建一个顶点缓冲对象
glGenBuffers(1, &VBO);
// 将顶点缓冲对象绑定到GL_ARRAY_BUFFER,从这一刻起,我们使用的任何(在GL_ARRAY_BUFFER目标上的)缓冲调用都会用来配置当前绑定的缓冲(VBO)
glBindBuffer(GL_ARRAY_BUFFER, VBO);
// 将定义的顶点数据复制到缓冲的内存中,第三个参数为NULL时则不进行复制,仅申请规定大小的内存,且内存为为初始化状态
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 删除顶点缓冲对象
glDeleteBuffer(1,&VBO);
glBufferData的最后一个参数usage指定来我们希望显卡如何管理给定的数据,例如一下三种形式:
- GL_STATIC_DRAW :数据不会或几乎不会改变。
- GL_DYNAMIC_DRAW:数据会被改变很多。
- GL_STREAM_DRAW :数据每次绘制时都会改变。
当数据在每次渲染中不改变时最好使用GL_STATIC_DRAW, 而当一个缓冲中的数据被频繁改变时则使用GL_DYNAMIC_DRAW或GL_STREAM_DRAW,从而确保显卡将数据放入告诉写入的内存部分。当然除这三种外还包括很多别的类型,具体的可以参考文档,不同的显卡厂商对于这些类型有自己的实现来提高缓冲对象的性能,但是并不影响OpenGL开发者对这些数据的使用。
在gl4中我们能够使用glBufferData的上位替代,具体见文档
void glBufferStorage(GLenum target,GLsizeiptr size,const GLvoid * data,GLbitfield flags);
void glNamedBufferStorage(GLuint buffer,GLsizeiptr size,const void *data,GLbitfield flags);
VBO能够申请对应的内存区域,同时也使我们能够解释这些数据
例如以下这些接口
void glVertexAttribPointer( GLuint index, GLint size, GLenum type, GLboolean normalized, GLsizei stride, const void *offset);
void glVertexAttribIPointer( GLuint index, GLint size, GLenum type, GLsizei stride, const void *offset);
void glVertexAttribLPointer( GLuint index, GLint size, GLenum type, GLsizei stride, const void *offset);
这些接口基本实现一个事情,即设置属性索引为index属性的格式以及储存信息。该函数告诉我们属性index会从绑定到type的缓冲对象获取数据,并且我们需要注意这种关联是在这个函数被调用时建立的。
例如下面这个例子
glBindBuffer(GL_ARRAY_BUFFER, buf1);
glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, 0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
buf1首先被绑定到GL_ARRAY_BUFFER目标,然后设定属性0的格式与存储信息,然后将0绑定到GL_ARRAY_BUFFER目标,但是这并不影响属性0和buf1之间的关联,我们可以将GL_ARRAY_BUFFER看作一个设定buf1与属性0的接口,换绑接口并不会影响之前设定的联系
当0绑定到GL_ARRAY_BUFFER时,无法调用glVertexAttribPointer,因此在完成一个顶点缓冲对象的设定后,我们通常会将将0绑定到GL_ARRAY_BUFFER上,以避免错误修改顶点缓冲对象的响应配置
接下来我们将介绍glVertexAttribPointer如何定义数据的格式
从之前的顶点着色器代码中我们可以看到每个顶点属性都有一个对应的数据类型如vec3、vec2对应着3位float与2位float组成的向量。我们当然可以用整型组成向量如ivec3,或者双精度浮点数如dvec4(仅在OpenGL4.1中可用)。在OpenGL中,我们通过使用不同的函数来对应不同的数据类型。
glVertexAttribPointer --> float
glVertexAttribIPointer --> int
glVertexAttribLPointer --> double
然后是这些函数的第二个参数size,其可以为1-4中的任意整数,对应属性向量的1-4维度,这个参数表示每次从缓冲区数组中连续读取几个数据,这个数据不一定需要严格对应顶点属性的维度数,当顶点维度数小于size时,多余的位数会被忽略,大于size时会从(0,0,0,1)取对应位数来补充。
当size = 4,顶点属性为vec3类型
缓冲区读取(0.1,0.2,0.3,0.4)
实际aPos = (0.1,0.2,0.3)
当size = 3, 顶点属性为vec4类型
缓冲区读取(0.1,0.2,0.3)
实际aPos = (0.1,0.2,0.4,1.0)
Note:对于双精度类型不成立,缺少的维度会是undefined value
接着是函数的第3个变量type,对应着缓冲区存储数据的类型,当这些数据被顶点着色器读取时会转换成其所需要的类型。这里容易和之前的属性类型弄混,其逻辑关系如下:
vec必须使用glAttribPointer进行配置
ivec必须使用glAttribIPointer进行配置
dvec必须必须使用glAttribLPointer进行配置
函数类型决定输出的数据类型
glAttribPointer的type可设定为:GL_HALF_FLOAT,GL_FLOAT,GL_DOUBLE,GL_FIXED ...
glAttribIPointer的type可设定为:GL_BYTE,GL_UNSIGNED_BYTE,GL_SHORT ...
glAttribLPointer的type可设定为:GL_DOUBLE ...
type决定了在缓存中存储数据的类型,当从缓存中读取的时候,不同的type到对应的属性数据类型有各自的转换方式。比如glVertexAttribPointer到normalized参数决定了,当type为整型时是否使用integer normalization将整型转换为单精度浮点类型
其中glAttribPointer的size类型除了可以为1-4之间的整数之外还可以为GL_BGRA,其实际上也是一个4位数据,但是前三位是逆序读取的,这是为了和Direct3D中的特定类型进行匹配,从而方便将D3D的部分数据转换到OpenGL中,相关细节详见OpenGL wiki
最后一部分是函数的stride和offset参数,这两个参数从哪里开始读取顶点数据,以及读取完一个顶点数据后如何移动指针到下一个顶点数据。具体来说stride表示从当前顶点数据的开头到下一个顶点数据开头有多少字节,当stride为0时,OpenGL默认这些数据紧密排列,其根据size和type自动计算到下一个数据的字节数,stride则为size * sizeof(type)。offset表示开始读取的指针位置,源于一些历史原因其类型为(void),我们可以通过类型转换来将其他类型的指针转换为void。
通过灵活设置这些参数,我们能够实现不同的数据储存方式, 最经典的一种应用即交错存储
通常opengl更喜欢交错存储,因为我们可以一次性获取单个顶点所需的所有数据,而不需要从多个内存位置读取数据,类似cpu中的内存读取机制,这种数据访问方式会有更少的cache miss,更高的效率。但是对于需要经常更改的数据和静态数据同时存在的情况来说,这种存储方式是不理想的,比如我们可能希望将经常更改的数据放在高速写入的内存区,而将静态数据放在其他地方。此时我们可以讲两种数据分开存储,但对于静态数据而言,我们仍应尽可能使用交错存储的方式。
3.2 VAO(Vertex Array Buffer)
VBO用于保存顶点属性对应数据存储的顶点缓冲区,能够通过它设置顶点缓冲区的数据格式的解释。因此,但是这种解释并非存储在VBO中,而是针对当前OpenGL状态的一种设置,这意味着当我们想要使用另一种VBO以及另一种格式解释时需要重新进行数据格式的设置,这种操作过于繁琐。为了解决这一问题,OpenGL中出现了VAO这一概念
对于VAO,OpenGL wiki给出这样的解释
A Vertex Array Object (VAO) is an OpenGL Object that stores all of the state needed to supply vertex data (with one minor exception noted below). It stores the format of the vertex data as well as the Buffer Objects (see below) providing the vertex data arrays.
一个顶点数组对象是一个OpenGL对象,其保存提供顶点数据所需的所有状态。它保存顶点数据的格式以及提供顶点数据数组的缓冲对象
从这个解释中我们可以看到,VAO的作用实际上就是保存我们在VBO中所做的操作,从而使我们可以方便的复用VBO以及数据格式设置。但同时,OpenGL wiki中还给出了一个主意事项,较为重要
Note that a VAO merely references the buffers, it does not copy or freeze their contents; if referenced buffers are modified later, those changes will be seen when using the VAO.
VAO中所保存的是缓冲的引用,而非对其内容的复制,这意味着当我们在其他地方修改了这些缓冲,那么保存其引用的VAO在使用时也会应用这些变化
VAO同样属于OpenGL对象,因此其也有对应的创建以及销毁函数
glGenVertexArrays
glCreateVertexArrays (only available in gl4.5)
glDeleteVertexArrays
与之前的VBO不同点在于其绑定函数,VBO绑定其object name到GL_ARRAY_BUFFER目标,而VAO的绑定则不需要绑定到其他目标上
glBindVertexArray(GLuint name)
这意味着,OpenGL可以同时使用多个绑定至不同目标的缓冲对象,而顶点数组对象同时只能使用一个。注意,VAO不能在OpenGL上下文之间共享。
顶点着色器访问顶点数据是通过VAO进行的,因此我们需要通过VAO来设置是否激活某个顶点属性,他们通过如下接口实现
void glEnableVertexAttribArray(Gluint index);
void glDisableVertexAttribArray(GLuint index);
或者通过DSA(Direct State Access)来设置
void glEnableVertexArrayAttrib(GLuint vao,Gluint index);
void glDisableVertexArayAttrib(GLuint vao,GLuint index);
DSA是gl4.5之后加入的新特性,能够在不讲OpenGL对象绑定至上下文的情况下修改对象状态, 如果使用旧版本,则必须在此之前使用glBindVertexArray绑定至当前上下文。
同时,我们还需注意,当对应的属性未被激活是,顶点着色器在访问对应属性时会获得一个默认值,而非报错退出。
而且,OpenGL的compatibility profile中会将name为0的VAO对象设置为一个默认对象,这意味着在不主动绑定任何VAO时,所有修改VAO状态的操作都会默认应用到VAO 0上。而对于core profile,VAO 0并不是一个对象,所以在手动绑定一个VAO对象之前,我们不能使用任何修改VAO状态的操作。
VAO的使用相对简单,只需在绘制之前,将对应VAO绑定至当前上下文,即可应用其对应的VBO和数据格式配置
while(!glShouldWindowClose(window))}{
glBindVertexArray(VAO);
// draw call
... ...
}
3.3 Index buffers
最后我们介绍一下index buffer。之前在介绍Vertex Specification的过程中我们介绍过使用索引数组来决定绘制顺序以及进行数据压缩,而这则是通过index buffer实现的。
index buffer本身和VBO一样时Buffer Object, 只是通过绑定到GL_ELEMENT_ARRAY_BUFFER来将其当作索引数组来使用,其他操作和VBO一样。要使用这个索引缓冲对象,我们还需要在绘制时使用glDrawElements而不是DrawDrawArrays。
4 总结
我们可以从VBO已经index buffer中更进一步的理解buffer object,buffer object自身仅仅用来存储数据,规定数据的类型以及缓冲区大小。而这个buffer object的具体作用由绑定到的目标决定。绑定到GL_ARRAY_BUFFER是VBO,GL_ELEMENT_ARRAY_BUFFER则是index buffer。顶点数据格式设置的操作则是对OpenGL状态机进行操作,而这些绑定以及格式设置,又被绑定的VAO记录,从而可以方便的应用这些设置。