简介

VBO 是数据容器

一个 3D 模型的顶点坐标、UV、法线、颜色,要在 GPU 中保存下来,VBO 就承担了这个责任。
它的本质是一段缓冲。下面这份顶点坐标用 float 存放:

float vertices[] = {
    // position
     0.0f,  0.5f, 0.0f,
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f
};

接着使用 OpenGL 函数把数据放到 GPU 中:

GLuint vbo;

glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);

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

这里是 3 个顶点,一共 9 个 float。要画出它们,还要先绑定 VAO,并用 glVertexAttribPointer 把 position 指到这个 VBO,见后面的 VAO 一节。绘制时,count 填顶点个数:

glDrawArrays(GL_TRIANGLES, 0, 3);

glDrawArrays 按这个顺序取 VAO 中已经启用的顶点属性。

EBO 是为了高效使用 VBO 中的数据

一句话总结:VBO 存顶点,EBO 存顶点索引。

假如我们需要绘制 2 个三角形。每个三角形有 3 个顶点,不用索引时,VBO 中要放 6 个顶点。
这两个三角形共用一条边,6 个顶点里有 2 个是重复的。
使用 EBO 后,VBO 中只放 4 个顶点。EBO 保存的是这些顶点的编号,用编号就可以引用 VBO 中的整个顶点。
这样就实现了数据的复用,这就是 EBO 的作用。

比如这样 4 个顶点:

vertices = {
    A, // 索引 0
    B, // 索引 1
    C, // 索引 2
    D  // 索引 3
};

使用索引后:

unsigned int indices[] = {
    0, 1, 2, // 三角形 ABC
    0, 2, 3  // 三角形 ACD
};

对应的 OpenGL 函数。GL_ELEMENT_ARRAY_BUFFER 的绑定会记在当前 VAO 里,所以先绑定 VAO:

glBindVertexArray(vao);

GLuint ebo;

glGenBuffers(1, &ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);

glBufferData(
    GL_ELEMENT_ARRAY_BUFFER,
    sizeof(indices),
    indices,
    GL_STATIC_DRAW
);

glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr);

6 是索引个数。GL_UNSIGNED_INT 和上面的 unsigned int 一致。nullptr 表示从索引缓冲的开头读取。绘制时,VAO 中的顶点属性也要已经设置好。

VBO + EBO 的组合关系:

VBO
 ├── Vertex 0 = A
 ├── Vertex 1 = B
 ├── Vertex 2 = C
 └── Vertex 3 = D

EBO
 ├── 0
 ├── 1
 ├── 2
 ├── 0
 ├── 2
 └── 3

索引 0 取到的是顶点 A。顶点里如果还有 UV、法线,会和坐标一起被取出来。

VAO 是用来解释 VBO 数据的

很多时候,VBO 中的数据不仅仅是顶点坐标,还有很多其他类型。
需要一种方法来告诉 GPU 如何使用 VBO 中的数据,并把这份说明保存下来,这就是 VAO 的职责。

假如 VBO 中数据为:

float vertices[] = {
    // position          // UV
     0.0f,  0.5f, 0.0f,  0.5f, 1.0f,
    -0.5f, -0.5f, 0.0f,  0.0f, 0.0f,
     0.5f, -0.5f, 0.0f,  1.0f, 0.0f
};

既有 position 数据,也有 UV 数据,实际上 vertices 中每一行的数据格式是 X Y Z U V。
就 vertices 的第一行数据来说,OpenGL 需要知道:

0 ~ 2 → position
3 ~ 4 → UV

后面每一行都是一样的格式。

先绑定 VAO,再把这份 VBO 绑到 GL_ARRAY_BUFFER。glVertexAttribPointer 会把这一刻绑定的 VBO 记到对应属性上。之后换成别的 GL_ARRAY_BUFFER,该属性仍然读取原来的 VBO。

GLuint vao;

glGenVertexArrays(1, &vao);
glBindVertexArray(vao);

glBindBuffer(GL_ARRAY_BUFFER, vbo);

先获取 0 ~ 2 → position 的数据:

glVertexAttribPointer(
    0,
    3,
    GL_FLOAT,
    GL_FALSE,
    5 * sizeof(float),
    (void*)0
);

glEnableVertexAttribArray(0);

它表示,location 0 取 3 个 float,作为 position。

再获取 3 ~ 4 → UV 的数据:

glVertexAttribPointer(
    1,
    2,
    GL_FLOAT,
    GL_FALSE,
    5 * sizeof(float),
    (void*)(3 * sizeof(float))
);

glEnableVertexAttribArray(1);

它表示,location 1 取 2 个 float,作为 UV。location 要和 shader 里的 layout(location = N) 一致。

为什么需要 VAO

Core Profile 中,设置顶点属性、绑定 EBO、执行绘制,都要有一个已绑定的 VAO。一个模型用一个 VAO,就可以把这套配置留住。

假如有 模型 A 模型 B 模型 C 3 个模型数据,它们的数据格式也不一样:

模型 A:
position + UV

模型 B:
position + normal + UV

模型 C:
position + color

那么 OpenGL 需要知道:每个模型的 VBO 数据怎么对应到 shader 的 location,又该用哪一个 VBO 和 EBO。VAO 把这些配置保存起来。格式相同的两个模型,只要 VBO 或 EBO 不同,也各用一个 VAO。

使用模型 A 的数据就是:

glBindVertexArray(vao_A);
glDrawElements(GL_TRIANGLES, indexCountA, GL_UNSIGNED_INT, nullptr);

使用模型 B 的数据就是:

glBindVertexArray(vao_B);
glDrawElements(GL_TRIANGLES, indexCountB, GL_UNSIGNED_INT, nullptr);

模型没有索引时,改为调用 glDrawArrays,count 填该模型的顶点个数。

总结

为了存储数据,OpenGL 引入了 VBO 的概念;数据可以是顶点坐标、UV、法线、颜色等,具体类型在设置属性时声明。
为了避免 VBO 存放重复的顶点,OpenGL 引入了 EBO 的概念;使用顶点索引可以重复使用 VBO 的数据。
为了记住 VBO 中的数据怎么读取、读取哪一个缓冲,OpenGL 引入了 VAO 的概念。

一句话总结:VBO 是数据池,EBO 给 VBO 瘦身,VAO 给 VBO 翻译。