From 06843364fe013dc513937b22cef3c5d29d5d7628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 17:47:52 -0400 Subject: [PATCH] Soft Light by Photoshop's formula; stacks, coverage and adjustments in every mode proven (13.9a) --- src/Rendering/LayerRenderer.cpp | 2 - src/Rendering/SeparableBlend.cpp | 6 + src/Rendering/SeparableBlend.h | 2 +- tests/BlendModeTests.cpp | 7 +- tests/BlendingTests.cpp | 220 +++++++++++++++++++++++++++++++ tests/ClippingStackTests.cpp | 38 ++++++ 6 files changed, 268 insertions(+), 7 deletions(-) create mode 100644 tests/BlendingTests.cpp diff --git a/src/Rendering/LayerRenderer.cpp b/src/Rendering/LayerRenderer.cpp index cd7e691..44d8e8f 100644 --- a/src/Rendering/LayerRenderer.cpp +++ b/src/Rendering/LayerRenderer.cpp @@ -20,8 +20,6 @@ QPainter::CompositionMode compositionMode(LayerBlendMode mode) return QPainter::CompositionMode_Screen; case LayerBlendMode::overlay: return QPainter::CompositionMode_Overlay; - case LayerBlendMode::softLight: - return QPainter::CompositionMode_SoftLight; case LayerBlendMode::darken: return QPainter::CompositionMode_Darken; case LayerBlendMode::lighten: diff --git a/src/Rendering/SeparableBlend.cpp b/src/Rendering/SeparableBlend.cpp index d8779ce..1c232be 100644 --- a/src/Rendering/SeparableBlend.cpp +++ b/src/Rendering/SeparableBlend.cpp @@ -1,5 +1,6 @@ #include "Rendering/SeparableBlend.h" #include +#include #include namespace { @@ -22,6 +23,7 @@ float dodge(float backdrop, float source) bool SeparableBlend::needsSurface(LayerBlendMode mode) { switch (mode) { + case LayerBlendMode::softLight: case LayerBlendMode::linearBurn: case LayerBlendMode::linearDodge: case LayerBlendMode::vividLight: @@ -39,6 +41,10 @@ bool SeparableBlend::needsSurface(LayerBlendMode mode) float SeparableBlend::channel(LayerBlendMode mode, float backdrop, float source) { switch (mode) { + case LayerBlendMode::softLight: + // Photoshop's, as Core Image's: the PDF's strays 17 levels. + return source <= 0.5f ? 2 * backdrop * source + backdrop * backdrop * (1 - 2 * source) + : 2 * backdrop * (1 - source) + std::sqrt(backdrop) * (2 * source - 1); case LayerBlendMode::linearBurn: return std::max(0.0f, backdrop + source - 1); case LayerBlendMode::linearDodge: diff --git a/src/Rendering/SeparableBlend.h b/src/Rendering/SeparableBlend.h index a603e01..595b84e 100644 --- a/src/Rendering/SeparableBlend.h +++ b/src/Rendering/SeparableBlend.h @@ -1,7 +1,7 @@ #pragma once #include "Document/LayerAppearance.h" -// Swift's SeparableBlend: channel-by-channel modes QPainter lacks. +// Swift's SeparableBlend: channel-by-channel modes QPainter lacks or differs on. namespace SeparableBlend { // Blended by hand against a surface, Swift's `needsSurface`. bool needsSurface(LayerBlendMode mode); diff --git a/tests/BlendModeTests.cpp b/tests/BlendModeTests.cpp index c2878ef..c9ca8b9 100644 --- a/tests/BlendModeTests.cpp +++ b/tests/BlendModeTests.cpp @@ -70,11 +70,10 @@ Rgb reference(LayerBlendMode mode, const Rgb &backdrop, const Rgb &source) case LayerBlendMode::multiply: result[channel] = b * s; break; case LayerBlendMode::screen: result[channel] = b + s - b * s; break; case LayerBlendMode::overlay: result[channel] = hardLight(s, b); break; - case LayerBlendMode::softLight: { - const double d = b <= 0.25 ? ((16 * b - 12) * b + 4) * b : std::sqrt(b); - result[channel] = s <= 0.5 ? b - (1 - 2 * s) * b * (1 - b) : b + (2 * s - 1) * (d - b); + // Photoshop's, as Core Image computes it (Compositor 1.4). + case LayerBlendMode::softLight: + result[channel] = s <= 0.5 ? 2 * b * s + b * b * (1 - 2 * s) : 2 * b * (1 - s) + std::sqrt(b) * (2 * s - 1); break; - } case LayerBlendMode::darken: result[channel] = std::min(b, s); break; case LayerBlendMode::lighten: result[channel] = std::max(b, s); break; case LayerBlendMode::difference: result[channel] = std::abs(b - s); break; diff --git a/tests/BlendingTests.cpp b/tests/BlendingTests.cpp new file mode 100644 index 0000000..41d1944 --- /dev/null +++ b/tests/BlendingTests.cpp @@ -0,0 +1,220 @@ +#include "Rendering/EditorCanvas.h" +#include "IO/ImageExporter.h" +#include "RenderFixtures.h" +#include +#include +#include + +// Compositor 1.4's blending: stacks, adjustments in a mode, Soft Light. +namespace { +ImportedImage filled(int width, int height, QColor colour, const QString &name) +{ + const QImage image = solid(width, height, colour.rgba()); + return ImportedImage(image, image, name); +} + +// A canvas at 1:1 over the document. +struct Scene { + EditorSession session; + CanvasView canvas{session}; + Scene(int width, int height) + { + session.createDocument(width, height); + session.setShowsTransformControls(false); + canvas.resize(width + 40, height + 40); + session.viewport.resize(QSizeF(width + 40, height + 40), 1, QSizeF(width, height)); + session.zoom(1); + } + QImage exported() { return ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_ARGB32); } + // Copy Merged of the whole canvas. + QImage merged() + { + session.selectAll(); + const QImage image = session.renderMergedPixels().value().image.convertToFormat(QImage::Format_ARGB32); + session.deselect(); + return image; + } + // The worst channel gap to the export, inside the border. + int canvasGap() + { + const QImage expected = exported(), shot = canvas.grab().toImage().convertToFormat(QImage::Format_ARGB32); + const QPointF origin = session.viewport.viewPoint(QPointF(0, 0), session.document().value().size()); + int worst = 0; + for (int y = 1; y < expected.height() - 1; ++y) { + for (int x = 1; x < expected.width() - 1; ++x) { + const QRgb wanted = expected.pixel(x, y), shown = shot.pixel(int(origin.x() + x + 0.5), int(origin.y() + y + 0.5)); + worst = std::max({worst, std::abs(qRed(wanted) - qRed(shown)), std::abs(qGreen(wanted) - qGreen(shown)), + std::abs(qBlue(wanted) - qBlue(shown))}); + } + } + return worst; + } + void mode(LayerBlendMode mode) + { + session.setLayerBlendMode(mode); + } +}; + +std::array rgb(QRgb pixel) +{ + return {qRed(pixel), qGreen(pixel), qBlue(pixel)}; +} + +bool near(QRgb pixel, std::array expected, int tolerance) +{ + const std::array actual = rgb(pixel); + for (int channel = 0; channel < 3; ++channel) { + if (std::abs(actual[size_t(channel)] - expected[size_t(channel)]) > tolerance) + return false; + } + return true; +} + +QString text(QRgb pixel) +{ + return QStringLiteral("(%1, %2, %3)").arg(qRed(pixel)).arg(qGreen(pixel)).arg(qBlue(pixel)); +} +} + +class BlendingTests : public QObject { + Q_OBJECT +private slots: + void clippingStacksBlendInTheirBasesMode_data(); + void clippingStacksBlendInTheirBasesMode(); + void adjustmentsBlendInAnyMode_data(); + void adjustmentsBlendInAnyMode(); + void softLightMatchesPhotoshop(); + void softEdgedLayersBlendTheSame_data(); + void softEdgedLayersBlendTheSame(); +}; + +void BlendingTests::clippingStacksBlendInTheirBasesMode_data() +{ + QTest::addColumn("mode"); + QTest::newRow("linearDodge") << LayerBlendMode::linearDodge; + QTest::newRow("colorDodge") << LayerBlendMode::colorDodge; + QTest::newRow("multiply") << LayerBlendMode::multiply; +} + +// Swift's: the stack takes its base's mode wherever drawn. +void BlendingTests::clippingStacksBlendInTheirBasesMode() +{ + QFETCH(LayerBlendMode, mode); + Scene scene(100, 100); + EditorSession &session = scene.session; + session.insert(filled(100, 100, QColor::fromRgbF(0.4f, 0.2f, 0.1f), QStringLiteral("Base"))); + session.insert(filled(100, 100, QColor::fromRgbF(0.3f, 0.3f, 0.3f), QStringLiteral("Blended"))); + const QUuid blended = session.activeLayerID().value(); + scene.mode(mode); + session.insert(filled(50, 50, QColor::fromRgbF(0.2f, 0.05f, 0), QStringLiteral("Clipped")), QPointF(25, 25)); + QVERIFY(session.linkMask(blended, session.activeLayerID().value())); + const QImage exported = scene.exported(), merged = scene.merged(); + // Under the clipped layer, and the stack's base alone. + for (const QPoint point : {QPoint(20, 20), QPoint(80, 80)}) + QCOMPARE(text(merged.pixel(point)), text(exported.pixel(point))); + if (mode == LayerBlendMode::linearDodge) { + QVERIFY2(near(exported.pixel(20, 20), {153, 64, 26}, 1), qPrintable(text(exported.pixel(20, 20)))); + QVERIFY2(near(exported.pixel(80, 80), {179, 128, 102}, 1), qPrintable(text(exported.pixel(80, 80)))); + } + QVERIFY(text(exported.pixel(20, 20)) != text(exported.pixel(80, 80))); + QVERIFY(scene.canvasGap() <= 1); +} + +void BlendingTests::adjustmentsBlendInAnyMode_data() +{ + QTest::addColumn("mode"); + QTest::addColumn("clipped"); + QTest::addColumn>("expected"); + // (0.2, 0.4, 0.6) inverted is (0.8, 0.6, 0.4). + QTest::newRow("difference") << LayerBlendMode::difference << false << QList{153, 51, 51}; + QTest::newRow("linearLight") << LayerBlendMode::linearLight << false << QList{204, 153, 102}; + QTest::newRow("vividLight in a stack") << LayerBlendMode::vividLight << true << QList{128, 128, 128}; + QTest::newRow("subtract in a stack") << LayerBlendMode::subtract << true << QList{0, 0, 51}; +} + +// Blended at full coverage, alpha restored, then mixed by opacity. +void BlendingTests::adjustmentsBlendInAnyMode() +{ + QFETCH(LayerBlendMode, mode); + QFETCH(bool, clipped); + QFETCH(QList, expected); + Scene scene(40, 30); + EditorSession &session = scene.session; + session.insert(filled(40, 30, QColor::fromRgbF(0.2f, 0.4f, 0.6f), QStringLiteral("Photo"))); + const QUuid photo = session.activeLayerID().value(); + session.addAdjustment(AdjustmentKind::invert); + session.setAdjustmentEditingID(std::nullopt); + const QUuid adjustment = session.activeLayerID().value(); + scene.mode(mode); + if (clipped) + QVERIFY(session.linkMask(photo, adjustment)); + const QImage exported = scene.exported(); + const QRgb shown = exported.pixel(10, 10); + QVERIFY2(near(shown, {expected[0], expected[1], expected[2]}, 1), qPrintable(text(shown))); + QCOMPARE(qAlpha(shown), 255); + QCOMPARE(text(scene.merged().pixel(10, 10)), text(shown)); + QVERIFY(scene.canvasGap() <= 1); + // At half opacity the result mixes halfway back. + session.selectLayer(adjustment); + session.setLayerOpacity(0.5); + const QRgb half = scene.exported().pixel(10, 10); + QVERIFY2(near(half, {(expected[0] + 51) / 2, (expected[1] + 102) / 2, (expected[2] + 153) / 2}, 1), qPrintable(text(half))); +} + +// Photoshop's: 2·b·(1 − s) + √b·(2s − 1) above half. +void BlendingTests::softLightMatchesPhotoshop() +{ + const auto blended = [](double backdrop, double source) { + Scene scene(10, 10); + scene.session.insert(filled(10, 10, QColor::fromRgbF(float(backdrop), float(backdrop), float(backdrop)), QStringLiteral("Base"))); + scene.session.insert(filled(10, 10, QColor::fromRgbF(float(source), float(source), float(source)), QStringLiteral("Soft"))); + scene.mode(LayerBlendMode::softLight); + const int exported = qRed(scene.exported().pixel(5, 5)); + if (qRed(scene.merged().pixel(5, 5)) != exported || scene.canvasGap() > 1) + return -1; + return exported; + }; + // 2·0.5·0.1 + √0.5·0.8 = 0.666: 170. + QCOMPARE(blended(0.5, 0.9), 170); + // Dark: √(5/255) is 0.140, so 36; the PDF gives 19. + QCOMPARE(blended(5 / 255.0, 1), 36); + // Below half, 2bs + b²(1 − 2s): 0.1375 here. + QCOMPARE(blended(0.25, 0.2), 35); +} + +void BlendingTests::softEdgedLayersBlendTheSame_data() +{ + QTest::addColumn("mode"); + QTest::newRow("softLight") << LayerBlendMode::softLight; + QTest::newRow("overlay") << LayerBlendMode::overlay; + QTest::newRow("multiply") << LayerBlendMode::multiply; + QTest::newRow("colorDodge") << LayerBlendMode::colorDodge; +} + +// A soft brush's layer, mostly partly clear, alike everywhere. +void BlendingTests::softEdgedLayersBlendTheSame() +{ + QFETCH(LayerBlendMode, mode); + Scene scene(60, 50); + const QImage photo = noise(60, 50, 4); + scene.session.insert(ImportedImage(photo, photo, QStringLiteral("Photo"))); + QImage soft(28, 18, QImage::Format_RGBA8888_Premultiplied); + soft.fill(Qt::transparent); + { + QPainter painter(&soft); + QRadialGradient gradient(QPointF(14, 9), 11); + gradient.setColorAt(0.3, QColor::fromRgbF(1, 0.05f, 0, 1)); + gradient.setColorAt(1, QColor::fromRgbF(1, 0.05f, 0, 0)); + painter.fillRect(soft.rect(), gradient); + } + scene.session.insert(ImportedImage(soft, soft, QStringLiteral("Paint")), QPointF(30, 23)); + scene.mode(mode); + const QImage exported = scene.exported(); + // Partly clear pixels blend, as do the opaque ones. + QVERIFY(exported.pixel(30, 23) != photo.pixel(30, 23) && exported.pixel(23, 23) != photo.pixel(23, 23)); + QCOMPARE(scene.merged(), exported); + QVERIFY(scene.canvasGap() <= 1); +} + +QTEST_MAIN(BlendingTests) +#include "BlendingTests.moc" diff --git a/tests/ClippingStackTests.cpp b/tests/ClippingStackTests.cpp index e152709..3e223b9 100644 --- a/tests/ClippingStackTests.cpp +++ b/tests/ClippingStackTests.cpp @@ -10,6 +10,7 @@ private slots: void aStackBlendsOnceWithItsBasesMode(); void laterChildrenCoverEarlierOnes(); void aStackInheritsThePaintersOpacity(); + void stacksAndCoverageTakeTheDevicesPixels(); }; void ClippingStackTests::init() @@ -138,5 +139,42 @@ void ClippingStackTests::aStackInheritsThePaintersOpacity() QCOMPARE(reds(surface), (std::array{127, 64, 16, 0})); } +// Compositor 1.4: surfaces in device pixels keep half units sharp. +void ClippingStackTests::stacksAndCoverageTakeTheDevicesPixels() +{ + Scene scene; + Layer white = placed(solid(4, 2, qRgba(255, 255, 255, 255))); + white.transform = placedAt({0, 0}, {4, 2}); + Layer stripe = placed(solid(1, 2, qRgba(255, 0, 0, 255))); + stripe.transform = placedAt({0.5, 0}, {1, 2}); + stripe.transform.sampling = LayerSampling::nearest; + const QUuid base = scene.add(white); + const QUuid child = scene.add(stripe); + scene.layers[child].source = base; + const auto row = [&](const std::vector &ids) { + LiveMaskRenderer live = scene.renderer(); + live.prepareStacks(ids, [](QUuid) { return std::nullopt; }, [](QUuid) { return LayerBlendMode::normal; }); + QImage surface = BrushRaster::context(8, 4, false); + QPainter painter(&surface); + painter.scale(2, 2); + for (const QUuid &id : ids) + live.drawComposite(id, painter); + painter.end(); + QList pixels; + for (int x = 0; x < 4; ++x) + pixels << surface.pixel(x, 1); + return pixels; + }; + const QRgb red = qRgba(255, 0, 0, 255), clear = qRgba(0, 0, 0, 0), whiteness = qRgba(255, 255, 255, 255); + QCOMPARE(row({base, child}), (QList{whiteness, red, red, whiteness})); + // Coverage from a source further down: a layer between. + const QUuid between = scene.add(placed(solid(2, 2, qRgba(0, 0, 0, 0)))); + const QUuid target = scene.add(white); + scene.layers[child].source = std::nullopt; + scene.layers[child].blendMode = LayerBlendMode::normal; + scene.layers[target].source = child; + QCOMPARE(row({child, between, target}), (QList{clear, whiteness, whiteness, clear})); +} + QTEST_MAIN(ClippingStackTests) #include "ClippingStackTests.moc"