Qt使用OpenGL进行多线程离屏渲染

基于Qt WidgetsQt程序,控件的刷新默认情况下都是在UI线程中依次进行的,换言之,各个控件的QWidget::paintEvent方法会在UI线程中串行地被调用。如果某个控件的paintEvent非常耗时(等待数据时间+CPU处理时间+GPU渲染时间),会导致刷新帧率下降,界面的响应速度变慢。

假如这个paintEvent耗时的控件没有使用OpenGL渲染,完全使用CPU渲染。这种情况处理起来比较简单,只需要另外开一个线程用CPU往QImage里面渲染,当主线程调用到这个控件的paintEvent时,再把渲染好的QImage画出来就可以了,单纯绘制一个QImage还是很快的。

如果这个paintEvent耗时的控件使用了OpenGL渲染,情况会复杂一些,因为想要把OpenGL渲染过程搬到另外一个线程中并不是直接把OpenGL调用从UI线程搬到渲染线程就可以的,是需要做一些准备工作的。另外,UI线程如何使用渲染线程的渲染结果也是一个需要思考的问题。

以绘制一个迭代了15次的Sierpinski三角形为例,它总共有3^15=14348907个三角形,在我的MX150显卡上绘制一次需要30ms左右的时间。因此如果我在UI线程渲染这些顶点的话,UI线程的刷新帧率就会掉到30帧左右。现在我们来看一下如何在另一个线程中渲染这些三角形。demo仓库地址:https://github.com/im-red/offscreen_render

软硬件环境

CPU:Intel® Core™ i5-8250U CPU @ 1.60GHz

GPU:NVIDIA GeForce MX150(Driver:388.19)

OS:Microsoft Windows 10 Home 10.0.18362

Compiler:MSVC 2017

Optimization flag:O2

Qt version:5.12.1

OpenGL version:4.6.0

概述

有以下主要的类或方法:

  1. GLWidget

这个类在UI线程中使用,继承了QOpenGLWidget,负责将渲染线程渲染结果绘制到屏幕上。

  1. Renderer

这个类在渲染线程中使用,负责将三角形渲染到离屏framebuffer中。

  1. RenderThread

渲染线程管理类,负责初始化渲染线程OpenGLcontext

  1. TextureBuffer

纹理缓存类,负责将Renderer渲染好的图像缓存到纹理中,供UI线程绘制使用。

  1. RenderThread::run

渲染线程的例程,负责调用Renderer的方法渲染图像,在Renderer渲染好一帧图像后将图像保存在TextureBuffer中。

context

OpenGL需要context来保存状态,context虽然可以跨线程使用,但无法在多个线程中同时使用,在任意时刻,只能绑定在一个线程中。因此我们需要为渲染线程创建一个独立的context

数据共享

UI线程如何访问渲染线程的渲染结果。有两种思路:

  1. 将渲染结果读进内存,生成QImage,再传给UI线程。这种方式的优点是实现简单。缺点则是性能可能差一些,把显存读进内存是一个开销比较大的操作。

  2. 将渲染结果保存到纹理中,UI线程绑定纹理绘制到屏幕上。这种方式的优点是性能较方法1好。缺点是为了让两个线程能够共享纹理,需要做一些配置。

在此,我们选择的是方法2。

初始化渲染线程

了解到上面的这些信息后,我们来看一下如何初始化渲染线程。

由于需要UI线程能够和渲染线程共享数据,需要调用QOpenGLContext::setShareContext来设置,而这个方法又需要在QOpenGLContext::create方法前调用。UI线程contextQOpenGLContext::create方法调用我们是无法掌握的,因此需要渲染线程context来调用QOpenGLContext::setShareContext。由于调用时需要确保UI线程context已经初始化,因此在GLWidget::initializeGL中初始化渲染线程比较好,相关代码如下:

void GLWidget::initializeGL()
{
    initRenderThread();
    ...
}
...
void GLWidget::initRenderThread()
{
    auto context = QOpenGLContext::currentContext();
    auto mainSurface = context->surface();

    auto renderSurface = new QOffscreenSurface(nullptr, this);
    renderSurface->setFormat(context->format());
    renderSurface->create();

    context->doneCurrent();
    m_thread = new RenderThread(renderSurface, context, this);
    context->makeCurrent(mainSurface);

    connect(m_thread, &RenderThread::imageReady, this, [this](){
        update();
    }, Qt::QueuedConnection);
    m_thread->start();
}
...
RenderThread::RenderThread(QSurface *surface, QOpenGLContext *mainContext, QObject *parent)
    : QThread(parent)
    , m_running(true)
    , m_width(100)
    , m_height(100)
    , m_mainContext(mainContext)
    , m_surface(surface)
{
    m_renderContext = new QOpenGLContext;
    m_renderContext->setFormat(m_mainContext->format());
    m_renderContext->setShareContext(m_mainContext);
    m_renderContext->create();
    m_renderContext->moveToThread(this);
}
...

