这篇文章通过几个简单实例,讨论了OpenGL编程中的 VBO,VAO 和 EBO 概念。
1. VBO 和 VAO
1.1 VBO(Vertex Buffer Object) 顶点缓冲对象
VBO 是显存中的一片缓冲区域,存放从内存中提交过来的顶点数据。GPU 绘制时,需要对 VBO 中的数据进行解析,以便将数据正确的提交给着色器中对应的属性。例如,GPU 需要知道 VBO 中哪一块数据是某个顶点的坐标,哪一块数据是某个顶点的顶点颜色等,通过调用 glVertexAttribPointer
方法来设置解析规则,GPU 能够取到正确的数据供着色器使用
1.2 VAO(Vertex Array Object)顶点数组对象
上述 glVertexAttribPoint
的调用结果被记录到 VAO 中,最终绘制的时候直接通过 VAO 中存储的指针去缓冲区取数据,而不需要再重复解析 VBO,VAO 和 VBO 的关系大概是这样:
- VBO 是纯数据的缓冲区,示意图中分别用两个VBO来保存三角形的顶点位置和顶点颜色数据
- VAO 是一个数组,保存每一类顶点属性的解析结果,OpenGL中貌似最多支持 16 种顶点属性,这里的顶点属性就是
glVertexAttribPointer
方法的第一个参数指定的,通常0表示顶点坐标,1表示顶点颜色 - 使用 VAO 的好处是,你只需要针对 VBO 做一次解析,将结果存储到 VAO 中,每一帧渲染使用 VAO 的指针来访问缓冲区数据,而不需要每一帧都做解析
在 [OpenGL]绘制三角形 这篇文章中,我们实现了一个完整的绘制三角形的程序,完整的代码都贴在了该文章的最后一部分,我们这里来看看核心的绘制部分代码如下
// draw_triangle.h
#include "shader_common.h"
const GLchar* vertexShaderSource = "#version 330 core\n"
"layout (location = 0) in vec3 position;\n"
"void main()\n"
"{\n"
"gl_Position = vec4(position.x, position.y, position.z, 1.0);\n"
"}\0";
const GLchar* fragmentShaderSource = "#version 330 core\n"
"out vec4 color;\n"
"void main()\n"
"{\n"
"color = vec4(1.0f, 0.5f, 0.2f, 1.0f);\n"
"}\n\0";
int draw_trangle()
{
// 初始化 OpenGL
init_opengl();
// 编译和链接着色器
GLuint shaderProgram = compile_shader(vertexShaderSource, fragmentShaderSource);
// 顶点数据
GLfloat vertices[] = {
-0.5f, -0.5f, 0.0f, // Left
0.5f, -0.5f, 0.0f, // Right
0.0f, 0.5f, 0.0f // Top
};
// 申请缓冲区
GLuint VBO, VAO;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
// 绑定VAO,表示在这之后针对 VBO 的解析都会记录在 VAO 中
glBindVertexArray(VAO);
// 提交数据
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 使用Buffer中的数据在 VAO 生成 0 号顶点属性的指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
// 启用 0 号顶点属性
glEnableVertexAttribArray(0);
// 解绑VBO
glBindBuffer(GL_ARRAY_BUFFER, 0);
// 解绑VAO
glBindVertexArray(0);
while (!glfwWindowShouldClose(window))
{
// 处理事件
glfwPollEvents();
// 清屏
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
// 指定着色器程序
glUseProgram(shaderProgram);
// 绑定VAO
glBindVertexArray(VAO);
// 绘制指令
glDrawArrays(GL_TRIANGLES, 0, 3);
// 解绑 VAO
glBindVertexArray(0);
// 双缓冲交换
glfwSwapBuffers(window);
}
glDeleteVertexArrays(1, &VAO);
glDeleteBuffers(1, &VBO);
glfwTerminate();
return 0;
}
可以看到,我们在初始化时将 0 号属性的解析
// 使用Buffer中的数据在 VAO 生成 0 号顶点属性的指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
记录在了 VAO 中,在后续的渲染循环中,我们没有再访问 VBO,而是直接通过绑定 VAO 来调用渲染命令:
// 绑定VAO
glBindVertexArray(VAO);
// 绘制指令
glDrawArrays(GL_TRIANGLES, 0, 3);
1.3 VAO 对应多个 VBO
我们来实现一个程序,将三角形的顶点和颜色数据分别用两个 VBO 提交,然后解析到同一个 VAO 中,再绑定这个 VAO 进行绘制。我们需要在上述的代码中做如下几个更改:
- 首先,着色器代码需要支持除顶点位置(0号顶点属性)之外的另外一个属性:顶点颜色,也就是 1 号顶点属性,着色器代码修改如下
const GLchar* vertexShaderSource = "#version 330 core\n"
"layout (location = 0) in vec3 position;\n"
"layout (location = 1) in vec4 color;\n"
"out vec4 v_color;\n"
"void main()\n"
"{\n"
"gl_Position = vec4(position.x, position.y, position.z, 1.0);\n"
"v_color = color;\n"
"}\0";
const GLchar* fragmentShaderSource = "#version 330 core\n"
"in vec4 v_color;\n"
"layout(location=0) out vec4 o_fragColor;\n"
"void main()\n"
"{\n"
"o_fragColor = v_color;\n"
"}\n\0";
- 其次,我们需要在内存中分配两个区域分别存放顶点的位置和颜色信息
// 顶点数据
GLfloat vertices[] = {
-0.5f, -0.5f, 0.0f, // Left
0.5f, -0.5f, 0.0f, // Right
0.0f, 0.5f, 0.0f // Top
};
GLfloat colors[] = {
1.0f, 0.0f, 0.0f, 1.0f,
0.0f, 1.0f, 0.0f, 1.0f,
0.0f, 0.0f, 1.0f, 1.0f
};
- 再次,在渲染循环之前分别提交顶点位置和颜色信息,绑定到不同的 VBO,并申请 VAO 记录两个 VBO 的解析结果
// 申请缓冲区
GLuint VBO, VAO, VBO2;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
glGenBuffers(1, &VBO2);
// 绑定VAO
glBindVertexArray(VAO);
// 提交数据和解析规则
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER, VBO2);
glBufferData(GL_ARRAY_BUFFER, sizeof(colors), colors, GL_STATIC_DRAW);
glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 4 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(1);
// 解绑VBO
glBindBuffer(GL_ARRAY_BUFFER, 0);
// 解绑VAO
glBindVertexArray(0);
1.4 渲染部分完整代码
这里的初始化方法 init_opengl
和着色器编译方法 compile_shader
可以查看 [OpenGL]绘制三角形 文章的代码部分,渲染部分的完整代码如下
int draw_trangle()
{
// 初始化 OpenGL
init_opengl();
// 编译和链接着色器
GLuint shaderProgram = compile_shader(vertexShaderSource, fragmentShaderSource);
// 顶点数据
GLfloat vertices[] = {
-0.5f, -0.5f, 0.0f, // Left
0.5f, -0.5f, 0.0f, // Right
0.0f, 0.5f, 0.0f // Top
};
GLfloat colors[] = {
1.0f, 0.0f, 0.0f, 1.0f,
0.0f, 1.0f, 0.0f, 1.0f,
0.0f, 0.0f, 1.0f, 1.0f
};
// 申请缓冲区
GLuint VBO, VAO, VBO2;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
glGenBuffers(1, &VBO2);
// 绑定VAO
glBindVertexArray(VAO);
// 提交数据和解析规则
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER, VBO2);
glBufferData(GL_ARRAY_BUFFER, sizeof(colors), colors, GL_STATIC_DRAW);
glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 4 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(1);
// 解绑VBO
glBindBuffer(GL_ARRAY_BUFFER, 0);
// 解绑VAO
glBindVertexArray(0);
while (!glfwWindowShouldClose(window))
{
// 处理事件
glfwPollEvents();
// 清屏
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
// 指定着色器程序
glUseProgram(shaderProgram);
// 绑定VAO
glBindVertexArray(VAO);
// 绘制指令
glDrawArrays(GL_TRIANGLES, 0, 3);
// 解绑 VAO
glBindVertexArray(0);
// 双缓冲交换
glfwSwapBuffers(window);
}
glDeleteVertexArrays(1, &VAO);
glDeleteBuffers(1, &VBO);
glfwTerminate();
return 0;
}
渲染的结果:
2. EBO
2.1 绘制两个三角形
我们来考虑绘制两个三角形的情形,假如我们复用上述三角形其中两个顶点,另外增加一个顶点,构成一个新的三角形,首先能想到有两种方法可以绘制两个三角形:
- 第一种方法,增加顶点位置数组和颜色数组的长度到6,复用刚才的绘制代码
// 顶点数据
GLfloat vertices[] = {
-0.5f, -0.5f, 0.0f, // Left
0.5f, -0.5f, 0.0f, // Right
0.0f, 0.5f, 0.0f, // Top
-0.5f, -0.5f, 0.0f, // Left
0.5f, -0.5f, 0.0f, // Right
0.0f, -1.0f, 0.0f, // Down
};
// 颜色数据
GLfloat colors[] = {
1.0f, 0.0f, 0.0f, 1.0f,
0.0f, 1.0f, 0.0f, 1.0f,
0.0f, 0.0f, 1.0f, 1.0f,
1.0f, 0.0f, 0.0f, 1.0f,
0.0f, 1.0f, 0.0f, 1.0f,
0.0f, 0.0f, 1.0f, 1.0f
};
在调用 glDrawArrays
方法时要修改传参,渲染6个顶点
glDrawArrays(GL_TRIANGLES, 0, 6);
- 第二种方法,分别使用两个 VAO 保存两个三角形的解析数据进行绘制,绘制部分的代码如下:
int draw_trangle()
{
// 初始化 OpenGL
init_opengl();
// 编译和链接着色器
GLuint shaderProgram = compile_shader(vertexShaderSource, fragmentShaderSource);
// 三角形1顶点数据
GLfloat vertices[] = {
-0.5f, -0.5f, 0.0f, // Left
0.5f, -0.5f, 0.0f, // Right
0.0f, 0.5f, 0.0f, // Top
};
// 三角形2顶点数据
GLfloat vertices2[] = {
-0.5f, -0.5f, 0.0f, // Left
0.5f, -0.5f, 0.0f, // Right
0.0f, -1.0f, 0.0f, // Down
};
// 颜色数据
GLfloat colors[] = {
1.0f, 0.0f, 0.0f, 1.0f,
0.0f, 1.0f, 0.0f, 1.0f,
0.0f, 0.0f, 1.0f, 1.0f,
0.0f, 0.5f, 0.5f, 1.0f
};
// 申请缓冲区
GLuint VBO1, VBO2, ColorVBO, VAO1, VAO2;
glGenVertexArrays(1, &VAO1);
glGenVertexArrays(1, &VAO2);
glGenBuffers(1, &VBO1);
glGenBuffers(1, &VBO2);
glGenBuffers(1, &ColorVBO);
// 绑定VAO
glBindVertexArray(VAO1);
// 提交数据和解析规则
glBindBuffer(GL_ARRAY_BUFFER, VBO1);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER, ColorVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(colors), colors, GL_STATIC_DRAW);
glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 4 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(1);
// 解绑VBO
glBindBuffer(GL_ARRAY_BUFFER, 0);
// 绑定VAO
glBindVertexArray(VAO2);
// 提交数据和解析规则
glBindBuffer(GL_ARRAY_BUFFER, VBO2);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices2), vertices2, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER, ColorVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(colors), colors, GL_STATIC_DRAW);
glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 4 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(1);
// 解绑VBO
glBindBuffer(GL_ARRAY_BUFFER, 0);
// 解绑VAO
glBindVertexArray(0);
while (!glfwWindowShouldClose(window))
{
// 处理事件
glfwPollEvents();
// 清屏
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
// 指定着色器程序
glUseProgram(shaderProgram);
// 绑定VAO
glBindVertexArray(VAO1);
// 绘制指令
glDrawArrays(GL_TRIANGLES, 0, 3);
// 绑定VAO
glBindVertexArray(VAO2);
// 绘制指令
glDrawArrays(GL_TRIANGLES, 0, 3);
// 解绑 VAO
glBindVertexArray(0);
// 双缓冲交换
glfwSwapBuffers(window);
}
glDeleteVertexArrays(1, &VAO1);
glDeleteBuffers(1, &VBO1);
glfwTerminate();
return 0;
}
两种方法渲染结果相同,示意图
2.2 优化
上述种方法,其实都有顶点数据的冗余,包括位置和颜色数据的冗余,6个顶点我们需要使用 6 个 float3 来表示位置和颜色,当需要渲染的顶点数时会造成更大的冗余,我们看看如何针对这个问题来进行优化,事实上我们复用了两个顶点,也就是说其实 4 个顶点数据就够用了,为此我们引入了EBO(Element Buffer Object),索引缓冲对象,来解决数据冗余的问题。
使用 EBO 来解决上面两个三角形数据冗余问题的思路是这样的:只保存4个顶点数据,引入EBO来存储两个三角形对于顶点数据的索引:
// 四个顶点,有两个复用
GLfloat vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f,
0.0f, -1.0f, 0.0f,
};
// 四个颜色,两个复用
GLfloat colors[] = {
1.0f, 0.0f, 0.0f, 1.0f,
0.0f, 1.0f, 0.0f, 1.0f,
0.0f, 0.0f, 1.0f, 1.0f,
0.0f, 0.0f, 1.0f, 1.0f
};
GLuint indices[] = {
0, 1, 2, // 第一个三角形使用的顶点下标
0, 1, 3 // 第二个三角形使用的顶点下标
};
简单来说就是,VBO 中存放的是去重后的顶点数据,当顶点复用数目较多时可以节省很多存储空间,另外单独开辟一个EBO缓冲区来存储每个顶点的实际数据在 VBO 中对应的下标值,在提交时将EBO信息也提交,计算结果得到 VAO,绘制时绑定 VAO 来访问顶点数据。我这里使用了相同的下标数组 indices
来使用顶点坐标和顶点颜色数据,你也可以使用另外的数组来指定不同三角形使用的颜色值,如
GLuint colorIndices[] = {
0, 1, 2,
1, 2, 3
};
只要在下面为 VAO 绑定属性时传递正确的数据就可以了。使用 EBO 来绘制两个三角形的完整逻辑代码:
int draw_trangle()
{
init_opengl();
GLuint shaderProgram = compile_shader(vertexShaderSource, fragmentShaderSource);
// 四个顶点,有两个复用
GLfloat vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f,
0.0f, -1.0f, 0.0f,
};
// 四个颜色,两个复用
GLfloat colors[] = {
1.0f, 0.0f, 0.0f, 1.0f,
0.0f, 1.0f, 0.0f, 1.0f,
0.0f, 0.0f, 1.0f, 1.0f,
0.0f, 0.0f, 1.0f, 1.0f
};
GLuint indices[] = {
0, 1, 2, // 第一个三角形使用的顶点下标
0, 1, 3 // 第二个三角形使用的顶点下标
};
// 缓冲区生成
GLuint VBO, ColorVBO, VAO, EBO;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
glGenBuffers(1, &ColorVBO);
glGenBuffers(1, &EBO);
glBindVertexArray(VAO);
// 解析并提交位置属性
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0);
// 解析并提交颜色属性
glBindBuffer(GL_ARRAY_BUFFER, ColorVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(colors), colors, GL_STATIC_DRAW);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 4 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(1);
// 解绑缓冲区
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
while (!glfwWindowShouldClose(window))
{
glfwPollEvents();
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
// 指定着色器
glUseProgram(shaderProgram);
// 绑定 VAO
glBindVertexArray(VAO);
// 根据索引绘制
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
glBindVertexArray(0);
glfwSwapBuffers(window);
}
glDeleteVertexArrays(1, &VAO);
glDeleteBuffers(1, &VBO);
glDeleteBuffers(1, &EBO);
glfwTerminate();
return 0;
}
需要特别注意的几点:
- 索引数组的类型必须用
GLuint
,不要误用GLfloat
,否则将无法得到你想要的绘制结果 - 注意颜色数据的尺寸
- 绘制的 API 发生了变化,不再是
glDrawArrays
而是glDrawElements
,需要注意传参的顺序。