Soft Light by Photoshop's formula; stacks, coverage and adjustments in every mode proven (13.9a)
This commit is contained in:
1 parent
cb85b86d58
commit
06843364fe
6 files changed
+268
-7
No files matched your search
@@ -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:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "Rendering/SeparableBlend.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
|
||||
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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
#include "Rendering/EditorCanvas.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "RenderFixtures.h"
|
||||
#include <QPainter>
|
||||
#include <QRadialGradient>
|
||||
#include <QtTest>
|
||||
|
||||
// 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<int, 3> rgb(QRgb pixel)
|
||||
{
|
||||
return {qRed(pixel), qGreen(pixel), qBlue(pixel)};
|
||||
}
|
||||
|
||||
bool near(QRgb pixel, std::array<int, 3> expected, int tolerance)
|
||||
{
|
||||
const std::array<int, 3> 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<LayerBlendMode>("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<LayerBlendMode>("mode");
|
||||
QTest::addColumn<bool>("clipped");
|
||||
QTest::addColumn<QList<int>>("expected");
|
||||
// (0.2, 0.4, 0.6) inverted is (0.8, 0.6, 0.4).
|
||||
QTest::newRow("difference") << LayerBlendMode::difference << false << QList<int>{153, 51, 51};
|
||||
QTest::newRow("linearLight") << LayerBlendMode::linearLight << false << QList<int>{204, 153, 102};
|
||||
QTest::newRow("vividLight in a stack") << LayerBlendMode::vividLight << true << QList<int>{128, 128, 128};
|
||||
QTest::newRow("subtract in a stack") << LayerBlendMode::subtract << true << QList<int>{0, 0, 51};
|
||||
}
|
||||
|
||||
// Blended at full coverage, alpha restored, then mixed by opacity.
|
||||
void BlendingTests::adjustmentsBlendInAnyMode()
|
||||
{
|
||||
QFETCH(LayerBlendMode, mode);
|
||||
QFETCH(bool, clipped);
|
||||
QFETCH(QList<int>, 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<LayerBlendMode>("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"
|
||||
@@ -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<int, 4>{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<QUuid> &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<QRgb> 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<QRgb>{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<QRgb>{clear, whiteness, whiteness, clear}));
|
||||
}
|
||||
|
||||
QTEST_MAIN(ClippingStackTests)
|
||||
#include "ClippingStackTests.moc"
|
||||
Reference in new issue
Block a user