体素引擎不是一个周末能搞定的东西。如果你的目标只是「画几个方块」,那随便起一个 Qt 工程就行。但如果目标是「一个能持续迭代、能给别人看、能作为课程交付的引擎项目」,那在敲第一行渲染代码之前,有三件事值得花两个小时。

1. 跑起来 — 一键构建

很多人学图形学死在第一步:环境配不起来。这真的不是你的问题——OpenGL 本身不负责窗口创建,Qt 的 OpenGL 模块有自己的一套初始化流程,CMake 在跨平台时各种坑。

1.1 工程模板

一个最小的 Qt + OpenGL 工程只需要四个文件:

voxel-engine/
├── CMakeLists.txt
├── main.cpp
├── GLWidget.h
└── GLWidget.cpp

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
project(VoxelEngine VERSION 0.1.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)

find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets OpenGLWidgets)

add_executable(voxel-engine
    main.cpp
    GLWidget.cpp
)

target_link_libraries(voxel-engine PRIVATE
    Qt6::Core
    Qt6::Gui
    Qt6::Widgets
    Qt6::OpenGLWidgets
)

如果你还在用 Qt5,把 Qt6 换成 Qt5OpenGLWidgets 换成 OpenGL。其他都一样。

main.cpp

#include <QApplication>
#include "GLWidget.h"

int main(int argc, char *argv[]) {
    QApplication app(argc, argv);

    GLWidget widget;
    widget.resize(800, 600);
    widget.setWindowTitle("Voxel Engine — 第0章");
    widget.show();

    return app.exec();
}

GLWidget.h

#pragma once
#include <QOpenGLWidget>
#include <QOpenGLFunctions>

class GLWidget : public QOpenGLWidget, protected QOpenGLFunctions {
    Q_OBJECT
public:
    explicit GLWidget(QWidget *parent = nullptr);

protected:
    void initializeGL() override;
    void resizeGL(int w, int h) override;
    void paintGL() override;
};

GLWidget.cpp

#include "GLWidget.h"
#include <QDebug>

GLWidget::GLWidget(QWidget *parent)
    : QOpenGLWidget(parent)
{
}

void GLWidget::initializeGL() {
    initializeOpenGLFunctions();
    qDebug() << "OpenGL version:" << (const char*)glGetString(GL_VERSION);
    glClearColor(0.1f, 0.1f, 0.15f, 1.0f);
}

void GLWidget::resizeGL(int w, int h) {
    glViewport(0, 0, w, h);
}

void GLWidget::paintGL() {
    glClear(GL_COLOR_BUFFER_BIT);
}

1.2 构建

# 在 voxel-engine/ 目录下
cmake -B build -DCMAKE_PREFIX_PATH=/path/to/Qt/6.x.x/gcc_64
cmake --build build
./build/voxel-engine

如果你用 Qt Creator,直接打开 CMakeLists.txt 就行。Qt Creator 会自动检测 Qt 路径并生成构建配置。

1.3 常见卡住点

问题 原因 解决
find_package(Qt6...) 失败 没装 OpenGL 模块 apt install qt6-base-dev 或 Qt 在线安装器勾选 OpenGL
initializeOpenGLFunctions() 返回 false QOpenGLWidget 需要先 makeCurrent 或者显卡驱动太老 这个模板里 initializeGL 中调用是对的,不用手动 makeCurrent
Windows 上编译不过 缺少 OpenGL 库(Windows SDK 自带) 确保安装了 Windows SDK 的 OpenGL 支持
macOS 构建失败 Qt6 默认用的 Metal 后端 没有问题,macOS 上 Qt6 的 OpenGL 兼容层正常工作

1.4 验证是否成功

程序跑起来应该看到一个深蓝黑色的空白窗口。在终端里(或 Qt Creator 的输出面板)会打印类似:

OpenGL version: 4.6.0 NVIDIA 535.xxx

如果你的显卡只支持到 OpenGL 3.3,也没关系——体素引擎的核心功能不需要 4.x 特性。后续所有代码会保证在 3.3 核心模式上运行。


