第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 的优势:

  1. vsync 对齐 — 不多画帧,不撕裂
  2. 自动休眠 — 切到其他窗口时 CPU 占用降到零
  3. 代码简单 — 不需要管理 QTimer 的生命周期
  4. 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_projectionresizeGL 中更新
  • m_renderables 是个多态容器
  • 每帧遍历 → 检查可见性 → 调用 render()

它不依赖任何具体的渲染对象类型。后面我们加的 ChunkMeshSkyboxCrosshair 都只需实现 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 管理、纹理图集、以及面剔除——从"六个面都画"变成"只画看得见的面”。


架构师复盘

如果重来一次,我初期做错了两件事:

  1. 渲染对象和着色器绑得太紧。 早年的 Cube 类自己在构造里加载着色器,后来想给同一个 Cube 用不同 Shader(正常渲染 / 阴影 / 线框模式)时发现结构不对。这一章直接把 ShaderManager 抽出来了——让着色器和物体解耦。

  2. view/proj 矩阵在每个 render() 里重复算。 刚开始我是每个 IRenderable 自己持有 Camera 引用,各自算 view。后来发现所有对象的 view/proj 在同一帧里是一样的,没必要算 N 次。现在由 GLWidget 统一传进来。


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