QPainter rotation prevents correct QPixmap rendering

2

Reported to Qt as a bug: https://bugreports.qt.io/browse/QTBUG-93475


I am re-drawing a QPixmap multiple times in different locations, with differnt rotations by transforming the QPainter. In certain situations the QPixmap is not drawing correctly. The GIF below shows my initial discovery of this issue with a QPixmap containing a green cylinder, notice how the rendering behaves as expected to the left of the GIF, but there is a boundary beyond which the rendering is incorrect. The QPixmap content appears to stick in place, and the pixels at the edge appear to smear out accross the rest of the pixmap. In the GIF there is a magenta background to the QPixmap, this because the targetRect used by QPainter::drawPixmap() is also beuing used to seperately fill a rectangle underneath the pixmap, this was because I wanted to check that the target rect was being computed correctly.

Strange behaviour of pixmap drawing

Minimum reproducable example:

To keep things simple I am simply filling the QPixmap with magenta pixels, with a 1 pixel wide transparent edge so that the smearing causes the pixmaps to dissapear completely. It doesn't show the image "sticking" in place but it clearly shows the boundary as beyond it the pixmaps seem to dissapear.

I have been experimenting with this myself and I believe this to be entirely caused by the rotating of the QPainter.

The angle of rotation seems to have an effect, if all of the pixmaps are rotated to the same angle then the boundary changes from a fuzzy diagonal line (where fuzzy means the boundary for dissapearing is different for each pixmap) to a sharp 90 degree corner (where sharp means that the boundary for dissapearing is the same for all pixmaps).

The range of different angles also seems to play a part, if the randomly generated angles are in a small 10 degree range, then the boundary is just a slightly fuzzier right angle, with a bevelled corner. There seems to be a progression from sharp right angle to fuzzy diagonal line as the number of different rotations is applied.

Code

QtTestBed/pro:

QT += widgets

CONFIG += c++17
CONFIG -= app_bundle

# The following define makes your compiler emit warnings if you use
# any feature of Qt which as been marked deprecated (the exact warnings
# depend on your compiler). Please consult the documentation of the
# deprecated API in order to know how to port your code away from it.
DEFINES += QT_DEPRECATED_WARNINGS

# You can also make your code fail to compile if you use deprecated APIs.
# In order to do so, uncomment the following line.
# You can also select to disable deprecated APIs only up to a certain version of Qt.
#DEFINES += QT_DISABLE_DEPRECATED_BEFORE=0x060000    # disables all the APIs deprecated before Qt 6.0.0

SOURCES += \
        MainWindow.cpp \
        main.cpp

# Default rules for deployment.
qnx: target.path = /tmp/$${TARGET}/bin
else: unix:!android: target.path = /opt/$${TARGET}/bin
!isEmpty(target.path): INSTALLS += target

HEADERS += \
    MainWindow.h

main.cpp:

#include "MainWindow.h"

#include <QApplication>

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    MainWindow w;
    w.show();
    return a.exec();
}

MainWindow.h:

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QWidget>
#include <QMouseEvent>
#include <QWheelEvent>
#include <QPainter>

#include <random>

class MainWindow : public QWidget {
    Q_OBJECT
public:
    MainWindow();

    void wheelEvent(QWheelEvent* event) override;
    void mouseReleaseEvent(QMouseEvent* /*event*/) override;
    void mousePressEvent(QMouseEvent* event) override;
    void mouseMoveEvent(QMouseEvent* event) override;
    void resizeEvent(QResizeEvent* /*event*/) override;
    void paintEvent(QPaintEvent* event) override;

private:
    struct PixmapLocation {
        QPointF location_;
        qreal rotation_;
        qreal radius_;
    };

    QPixmap pixmap_;
    std::vector<PixmapLocation> drawLocations_;

    qreal panX_ = 0.0;
    qreal panY_ = 0.0;
    qreal scale_ = 1.0;

    bool dragging_ = false;
    qreal dragX_ = 0.0;
    qreal dragY_ = 0.0;

    QPointF transformWindowToSimCoords(const QPointF& local) const;
    QPointF transformSimToWindowCoords(const QPointF& sim) const;

    static qreal randomNumber(qreal min, qreal max);
};

#endif // MAINWINDOW_H

MainWindow.cpp:

    #include "MainWindow.h"

