目标:在 Qt 6.4 + OpenGL Core 环境下,把渲染逻辑放到独立线程执行,GUI 线程只负责把渲染结果呈现出来,并最终以动态库形式组织渲染模块。
Bilibili 同步视频
一、为什么要独立渲染线程
在传统的 QOpenGLWidget 用法里,渲染、事件处理、UI 布局全部跑在 GUI 线程。当场景复杂之后,一帧的 paintGL 可能直接阻塞消息循环,导致窗口卡顿、输入延迟。
把渲染拆到独立线程的好处很直接:
-
GUI 线程只干 “显示” 这一件事,帧率稳定。
-
渲染线程可以充分利用多核 CPU/GPU,做更重的几何、材质、后处理计算。
-
后期更容易扩展成命令队列、多 Pass、异步资源加载等引擎常见结构。
但 Qt 的 OpenGL 上下文对线程敏感,直接跨线程访问 FBO、VAO 会触发驱动层访问冲突。因此需要先理解 Qt 官方推荐的多线程模型。
二、Qt 6.4 官方的多线程渲染建议
Qt 6.4 文档在 QOpenGLWidget 的 Threading 段落里给出了两条路:
-
工作线程做离屏渲染,主线程采样纹理 工作线程持有与 GUI 上下文共享的第二个
QOpenGLContext,渲染到自己的 FBO,然后把纹理 ID 交给 GUI 线程显示。 -
直接把
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 继承 QOpenGLWidget 和 QOpenGLFunctions_3_3_Core,职责只有三个:
-
在 GUI 线程创建
QOffscreenSurface。 -
创建共享上下文并移交给
RenderThread。 -
在
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.dll 和 softwarer-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.txt 里 set(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 游戏引擎从零开始的研发过程,欢迎交流。