本文简述如何在OpenGL 4.3以上渲染一个矩体(cuboid),有时也称为 矩形体/立方体/正方体/长方体
前言
Minecraft 中的模型由一个个cube组成,这里的cube不是正方体而是矩体,也就是6个面中的每个面都是矩形。
在 Minecraft 1.16 中,一个cube包含6个quad。假定我们渲染一个普通的完整不透明方块,即一个正方体,那么这个模型的6个面中的每个面有一个quad。一个quad即一个矩形,包含了4个顶点的属性(attribute),因此一个cube便有 4 * 6 = 24 个顶点。
Minecraft 会创建 VBO,将这些 attributes 批量传入OpenGL内置shader,即使用固定管线,传入投影矩阵(projection)并使用 glDrawArrays 在 GL_QUADS 模式下渲染。
那么我们如何在 Modern OpenGL(core-profile)中渲染这个矩体呢?
1. Vertex Buffer Object
VBO(Vertex Buffer Object)即顶点缓冲对象,规范于OpenGL 1.5 (2003)。VBO是在显存中分配出的一块内存空间,用于存放大量顶点的属性(attribute),如坐标 position、颜色 color、法向量 normal、纹理映射 uv、光照映射 lightmap等。渲染时,由于VBO的数据是存储在显存中而不是内存中,不需要从CPU向GPU传入数据,GPU可以直接从VBO中按顺序读取数据,且这些数据是对齐的,性能更高。
创建并使用VBO
所有的缓冲对象都由下面的方法所生成一个ID表示:
GLuint VBO_id; // a generated buffer object name
glGenBuffers(/* number */ 1, &VBO_id);
OpenGL中有各种类型的缓冲对象,VBO所对应的类型为 GL_ARRAY_BUFFER。我们可以使用 glBindBuffer 同时绑定多个不同类型的缓冲对象。下面的方法可以将当前的 GL_ARRAY_BUFFER 绑定到 VBO_id,当第二个参数为 0 时,即解除绑定(unbind):
glBindBuffer(/* target */ GL_ARRAY_BUFFER, VBO_id);
绑定之后,我们对所有 target 为 GL_ARRAY_BUFFER 的操作都会应用到当前所绑定的缓冲对象上,即我们刚刚生成的 VBO_id。接着我们使用 glBufferData 来复制内存中的数据到缓冲对象中去,即显存里:
glBufferData(/* target */ GL_ARRAY_BUFFER, /* size */ sizeof(vertices), /* data */ vertices, /* usage */ GL_DYNAMIC_DRAW);
这里,size 即数据的大小,单位为字节。usage 即用途,由频率(frequency)和性质(nature)两部分组成,每个部分有三种选择,可以组合出9种用途。
频率包括:
- STREAM
数据的内容只能修改一次,并至多使用一次。 - STATIC
数据的内容只能修改一次,并可以使用很多次。 - DYNAMIC
数据的内容可以反复修改,也可以使用很多次。
性质包括:
- DRAW
数据的内容由CPU传入,在GPU中获得该数据用于渲染和图像相关指令。 - READ
数据的内容来自于GPU自身,而在CPU中获得该数据使用。 - COPY
数据的内容来自于GPU自身,并在GPU中用于渲染和图像相关指令。
不同的 usage 所分配的内存空间是不同的,这里我们的VBO用于实时渲染,并且数据内容可能会发生变化(比如坐标),因此我们选择 GL_DYNAMIC_DRAW。
2. 连接顶点属性
在顶点着色器(vertex shader)中我们可以定义输入的每个顶点属性的变量名与类型。
- 这里所说的顶点属性为通用顶点属性(generic vertex attribute),相比之下固定管线中存在内置顶点属性,但这些在可编程管线中已全部废弃,所以我们通常说的顶点属性都指通用顶点属性,也就是我们自己定义的。
我们必须要指定我们VBO的数据是如何布局的,这样着色器才能正确地得到每个顶点的每个属性,这就需要使用 glVertexAttribPointer。
假设我们的顶点属性中只包含一个坐标,类型为 vec3,它的布局如下:
一个 vec3 包含3个分量(component),即 x, y, z 分量。如果每个顶点的属性之间没有额外空间,如 pos1,pos2,color1,color2 称为紧密排布(tightly packed)。而对于 pos1,color1,pos2,color2 则称为交错排布(interleaved),需要指定 stride。通常情况下,使用交错排布性能更好,因其内存对齐和序列化存储,但会占用更多显存空间。
glVertexAttribPointer(/* index */ 0, /* size */ 3, /* type*/ GL_FLOAT, /* normalized */ GL_FALSE, /* stride */ 3 * sizeof(float), (void*) 0);
glEnableVertexAttribArray(0);
- index - 指定了要配置的通用顶点属性的索引。假设我们在顶点着色器中指定的坐标属性带有 layout (location = 0),我们这里就要输入 0。
- size - 指定了该顶点属性的大小,即分量数,该值只能是1,2,3,4。这里使用的是一个 vec3,所以分量数是 3。
- type - 指定了VBO数据中对应此属性的类型,这里是 GL_FLOAT。可选的值有 GL_BYTE, GL_UNSIGNED_BYTE, GL_SHORT, GL_UNSIGNED_SHORT, GL_INT, GL_UNSIGNED_INT, GL_HALF_FLOAT, GL_FLOAT, GL_DOUBLE, GL_FIXED, GL_INT_2_10_10_10_REV, GL_UNSIGNED_INT_2_10_10_10_REV 和 GL_UNSIGNED_INT_10F_11F_11F_REV。
- normalized - 指定了数据是否要被标准化。如果数据类型的符号型数值,则转换为[-1, 1]的浮点值,如果是无符号型数值,则转换为[0, 1]的浮点值。如果不标准化,则直接转换为浮点值。坐标不需要标准化,所以这里输入 GL_FALSE。
- stride - 指定了每组顶点属性数组的长度,这里只有循环交错的三个浮点数,所以步数为 3 * 4 = 12 字节。当我们确定顶点属性是紧密排布的,可以传入 0。当有更多复杂的顶点属性时,我们可能需要添加padding来保证数据对齐。
- pointer - 指定了该属性的第一个值在每个顶点属性数组的偏移量,这里的坐标就在数组的开始,所以传入 0。
注意:glVertexAttribPointer 最终都会将数据转成浮点型。如果使用 glVertexAttribIPointer,则会转成整数类型,并且 type 只能 GL_BYTE, GL_UNSIGNED_BYTE, GL_SHORT, GL_UNSIGNED_SHORT, GL_INT 和 GL_UNSIGNED_INT。这个方法不含 normalized 参数。
调用 glVertexAttribPointer 后,所有的这些配置会被保存成一个状态,这个状态也包括当前所绑定的VBO,这样以后就会从这个VBO中读取数据。如果使用VAO,则该状态会保存进VAO,这样就不再需要绑定VBO,即省事也能提高性能。
所有顶点属性默认都是关闭的,需要使用 glEnableVertexAttribArray 来给出它的 location,即通用顶点属性的索引。
3. Element Buffer Object
EBO(Element Buffer Object)即元素缓冲对象,或称为索引缓冲对象,规范于OpenGL 1.5 (2003)。假设我们要渲染的矩体中的顶点属性只包含坐标,那么事实上我们只需要8个坐标而不是24个,这样8个顶点就可以满足需求,同时可以节省显存占用,提高利用率和性能。EBO存储了一个组索引,OpenGL能按照这种顺序使用顶点,这也称为索引绘制(indexed drawing)。
GLuint EBO_id;
glGenBuffers(/* number */ 1, &EBO_id);
与VBO类似,我们需要绑定EBO并使用 glBufferData 上传数据,但这里的 target 是 GL_ELEMENT_ARRAY_BUFFER。EBO的数据一般不需要修改,所以这里的 usage 使用 GL_STATIC_DRAW。
glBindBuffer(/* target */ GL_ELEMENT_ARRAY_BUFFER, EBO_id);
glBufferData(/* target */ GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, /* usage */ GL_STATIC_DRAW);
4. Vertex Array Object
VAO(Vertex Array Object)即顶点数组对象,规范于OpenGL 3.0 (2008)。在Modern OpenGL(core-profile)中必须绑定VAO才能进行渲染。VAO可以保存我们的顶点属性配置,这样我们只需要配置好一个VAO,就不需要在每次渲染时调用 glVertexAttribPointer 等操作,并且可以轻松在不同的VBO或者是相同的VBO中使用不同的顶点属性配置之间进行切换。当绑定VAO时,会切换 glEnableVertexAttribArray 和 glDisableVertexAttribArray 状态,进行调用 glVertexAttribPointer 的顶点属性配置,并使用某个EBO。
注意,VAO只会与某个EBO相关联而不会与某个VBO相关联,它会与在绑定VAO时调用 glVertexAttribPointer 的顶点属性配置时所绑定的VBO相关联,也就是那时的 GL_ARRAY_BUFFER,所以不同的VAO可以使用相同的VBO,同时也能使用不同的顶点属性配置。此外,顶点属性默认就是关闭的,所以在某个的VAO内我们只需使用 glEnableVertexAttribArray 而几乎无需使用 glDisableVertexAttribArray,除非需要重新配置顶点属性。
生成一个VAO:
GLuint VAO_id;
glGenVertexArrays(/* number */ 1, &VAO_id);
接着使用 glBindVertexArray 绑定并配置VAO,配置好后解绑表示不再配置该VAO,当需要渲染时,我们只需重新绑定这个VAO即可。注意,VAO只需要在渲染前创建并配置好,通常不在渲染循环中创建。
// 1. first bind our VAO
glBindVertexArray(VAO_id);
// 2. bind VBO for vertex attribute configuration, VBO data has already been uploaded.
glBindBuffer(GL_ARRAY_BUFFER, VBO_id);
// 3. connect a EBO with VAO, EBO data has already been uploaded.
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO_id);
// 4. then set our vertex attributes pointers
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*) 0);
glEnableVertexAttribArray(0);
// 5. unbind and finish creation
glBindVertexArray(0);
开始渲染工作
假定渲染这样一个正方体,0为坐标原点,01的方向向量为x,03的方向向量为y,40的方向向量为z:
则顶点数据:
float vertices[] = {
0.0f, 0.0f, 0.0f, // 0
1.0f, 0.0f, 0.0f, // 1
1.0f, 1.0f, 0.0f, // 2
0.0f, 1.0f, 0.0f // 3
0.0f, 0.0f, -1.0f, // 4
1.0f, 0.0f, -1.0f, // 5
1.0f, 1.0f, -1.0f, // 6
0.0f, 1.0f, -1.0f // 7
};
OpenGL中渲染任何图形最终只会转换为3种:点,线,三角形。所以我们要渲染正方体的6个面,每个面都是一个矩形,则由两个三角形组成,一共需要渲染12个三角形,即 mode 为 GL_TRIANGLES。一个三角形需要3个顶点,在OpenGL中默认以逆时针方向为正面,OpenGL 2.0起可以通过 glFrontFace 来设置顺时针还是逆时针方向为正面,这里我们遵循默认行为,则EBO数据:
unsigned int indices[] = {
0, 1, 3, // front
3, 1, 2,
1, 5, 2, // right
2, 5, 6,
5, 4, 6, // back
6, 4, 7,
4, 0, 7, // left
7, 0, 3,
3, 2, 7, // top
7, 2, 6,
0, 4, 1, // bottom
1, 4, 5
};
接着我们再使用 glDrawElements 来进行绘制。因为我们在VAO中已经绑定了EBO,所以 indices 需要传入所使用的EBO数据的指针即可,即起始偏移量(如果没绑定EBO,则为EBO数组的指针)。type 则指定了EBO的数据类型。VBO只包含一个正方体的数据,所以 count 为 3 * 12 = 36。则渲染循环:
glUseProgram(shaderProgram);
glBindVertexArray(VAO_id);
glDrawElements(/* mode */ GL_TRIANGLES, /* count */ 36, /* type */ GL_UNSIGNED_INT, /* indices */ 0)
glBindVertexArray(0);
编写shader、投影矩阵、坐标系等较为复杂,这里暂不介绍。回到 Minecraft 中,如果每个面使用不同的纹理,我们还是需要24个顶点,即使有3个顶点的坐标值是相同的,但是其他属性不同,因此不同情况下要采取不同的策略,比如把所有顶点坐标存在一个VBO,其他数据存在另一个VBO等。
本文部分内容翻译自https://learnopengl.com/。