2. 架构先行 — 看一眼全景图

在写 ChunkMeshWorld 这些类之前,先把模块画出来。这不是「最终设计」,这只是一个起点——后面每章都可能调整。但它能让你在每一章都知道「我现在在改哪个模块、会影响哪些模块」。

2.1 模块全景

graph TD
    subgraph APP["应用层"]
        MAIN["main.cpp<br/>QApplication 启动"]
        EDITOR["VoxelEditor<br/>编辑器窗口"]
    end

    subgraph RENDER["渲染层"]
        GLW["GLWidget<br/>QOpenGLWidget<br/>管理 OpenGL 状态"]
        MESH["Mesh / Cube<br/>顶点缓冲 + VAO<br/>纹理图集管理"]
        SHADER["ShaderManager<br/>着色器编译/缓存"]
    end

    subgraph WORLD["世界层"]
        CHUNK["Chunk<br/>16x256x16 方块容器<br/>负责 Mesh 生成"]
        TERRAIN["TerrainGenerator<br/>程序化地形<br/>Perlin / Simplex Noise"]
        REGION["ChunkManager<br/>动态加载/卸载<br/>线程池调度"]
    end

    subgraph CORE["基础设施"]
        CAM["Camera<br/>FPS 控制器<br/>视锥体"]
        INPUT["InputManager<br/>键盘/鼠标绑定<br/>射线拾取"]
        STORAGE["WorldStorage<br/>持久化读写<br/>Diff 压缩"]
    end

    MAIN --> EDITOR
    EDITOR --> GLW
    GLW --> MESH
    GLW --> SHADER
    GLW --> CAM

    EDITOR --> CHUNK
    CHUNK --> TERRAIN
    CHUNK --> MESH
    REGION --> CHUNK
    REGION --> STORAGE

    CAM --> INPUT

模块职责一句话:

模块 职责 依赖
GLWidget 继承 QOpenGLWidget,管理 OpenGL 上下文和主渲染循环 Qt OpenGL
Mesh / Cube 管理 VBO/VAO/EBO,按需生成顶点数据 OpenGL 3.3
ShaderManager 从文件加载着色器源码,编译缓存,提供热重载 OpenGL
Chunk 16×256×16 的方块容器,核心职责是把方块数据转成可渲染的 Mesh Mesh
TerrainGenerator 根据世界种子和坐标生成方块类型(空气/石头/泥土等) Noise 库
ChunkManager 以玩家位置为中心,动态加载/卸载 Chunk,管理线程池 Chunk, QThreadPool
Camera FPS 风格相机,计算 view/projection 矩阵,维护视锥体 GLM 或 QMatrix4x4
InputManager 统一管理键盘/鼠标输入,处理射线拾取(ray casting) Camera, ChunkManager
WorldStorage 二进制格式读写,Diff 存储(只存改过的 Chunk) Chunk, zlib

2.2 核心接口预览

在开始写具体实现之前,定几个关键接口的形态。后续各章的代码都围绕这些接口展开。

// IRenderable.h — 所有可渲染对象的基类
class IRenderable {
public:
    virtual ~IRenderable() = default;
    virtual void render(QOpenGLFunctions *gl) = 0;
    virtual bool isVisible() const { return true; }
};

// IChunkProvider.h — Chunk 数据源接口
class IChunkProvider {
public:
    virtual ~IChunkProvider() = default;
    virtual BlockType getBlock(int x, int y, int z) const = 0;
};

// IMeshBuilder.h — 从方块数据生成顶点
class IMeshBuilder {
public:
    virtual ~IMeshBuilder() = default;
    virtual void build(const IChunkProvider &provider,
                       std::vector<Vertex> &outVertices,
                       std::vector<GLuint> &outIndices) = 0;
};

