基於Qt Widgets
的Qt
程序,控件的刷新默認情況下都是在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
概述
有以下主要的類或方法:
GLWidget
這個類在UI線程中使用,繼承了QOpenGLWidget
,負責將渲染線程渲染結果繪製到屏幕上。
Renderer
這個類在渲染線程中使用,負責將三角形渲染到離屏framebuffer
中。
RenderThread
渲染線程管理類,負責初始化渲染線程OpenGL
的context
。
TextureBuffer
紋理緩存類,負責將Renderer
渲染好的圖像緩存到紋理中,供UI線程繪製使用。
RenderThread::run
渲染線程的例程,負責調用Renderer
的方法渲染圖像,在Renderer
渲染好一幀圖像後將圖像保存在TextureBuffer
中。
context
OpenGL
需要context
來保存狀態,context
雖然可以跨線程使用,但無法在多個線程中同時使用,在任意時刻,只能綁定在一個線程中。因此我們需要爲渲染線程創建一個獨立的context
。
數據共享
UI線程如何訪問渲染線程的渲染結果。有兩種思路:
-
將渲染結果讀進內存,生成
QImage
,再傳給UI線程。這種方式的優點是實現簡單。缺點則是性能可能差一些,把顯存讀進內存是一個開銷比較大的操作。 -
將渲染結果保存到紋理中,UI線程綁定紋理繪製到屏幕上。這種方式的優點是性能較方法1好。缺點是爲了讓兩個線程能夠共享紋理,需要做一些配置。
在此,我們選擇的是方法2。
初始化渲染線程
瞭解到上面的這些信息後,我們來看一下如何初始化渲染線程。
由於需要UI線程能夠和渲染線程共享數據,需要調用QOpenGLContext::setShareContext
來設置,而這個方法又需要在QOpenGLContext::create
方法前調用。UI線程context
的QOpenGLContext::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線程context
與mainSurface
的關聯,這是爲了能夠使UI線程的context
和渲染線程的context
設置共享關係。待渲染線程初始化完成後,再將UI線程context
與mainSurface
進行關聯。然後設置一個連接用於接收渲染線程的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
將渲染線程渲染結果保存到紋理中,在Qt
中OpenGL
調用都需要通過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處理的代碼移到獨立線程也許是個不錯的選擇。具體採用哪種方案還是要根據實際測試效果來決定。