图形学初探——使用OpenGL写一个简单的Minectaft渲染Demo

简介

又又又又整新活了,最近闲的无聊学习了一下Games101,然后为了熟悉光栅化,使用 C++和 OpenGL 写了一个简单的 Minecraft 的渲染 Demo,最终的效果大概如下图所示:

这个 Demo 支持类和 Minecraft 原版相似的相机移动,动态区块生成,以及简单的着色(当然着色不是这里的重点,能看就行)。趁热打铁做个架构和技术细节的解析。

框架

由于 OpenGL 是通过看https://learnopengl-cn.github.io/入门的,因此这里使用的是同款框架,GLFW+GLAD,此处不再叙述。下面列出了一个简单的软件架构图:

1
2
3
4
5
6
7
8
9
10
11
12
13

【渲染线程】 【区块生成线程】


OpenGL渲染主循环开始
|
检测相机移动 区块地形生成
| |
请求当前帧要渲染的区块Mesh <----通信----> 区块Mesh构建
|
渲染区块
|
OpenGL渲染主循环结束

接下来的部分打算详细聊聊每个部分。

渲染线程

渲染线程即为 OpenGL 的主循环,总体来说也比较简单,就是首先处理鼠标和键盘事件,然后执行实际的渲染,如下所示。

1
2
3
4
5
6
7
//handle mouse & keyboard事件
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
this->shader->use();
shader->setMat4("projection", Config::getProjectionMatrix());
shader->setMat4("view", this->camera_->getViewMatrix());
this->level->draw(shader);

处理鼠标和键盘事件的主要目的是控制相机,在 CG 中相机由拍摄方向(镜头方向),相机坐标系的向上方向,以及相机位置三个参数控制。由于在 MC 中玩家没有类似 FPS 游戏侧头瞄准那种转头,因此相机坐标系的向上方向一定是世界坐标系的 y 轴,故此处仅键盘鼠标需要控制相机方向front以及相机位置p。对于前者可使用鼠标控制,直接定义欧拉角,然后通过直角坐标系向球坐标转换即可。而对于后者,直接使用 WASD 控制镜头当前方向,此处需要注意的是,在 MC 中玩家操控时候的前后左右实际上是相对方向(front.x,0,front.z)的,也就是说相机的左右偏转角会影响玩家方向,但是俯仰角不会。

在实际的渲染部分,首先通过全局着色器更新投影和试图矩阵,然后调用level->draw()函数。这一函数是一个异步请求,它在当前帧内获取当前相机位置周围一圈所有的区块,然后向区块生成器请求构建好的网格,如果请求的区块的网格不存在区块生成器只会将其加入生成队列而不会阻塞渲染线程。反之如果存在则渲染即可,如下所示:

1
2
3
4
5
6
7
8
9
void Level::draw(Shader *shader) {
auto activeChunkList = getActiveChunkList(this->playerPos, Config::load_radius);
for (auto &pos : activeChunkList) {
auto *chunk = this->chunk_cache_.getChunk(pos);
if (!chunk) continue;
chunk->trySendData();
chunk->draw(shader);
}
}

为了提升帧率,这里的渲染也有一些细节。首先,区块生成器提供的网格以子区块(16x16x16,一个高 256 的区块包含 16 个这样的子区块)为单位,以降低 VAO 绑定和解绑次数,每个子区块都会生成一个网格。另外,子区块中的网格是多个方块面(包括顶点数组和索引数组)构成的集合,每个方块面都有各自的纹理。最后,为了降低降低纹理重新绑定次数以提升性能。具有相同纹理的方块面的网格在索引数组中是连续的,这样可以极大减少纹理重新绑定的次数。在这一前提下,单个子区块的绘制逻辑如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void SubChunk::draw(Shader* shader) {
if (this->mesh_) {
auto model = glm::translate(
glm::mat4(1.0), glm::vec3(this->chunk_pos_.x * 16, this->sub_chunk_index_ * 16, this->chunk_pos_.z * 16));
shader->setMat4("model", model); //在这里根据区块位置设定模型试图变换
this->mesh_->draw(); //调用SubChunkMesh::draw()
}
}



void SubChunkMesh::draw() {
if (this->VAO == 0) return;
glBindVertexArray(this->VAO);
//texture可以理解为索引数组的索引,它将具有相同纹理的方块面聚集到一起,键是纹理ID,值是属于该纹理的顶点在索引数组中的偏移量和顶点个数
for (auto &kv : this->texture_mappings_) {
glBindTexture(GL_TEXTURE_2D, kv.first);
glDrawElements(GL_TRIANGLES, (GLuint)kv.second.first, GL_UNSIGNED_INT,
(void *)(kv.second.second * sizeof(GLuint)));
}
}

最后,chunk->trySendData();即为创建 VAO 并向 OpenGL 上下文发送数据的过程,因此这一过程只能放到主线程(即渲染线程)中。另外一提,以子区块而不是区块为单位进行渲染是因为性能和灵活性之间存在 trade-off,如果渲染粒度太大,一旦更新地形(如玩家破坏方块),这样整个区块的网格都要重新构建,因此最终选择了折中方案。