这几个接口定义了引擎的三个核心能力:「画出来」、「知道画什么」、「怎么从数据变成顶点」。后面你会发现很多优化(实例化、Greedy Meshing、AO)都是在这几个接口的实现层做文章,而不需要改调用方代码——这就是面向接口编程的价值。


3. 建立约定 — 一套活得下去的规范

规范和架构图一样,不是一次定死的。但它能防止你的项目变成「每个文件风格不同、变量名猜不透」的代码废墟。

3.1 目录结构约定

voxel-engine/
├── CMakeLists.txt              # 顶层构建
├── README.md
├── src/
│   ├── main.cpp
│   ├── app/                    # 应用层
│   │   └── VoxelEditor.h/cpp
│   ├── render/                 # 渲染层
│   │   ├── GLWidget.h/cpp
│   │   ├── Mesh.h/cpp
│   │   ├── Cube.h/cpp
│   │   └── ShaderManager.h/cpp
│   ├── world/                  # 世界层
│   │   ├── Chunk.h/cpp
│   │   ├── ChunkManager.h/cpp
│   │   └── TerrainGenerator.h/cpp
│   └── core/                   # 基础设施
│       ├── Camera.h/cpp
│       ├── InputManager.h/cpp
│       └── WorldStorage.h/cpp
├── shaders/                    # GLSL 着色器
│   ├── chunk.vert
│   └── chunk.frag
├── assets/                     # 纹理等资源
│   └── atlas.png
└── tests/                      # 单元测试
    └── test_Chunk.cpp

原则:一个 .h + 一个 .cpp 对应一个类。不做 all.h 那种一百个头文件糅在一起的写法。

3.2 命名约定

类别 规则 例子
类名 PascalCase ChunkManager, TerrainGenerator
方法名 camelCase generateMesh(), isInFrustum()
成员变量 m_ 前缀 + camelCase m_chunkSize, m_isDirty
常量 k 前缀 + PascalCase kChunkWidth = 16, kMaxChunksLoaded = 256
命名空间 ve:: (voxel engine 缩写) ve::BlockType, ve::Chunk

3.3 代码风格

几条足以保证一致性、不会引发宗教战争:

// 1. 永远用 #pragma once,不写 include guard
#pragma once

// 2. 命名空间不缩进
namespace ve {

class Chunk {
public:
    // 3. public → protected → private 顺序
    Chunk(int cx, int cz);

    // 4. 简单的 getter 直接在头文件里写实现
    bool isDirty() const { return m_isDirty; }

private:
    int m_chunkX, m_chunkZ;
    bool m_isDirty = true;
};

} // namespace ve

3.4 Git 提交约定

feat: 添加 Chunk 动态加载卸载
fix: 修复视锥体剔除 AABB 计算错误
refactor: Chunk Mesh 构建改用 Greedy Meshing
docs: 第 3 章 Chunk 系统文档

不做: 一次提交改 20 个文件、commit message 写 “update”、“fix bug”。


4. 这一章的「架构师复盘」

如果让我现在重新开始这个项目,我会做三件不同的事:

  1. 一开始就写 CMake 构建脚本而不是手动敲 g++。 早年我都是用 Qt Creator 的默认构建、手动加 lib,后来项目膨胀到几十个文件才补 CMake,那两天改构建的痛苦至今记得。这也是为什么我把 CMake 放在第0章最前面。

  2. 接口先于实现。 我以前的做法是「先写一个能跑的 Chunk,再慢慢抽象接口」。结果就是接口被具体实现拖累,改起来很痛苦。现在我会先把 IRenderableIChunkProvider 这些定下来,哪怕一开始只有一种实现,结构也是对的。

  3. 目录从一开始就分模块。 最开始我把所有文件平铺在 src/ 下,等文件超过 15 个时,找一个类要翻半天。后来才拆成 render/world/core/。现在直接按模块建目录,省得后面搬家。


下一步

第 1 章会基于这个地基,写第一个真正意义上的渲染代码——一个带纹理的草方块。我们会在这里引入 Mesh 类、面剔除、以及「为什么不是六个面都画」的第一次性能思考。


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