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處理的代碼移到獨立線程也許是個不錯的選擇。具體採用哪種方案還是要根據實際測試效果來決定。

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