Horse3D 游戏引擎研发笔记(一):基于独立的渲染线程绘制彩色三角形

92 阅读4分钟

目标:在 Qt 6.4 + OpenGL Core 环境下,把渲染逻辑放到独立线程执行,GUI 线程只负责把渲染结果呈现出来,并最终以动态库形式组织渲染模块。

Bilibili 同步视频

Horse3D 游戏引擎研发笔记(一):基于独立的渲染线程绘制彩色三角形

一、为什么要独立渲染线程

在传统的 QOpenGLWidget 用法里,渲染、事件处理、UI 布局全部跑在 GUI 线程。当场景复杂之后,一帧的 paintGL 可能直接阻塞消息循环,导致窗口卡顿、输入延迟。

把渲染拆到独立线程的好处很直接:

  • GUI 线程只干 “显示” 这一件事,帧率稳定。

  • 渲染线程可以充分利用多核 CPU/GPU,做更重的几何、材质、后处理计算。

  • 后期更容易扩展成命令队列、多 Pass、异步资源加载等引擎常见结构。

但 Qt 的 OpenGL 上下文对线程敏感,直接跨线程访问 FBO、VAO 会触发驱动层访问冲突。因此需要先理解 Qt 官方推荐的多线程模型。

二、Qt 6.4 官方的多线程渲染建议

Qt 6.4 文档在 QOpenGLWidget 的 Threading 段落里给出了两条路:

  1. 工作线程做离屏渲染,主线程采样纹理 工作线程持有与 GUI 上下文共享的第二个 QOpenGLContext,渲染到自己的 FBO,然后把纹理 ID 交给 GUI 线程显示。

  2. 直接把 QOpenGLWidget 的上下文移到工作线程 需要重载 paintEvent 为空,并小心地通过 moveToThread 切换上下文亲和性,渲染完再切回 GUI 线程触发合成。

第一条路更符合引擎思路:渲染管线自己管理 target,显示端只负责 present。我们项目最终采用的就是这条路。

三、项目结构

e:WorkSpacesoftwarer-horse
├── CMakeLists.txt                  # 顶层:统一输出目录、add_subdirectory
├── Dragon                          # 渲染核心动态库
│   ├── CMakeLists.txt
│   ├── dragon_global.h             # DLL 导出宏
│   ├── RenderThread.h / .cpp       # 独立渲染线程
│   ├── Screen.h / .cpp             # IScreen:基于 QOpenGLWidget 的显示端
├── softwarer-horse                 # 可执行程序
│   ├── CMakeLists.txt
│   ├── main.cpp
│   ├── softwarerhorse.h / .cpp     # 主窗口

Dragon 是 SHARED 库,softwarer-horse.exe 链接 Dragon.lib,运行时依赖 Dragon.dll

四、核心实现

4.1 渲染线程 RenderThread

RenderThread 继承 QThread,负责:

  • 持有与 GUI 共享的 QOpenGLContext

  • QOffscreenSurface 上渲染三角形到 FBO。

  • 维护双缓冲 FBO,每帧渲染完成后交换,并暴露 front texture ID。

  • 通过 QWaitCondition 实现 “按需渲染”:GUI 请求一帧,线程渲染一帧,渲染完成发 frameReady() 信号。

关键代码片段:

void RenderThread::run()
{
    if (!m_context->makeCurrent(m_surface)) {
        qWarning("RenderThread: makeCurrent failed");
        return;
    }
    initializeOpenGLFunctions();
    initRenderResources();

    while (!m_stop) {
        m_requestMutex.lock();
        m_waitCondition.wait(&m_requestMutex);
        m_requestMutex.unlock();

        if (m_stop) break;

        renderFrame();
        glFinish();

        emit frameReady();
    }

    m_context->doneCurrent();
}

void RenderThread::renderNext()
{
    QMutexLocker locker(&m_requestMutex);
    m_waitCondition.wakeOne();
}

使用 glFinish() 是为了确保 GPU 真正完成绘制,避免 GUI 线程拿到还没写满的纹理。

4.2 显示端 IScreen

IScreen 继承 QOpenGLWidgetQOpenGLFunctions_3_3_Core,职责只有三个:

  1. 在 GUI 线程创建 QOffscreenSurface

  2. 创建共享上下文并移交给 RenderThread

  3. paintGL() 里把 RenderThread 的 front texture 用全屏四边形画出来。

共享上下文创建是重点。Windows 上 wglShareLists 要求 GUI 线程不能持有当前 OpenGL 上下文,所以必须先 doneCurrent()

