一、BO(Buffer Object,缓冲对象)
缓冲对象是OpenGL管理的一段内存,为了与我们CPU的内存区分开,一般称OpenGL管理的内存为:显存。
显存,也就是显卡里的内存。显卡访问显存比较快,而Buffer Object,就是由OpenGL维护的一块显存区域。比如说在一块显存为2G的显卡里,分配了128K大小的内存区域给OpenGL使用,这个128K大小的内存区域,就叫一个Buffer Object。
由于显卡访问显存,比访问内存(CPU里的内存区域)要快很多。而且显卡做运算,一般都是访问显存的数据,然后运算得到结果,并把结果也都保存在显存中。所以一般,需要先把数据,从内存传输到显存中去。
显卡里申请的这片显存区域,存放顶点数据,就叫VBO,存放图像数据,就叫PBO,根据它存放的数据的不同,有不同的叫法。
二、VBO(Vertex Buffer Object,顶点缓冲对象)
- 顶点数据输入
在开始绘制图形之前,我们必须先给OpenGL输入一些顶点数据。这些数据一开始存在C++语言创建的CPU的内存中,比如指定三个顶点的坐标,存在float数组内。
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
而后需要把这个顶点数据作为输入发送给OpenGL的图形渲染管线的第一个阶段:顶点着色器。顶点着色器会在GPU上创建显存用于储存这些顶点数据,同时我们还需要告诉OpenGL如何解释这些显存(比如告诉OpenGL,顶点数据前三个是物体的三维坐标,后三个是顶点法线,再后两个是纹理坐标)。
顶点缓冲对象(VBO)的作用就是管理这个在GPU上创建的显存。使用这些缓冲对象的好处是我们可以一次性的发送一大批数据到显卡上,而不是每个顶点发送一次。从CPU把数据发送到显卡相对较慢,所以只要可能我们都要尝试尽量一次性发送尽可能多的数据。当数据发送至显卡的内存中后,顶点着色器访问顶点是个非常快的过程的。
- 首先使用glGenBuffers函数和一个unsigned int变量生成一个VBO对象(顶点缓冲对象):
unsigned int VBO;
glGenBuffers(1, &VBO);
void glGenBuffers(GLsizei n,GLuint * buffers);
个人理解这个函数的解释如下:
将n个当前未使用的缓冲对象名称(也就是ID),保存到buffers所指的内存区域中。这n个缓冲对象ID不一定是连续的整型数据(比如可能是1,5,8,而不一定是1,2,3,它们之间没有连续关系)
当然也可以声明一个unsigned int 数组,那么创建的n个缓冲对象的ID会依次保存在数组里。
unsigned int VBO[3];
glGenBuffers(3,VBO);
也就是说,这时候VBO内会是一个从未被使用过的缓冲对象的ID,类似于起名字,起了一个全人类都没用过的名字,自然意味着独一无二。
我的理解中,glGenBuffers函数起了一个名字,这个名字应该对应了GPU内的显存的地址,VBO内存储的那个名字(ID),可能就是那片显存的起始地址。也就是说,VBO(unsigned int 变量)成了一个将指向GPU内开辟的一块内存空间的指针。我认为此时还未指向。
- 然后使用glBindBuffer函数给这个生成的顶点缓冲对象绑定一个缓冲类型
glBindBuffer(GL_ARRAY_BUFFER, VBO);
void glBindBuffer(GLenum target,GLuint buffer);
arget: 缓冲对象的类型,GL_ARRAY_BUFFER:数组缓冲区,存储颜色、位置、纹理坐标等顶点属性,或者其它自定义属性。
buffer: 要绑定的缓冲对象的名称(ID), 即我们在glGenBuffers函数里生成的ID。
glBindBuffer函数完成了三项工作:
- 如果是第一次绑定buffer,且buffer是一个非0的unsigned int。那么将创建一个与该名称相对应的新缓冲对象。
- 如果绑定到一个已经创建的缓冲对象,那么它将成为当前被激活的缓冲对象。
- 如果buffer为0,那么OpenGL将不再对当前的target应用任何缓冲对象。
按照我的理解 : 上一步中生成了一个独一无二的ID,将指向一片显存区域,这一步中,那个ID正式指向显存内的一片显存区域(缓冲对象),此时应该只知道该显存区域的开头地址。并且将告诉OpenGL,这片显存的类型是GL_ARRAY_BUFFER型的。里面储存的将是数组类型的数据。
从这一刻起,任何在GL_ARRAY_BUFFER目标上的缓冲调用都会用来配置当前绑定的缓冲对象(VBO)。
在OpenGL红包书中给出了一个恰当的比喻:
绑定对象的过程就像设置铁路的道岔开关,每一个缓冲类型(比如GL_ARRAY_BUFFER)中的各个缓冲对象(比如生成了多个缓冲对象,ID为:123,322,111)就像不同的轨道一样,我们将开关设置为其中一个的状态(比如绑定123为GL_ARRAY_BUFFER),那么之后的列车(针对GL_ARRAY_BUFFER的改动)都会驶入这条轨道(123缓冲对象)。
- 然后使用glBufferData函数将数据储存进那片显存区域中(缓冲对象)
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBufferData是一个专门用来把用户定义的数据复制到当前绑定缓冲的函数。
它的第一个参数是目标缓冲的类型:顶点缓冲对象当前绑定到GL_ARRAY_BUFFER目标上。
第二个参数指定传输数据的大小(以字节为单位);用一个简单的sizeof计算出就行。
第三个参数是我们希望发送的实际数据。
第四个参数指定了我们希望显卡如何管理给定的数据。它有三种形式:
GL_STATIC_DRAW :数据不会或几乎不会改变。
GL_DYNAMIC_DRAW:数据会被改变很多。
GL_STREAM_DRAW :数据每次绘制时都会改变。
这个函数完成了两个任务:在显存中分配数据所需得储存空间,将数据从CPU内存中拷贝到GPU显存中。
我的理解是:glBindBuffer那步只知道显存区域的开头地址,glBufferData正式根据需要传入的数据的大小开辟显存空间,并将CPU内存中储存的数据一次性拷贝进这片显存空间中,同时告诉显卡,这些数据的改变频率。
三、VAO(Vertex Array Object,顶点数组对象)
我们已经把顶点数据发送给了GPU,但OpenGL还不知道它该如何解释内存中的顶点数据。GPU内的这块显存区域里是紧密连续的一个个数据,我们需要告诉OpenGL,从哪里到哪里是一个顶点的数据,从哪里到哪里是这个顶点的RGB值(如果有的话)等。
同时,我们也该告诉OpenGL,该如何将顶点数据链接到顶点着色器的属性上。
注:顶点着色器中可以有好几个顶点属性,使用关键字 in 来声明,如下面举例的着色器所示,由于我们只关心位置(Position)数据,所以我们只需要一个顶点属性,输入的该是顶点的三维坐标。
#version 330 core
layout (location = 0) in vec3 aPos;
void main()
{
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}
我们必须手动指定输入数据的哪一个部分对应顶点着色器的哪一个顶点属性。所以,我们必须在渲染前指定OpenGL该如何解释顶点数据。
在上文中,我们开辟了一片GPU显存空间,并把顶点数据拷贝了进去,在那片显存空间中的数据应该是如下的形式:
根据需要,我们应该如下解释这段数据:
1、每个数值被储存为32位(4字节)浮点值。
2、每个顶点三维坐标包含3个这样的值。
3、每个顶点三维坐标之间没有空隙(或其他值)。这几个值在数组中紧密排列(Tightly Packed)。
4、数据中第一个值在缓冲(申请的那片显存空间)开始的位置。
1、glVertexAttribPointer
使用glVertexAttribPointer函数可以告诉OpenGL该如何解析这些数据(应用到逐个顶点属性上)
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glVertexAttribPointer函数的参数非常多:
第一个参数指定我们要配置的顶点属性。即在顶点着色器中使用layout(location = 0)定义了position顶点属性的位置值(Location),把顶点属性的位置值设置为0,因为这里我们希望把数据传递到这一个顶点属性中,所以这里我们传入0。
第二个参数指定顶点属性的大小。在例子中,三维坐标是一个vec3,它由3个值组成,所以大小是3。
第三个参数指定数据的类型,这里是GL_FLOAT(GLSL中vec*都是由浮点数值组成的)。
第四个参数定义我们是否希望数据被标准化(Normalize)。如果我们设置为GL_TRUE,所有数据都会被映射到0(对于有符号型signed数据是-1)到1之间。
第五个参数叫做步长(Stride),它告诉我们在连续的数据中,一个顶点的三维坐标到下一个顶点的三维坐标的间隔。由于下个顶点的三维坐标在3个float之后,我们把步长设置为3 * sizeof(float)。
最后一个参数的类型是void*,所以需要我们进行这个奇怪的强制类型转换。它表示位置数据在所开辟的那片显存空间中起始位置的偏移量(Offset)。由于顶点的三维数据就在数组的开头,也就是那片显存空间中的开头,所以这里是 (void*) 0。
2 、glEnableVertexAttribArray
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(0)启用了顶点着色器的(location = 0)的属性变量
默认情况下,出于性能考虑,所有顶点着色器的属性(Attribute)变量都是关闭的,意味着数据在着色器端是不可见的,哪怕数据已经上传到GPU。只有由glEnableVertexAttribArray启用指定属性,才可在顶点着色器中访问逐顶点的属性数据。数据在GPU端是否可见,即,着色器能否读取到数据,由是否启用了对应的属性决定,这就是glEnableVertexAttribArray的功能,允许顶点着色器读取GPU显存内的数据。
至此,我们已经完成了:
1、将数据从CPU内存传输进GPU显存中
2、告诉OpenGL该如何解释显存内的数据
3、赋予了顶点着色器读取显存内数据的权限
于是就可以开始渲染了:
// 0. 复制顶点数组到缓冲中供OpenGL使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 1. 设置顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
//2.赋予顶点着色器读取显存内数据的权限
glEnableVertexAttribArray(0);
// 3. 当我们渲染一个物体时要使用着色器程序
glUseProgram(shaderProgram);
// 4. 绘制物体
渲染的程序代码();
3、重复使用
每当我们绘制一个物体的时候都必须重复这一过程,但是如果有超过5个顶点属性,上百个不同物体呢。这显然是不显示的,有没有一些方法可以使我们把所有这些状态配置储存在一个对象中,并且可以通过绑定这个对象来恢复状态呢?
顶点数组对象(Vertex Array Object, VAO)就是这个作用。它可以像顶点缓冲对象那样被绑定,任何随后的顶点属性调用都会储存在这个VAO中。这样的好处就是,当配置顶点属性指针时,你只需要将那些调用执行一次,之后在绘制物体的时候只需要绑定相应的VAO就行了。这使得在不同顶点数据和顶点属性配置之间的切换变得非常简单,只需要绑定不同的VAO就行了。刚刚设置的所有状态都将存储在VAO中。
一个顶点数组对象会储存以下这些内容:
- glEnableVertexAttribArray和glDisableVertexAttribArray的调用。
- 通过glVertexAttribPointer设置的顶点属性配置。
- glVertexAttribPointer是对哪块显存区域(顶点缓冲对象)内的数据做出的解释。
每一个VAO包括了,如何解释数据、解释哪段数据、读取数据权限的启用。所以每次渲染物体前,绑定配置好的VAO就可以直接开始渲染了。
而每次配置VAO,首先与VBO类似,需要先生成,再绑定VAO,然后绑定相应的VBO,再配置对应的对数据的解释,之后就可以解绑VAO供以后使用了。
配置VAO的流程如下:
//生成顶点缓冲对象,顶点数组对象
unsigned int VBO, VAO;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
//先绑定顶点数组对象
glBindVertexArray(VAO);
//绑定顶点缓冲对象
glBindBuffer(GL_ARRAY_BUFFER, VBO);
//将数据存入顶点缓冲对象所指的内存中
glBufferData(GL_ARRAY_BUFFER, mouseVertices.size() * sizeof(Vertex), &mouseVertices[0], GL_STATIC_DRAW);
//告诉OpenGL该如何解析顶点坐标数据
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0);
//启用顶点位置属性
glEnableVertexAttribArray(0);//这里的0是顶点着色器中location=0中的0
//解绑顶点数组对象
glBindVertexArray(0);
VAO的使用就很简单了:
//选用进行渲染的着色器小程序
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
渲染的程序代码();
四、EBO(Element Buffer Object,索引缓冲对象)
理解了VBO的概念之后,EBO就变得很简单了,它同样是一个缓冲对象,只是里面储存的不是VBO那样的顶点数据,而是索引数据,它指导了绘制图形时,经过怎么样的顺序选用VBO内的顶点数据。
举个例子:
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 // 第二个三角形
};
顶点数据vertices数组,索引数据indices数组,由四个顶点绘制了两个三角形组成了一个矩形,其中有两个顶点重复使用。