diff options
| author | Jarkko Koivikko <jarkko.koivikko@code-q.fi> | 2026-08-22 17:38:29 +0300 |
|---|---|---|
| committer | Qt Cherry-pick Bot <cherrypick_bot@qt-project.org> | 2026-09-04 15:55:00 +0000 |
| commit | d9ae6cd5cfcd0ebb8154c8a99bd86c53a1ca7221 (patch) | |
| tree | 0d2a1fcd4641a256eff12e325bde8b17efc5998f | |
| parent | da07b4a589dda530a0ef5a667d04780029a412f1 (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.cpp | 15 | ||||
| -rw-r--r-- | tests/auto/quick/qquickitemlayer/data/DevicePixelStripes.qml | 40 | ||||
| -rw-r--r-- | tests/auto/quick/qquickitemlayer/tst_qquickitemlayer.cpp | 79 |
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; |
