第 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);     // 逆时针 = 正面

三条线干了什么:

  1. glEnable(GL_CULL_FACE) — 开启面剔除
  2. glCullFace(GL_BACK) — 背面不画。一个面是正面还是背面,取决于顶点顺序是逆时针还是顺时针
  3. 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/VAO
  • Cube = 渲染对象层,实现 IRenderable,持有一个静态共享的 Mesh
  • 纹理图集 + 面剔除在这个阶段就位,后面 Chunk 系统直接吃现成的

下一章是第一阶段的核心——把 16×256×16 个方块装进一个 Chunk,再让 Chunk 系统跑起来。


架构师复盘

做 Cube 时我掉过两个坑:

  1. 每个 Cube 独立创建 VAO。 早期代码是 Cube 构造 = new VBO + new VAO,画 1000 个方块 = 1000 个 VAO,显存里塞满了重复数据。改成一个静态共享 Mesh 之后 1000 个方块还是 1 个 VAO,绘制时只改 u_model uniform。

  2. 纹理图集不是一开始就做的。 我最初每个方块类型一个单独的纹理文件,画草方块时 bindTexture(grass),画石头时 bindTexture(stone),纹理切换开销吃掉了大量帧时间。图集的好处是编译期就设计好、不给自己留后路。


系列文章:体素引擎从零构建 | 作者:Logic