MainWindow::MainWindow()
    : pixmap_(30, 50)
{
    setAutoFillBackground(true);

    constexpr int count = 10000;
    constexpr qreal area = 10000.0;
    constexpr qreal size = 44.0;

    for (int i = 0; i < count; ++i) {
        // qreal rotation = 0.0; // No rotation fixes the issue
        // qreal rotation = 360.0; // No rotation fixes the issue
        // qreal rotation = 180.0; // Mirroring also fixes the issue
        // qreal rotation = 90.0; // The boundary is now a corner, and has a sharp edge (i.e. all images dissapear at the same point)
        // qreal rotation = 0.1; // The boundary is now a corner, and has a sharp edge (i.e. all images dissapear at the same point)
        // qreal rotation = randomNumber(0.0, 10.0); // The boundary is still a corner, with a bevel, with a fuzzy edge (i.e. not all images dissapear at the same point)
        qreal rotation = randomNumber(0.0, 360.0); // The boundary appears to be a diagonal line with a fuzzy edge (i.e. not all images dissapear at the same point)

        drawLocations_.push_back(PixmapLocation{ QPointF(randomNumber(-area, area), randomNumber(-area, area)), rotation, size });
    }

    // Make edges transparent (the middle will be drawn over)
    pixmap_.fill(QColor::fromRgba(0x000000FF));

    /*
     * Fill with magenta almost up to the edge
     *
     * The transparent edge is required to see the effect, the misdrawn pixmaps
     * appear to be a smear of the edge closest to the boundary between proper
     * rendering and misrendering. If the pixmap is a solid block of colour then
     * the effect is masked by the fact that the smeared edge looks the same as
     * the correctly drawn pixmap.
     */
    QPainter p(&pixmap_);
    p.setPen(Qt::NoPen);
    constexpr int inset = 1;
    p.fillRect(pixmap_.rect().adjusted(inset, inset, -inset, -inset), Qt::magenta);

    update();
}

void MainWindow::wheelEvent(QWheelEvent* event)
{
    double d = 1.0 + (0.001 * double(event->angleDelta().y()));
    scale_ *= d;
    update();
}

void MainWindow::mouseReleaseEvent(QMouseEvent*)
{
    dragging_ = false;
}

void MainWindow::mousePressEvent(QMouseEvent* event)
{
    dragging_ = true;
    dragX_ = event->pos().x();
    dragY_ = event->pos().y();
}

void MainWindow::mouseMoveEvent(QMouseEvent* event)
{
    if (dragging_) {
        panX_ += ((event->pos().x() - dragX_) / scale_);
        panY_ += ((event->pos().y() - dragY_) / scale_);
        dragX_ = event->pos().x();
        dragY_ = event->pos().y();
        update();
    }
}

void MainWindow::resizeEvent(QResizeEvent*)
{
    update();
}

void MainWindow::paintEvent(QPaintEvent* event)
{
    QPainter paint(this);
    paint.setClipRegion(event->region());
    paint.translate(width() / 2, height() / 2);
    paint.scale(scale_, scale_);
    paint.translate(panX_, panY_);

    for (const PixmapLocation& entity : drawLocations_) {
        paint.save();
        QPointF centre = entity.location_;
        const qreal scale = (entity.radius_ * 2) / std::max(pixmap_.width(), pixmap_.height());

        QRectF targetRect(QPointF(0, 0), pixmap_.size() * scale);
        targetRect.translate(centre - QPointF(targetRect.width() / 2, targetRect.height() / 2));

        // Rotate our pixmap
        paint.translate(centre);
        paint.rotate(entity.rotation_);
        paint.translate(-centre);

        // paint.setClipping(false); // This doesn't fix it so it isn't clipping
        paint.drawPixmap(targetRect, pixmap_, QRectF(pixmap_.rect()));
        // paint.setClipping(true); // This doesn't fix it so it isn't clipping
        paint.restore();
    }
}

QPointF MainWindow::transformWindowToSimCoords(const QPointF& local) const
{
    qreal x = local.x();
    qreal y = local.y();
    // Sim is centred on screen
    x -= (width() / 2);
    y -= (height() / 2);
    // Sim is scaled
    x /= scale_;
    y /= scale_;
    // Sim is transformed
    x -= panX_;
    y -= panY_;
    return { x, y };
}

QPointF MainWindow::transformSimToWindowCoords(const QPointF& sim) const
{
    qreal x = sim.x();
    qreal y = sim.y();
    // Sim is transformed
    x += panX_;
    y += panY_;
    // Sim is scaled
    x *= scale_;
    y *= scale_;
    // Sim is centred on screen
    x += (width() / 2);
    y += (height() / 2);
    return { x, y };
}

qreal MainWindow::randomNumber(qreal min, qreal max)
{
    static std::mt19937 entropy = std::mt19937();

    std::uniform_real_distribution<qreal> distribution{ min, max };
    //    distribution.param(typename decltype(distribution)::param_type(min, max));
    return distribution(entropy);
}

My research into the issue

Screen captures of the minimum reproducable code example

Top left above shows all pixmaps drawing correctly, at random angles

Top right above shows the same instance as top left, panned so that the pixmaps are over the fuzzy boundary, with the bottom rightmost pixmaps not being drawn (or to be more accurate, are being drawn as entirely transparent pixels, due to the transparent edge being smeared over the entire image)

Bottom left shows all pixmaps being rotated by 0.1 degree, this leads to a sharp boundary, which when the square is panned to overlap it, clips the square to a rectangle.

Bottom right shows a small range of random angles, between 0.0 and 10.0, this leads to a slightly fuzzier, but still vertical edge, this looks similar to bottom left, but as well as the sharp clipped edge, there is also a slight gradient effect as some of the pixmaps closer to the edge have also not be rendered correctly.

