体素引擎不是一个周末能搞定的东西。如果你的目标只是「画几个方块」,那随便起一个 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 换成 Qt5,OpenGLWidgets 换成 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. 架构先行 — 看一眼全景图
在写 Chunk、Mesh、World 这些类之前,先把模块画出来。这不是「最终设计」,这只是一个起点——后面每章都可能调整。但它能让你在每一章都知道「我现在在改哪个模块、会影响哪些模块」。
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. 这一章的「架构师复盘」
如果让我现在重新开始这个项目,我会做三件不同的事:
-
一开始就写 CMake 构建脚本而不是手动敲 g++。 早年我都是用 Qt Creator 的默认构建、手动加 lib,后来项目膨胀到几十个文件才补 CMake,那两天改构建的痛苦至今记得。这也是为什么我把 CMake 放在第0章最前面。
-
接口先于实现。 我以前的做法是「先写一个能跑的 Chunk,再慢慢抽象接口」。结果就是接口被具体实现拖累,改起来很痛苦。现在我会先把
IRenderable、IChunkProvider这些定下来,哪怕一开始只有一种实现,结构也是对的。 -
目录从一开始就分模块。 最开始我把所有文件平铺在
src/下,等文件超过 15 个时,找一个类要翻半天。后来才拆成render/、world/、core/。现在直接按模块建目录,省得后面搬家。
下一步
第 1 章会基于这个地基,写第一个真正意义上的渲染代码——一个带纹理的草方块。我们会在这里引入 Mesh 类、面剔除、以及「为什么不是六个面都画」的第一次性能思考。
系列文章:体素引擎从零构建 | 作者:Logic