区块生成部分

区块生成部分主要为地形生成和网格构建两个部分。对于前者,由于不是重点这里就做的比较粗糙,这里直接用柏林噪声做了个简单的随机,以 y=64 为基准,生成偏移量,突出的部分即为山峰,凹下去低于 64 的部分全部填上水方块形成湖泊,最后把地形表面的泥土替换成草方块即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void PerlinTerrainGeneratror::fill(Chunk *chunk) {
for (int x = 0; x < 16; x++) {
for (int z = 0; z < 16; z++) {
auto xx = chunk->pos().x * 16 + x;
auto zz = chunk->pos().z * 16 + z;
auto height = (int)(this->perlin_->octave2D_11(xx * 0.03, zz * 0.03, 4) * 16) + 64;
chunk->setBlock(x, height, z, grass);
for (int i = 64; i >= height; i--) {
chunk->setBlock(x, i, z, water);
}
for (int i = height - 1; i >= 0; i--) {
chunk->setBlock(x, i, z, dirt);
}
}
}
}

而对于网格构建部分,上文提到,软件以子区块为单位进行构建,具体来说,算法遍历当前子区块内的所有非空气方块,检测周围方块是不是空气(如果越界也算是空气),如果是则把该面视作需要渲染的面,此时算法搜集该面的坐标、方向以及方块类型发送给网格构建器,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void SubChunk::createMesh() {
this->mesh_ = new SubChunkMesh();
for (int x = 0; x < 16; x++) {
for (int y = 0; y < 16; y++) {
for (int z = 0; z < 16; z++) {
auto b = getBlock(x, y, z);
auto p = BlockPos{x, y, z};
if (b == air) continue;
if (!hasBlock(x + 1, y, z)) mesh_->AddFace({px, b, p});
if (!hasBlock(x - 1, y, z)) mesh_->AddFace({nx, b, p});
if (!hasBlock(x, y + 1, z)) mesh_->AddFace({py, b, p});
if (!hasBlock(x, y - 1, z)) mesh_->AddFace({ny, b, p});
if (!hasBlock(x, y, z + 1)) mesh_->AddFace({pz, b, p});
if (!hasBlock(x, y, z - 1)) mesh_->AddFace({nz, b, p});
}
}
}
mesh_->buildData();
}

然后,网格构建器根据收到的所有面的信息,首先生成整个网格顶点的坐标、法线方向以及纹理 UV 等信息,由于这里的所有面都是和坐标轴垂直的,因此相关计算较为简单。然后,网格构建器重构索引数组,将具有相同纹理的面聚集到一起,并生成对应的纹理映射(即上面提到的texture_mappings_)。这一步的源码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
void SubChunkMesh::buildData() {
std::unordered_map<GLuint, std::vector<FacePointList>> temp_textures_ids_;
for (auto &block : this->blocks_) { // 遍历每一个面
for (auto block_face : block.second) {
auto face = static_cast<Face>(block_face.first);
auto face_info = block_face.second;
auto vs = createFaceVertices(face);
auto normal = getFaceNormal(face);
// 4个顶点
for (int i = 0; i < vs.size(); i++) {
auto U = 0.0f, V = 0.0f;
if (i == 1) V = 1.0f;
if (i == 2) U = 1.0f;
if (i == 3) {
U = 1.0f, V = 1.0f;
}
auto vi = vs[i];
auto dx = static_cast<GLfloat>(vi % 2);
auto dy = static_cast<GLfloat>(vi >= 4 ? 1 : 0);
auto dz = static_cast<GLfloat>((vi % 4) >= 2 ? 1 : 0);
VertexAttribute va{static_cast<GLfloat>(face_info.pos.x + dx),
static_cast<GLfloat>(face_info.pos.y + dy),
static_cast<GLfloat>(face_info.pos.z + dz),
1.0f,0.35f,0.21f,U,V,
(float)normal.x,
(float)normal.y,
(float)normal.z};
this->vertices_.push_back(va);
}
auto size = (GLuint)vertices_.size();
FacePointList list{size - 4u, size - 3u, size - 2u, size - 3u, size - 2u, size - 1u};
temp_textures_ids_[TexturePool::instance().getTextureID(block_face.second.type, block_face.second.face)]
.push_back(list);
}
}

for (auto &kv : temp_textures_ids_) {
this->texture_mappings_[kv.first] = {6 * kv.second.size(), this->indices_.size()};
for (auto face : kv.second) {
indices_.push_back(face.p1);
indices_.push_back(face.p2);
indices_.push_back(face.p3);
indices_.push_back(face.p4);
indices_.push_back(face.p5);
indices_.push_back(face.p6);
}
}
};

最后,有关异步的部分由于不少重点就不再过多赘述,这里使用了简单的任务队列以及线程池来完成这一工作。

小结

初学图形学各方面都不熟悉,因此写这个还是有点煎熬,在设计架构和编写相机移动上多花了点时间,不过好在起码熟悉了 OpenGL 编程的基本思路和方法,另外整个项目是开源的,代码在https://github.com/hhhxiao/CubeWorld,有兴趣的可以看看。