OpenGL 编程/Glescraft 1
当今 CPU 和 GPU 的处理能力使人们能够渲染一个完全由小立方体组成的 3D 世界。一些知名的游戏,例如 Minecraft(它反过来受到了 Infiniminer 的启发)和 Voxatron 都是这样做的。除了这些游戏所呈现的独特外观之外,将自己限制在网格上的立方体允许进行许多简化。在本教程系列中,我们将了解如何管理大量立方体以及如何高效地绘制它们。
要做的第一件重要的事情是将我们要渲染的体素世界细分为易于管理的区块。这样,我们可以决定哪些区块在屏幕上可见,并跳过渲染不可见的区块。我们还可以通过这种方式管理内存资源;例如,我们不需要为 GPU 内存中的不可见区块保留顶点缓冲区对象。但区块应该足够大,以至于我们不会花费大部分时间来跟踪它们。让我们使区块的大小可配置,但从 16x16x16(4096)个块的相对较小的区块开始。我们将使用一个字节来存储一个块的类型。表示此类区块的结构将如下所示
#define CX 16
#define CY 16
#define CZ 16
struct chunk {
uint8_t blk[CX][CY][CZ];
GLuint vbo;
int elements;
bool changed;
chunk() {
memset(blk, 0, sizeof(blk));
elements = 0;
changed = true;
glGenBuffers(1, &vbo);
}
~chunk() {
glDeleteBuffers(1, &vbo);
}
uint8_t get(int x, int y, int z) {
return blk[x][y][z];
}
void set(int x, int y, int z, uint8_t type) {
blk[x][y][z] = type;
changed = true;
}
void update() {
changed = false;
// Fill in the VBO here
}
void render() {
if(changed)
update();
// Render the VBO here
}
};
blk 数组将保存块类型。get() 和 set() 函数相当简单,但在后面会派上用场。当调用 set() 函数时,changed 标志将被设置为 true。当渲染区块且内容已更改时,这将触发对 update() 函数的调用,该函数将更新顶点缓冲区对象 (VBO)。将体素数据转换为填充 VBO 的多边形网格的过程称为“网格化”;高级算法被称为 等值面提取。有关更多详细信息,请参阅 0fps 平滑体素地形。
在其他教程中,我们使用 GLfloat 坐标来表示顶点、纹理坐标、颜色等等。OpenGL 允许我们也使用其他类型。在我们的体素世界中,所有立方体都具有相同的尺寸。因此,我们可以使用整数来表示坐标。即使世界可以是无限的,在一个区块内,我们只需要非常小的整数来表示所有可能的坐标。如果我们使用 GLbytes,我们可以有从 -128 到 +127 的坐标,这对我们 16x16x16 的区块来说绰绰有余!
我们可以在着色器中使用最多四个分量的向量,所以除了我们的 x、y 和 z 坐标之外,我们还可以添加另一个字节的信息。我们将在这个“坐标”中存储块的类型。在后面的教程中,我们将使用它来推导出纹理坐标,但现在我们可以使用它为每个类型赋予自己的颜色。
每个顶点只有 4 个字节,使用 IBO 就不再有太大好处。当我们为立方体的每个面赋予不同的颜色或纹理时,使用 IBO 实际上会增加内存使用量。
练习
- 为什么我们不能使用 GL_BYTE 作为 IBO 索引来渲染我们的区块?
- 计算渲染一个立方体所需的内存,包括使用和不使用 IBO,以及使用和不使用每个面的单独颜色。
我们将使用一个四维字节向量。不幸的是,没有为其提供的预定义类型,但 GLM 允许我们快速创建一个与其他向量类型工作方式相同的类型
typedef glm::tvec4<GLbyte> byte4;
现在我们可以创建一个足够大的数组来容纳所有顶点,并将其填充。您应该已经知道如何制作一个立方体。如果我们将顶点按正确的顺序放置,我们可以使用glEnable(GL_CULL_FACE)来避免绘制立方体的内部面。
void update() {
changed = false;
byte4 vertex[CX * CY * CZ * 6 * 6];
int i = 0;
for(int x = 0; x < CX; x++) {
for(int y = 0; y < CY; y++) {
for(int z = 0; z < CZ; z++) {
uint8_t type = blk[x][y][z];
// Empty block?
if(!type)
continue;
// View from negative x
vertex[i++] = byte4(x, y, z, type);
vertex[i++] = byte4(x, y, z + 1, type);
vertex[i++] = byte4(x, y + 1, z, type);
vertex[i++] = byte4(x, y + 1, z, type);
vertex[i++] = byte4(x, y, z + 1, type);
vertex[i++] = byte4(x, y + 1, z + 1, type);
// View from positive x
vertex[i++] = byte4(x + 1, y, z, type);
vertex[i++] = byte4(x + 1, y + 1, z, type);
vertex[i++] = byte4(x + 1, y, z + 1, type);
vertex[i++] = byte4(x + 1, y + 1, z, type);
vertex[i++] = byte4(x + 1, y + 1, z + 1, type);
vertex[i++] = byte4(x + 1, y , z + 1, type);
// Repeat for +y, -y, +z, and -z directions
...
}
}
}
elements = i;
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, elements * sizeof *vertex, vertex, GL_STATIC_DRAW);
}
练习
- 编写代码来创建 y 和 z 方向的面上的顶点。
- 为六个面中的每一个编写代码非常繁琐。你能想到一个更好的方法吗?
在我们能够绘制任何东西之前,这里是我们绘制体素所需的着色器。首先是顶点着色器
#version 120
attribute vec4 coord;
uniform mat4 mvp;
varying vec4 texcoord;
void main(void) {
texcoord = coord;
gl_Position = mvp * vec4(coord.xyz, 1);
}
顶点通过属性进入顶点着色器coord。您应该创建一个模型-视图-投影矩阵,它作为统一变量传递mvp。顶点着色器将通过变化量将输入坐标未经修改地传递给片段着色器texcoord。一个基本的片段着色器如下所示
#version 120
varying vec4 texcoord;
void main(void) {
gl_FragColor = vec4(texcoord.w / 128.0, texcoord.w / 256.0, texcoord.w / 512.0, 1.0);
}
请记住我们使用的是 GL_BYTEs,因此“w”坐标在 0..255 范围内。OpenGL 不会神奇地将其映射到 0..1 范围内,因此我们必须将其除以获得片段颜色可用的值。
练习
- 这些着色器是否只适用于我们的byte4顶点?
渲染整个区块现在非常容易。我们只需将图形卡指向我们的 VBO,并让它绘制所有三角形
void render() {
if(changed)
update();
// If this chunk is empty, we don't need to draw anything.
if(!elements)
return;
glEnable(GL_CULL_FACE);
glEnable(GL_DEPTH_TEST);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glVertexAttribPointer(attribute_coord, 4, GL_BYTE, GL_FALSE, 0, 0);
glDrawArrays(GL_TRIANGLES, 0, elements);
}
练习
- 创建一个区块,set()块到随机值,定位一个相机,并将其渲染。
- 将相机放在区块内部。尝试打开和关闭 GL_CULL_FACE。
现在我们已经知道如何绘制一个区块,我们希望绘制很多区块。有许多方法可以管理区块的集合;您可以只使用区块的三维数组,或使用 八叉树 结构,或者您可以拥有一个数据库来保存您拥有的任何区块。例如,Minecraft 曾经将区块存储在硬盘上,区块的坐标被编码在文件名中,基本上使用文件系统作为数据库。
让我们创建一个“超级区块”,它基本上是一个指向普通区块的三维数组指针。非常天真地,它看起来像这样
#define SCX 16
#define SCY 16
#define SCZ 16
struct superchunk {
chunk *c[SCX][SCY][SCZ];
superchunk() {
memset(c, 0, sizeof c);
}
~superchunk() {
for(int x = 0; x < SCX; x++)
for(int y = 0; y < SCX; y++)
for(int z = 0; z < SCX; z++)
delete c[x][y][z];
}
uint8_t get(int x, int y, int z) {
int cx = x / CX;
int cy = y / CY;
int cz = z / CZ;
x %= CX;
y %= CY;
z %= CZ;
if(!c[cx][cy][cz])
return 0;
else
return c[cx][cy][cz]->get(x, y, z);
}
void set(int x, int y, int z, uint8_t type) {
int cx = x / CX;
int cy = y / CY;
int cz = z / CZ;
x %= CX;
y %= CY;
z %= CZ;
if(!c[cx][cy][cz])
c[cx][cy][cz] = new chunk();
c[cx][cy][cz]->set(x, y, z, type);
}
void render() {
for(int x = 0; x < SCX; x++)
for(int y = 0; y < SCY; y++)
for(int z = 0; z < SCZ; z++)
if(c[x][y][z])
c[x][y][z]->render();
}
};
基本上,超级区块也实现了get(), set()和render()普通区块具有的函数,并将这些函数委托给相应的区块(s)。上面的示例中的render()函数缺少一个重要功能:它应该在渲染每个区块之前更改模型矩阵,以便它们被翻译到正确的位置,否则它们都会被绘制在彼此的顶部
if(c[x][y][z]) {
glm::mat4 model = glm::translate(glm::mat4(1), glm::vec3(x * CX, y * CY, z * CZ));
// calculate the full MVP matrix here and pass it to the vertex shader
c[x][y][z]->render();
}
练习
- 创建一个超级区块,set()块到随机值,定位一个相机,并将其渲染。
- 通过使用 柏林噪声 或 单纯噪声 为每个 x、z 坐标计算高度来创建一个景观(例如,使用 GLM 的glm::simplex()函数)。
- 找出 地球 的总表面积是多少。如果我们的体素是 1 立方米,那么你需要多少个体素才能覆盖地球?仅使用每个体素一个字节,这是否适合您的计算机硬盘驱动器?