void IScreen::initializeGL()
{
    initializeOpenGLFunctions();
    initDisplayResources();

    m_offscreenSurface = new QOpenGLSurface();
    m_offscreenSurface->setFormat(context()->format());
    m_offscreenSurface->create();

    doneCurrent();  // 释放 GUI 上下文,否则 wglShareLists 会失败

    QOpenGLContext *workerContext = new QOpenGLContext();
    workerContext->setShareContext(context());
    workerContext->setFormat(context()->format());
    if (!workerContext->create()) {
        qWarning("IScreen: failed to create worker context");
        delete workerContext;
        return;
    }

    m_renderThread = new RenderThread(workerContext, this);
    workerContext->moveToThread(m_renderThread);

    connect(m_renderThread, &RenderThread::frameReady,
            this, QOverload<>::of(&IScreen::update));

    m_renderThread->start();
    m_renderThread->renderNext();
}

paintGL() 里采样纹理:

void IScreen::paintGL()
{
    glClear(GL_COLOR_BUFFER_BIT);

    GLuint tex = m_renderThread->frontTexture();
    if (tex == 0) return;

    m_program->bind();
    glActiveTexture(GL_TEXTURE0);
    glBindTexture(GL_TEXTURE_2D, tex);
    m_program->setUniformValue("tex", 0);

    glBindVertexArray(m_vao);
    glDrawArrays(GL_TRIANGLES, 0, 6);

    m_renderThread->renderNext();
}

注意 paintGL() 显示完才会请求下一帧,形成 “显示 → 请求 → 渲染 → 显示” 的节奏,避免读写竞争导致的闪烁或黑屏。

4.3 CMake 与部署

顶层 CMakeLists.txt 统一输出目录:

set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/Library)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/Library)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/Runtime)

这样 Dragon.dllsoftwarer-horse.exe 会在同一目录,避免运行时找不到 DLL。

为了让 VS 里按 F5 也能正确加载 Qt DLL,给可执行目标加了调试器工作目录属性:

set_target_properties(${PROJECT_NAME} PROPERTIES
    WIN32_EXECUTABLE TRUE
    VS_DEBUGGER_WORKING_DIRECTORY "$<TARGET_FILE_DIR:${PROJECT_NAME}>"
)

同时用 windeployqt 在 post-build 阶段自动把 Qt 运行时依赖复制到 exe 目录:

if(WIN32)
    get_target_property(_qt_core_location Qt${QT_VERSION_MAJOR}::Core IMPORTED_LOCATION_RELEASE)
    get_filename_component(_qt_bin_dir ${_qt_core_location} DIRECTORY)
    find_program(WINDEPLOYQT_EXECUTABLE windeployqt HINTS ${_qt_bin_dir} NO_DEFAULT_PATH)
    if(WINDEPLOYQT_EXECUTABLE)
        add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
            COMMAND ${WINDEPLOYQT_EXECUTABLE}
                --dir "$<TARGET_FILE_DIR:${PROJECT_NAME}>"
                "$<TARGET_FILE:${PROJECT_NAME}>"
        )
    endif()
endif()

五、踩坑记录

5.1 界面闪烁

最初 RenderThread::run() 里是无条件循环渲染,主线程显示节奏和渲染节奏不同步,导致读到未完成帧。解决办法是改成按需渲染:主线程显示完请求下一帧,线程渲染完通过信号通知更新。

5.2 三角形消失 / 黑屏

共享上下文创建失败。根本原因是 Windows 上 wglShareLists 要求 GUI 线程先释放当前上下文,所以必须在创建 worker context 前调用 doneCurrent()

5.3 RenderThread::metaObject 链接错误

RenderThread 头文件写了 Q_OBJECT,但 Dragon 库目标没有开 AUTOMOC,导致 moc 生成的元对象代码没有编译进库。解决:在 Dragon/CMakeLists.txtset(CMAKE_AUTOMOC ON)

5.4 VS 里运行找不到 Qt DLL

windeployqt 已经把 DLL 复制到 Runtime/ 目录,但 VS 默认调试工作目录不是 exe 所在目录。解决:设置 VS_DEBUGGER_WORKING_DIRECTORY$<TARGET_FILE_DIR:${PROJECT_NAME}>

六、当前成果

现在项目已经能稳定运行:

  • 独立渲染线程在离屏 FBO 上绘制彩色三角形。

  • GUI 线程通过共享纹理采样并显示。

  • 渲染模块打包为 Dragon.dll,主程序通过导入库链接。

  • VS 直接 F5 运行无额外配置。

七、下一步

  • 把顶点、材质、 uniform、相机等概念抽象成引擎资源类。

  • 引入渲染命令队列,让主线程以 “提交命令” 的方式驱动渲染线程。

  • 支持 Dear ImGui 或 Qt Widget 叠加调试 UI。

  • 尝试把渲染线程渲染目标升级为多采样 FBO + 后处理链。


Horse3D 游戏引擎研发笔记(一):基于独立的渲染线程绘制彩色三角形

本系列记录 Horse3D 游戏引擎从零开始的研发过程,欢迎交流。