第0章把工程架子搭好了,能跑起来一个黑窗口。这一章在它上面加肌肉——主循环怎么驱动、渲染对象怎么管理、三角形怎么画。读完你会有一个「能往里加方块」的渲染框架。
1. 主循环选型
渲染引擎的心脏是主循环。Qt 给了我们两个选项。
1.1 方案 A:QTimer 驱动
class GLWidget : public QOpenGLWidget {
QTimer *timer_;
QElapsedTimer fpsClock_;
int frameCount_ = 0;
public:
GLWidget() {
timer_ = new QTimer(this);
connect(timer_, &QTimer::timeout, this, [this] {
updateLogic(16.0f); // 逻辑更新
update(); // 触发 paintGL
});
timer_->start(16); // ~60 FPS
fpsClock_.start();
}
void updateLogic(float dt) {
// 物理、动画等
}
void paintGL() override {
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
renderScene();
frameCount_++;
if (fpsClock_.elapsed() >= 1000) {
qDebug() << "FPS:" << frameCount_;
frameCount_ = 0;
fpsClock_.restart();
}
}
};
QTimer 的好处是你能精确控制更新频率,适合需要固定时间步长的物理模拟。但问题是:QTimer 不受 vsync 约束,在 144Hz 显示器上开 16ms timer 会产生撕裂或者白白多画帧。
1.2 方案 B:requestUpdate()
class GLWidget : public QOpenGLWidget {
QElapsedTimer fpsClock_;
int frameCount_ = 0;
public:
void paintGL() override {
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
renderScene();
frameCount_++;
if (fpsClock_.elapsed() >= 1000) {
qDebug() << "FPS:" << frameCount_;
frameCount_ = 0;
fpsClock_.restart();
}
requestUpdate(); // 请求下一帧
}
};
requestUpdate() 在 paintGL 结束时调用,告诉 Qt「画完了,下一个空闲周期再叫我」。Qt 会合并连续的更新请求、在窗口不可见时自动停止、并和显示器的刷新率对齐。
1.3 为什么选方案 B
体素引擎和实时射击游戏不一样——帧率不需要拉到 300 FPS 才叫好。60 FPS 稳定输出就是满分。方案 B 的优势:
- vsync 对齐 — 不多画帧,不撕裂
- 自动休眠 — 切到其他窗口时 CPU 占用降到零
- 代码简单 — 不需要管理 QTimer 的生命周期
- Qt6 推荐 — 官方文档明确建议用
requestUpdate()替代 QTimer 驱动
唯一需要注意的是:requestUpdate() 不会在后台标签页触发 paintGL。如果你需要离屏渲染(比如生成截图),用 QOpenGLWidget::grabFramebuffer()。
2. IRenderable — 渲染接口抽象
在第0章的架构图里我们提了一个 IRenderable 接口。这一节把它落成代码。
2.1 为什么现在就要加接口
很多人习惯「先写个能跑的具体类,以后再说抽象」。但在渲染系统里,这个顺序是反的——等你写了一堆 drawCube()、drawChunk() 的具体函数,再想抽接口会发现每个函数的参数表都不一样。
从第一天就定接口,最大的好处不是「多态」,而是强迫你想清楚每个渲染对象到底暴露什么。
2.2 接口定义
// IRenderable.h
#pragma once
#include <QOpenGLFunctions>
#include <QMatrix4x4>
namespace ve {
class IRenderable {
public:
virtual ~IRenderable() = default;
/// 渲染自身。调用方已设置好 view/projection 矩阵。
/// @param gl OpenGL 函数指针
/// @param view 当前相机的 view 矩阵
/// @param proj 当前相机的 projection 矩阵
virtual void render(QOpenGLFunctions *gl,
const QMatrix4x4 &view,
const QMatrix4x4 &proj) = 0;
/// 是否在当前帧可见(视锥体外的对象可跳过渲染)
virtual bool isVisible() const { return true; }
/// 渲染优先级(数值越小越先画,用于透明排序等)
virtual int renderOrder() const { return 0; }
/// 获取世界空间包围盒(用于视锥体剔除,默认无限大 = 永远可见)
virtual void getBoundingBox(QVector3D &min, QVector3D &max) const {
min = QVector3D(-1e9f, -1e9f, -1e9f);
max = QVector3D( 1e9f, 1e9f, 1e9f);
}
};
} // namespace ve
五个虚函数,各有用处:
| 方法 | 用途 |
|---|---|
render() |
核心。view/proj 由外部传入而不是自己算——多个对象共享同一套相机矩阵 |
isVisible() |
视锥体剔除的快速通道。不实现这个也行,默认永远可见 |
renderOrder() |
半透明对象需要从远到近排序画 |
getBoundingBox() |
给剔除系统用的 AABB |
~IRenderable() |
虚析构,确保 delete basePtr 正确调用子类析构 |
2.3 与 ShaderProgram 的关系
这里有一个设计决策:要不要把 QOpenGLShaderProgram 放进 IRenderable?
我的答案是不要。着色器属于渲染管线,不属于渲染对象。一个 Mesh 可以被不同的 Shader 画(比如正常渲染 vs 深度预渲染 vs 阴影贴图)。把 Shader 绑在对象上会限制复用。
实际做法:GLWidget 持有一个 ShaderManager,在渲染循环中先绑定 Shader,再遍历 IRenderable 列表挨个画。
3. 渲染的主循环骨架
有了接口,把 paintGL 的逻辑搭起来:
// GLWidget.h — 声明
class GLWidget : public QOpenGLWidget, protected QOpenGLFunctions_3_3_Core {
Q_OBJECT
public:
explicit GLWidget(QWidget *parent = nullptr);
void addRenderable(std::unique_ptr<IRenderable> obj);
protected:
void initializeGL() override;
void resizeGL(int w, int h) override;
void paintGL() override;
private:
Camera m_camera;
std::vector<std::unique_ptr<IRenderable>> m_renderables;
QMatrix4x4 m_projection;
};
// GLWidget.cpp — paintGL 实现
void GLWidget::paintGL() {
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
QMatrix4x4 view = m_camera.viewMatrix();
// 遍历所有渲染对象
for (auto &obj : m_renderables) {
if (!obj->isVisible())
continue;
obj->render(this, view, m_projection);
}
requestUpdate();
}
void GLWidget::addRenderable(std::unique_ptr<IRenderable> obj) {
m_renderables.push_back(std::move(obj));
}
这个设计的节奏:
m_camera负责算 view 矩阵m_projection在resizeGL中更新m_renderables是个多态容器- 每帧遍历 → 检查可见性 → 调用
render()
它不依赖任何具体的渲染对象类型。后面我们加的 ChunkMesh、Skybox、Crosshair 都只需实现 IRenderable,不需要改 paintGL 一行代码。
4. Camera — 辅助类
IRenderable 依赖 view/proj 矩阵。Camera 负责产出 view 矩阵:
// Camera.h
#pragma once
#include <QMatrix4x4>
#include <QVector3D>
namespace ve {
class Camera {
public:
Camera();
void setPosition(const QVector3D &pos) { m_position = pos; m_dirty = true; }
void setRotation(float yaw, float pitch);
void moveForward(float delta);
void moveRight(float delta);
QMatrix4x4 viewMatrix();
QVector3D position() const { return m_position; }
QVector3D forward() const;
private:
void updateVectors();
QVector3D m_position{0, 80, 0};
float m_yaw = -90.0f;
float m_pitch = 0.0f;
QVector3D m_forward, m_right, m_up;
QMatrix4x4 m_viewMatrix;
bool m_dirty = true;
};
} // namespace ve
// Camera.cpp — 关键方法
void Camera::updateVectors() {
float yawRad = qDegreesToRadians(m_yaw);
float pitchRad = qDegreesToRadians(m_pitch);
m_forward = QVector3D(
cos(yawRad) * cos(pitchRad),
sin(pitchRad),
sin(yawRad) * cos(pitchRad)
).normalized();
m_right = QVector3D::crossProduct(m_forward, QVector3D(0, 1, 0)).normalized();
m_up = QVector3D::crossProduct(m_right, m_forward).normalized();
m_viewMatrix.setToIdentity();
m_viewMatrix.lookAt(m_position, m_position + m_forward, m_up);
m_dirty = false;
}
QMatrix4x4 Camera::viewMatrix() {
if (m_dirty) updateVectors();
return m_viewMatrix;
}
注意点: yaw 初始值设为 -90 度,让相机默认朝向 -Z 方向(OpenGL 的默认前方)。这和 Minecraft 的默认朝向一致。
5. ShaderManager — 着色器的家
// ShaderManager.h
#pragma once
#include <QOpenGLShaderProgram>
#include <QHash>
#include <QString>
namespace ve {
class ShaderManager {
public:
/// 加载着色器对,返回程序指针(由 ShaderManager 管理生命周期)
QOpenGLShaderProgram *load(const QString &name,
const QString &vertPath,
const QString &fragPath);
/// 按名称获取已加载的着色器,未找到返回 nullptr
QOpenGLShaderProgram *get(const QString &name) const;
/// 重新编译所有着色器(用于热重载调试)
void reloadAll();
private:
QHash<QString, QOpenGLShaderProgram*> m_programs;
};
} // namespace ve
// ShaderManager.cpp — 核心实现
QOpenGLShaderProgram *ShaderManager::load(
const QString &name,
const QString &vertPath,
const QString &fragPath)
{
auto *prog = new QOpenGLShaderProgram;
// 注意:这里用文件路径加载,不用 Qt 资源系统
// 读者可以直接改 shaders/ 下的文件,不需要管 .qrc
if (!prog->addShaderFromSourceFile(QOpenGLShader::Vertex, vertPath)) {
qWarning() << name << "vertex shader:" << prog->log();
delete prog;
return nullptr;
}
if (!prog->addShaderFromSourceFile(QOpenGLShader::Fragment, fragPath)) {
qWarning() << name << "fragment shader:" << prog->log();
delete prog;
return nullptr;
}
if (!prog->link()) {
qWarning() << name << "link:" << prog->log();
delete prog;
return nullptr;
}
m_programs[name] = prog;
return prog;
}
ShaderManager 用文件路径而不是 Qt 资源系统,原因是体素引擎的读者需要能直接用 VSCode 打开 shaders/chunk.frag 改两行、F5 看效果。用 .qrc 也能做到,但多一步 rcc 编译,对新手不友好。
6. 第一个三角形 → 第一个方块
有了 Camera、ShaderManager、IRenderable,画第一个彩色三角形只需要 20 行:
auto *program = shaderMgr.load("simple",
"shaders/simple.vert", "shaders/simple.frag");
program->bind();
float vertices[] = {
// x, y, z, r, g, b
-0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f,
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f,
0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f,
};
program->enableAttributeArray("a_position");
program->setAttributeArray("a_position", GL_FLOAT, vertices, 3, 6 * sizeof(float));
program->enableAttributeArray("a_color");
program->setAttributeArray("a_color", GL_FLOAT, vertices + 3, 3, 6 * sizeof(float));
program->setUniformValue("u_view", viewMatrix);
program->setUniformValue("u_proj", projMatrix);
glDrawArrays(GL_TRIANGLES, 0, 3);
program->disableAttributeArray("a_position");
program->disableAttributeArray("a_color");
program->release();
对应着色器:
// shaders/simple.vert
#version 330 core
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_color;
uniform mat4 u_view;
uniform mat4 u_proj;
out vec3 v_color;
void main() {
gl_Position = u_proj * u_view * vec4(a_position, 1.0);
v_color = a_color;
}
// shaders/simple.frag
#version 330 core
in vec3 v_color;
out vec4 fragColor;
void main() {
fragColor = vec4(v_color, 1.0);
}
7. 章节收尾
graph TD
MAIN["GLWidget::paintGL()"]
CAM["Camera<br/>view/proj 矩阵"]
SHADER["ShaderManager<br/>着色器缓存"]
REN["IRenderable 列表"]
GL["OpenGL 调用"]
MAIN --> CAM
MAIN --> SHADER
SHADER --> REN
CAM --> REN
REN --> GL
这一章搭起来的骨架:
IRenderable接口 → 所有渲染对象的统一入口Camera→ 受控的 view 矩阵ShaderManager→ 着色器的加载与缓存GLWidget::paintGL()→ 驱动整个渲染循环
下一章会写 IRenderable 的第一个真正实现:一个带纹理的方块。你会在那里看到 Mesh 类的 VBO/VAO 管理、纹理图集、以及面剔除——从"六个面都画"变成"只画看得见的面”。
架构师复盘
如果重来一次,我初期做错了两件事:
-
渲染对象和着色器绑得太紧。 早年的
Cube类自己在构造里加载着色器,后来想给同一个 Cube 用不同 Shader(正常渲染 / 阴影 / 线框模式)时发现结构不对。这一章直接把 ShaderManager 抽出来了——让着色器和物体解耦。 -
view/proj 矩阵在每个 render() 里重复算。 刚开始我是每个 IRenderable 自己持有 Camera 引用,各自算 view。后来发现所有对象的 view/proj 在同一帧里是一样的,没必要算 N 次。现在由 GLWidget 统一传进来。
系列文章:体素引擎从零构建 | 作者:Logic