aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJarkko Koivikko <jarkko.koivikko@code-q.fi>2026-08-22 17:38:29 +0300
committerQt Cherry-pick Bot <cherrypick_bot@qt-project.org>2026-09-04 15:55:00 +0000
commitd9ae6cd5cfcd0ebb8154c8a99bd86c53a1ca7221 (patch)
tree0d2a1fcd4641a256eff12e325bde8b17efc5998f
parentda07b4a589dda530a0ef5a667d04780029a412f1 (diff)
Pair the layer texture size with its destination rect
QQuickShaderEffectSource rounds the texture size and the destination rect of its node independently, so the two only agree by luck. When they disagree the texture is rescaled on the way to the screen, and as QQuickItemLayer leaves smooth off the filtering is Nearest, so the result is a duplicated or dropped pixel column rather than a soft edge. 51f24ef120 established the pairing for integer scale factors by ceiling the destination rect to whole logical units so that it matched a texture of qCeil(w) * dpr. That holds the two together, but only by growing the destination: a layered item whose width is 100.3 is drawn 100.7 units wide, magnifying its content. The recent change to compute the texture as qCeil(w * dpr) fixed that magnification at fractional ratios and gave up the pairing above dpr 1 in exchange. Neither has to be sacrificed. Derive both from the item's size in device pixels: the texture is qRound(sourceSize * dpr), and the destination rect is the item's own extent snapped to the same grid, qRound(itemSize * dpr) / dpr. The two then agree exactly whenever the source rect is the item's own rect, which is every layer, and the content keeps its true scale to within half a device pixel. Counting the two failures over 16000 fractional widths with the item at a whole position, where "1:1" is textureSize == destination * dpr and "scale" is textureSize == width * dpr: 1:1 scale dpr before qCeil this before qCeil this 1.0 0 0 0 6858 6858 0 1.5 8001 10287 0 11430 6858 0 2.0 0 6858 0 11430 6858 0 3.0 0 9144 0 11430 6858 0 The pad the destination adds for an item at a fractional position is now taken in device pixels too, so it is one device pixel when the item is off the grid and none when it is on it - where it previously added a whole logical unit either way, unpaired against the texture. It cannot compensate for an ancestor's fractional offset, since x() is parent-local; covering that means growing the source rect rather than the target, and is left alone. The pairing reaches layer.enabled without an effect. With layer.effect set the destination is the effect's own quad at the full fractional size, so the mismatch survives there. It also repairs QtQuick.VectorImage's SVG filter chains, which already set an explicit qRound texture size against a ceiled destination - the same mismatch approached from the other side. Sizes the caller set explicitly are untouched, and layer-pixel-coverage renders identically: at dpr 1 its 100 and 100.5 unit edges give the same texture and the same destination rect under both formulas. Task-number: QTBUG-149373 Task-number: QTBUG-101269 Assisted-by: Claude Code Change-Id: I16ae0aca1e25784ea4530c4c62d360b6c09e3a3f Reviewed-by: Laszlo Agocs <laszlo.agocs@qt.io> (cherry picked from commit 97beec0b03a23fa8c1c5416ebe651c8ce86fbaec) Reviewed-by: Qt Cherry-pick Bot <cherrypick_bot@qt-project.org>
-rw-r--r--src/quick/items/qquickshadereffectsource.cpp15
-rw-r--r--tests/auto/quick/qquickitemlayer/data/DevicePixelStripes.qml40
-rw-r--r--tests/auto/quick/qquickitemlayer/tst_qquickitemlayer.cpp79
3 files changed, 127 insertions, 7 deletions
diff --git a/src/quick/items/qquickshadereffectsource.cpp b/src/quick/items/qquickshadereffectsource.cpp
index 8d01b7b62c..3be7f50031 100644
--- a/src/quick/items/qquickshadereffectsource.cpp
+++ b/src/quick/items/qquickshadereffectsource.cpp
@@ -673,11 +673,11 @@ QSGNode *QQuickShaderEffectSource::updatePaintNode(QSGNode *oldNode, UpdatePaint
: m_sourceRect;
m_texture->setRect(sourceRect);
QQuickItemPrivate *d = static_cast<QQuickItemPrivate *>(QObjectPrivate::get(this));
- const float dpr = d->effectiveDevicePixelRatio();
+ const qreal dpr = d->effectiveDevicePixelRatio();
QSize textureSize = m_textureSize.isEmpty()
- ? QSize(qCeil(qAbs(sourceRect.width()) * dpr), qCeil(qAbs(sourceRect.height()) * dpr))
+ ? (QSizeF(qAbs(sourceRect.width()), qAbs(sourceRect.height())) * dpr)
+ .toSize().expandedTo(QSize(1, 1))
: m_textureSize;
- Q_ASSERT(!textureSize.isEmpty());
const QSize minTextureSize = d->sceneGraphContext()->minimumFBOSize();
// Keep power-of-two by doubling the size.
@@ -738,10 +738,11 @@ QSGNode *QQuickShaderEffectSource::updatePaintNode(QSGNode *oldNode, UpdatePaint
node->setHorizontalWrapMode(hWrap);
node->setVerticalWrapMode(vWrap);
- qreal dummy;
- qreal fx = std::modf(x(), &dummy);
- qreal fy = std::modf(y(), &dummy);
- QRectF targetRect(0, 0, qCeil(fx) + qCeil(width()), qCeil(fy) + qCeil(height()));
+ const qreal dx = x() * dpr;
+ const qreal dy = y() * dpr;
+ const QSizeF devicePixelSize(qCeil(dx - std::floor(dx)) + qMax(1, qRound(width() * dpr)),
+ qCeil(dy - std::floor(dy)) + qMax(1, qRound(height() * dpr)));
+ const QRectF targetRect(QPointF(0, 0), devicePixelSize / dpr);
node->setTargetRect(targetRect);
node->setInnerTargetRect(targetRect);
diff --git a/tests/auto/quick/qquickitemlayer/data/DevicePixelStripes.qml b/tests/auto/quick/qquickitemlayer/data/DevicePixelStripes.qml
new file mode 100644
index 0000000000..b09e4f4aa0
--- /dev/null
+++ b/tests/auto/quick/qquickitemlayer/data/DevicePixelStripes.qml
@@ -0,0 +1,40 @@
+// Copyright (C) 2026 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
+
+import QtQuick
+
+Item {
+ id: root
+
+ property real dpr: 1
+ property int pixelWidth: 1
+ property int pixelHeight: 1
+ property real position: 0
+
+ width: 420
+ height: 380
+
+ Item {
+ objectName: "target"
+ layer.enabled: true
+ x: root.position
+ width: root.pixelWidth / root.dpr
+ height: root.pixelHeight / root.dpr
+
+ Rectangle {
+ anchors.fill: parent
+ color: "black"
+ }
+
+ Repeater {
+ model: Math.floor(root.pixelWidth / 2)
+
+ Rectangle {
+ x: (index * 2 + 1) / root.dpr
+ width: 1 / root.dpr
+ height: root.pixelHeight / root.dpr
+ color: "white"
+ }
+ }
+ }
+}
diff --git a/tests/auto/quick/qquickitemlayer/tst_qquickitemlayer.cpp b/tests/auto/quick/qquickitemlayer/tst_qquickitemlayer.cpp
index fb18d2be54..deda5d0e12 100644
--- a/tests/auto/quick/qquickitemlayer/tst_qquickitemlayer.cpp
+++ b/tests/auto/quick/qquickitemlayer/tst_qquickitemlayer.cpp
@@ -13,7 +13,10 @@
#include <QtQuickTestUtils/private/qmlutils_p.h>
#include <QtQuickTestUtils/private/viewtestutils_p.h>
+#include <QtCore/qscopeguard.h>
+#include <QtGui/qscreen.h>
#include <QtGui/private/qguiapplication_p.h>
+#include <QtGui/private/qhighdpiscaling_p.h>
#include <QtGui/qpa/qplatformintegration.h>
class tst_QQuickItemLayer: public QQmlDataTest
@@ -64,6 +67,9 @@ private slots:
void effectSourceResizeToItem();
+ void layerBlitsTextureOneToOne_data();
+ void layerBlitsTextureOneToOne();
+
private:
void mirroringCheck(int mirroring, int x, bool shouldMirror, const QImage &fb);
};
@@ -475,6 +481,79 @@ void tst_QQuickItemLayer::effectSourceResizeToItem() // QTBUG-104442
QCOMPARE(effectSource->size(), image->size());
}
+void tst_QQuickItemLayer::layerBlitsTextureOneToOne_data()
+{
+ QTest::addColumn<qreal>("position");
+
+ QTest::newRow("aligned") << 0.0;
+ QTest::newRow("one device pixel right") << 0.5;
+ QTest::newRow("one device pixel left") << -0.5;
+}
+
+void tst_QQuickItemLayer::layerBlitsTextureOneToOne()
+{
+ QFETCH(qreal, position);
+
+#if !QT_CONFIG(highdpiscaling)
+ QSKIP("This test requires high-DPI scaling support");
+#endif
+
+ if (isOffscreen())
+ QSKIP(skipOffscreenMsg);
+
+ constexpr qreal dpr = 2.0;
+ constexpr int pixelWidth = 201;
+ constexpr int pixelHeight = 101;
+
+ QVERIFY2(QGuiApplication::allWindows().isEmpty(),
+ "setGlobalFactor() only takes effect while no window exists");
+
+ QScreen *screen = QGuiApplication::primaryScreen();
+ QHighDpiScaling::setGlobalFactor(dpr / screen->devicePixelRatio());
+ QGuiApplicationPrivate::resetCachedDevicePixelRatio();
+ const auto restoreScaling = qScopeGuard([]{
+ QHighDpiScaling::setGlobalFactor(1);
+ QGuiApplicationPrivate::resetCachedDevicePixelRatio();
+ });
+ QCOMPARE(screen->devicePixelRatio(), dpr);
+
+ QQuickView view;
+ view.setInitialProperties({{ "dpr", dpr },
+ { "pixelWidth", pixelWidth },
+ { "pixelHeight", pixelHeight },
+ { "position", position }});
+ view.setSource(testFileUrl("DevicePixelStripes.qml"));
+ view.showNormal();
+ QVERIFY(QTest::qWaitForWindowExposed(&view));
+ QCOMPARE(view.effectiveDevicePixelRatio(), dpr);
+
+ if (view.rendererInterface()->graphicsApi() == QSGRendererInterface::Software)
+ QSKIP("The software backend maps the target rect through a different path");
+
+ const QImage fb = view.grabWindow();
+ if (fb.width() < pixelWidth || fb.height() < pixelHeight)
+ QSKIP("Window is smaller than the item under test");
+
+ const int offset = qRound(position * dpr);
+ const int firstColumn = qMax(0, offset);
+ const int lastColumn = qMin(fb.width(), offset + pixelWidth);
+ const int row = pixelHeight / 2;
+ int misplaced = 0;
+ int firstMisplaced = -1;
+ for (int x = firstColumn; x < lastColumn; ++x) {
+ const QRgb expected = ((x - offset) % 2) ? qRgb(0xff, 0xff, 0xff) : qRgb(0, 0, 0);
+ if (fb.pixel(x, row) != expected) {
+ ++misplaced;
+ if (firstMisplaced < 0)
+ firstMisplaced = x;
+ }
+ }
+ QVERIFY2(misplaced == 0,
+ qPrintable(QStringLiteral("%1 of %2 pixels differ, first at x=%3")
+ .arg(misplaced).arg(lastColumn - firstColumn)
+ .arg(firstMisplaced)));
+}
+
void tst_QQuickItemLayer::mirroringCheck(int mirroring, int x, bool shouldMirror, const QImage &fb)
{
int offset = 10;