GLWidget::initRenderThread中,我们首先获得UI线程的context,以及其关联的mainSurface。然后为渲染线程创建了一个QOffscreenSurface,将其格式设置为与UI线程context相同。然后调用doneCurrent取消UI线程contextmainSurface的关联,这是为了能够使UI线程的context和渲染线程的context设置共享关系。待渲染线程初始化完成后,再将UI线程contextmainSurface进行关联。然后设置一个连接用于接收渲染线程的imageReady信号。最后启动渲染线程开始渲染。

RenderThread::RenderThread中,首先初始化渲染线程的context,由于RenderThread::RenderThread是在UI线程中调用的,还要调用moveToThread将其移到渲染线程中。

渲染线程例程

// called in render thread
void RenderThread::run()
{
    m_renderContext->makeCurrent(m_surface);

    TextureBuffer::instance()->createTexture(m_renderContext);

    Renderer renderer;

    while (m_running)
    {
        int width = 0;
        int height = 0;
        {
            QMutexLocker lock(&m_mutex);
            width = m_width;
            height = m_height;
        }
        renderer.render(width, height);
        TextureBuffer::instance()->updateTexture(m_renderContext, width, height);
        emit imageReady();
        FpsCounter::instance()->frame(FpsCounter::Render);
    }

    TextureBuffer::instance()->deleteTexture(m_renderContext);
}

渲染线程开始渲染时,首先绑定context和初始化TextureBuffer。然后在循环中重复执行渲染-保存纹理的循环

离屏渲染

其初始化在Renderer::init中进行,渲染在Renderer::render中进行,各类OpenGL基础教程中都有对离屏渲染的相关介绍和分析,此处不再赘述。

保存纹理

// called in render thread
void TextureBuffer::updateTexture(QOpenGLContext *context, int width, int height)
{
    Timer t("ImageBuffer::updateTexture");
    QMutexLocker lock(&m_mutex);

    auto f = context->functions();
    f->glActiveTexture(GL_TEXTURE0);
    f->glBindTexture(GL_TEXTURE_2D, m_texture);
    f->glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
    f->glCopyTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 0, 0, width, height, 0);
    f->glBindTexture(GL_TEXTURE_2D, 0);
    f->glFinish();
}

RenderThread::run中调用TextureBuffer::updateTexture将使用glCopyTexImage2D将渲染线程渲染结果保存到纹理中,在QtOpenGL调用都需要通过QOpenGLFunction对象,因此将渲染线程的QOpenGLContext对象传进来,可以获得其默认的QOpenGLFunction对象。

由于我们只使用了一个纹理来缓存图像,如果渲染线程渲染得比较快的话,有些帧就会来不及渲染被丢弃。当然你也可以改程序阻塞渲染线程避免被阻塞。

绘制纹理

void GLWidget::paintGL()
{
    Timer t("GLWidget::paintGL");
    glEnable(GL_TEXTURE_2D);

    m_program->bind();

    glBindVertexArray(m_vao);

    if (TextureBuffer::instance()->ready())
    {
        TextureBuffer::instance()->drawTexture(QOpenGLContext::currentContext(), sizeof(vertices) / sizeof(float) / 4);
    }

    glBindVertexArray(0);

    m_program->release();
    glDisable(GL_TEXTURE_2D);

    FpsCounter::instance()->frame(FpsCounter::Display);
}
...
// called in main thread
void TextureBuffer::drawTexture(QOpenGLContext *context, int vertextCount)
{
    Timer t("ImageBuffer::drawTexture");
    QMutexLocker lock(&m_mutex);

    auto f = context->functions();

    f->glBindTexture(GL_TEXTURE_2D, m_texture);

    f->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    f->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

    f->glActiveTexture(GL_TEXTURE0);

    f->glDrawArrays(GL_TRIANGLES, 0, vertextCount);
    f->glBindTexture(GL_TEXTURE_2D, 0);

    //f->glFinish();
}

GLWidget::paintGL中调用TextureBuffer::drawTexture来绘制缓存的纹理。

性能

上面所做的这一切,能够提高性能吗?很遗憾,答案是“不一定”。就这个demo而言,渲染过程几乎完全不需要等待数据和CPU处理(除了初始化时需要CPU计算),不断使用GPU进行渲染,这导致GPU占用率几乎达到了100%,成为了一个瓶颈。当主线程进行OpenGL调用时,很可能会因为正在处理渲染线程的OpenGL调用而被阻塞,导致帧率下降。使用NVIDIA Nsights Graphics实测结果如下:

在这里插入图片描述在这里插入图片描述
第一幅图是刚打开程序时的帧率,基本稳定在60帧,第二幅图是运行一段时间后的帧率,时常跌到30帧。就平均帧率而言,性能较单线程渲染还是有提升的。至于为什么运行一段时间后帧率会下降,猜想是GPU温度升高被降频导致的,使用GPU-Z观察GPU时钟频率可以验证这一猜想。

如果渲染过程中等待数据和CPU处理时间占了一定的比重的话,多线程离屏渲染就有优势了。不过在这种情况下,单把等待数据和CPU处理的代码移到独立线程也许是个不错的选择。具体采用哪种方案还是要根据实际测试效果来决定。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章