Taking screenshot of a QOpenGLWidget containing text

Arseni Mourzenko
Founder and lead developer
177
articles
July 18, 2024
Tags: c++ 1 qt 1 opengl 1 testing 8

Work­ing on a Qt ap­pli­ca­tion that uses OpenGL to draw lines and poly­gons, and QPainter to draw text, I was sur­prised to find that it is not par­tic­u­lar­ly straight­for­ward to make the ap­pli­ca­tion take screen­shots of it­self.

The con­text is an ap­pli­ca­tion that has a bunch of wid­gets that in­her­it from QOpenGLWidget, and that dis­play some data in a graph­i­cal way. Usu­al­ly, it comes to draw­ing a lot of lines, and add text here and there. OpenGL was used in or­der to make the lines dis­play faster—that was es­pe­cial­ly im­por­tant as the ap­pli­ca­tion was host­ed on a Rasp­ber­ry Pi. How­ev­er, as dis­play­ing text di­rect­ly in OpenGL is not par­tic­u­lar­ly straight­for­ward, I de­cid­ed to rely on an or­di­nary QPainter with a drawText() for this part, make it paint to an im­age, trans­form the im­age into a tex­ture, and make OpenGL dis­play a rec­tan­gle with the afore­men­tioned tex­ture. Rel­a­tive­ly sim­ple, un­til you need to grab a screen­shot.

In fact, in or­der to do re­gres­sion test­ing of some­thing which is most­ly graph­i­cal, I am re­ly­ing on snap­shot test­ing. A snap­shot test works like this: it sets up a giv­en con­text, ren­ders a wid­get to an im­age, and com­pares the im­age to a ref­er­ence, that is an im­age that is con­sid­ered “cor­rect.” This meant that the ap­pli­ca­tion had to take screen­shots of it­self, or at least of a giv­en wid­get. And this is where things start­ed to go wrong.

But first, let me in­tro­duce the ap­proach I was us­ing to ren­der el­e­ments in the first place. Let's start with stan­dard ini­tial­iza­tion:

void Widget::initializeGL() {
    initializeOpenGLFunctions();

    glClearColor(1.0, 0.8, 0.0, 1.0);  // Roughly matches #fc0.

    glEnable(GL_TEXTURE_2D);
    glEnable(GL_BLEND);
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

    glGenTextures(1, &textureID);
    glBindTexture(GL_TEXTURE_2D, textureID);

    this->image = QImage(200, 200, QImage::Format_RGBA8888);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

    glBindTexture(GL_TEXTURE_2D, 0);
}

void Widget::resizeGL(int w, int h) {
    glViewport(0, 0, w, h);
    glMatrixMode(GL_PROJECTION);
    glLoadIdentity();

    gluOrtho2D(0, w, 0, h);
    glMatrixMode(GL_MODELVIEW);
}

The paint­ing is made in two steps:

void Widget::paintGL() {
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    glLoadIdentity();

    this->paintDecor();
    this->paintTexture();
}

The paintDecor() draws the back­ground us­ing na­tive OpenGL:

void Widget::paintDecor() {
    glColor3f(1.0, 0.7, 0.0);
    for (auto x = 0; x < 200; x += 20) {
        for (auto y = 0; y < 200; y += 20) {
            glBegin(GL_TRIANGLES);
                glVertex2f(x, y);
                glVertex2f(x, y + 20);
                glVertex2f(x + 20, y + 20);
            glEnd();
        }
    }
}

Then, paintTexture() cre­ates the tex­ture and feeds it to OpenGL:

void Widget::paintTexture() {
    glBindTexture(GL_TEXTURE_2D, textureID);
    glColor4f(1.0f, 1.0f, 1.0f, 1.0f);

    this->createImage();

    glTexImage2D(
        GL_TEXTURE_2D,
        0,
        GL_RGBA,
        image.width(),
        image.height(),
        0,
        GL_RGBA,
        GL_UNSIGNED_BYTE,
        image.bits());

    glBegin(GL_QUADS);
        glTexCoord2f(0, 1); glVertex2f(0, 0);
        glTexCoord2f(1, 1); glVertex2f(200, 0);
        glTexCoord2f(1, 0); glVertex2f(200, 200);
        glTexCoord2f(0, 0); glVertex2f(0, 200);
    glEnd();

    glBindTexture(GL_TEXTURE_2D, 0);
}

In turn, it calls the func­tion that draws some stuff to the im­age with the help of QPainter:

void Widget::createImage() {
    this->image.fill(Qt::transparent);
    QPainter p(&this->image);

    this->createImage(p);

    p.end();

    this->image.save("image.png");
}

void Widget::createImage(QPainter &p) {
    p.setPen(QPen(QColor("#0cf"), 5));
    p.drawLine(QPointF(10, 10), QPointF(60, 60));
    p.drawLine(QPointF(60, 10), QPointF(10, 60));

    p.setCompositionMode(QPainter::CompositionMode_Clear);
    p.eraseRect(QRect(25, 25, 20, 20));
    p.setCompositionMode(QPainter::CompositionMode_SourceOver);

    auto font = QFont("Spectral", 13.0 * 3, QFont::Medium);
    font.setStyleStrategy(QFont::PreferAntialias);
    font.setHintingPreference(QFont::PreferFullHinting);
    p.setFont(font);

    p.setPen(QPen(QColor("#000"), 5));
    p.drawText(QRectF(50, 50, 0, 0), Qt::TextDontClip, "Black");

    p.setPen(QPen(QColor("#0cf"), 5));
    p.drawText(QRectF(50, 95, 0, 0), Qt::TextDontClip, "Blue");

    p.setPen(QPen(QColor("#fff"), 5));
    p.drawText(QRectF(50, 140, 0, 0), Qt::TextDontClip, "White");
}

The im­age gets also saved to a file and looks like this (shown here with a gray back­ground, in or­der to make white text vis­i­ble):

When com­bined with the back­ground by the Qt ap­pli­ca­tion, this is what is dis­played on screen:

A naive at­tempt to ren­der the wid­get to an im­age pre­sents, how­ev­er, vi­su­al ar­ti­facts around text. If the ren­der­ing seems fine for black text, it is all but fine as soon as col­or is used, and white text is the worst.

void Widget::takeScreenshot(QString fileName) {
    QPixmap pixmap(this->size());
    this->render(&pixmap, QPoint(), QRegion(this->rect()));
    pixmap.save(fileName);
}

An al­ter­na­tive that was sug­gest­ed to me on Stack­Over­flow, pro­vides a much bet­ter ren­der­ing, but there is a white glow around non-black text (in­clud­ing the white text—see how thick it is?):

void Widget::takeScreenshot(QString fileName) {
    this->grabFramebuffer().save(fileName);
}

Fi­nal­ly, the only so­lu­tion which I've found and which is quite ugly, IMHO, is to ren­der only the back­ground (i.e. the na­tive OpenGL stuff), take the screen­shot, and ask QPainter to paint di­rect­ly over the screen­shot.

void Widget::takeScreenshot(QString fileName) {
    QPixmap pixmap(this->size());
    this->takingScreenshot = true;
    this->render(&pixmap, QPoint(), QRegion(this->rect()));
    this->createImage();
    this->takingScreenshot = false;

    QPainter p(&pixmap);
    p.drawImage(QPointF(), this->image);
    p.end();

    pixmap.save(fileName);
}

with paintGL() mod­i­fied like this:

void Widget::paintGL() {
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    glLoadIdentity();

    this->paintDecor();

    if (!this->takingScreenshot) {
        this->paintTexture();
    }
}