I have a custom QQuickPaintedItem that draws whatever the user has painted onto it with the mouse. Up until now the implementation has been super simple, just drawing the entire image, even when zoomed in. I've noticed that the FPS is really slow when zoomed in and panning the image around, so I decided to incrementally improve the painting performance.
The current step I'm at is only drawing the subset of the image that is visible. To do this, I'm using this overload of QPainter::drawImage()
. Here's the smallest possible example that allows zooming and panning (the important part is recalculateStuff()
):
main.cpp:
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QDebug>
#include <QQuickItem>
#include <QImage>
#include <QQuickPaintedItem>
#include <QPainter>
#include <QtMath>
class ImageCanvas : public QQuickPaintedItem
{
Q_OBJECT
Q_PROPERTY(QPoint offset READ offset WRITE setOffset NOTIFY offsetChanged)
Q_PROPERTY(int zoom READ zoom WRITE setZoom NOTIFY zoomChanged)
Q_PROPERTY(QRect sourceRect READ sourceRect NOTIFY sourceRectChanged)
Q_PROPERTY(QRect targetRect READ targetRect NOTIFY targetRectChanged)
public:
ImageCanvas() :
mZoom(1)
{
// Construct a test image from coloured squares.
mImage = QImage(500, 500, QImage::Format_ARGB32);
QPainter painter(&mImage);
for (int y = 0; y < mImage.width(); y += 50) {
for (int x = 0; x < mImage.width(); x += 50) {
const QColor colour((x / 500.0) * 255, (y / 500.0) * 255, 0);
painter.fillRect(x, y, 50, 50, colour);
}
}
recalculateStuff();
}
QPoint offset() const {
return mOffset;
}
void setOffset(const QPoint &offset) {
mOffset = offset;
recalculateStuff();
emit offsetChanged();
}
int zoom() const {
return mZoom;
}
void setZoom(int zoom) {
mZoom = qMax(1, zoom);
recalculateStuff();
emit zoomChanged();
}
QRect targetRect() const {
return mTargetRect;
}
QRect sourceRect() const {
return mSourceRect;
}
void recalculateStuff() {
const QRect oldTargetRect = mTargetRect;
const QRect oldSourceRect = mSourceRect;
mTargetRect = QRect(0, 0, mImage.width() * mZoom, mImage.height() * mZoom);
mSourceRect = QRect(0, 0, mImage.width(), mImage.height());
const int contentLeft = mOffset.x();
if (contentLeft < 0) {
// The left edge of the content is outside of the viewport, so don't draw that portion.
mTargetRect.setX(qAbs(contentLeft));
mSourceRect.setX(qAbs(contentLeft));
}
const int contentTop = mOffset.y();
if (contentTop < 0) {
// The top edge of the content is outside of the viewport, so don't draw that portion.
mTargetRect.setY(qAbs(contentTop));
mSourceRect.setY(qAbs(contentTop));
}
const int contentRight = mOffset.x() + mImage.width();
const int viewportRight = qFloor(width());
if (contentRight > viewportRight) {
// The right edge of the content is outside of the viewport, so don't draw that portion.
mTargetRect.setWidth(mTargetRect.width() - (contentRight - viewportRight));
mSourceRect.setWidth(mSourceRect.width() - (contentRight - viewportRight));
}
const int contentBottom = mOffset.y() + mImage.height();
const int viewportBottom = qFloor(height());
if (contentBottom > viewportBottom) {
// The bottom edge of the content is outside of the viewport, so don't draw that portion.
mTargetRect.setHeight(mTargetRect.height() - (contentBottom - viewportBottom));
mSourceRect.setHeight(mSourceRect.height() - (contentBottom - viewportBottom));
}
if (mTargetRect != oldTargetRect)
emit targetRectChanged();
if (mSourceRect != oldSourceRect)
emit sourceRectChanged();
update();
}
void paint(QPainter *painter) override {
painter->translate(mOffset);
painter->drawImage(mTargetRect, mImage, mSourceRect);
}
protected:
void geometryChanged(const QRectF &, const QRectF &) override {
recalculateStuff();
}
signals:
void offsetChanged();
void zoomChanged();
void sourceRectChanged();
void targetRectChanged();
private:
QPoint mOffset;
int mZoom;
QRect mSourceRect;
QRect mTargetRect;
QImage mImage;
};
int main(int argc, char *argv[])
{
QGuiApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
QGuiApplication app(argc, argv);
qmlRegisterType<ImageCanvas>("App", 1, 0, "ImageCanvas");
QQmlApplicationEngine engine;
engine.load(QUrl("qrc:/main.qml"));
return app.exec();
}
#include "main.moc"
main.qml:
import QtQuick 2.10
import QtQuick.Controls 2.3
import App 1.0
ApplicationWindow {
id: window
width: 600
height: 600
visible: true
title: "targetRect=" + canvas.targetRect + " sourceRect=" + canvas.sourceRect
ImageCanvas {
id: canvas
anchors.fill: parent
offset: Qt.point(xOffsetSlider.value, yOffsetSlider.value)
zoom: zoomSpinBox.value
}
SpinBox {
id: zoomSpinBox
from: 1
to: 8
}
Slider {
id: xOffsetSlider
anchors.bottom: parent.bottom
width: parent.width - height
from: -window.width * canvas.zoom
to: window.width * canvas.zoom
ToolTip {
id: xOffsetToolTip
parent: xOffsetSlider.handle
visible: true
text: xOffsetSlider.value.toFixed(1)
Binding {
target: xOffsetToolTip
property: "visible"
value: !yOffsetToolTip.visible
}
}
}
Slider {
id: yOffsetSlider
anchors.right: parent.right
height: parent.height - width
orientation: Qt.Vertical
from: -window.height * canvas.zoom
scale: -1
to: window.height * canvas.zoom
ToolTip {
id: yOffsetToolTip
parent: yOffsetSlider.handle
text: yOffsetSlider.value.toFixed(1)
Binding {
target: yOffsetToolTip
property: "visible"
value: !xOffsetToolTip.visible
}
}
}
}
This works well when the zoom level is 1, but as soon as you zoom in, the target and source rects are wrong. I've been trying to fix it but I can't quite wrap my head around it. For example, one naive idea was to do all calculations with non-zoomed coordinates, and then scale the target rect afterwards:
diff --git a/main.cpp b/main.cpp
index 8409baf..06841b7 100644
--- a/main.cpp
+++ b/main.cpp
@@ -64,24 +64,24 @@ public:
const QRect oldTargetRect = mTargetRect;
const QRect oldSourceRect = mSourceRect;
- mTargetRect = QRect(0, 0, mImage.width() * mZoom, mImage.height() * mZoom);
+ mTargetRect = QRect(0, 0, mImage.width(), mImage.height());
mSourceRect = QRect(0, 0, mImage.width(), mImage.height());
- const int contentLeft = mOffset.x();
+ const int contentLeft = mOffset.x() / mZoom;
if (contentLeft < 0) {
// The left edge of the content is outside of the viewport, so don't draw that portion.
mTargetRect.setX(qAbs(contentLeft));
mSourceRect.setX(qAbs(contentLeft));
}
- const int contentTop = mOffset.y();
+ const int contentTop = mOffset.y() / mZoom;
if (contentTop < 0) {
// The top edge of the content is outside of the viewport, so don't draw that portion.
mTargetRect.setY(qAbs(contentTop));
mSourceRect.setY(qAbs(contentTop));
}
- const int contentRight = mOffset.x() + mImage.width();
+ const int contentRight = (mOffset.x() / mZoom) + mImage.width();
const int viewportRight = qFloor(width());
if (contentRight > viewportRight) {
// The right edge of the content is outside of the viewport, so don't draw that portion.
@@ -89,7 +89,7 @@ public:
mSourceRect.setWidth(mSourceRect.width() - (contentRight - viewportRight));
}
- const int contentBottom = mOffset.y() + mImage.height();
+ const int contentBottom = (mOffset.y() / mZoom) + mImage.height();
const int viewportBottom = qFloor(height());
if (contentBottom > viewportBottom) {
// The bottom edge of the content is outside of the viewport, so don't draw that portion.
@@ -97,6 +97,11 @@ public:
mSourceRect.setHeight(mSourceRect.height() - (contentBottom - viewportBottom));
}
+ mTargetRect.setX(mTargetRect.x() * mZoom);
+ mTargetRect.setY(mTargetRect.y() * mZoom);
+ mTargetRect.setWidth(mTargetRect.width() * mZoom);
+ mTargetRect.setHeight(mTargetRect.height() * mZoom);
+
if (mTargetRect != oldTargetRect)
emit targetRectChanged();
This doesn't work, as the image is increasingly stretched as you e.g. pan downwards with the zoom set to 2, instead of remaining at the same scale.
So, what is the correct way to calculate the target and source rects to ensure that I only draw the visible section of the image when it's zoomed in?