第 1 章画了一个彩色三角形。这一章把这个三角形变成立方体,把颜色变成纹理,再把画不着的面全删掉。
1. 一个方块的顶点数据
立方体有 6 个面,每个面 2 个三角形。最简单的做法是 36 个顶点(每个面独立顶点,方便设不同法线和纹理坐标):
struct Vertex {
float x, y, z; // 位置
float nx, ny, nz; // 法线
float u, v; // 纹理坐标
};
// 前面 (Z+) — 2 个三角形
Vertex frontFace[] = {
// 位置 法线 UV
{0,0,1, 0,0,1, 0.0f,0.0f}, {-0.5,-0.5,0.5},
{1,0,1, 0,0,1, 1.0f,0.0f}, { 0.5,-0.5,0.5},
{1,1,1, 0,0,1, 1.0f,1.0f}, { 0.5, 0.5,0.5},
{0,0,1, 0,0,1, 0.0f,0.0f},
{1,1,1, 0,0,1, 1.0f,1.0f},
{0,1,1, 0,0,1, 0.0f,1.0f}, {1,0,1, 0,0,1, 0.0f,1.0f},
};
等一下,这个数据有问题。 上面的 Vertex 结构是 8 个 float,但数组里的数字怎么又像位置又像 UV?这是我在故意展示一个坑——手动写顶点数据非常容易搞错 stride 和 offset。所以我们需要一个 Mesh 类来管理这些细节。
2. Mesh 类 — VBO/VAO 的 RAII 封装
// Mesh.h
#pragma once
#include <QOpenGLBuffer>
#include <QOpenGLVertexArrayObject>
#include <QOpenGLFunctions>
#include <vector>
namespace ve {
struct Vertex {
QVector3D position;
QVector3D normal;
QVector2D texCoord;
};
class Mesh {
public:
Mesh();
~Mesh();
// 禁止拷贝(VAO/VBO 不能浅拷贝)
Mesh(const Mesh&) = delete;
Mesh& operator=(const Mesh&) = delete;
// 允许移动
Mesh(Mesh&& other) noexcept;
Mesh& operator=(Mesh&& other) noexcept;
/// 上传顶点和索引数据,返回是否成功
bool upload(const std::vector<Vertex> &vertices,
const std::vector<GLuint> &indices);
/// 绑定 VAO 并绘制
void draw();
/// 顶点/索引数量
GLsizei vertexCount() const { return m_vertexCount; }
GLsizei indexCount() const { return m_indexCount; }
bool isValid() const { return m_vao.isCreated(); }
private:
QOpenGLVertexArrayObject m_vao;
QOpenGLBuffer m_vbo{QOpenGLBuffer::VertexBuffer};
QOpenGLBuffer m_ebo{QOpenGLBuffer::IndexBuffer};
GLsizei m_vertexCount = 0;
GLsizei m_indexCount = 0;
};
} // namespace ve
// Mesh.cpp — 核心实现
Mesh::Mesh() {
m_vao.create();
}
Mesh::~Mesh() {
m_vao.destroy();
m_vbo.destroy();
m_ebo.destroy();
}
Mesh::Mesh(Mesh&& other) noexcept {
*this = std::move(other);
}
Mesh& Mesh::operator=(Mesh&& other) noexcept {
std::swap(m_vao, other.m_vao);
std::swap(m_vbo, other.m_vbo);
std::swap(m_ebo, other.m_ebo);
m_vertexCount = other.m_vertexCount;
m_indexCount = other.m_indexCount;
return *this;
}
bool Mesh::upload(const std::vector<Vertex> &vertices,
const std::vector<GLuint> &indices)
{
if (vertices.empty()) return false;
QOpenGLFunctions *gl = QOpenGLContext::currentContext()->functions();
m_vao.bind();
// VBO
m_vbo.create();
m_vbo.bind();
m_vbo.allocate(vertices.data(),
static_cast<int>(vertices.size() * sizeof(Vertex)));
// EBO(元素缓冲 = 索引)
if (!indices.empty()) {
m_ebo.create();
m_ebo.bind();
m_ebo.allocate(indices.data(),
static_cast<int>(indices.size() * sizeof(GLuint)));
}
// 顶点属性:position (location = 0)
gl->glEnableVertexAttribArray(0);
gl->glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,
sizeof(Vertex),
reinterpret_cast<void*>(offsetof(Vertex, position)));
// 顶点属性:normal (location = 1)
gl->glEnableVertexAttribArray(1);
gl->glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE,
sizeof(Vertex),
reinterpret_cast<void*>(offsetof(Vertex, normal)));
// 顶点属性:texCoord (location = 2)
gl->glEnableVertexAttribArray(2);
gl->glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE,
sizeof(Vertex),
reinterpret_cast<void*>(offsetof(Vertex, texCoord)));
m_vao.release();
m_vertexCount = static_cast<GLsizei>(vertices.size());
m_indexCount = static_cast<GLsizei>(indices.size());
return true;
}
void Mesh::draw() {
if (!m_vao.isCreated()) return;
m_vao.bind();
if (m_indexCount > 0) {
glDrawElements(GL_TRIANGLES, m_indexCount, GL_UNSIGNED_INT, nullptr);
} else {
glDrawArrays(GL_TRIANGLES, 0, m_vertexCount);
}
m_vao.release();
}
关键细节:
- 移动构造里用
std::swap交换 VAO/VBO/EBO,因为 Qt 的 OpenGL 对象默认会被析构调用destroy()。swap 之后旧对象的资源转移到了新对象,旧对象析构时 destroy 的是一个空对象。 upload()中直接枚举 vertex attribute 的 location(0/1/2),这在只有一种顶点格式的引擎里足够。多格式时可以考虑用QOpenGLShaderProgram::attributeLocation()动态查询。
3. 面剔除 — 只画看得见的
立方体有 6 个面,但在体素世界里,两块相邻的方块之间那面是永远看不到的。早做面剔除能省掉大约 50% 的三角形。
// 在 initializeGL() 中
glEnable(GL_CULL_FACE); // 开启背面剔除
glCullFace(GL_BACK); // 剔除背面
glFrontFace(GL_CCW); // 逆时针 = 正面
三条线干了什么:
glEnable(GL_CULL_FACE)— 开启面剔除glCullFace(GL_BACK)— 背面不画。一个面是正面还是背面,取决于顶点顺序是逆时针还是顺时针glFrontFace(GL_CCW)— 逆时针 = 正面(默认值,显式写出来更清晰)
代价几乎为零。 GPU 的三角形装配阶段本来就要判断面朝向,不开面剔除只是不丢弃背面三角形而已。开了反而省了后面的光栅化和片段着色器计算。
4. 纹理图集 — 一张图搞定所有方块
Minecraft 里所有方块的纹理放在一张大图里,这叫纹理图集(Texture Atlas)。好处:
- 只绑一次纹理:画 10000 个不同方块不需要
glBindTexture换图 - 一个 Draw Call:配合实例化可以把所有方块合并成一次绘制
图集示意:
┌──────┬──────┬──────┬──────┐
│ 草顶 │ 草侧 │ 土 │ 石 │
├──────┼──────┼──────┼──────┤
│ 木板 │ 沙 │ 玻璃 │ 水 │
├──────┼──────┼──────┼──────┤
│ ... │ ... │ ... │ ... │
└──────┴──────┴──────┴──────┘
16x16 像素的格子,每个格子是 16x16 纹理
UV 计算:
struct AtlasEntry {
int col, row; // 图集中的列/行位置
};
// 方块顶面:使用草顶纹理(列 0,行 0)
// 方块侧面:使用草侧纹理(列 1,行 0)
// 方块底面:使用土纹理(列 2,行 0)
AtlasEntry grassTop = {0, 0};
AtlasEntry grassSide = {1, 0};
AtlasEntry dirt = {2, 0};
// 每个面 4 个顶点的 UV 坐标:
// 全图 256x256,每格 16x16 → 步长 = 16/256 = 0.0625
const float tileSize = 1.0f / 16.0f;
void computeUV(AtlasEntry e, float uvs[8]) {
float u0 = e.col * tileSize;
float v0 = e.row * tileSize;
float u1 = u0 + tileSize;
float v1 = v0 + tileSize;
// 逆时针:左下 → 右下 → 右上 → 左下 → 右上 → 左上
uvs[0] = u0; uvs[1] = v0; // 左下
uvs[2] = u1; uvs[3] = v0; // 右下
uvs[4] = u1; uvs[5] = v1; // 右上
uvs[6] = u0; uvs[7] = v0; // 左下
uvs[8] = u1; uvs[9] = v1; // 右上
uvs[10]= u0; uvs[11]= v1; // 左上
}
注意 UV 坐标方向: OpenGL 的纹理坐标系原点在左下角。如果图集从左上角开始排,需要 v = 1.0f - (row + 1) * tileSize 做翻转。也可以直接把图集存成左下角起算的格式,省掉翻转步骤。
5. 顶点着色器支持纹理
// shaders/chunk.vert
#version 330 core
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
layout(location = 2) in vec2 a_texCoord;
uniform mat4 u_view;
uniform mat4 u_proj;
uniform mat4 u_model; // 方块在世界空间的位置
out vec3 v_normal;
out vec2 v_texCoord;
void main() {
gl_Position = u_proj * u_view * u_model * vec4(a_position, 1.0);
v_normal = mat3(u_model) * a_normal;
v_texCoord = a_texCoord;
}
// shaders/chunk.frag
#version 330 core
in vec3 v_normal;
in vec2 v_texCoord;
uniform sampler2D u_atlas; // 纹理图集
out vec4 fragColor;
void main() {
vec4 texColor = texture(u_atlas, v_texCoord);
if (texColor.a < 0.5) discard; // 透明像素不画(玻璃、水等)
// 简单方向光
vec3 lightDir = normalize(vec3(0.5, 1.0, 0.3));
float diff = max(dot(normalize(v_normal), lightDir), 0.0);
float ambient = 0.4;
float lighting = ambient + diff * 0.6;
fragColor = vec4(texColor.rgb * lighting, texColor.a);
}
为什么要 discard 透明像素: 体素引擎里玻璃、水、树叶需要部分透明。简单的做法是用 discard 丢掉 alpha < 0.5 的片段。这是最基础的透明度处理,第 7 章会升级为真正的 blending。
6. 整合:Cube 类实现 IRenderable
// Cube.h
#pragma once
#include "IRenderable.h"
#include "Mesh.h"
namespace ve {
class Cube : public IRenderable {
public:
Cube();
void setPosition(int x, int y, int z) {
m_worldPos = QVector3D(x, y, z);
}
void render(QOpenGLFunctions *gl,
const QMatrix4x4 &view,
const QMatrix4x4 &proj) override;
void getBoundingBox(QVector3D &min, QVector3D &max) const override {
auto p = m_worldPos;
min = p - QVector3D(0.5f, 0.5f, 0.5f);
max = p + QVector3D(0.5f, 0.5f, 0.5f);
}
private:
QVector3D m_worldPos{0, 0, 0};
// 静态共享的 Mesh——所有 Cube 共享同一份顶点数据
static Mesh s_cubeMesh;
static bool s_meshReady;
static void initMesh(); // 第一次使用时创建
};
} // namespace ve
// Cube.cpp
Mesh Cube::s_cubeMesh;
bool Cube::s_meshReady = false;
void Cube::initMesh() {
// 生成 6 个面的顶点 + 索引
// ... 使用上面写的 computeUV
s_cubeMesh.upload(vertices, indices);
s_meshReady = true;
}
void Cube::render(QOpenGLFunctions *gl,
const QMatrix4x4 &view,
const QMatrix4x4 &proj)
{
if (!s_meshReady) initMesh();
// 每个 Cube 的 model 矩阵只有平移(方块不旋转不缩放)
QMatrix4x4 model;
model.translate(m_worldPos);
// 传 uniform
auto *prog = ShaderManager::instance().get("chunk");
prog->bind();
prog->setUniformValue("u_view", view);
prog->setUniformValue("u_proj", proj);
prog->setUniformValue("u_model", model);
s_cubeMesh.draw();
}
静态 Mesh 是关键。 所有 Cube 共享同一个 VAO,区别只有 u_model 矩阵(位置)。画 1000 个方块不是创建 1000 个 VAO,而是同一个 VAO 画 1000 次。
7. 章节收尾
这一章产出了 Mesh 类和 Cube 类。它们的定位:
graph TD
MESH["Mesh<br/>VBO + VAO + EBO<br/>RAII 自动回收"]
CUBE["Cube : IRenderable<br/>静态共享 Mesh<br/>每个实例只有位置"]
MESH --> CUBE
CUBE --> GLW["GLWidget::paintGL()<br/>遍历渲染列表"]
Mesh= 纯数据层,只管 VBO/VAOCube= 渲染对象层,实现IRenderable,持有一个静态共享的Mesh- 纹理图集 + 面剔除在这个阶段就位,后面 Chunk 系统直接吃现成的
下一章是第一阶段的核心——把 16×256×16 个方块装进一个 Chunk,再让 Chunk 系统跑起来。
架构师复盘
做 Cube 时我掉过两个坑:
-
每个 Cube 独立创建 VAO。 早期代码是
Cube 构造 = new VBO + new VAO,画 1000 个方块 = 1000 个 VAO,显存里塞满了重复数据。改成一个静态共享 Mesh 之后 1000 个方块还是 1 个 VAO,绘制时只改u_modeluniform。 -
纹理图集不是一开始就做的。 我最初每个方块类型一个单独的纹理文件,画草方块时
bindTexture(grass),画石头时bindTexture(stone),纹理切换开销吃掉了大量帧时间。图集的好处是编译期就设计好、不给自己留后路。
系列文章:体素引擎从零构建 | 作者:Logic