I have tried turning clipping off in the QPainter when drawing the pixmaps, this has had no effect.

I have tried seperately saving a copy of the QPainters transform and setting it back afterwards, this had no effect.

I have tried upgrading to Qt 6.0.3 (which claimed to have solved a number of graphical bugs), issue still present.

The absolute coordinates don't matter, I can offset all of the locations by QPointF(-10000, 10000), pan over to them and the dissapearing point is in the same relative position in the window.

To see the bug in action, scroll out, then click and drag in the window to move the pixmaps to the lower right of the screen, depending on how far out you have zoomed, a number of the pixmaps will no longer be drawn.

Update

I have also discovered that making the original QPixmap larger makes the issue worse, i.e. the boundary becomes apparent at more zoomed in levels, plus further rendering abberations occur. Note that they are still being scaled down to the same size as before, there are just more source pixels.

I changed pixmap_(30, 50) to pixmap_(300, 500)

Further abberations

The image above shows that when panning to move the pixmaps towards the bottom right, they dissapear sooner than before (i.e. while zoomed further in and more towards the top left), the curved arrow indicates the movement of the pixmaps drawn in an arc beyond the dissapearance boundary, they seem to be moving faster than the correct pixmaps are drawn as they are moved.

EDIT: Closer inspection shows that the apparent circular motion is not real, the order in which pixmaps are appearing and dissapearing just made it look that way. With the below update, you can see that there are concentric rings where the pixmaps that have dissapeard re-appear (very briefly) in the correct place, but the re-appeaance is only for a thin window that seems to be narrower than the size of the pixmap, so the content that is drawn appears again to be stuck in place, but the part shown is clipped.

Update

To see the pixmaps "stick" at the boundary, you can adjust the contents of MainWindow::MainWindow() to

MainWindow::MainWindow()
    : pixmap_(500, 500)
{
    setAutoFillBackground(true);
    
    constexpr int count = 10000;
    constexpr qreal area = 10000.0;
    constexpr qreal size = 44.0;
    
    for (int i = 0; i < count; ++i) {
        qreal rotation = randomNumber(0.0, 360.0);
        drawLocations_.push_back(PixmapLocation{ QPointF(randomNumber(-area, area), randomNumber(-area, area)), rotation, size });
    }
    
    // Make edges transparent (the middle will be drawn over)
    pixmap_.fill(QColor::fromRgba(0x000000FF));
    
    /*
     * Fill with magenta almost up to the edge
     *
     * The transparent edge is required to see the effect, the misdrawn pixmaps
     * appear to be a smear of the edge closest to the boundary between proper
     * rendering and misrendering. If the pixmap is a solid block of colour then
     * the effect is masked by the fact that the smeared edge looks the same as
     * the correctly drawn pixmap.
     */
    QPainter p(&pixmap_);
    p.setPen(Qt::NoPen);
    constexpr int smallInset = 1;
    const int bigInset = std::min(pixmap_.width(), pixmap_.height()) / 5;
    p.fillRect(pixmap_.rect().adjusted(smallInset, smallInset, -smallInset, -smallInset), Qt::magenta);
    p.fillRect(pixmap_.rect().adjusted(bigInset, bigInset, -bigInset, -bigInset), Qt::green);
    
    update();
}

Which results in See bottom right for partially drawn pixmaps see right hand edge for squares that appear to have been clipped. When moving them around, the square seems to get stuck in place and instead of the edge at the boundary dissapearing, the edge furthest from the boundary dissapears first.

c++
qt
transform
qpainter
qpixmap
asked on Stack Overflow Apr 14, 2021 by Troyseph • edited May 6, 2021 by Troyseph

1 Answer

1

This issue is very interesting. As far as I could test, your code looks good, I feel like this is a Qt bug, and I think you need to report it to Qt: https://bugreports.qt.io/. You should post a single piece of code to illustrate the issue, your second one from your "Update" edit is good: it makes it easy to reproduce the issu. Maybe you should also post a small video to illustrate how things are getting wrong when you zoom in/out or move the area with the mouse.

I tried some alternatives to hopefully find a workaround, but I found none:

  • Tried to use a QImage rather than a QPixmap, same issue
  • Tried to load the pixmap from a frozen png/qrc file, same issue
  • Tried to use QTransform to play with scale/translation/rotation, same issue
  • Tried Linux and Windows 10: same issue observed

Note that:

  • If you don't rotate (comment paint.rotate(entity.rotation_);), the issue is not visible
  • If your pixmap is a simple mono-colored square (simply fill your pixmap with a single color using pixmap_.fill(QColor::fromRgba(0x12345600));), the issue is not visible anymore. That's the most surprising, looks like a pixel from the image is being reused as background and messes things up but if all the image pixels are the same it does not lead to any display issue.

Workaround proposed by the Qt team

"The issue can easily be worked around by enabling the SmoothPixmapTransform render hint on the painter"

answered on Stack Overflow May 2, 2021 by jpo38 • edited May 6, 2021 by Troyseph

User contributions licensed under CC BY-SA 3.0