Color Smudge Opacity vs Flow

Recently, I spent some time rewriting the Krita smudge brush engine into a SAI-style watercolor brush engine, and it seems to have worked. Since I don’t have much time to commit to Git, I’ll provide some key code snippets below, hoping they’ll be useful:

Regarding Blending and Dilution:

void KisColorSmudgeStrategyBase::blendBrush(const QVector<KisPainter *> &dstPainters, KisColorSmudgeSourceSP srcSampleDevice,
                                       KisFixedPaintDeviceSP maskDab, bool preserveMaskDab, const QRect &neededRect,
                                       const QRect &srcRect, const QRect &dstRect, const KoColor &currentPaintColor,
                                       qreal opacity, qreal dilution,
                                       qreal colorRate, qreal smudgeRadius)
{
    if (m_smudgeMode == KisSmudgeLengthOptionData::DULLING_MODE) {
        this->sampleDullingColor(srcRect,
                                 smudgeRadius,
                                 srcSampleDevice, m_blendDevice,
                                 maskDab, &m_preparedDullingColor);

        KIS_SAFE_ASSERT_RECOVER(*m_preparedDullingColor.colorSpace() == *m_colorRateOp->colorSpace()) {
            m_preparedDullingColor.convertTo(m_colorRateOp->colorSpace());
        }
    }

    m_blendDevice->setRect(dstRect);
    m_blendDevice->lazyGrowBufferWithoutInitialization();

    const quint8 smudgeOpacity = qRound(opacity * 255.0);

    if (m_smudgeMode == KisSmudgeLengthOptionData::DULLING_MODE) {
        const qreal dullingRate = (m_preparedDullingColor.opacityF() - 1.0) * dilution + 1.0;

        const quint8 colorRateOpacity = qRound(colorRate * 255.0);
        const quint8 dullingRateOpacity = qRound(dullingRate * 255.0);

        blendInBackgroundWithDulling(m_blendDevice, srcSampleDevice,
                                     dstRect,
                                     m_preparedDullingColor, dullingRateOpacity,
                                     currentPaintColor, colorRateOpacity,
                                     smudgeOpacity);
    } else if (m_smudgeMode == KisSmudgeLengthOptionData::BLURRING_MODE) {
        blendInBackgroundWithBlurring(m_blendDevice, srcSampleDevice,
                                      neededRect, srcRect, dstRect,
                                      smudgeOpacity, smudgeRadius);
    } else {
        blendInBackgroundWithSmearing(m_blendDevice, srcSampleDevice,
                                      srcRect, dstRect,
                                      smudgeOpacity);
    }

    const bool preserveDab = preserveMaskDab && dstPainters.size() > 1;

    Q_FOREACH (KisPainter *dstPainter, dstPainters) {
        dstPainter->setOpacity(OPACITY_OPAQUE_U8);

        dstPainter->bltFixedWithFixedSelection(dstRect.x(), dstRect.y(),
                                               m_blendDevice, maskDab,
                                               maskDab->bounds().x(), maskDab->bounds().y(),
                                               m_blendDevice->bounds().x(), m_blendDevice->bounds().y(),
                                               dstRect.width(), dstRect.height());
        dstPainter->renderMirrorMaskSafe(dstRect, m_blendDevice, maskDab, preserveDab);
    }

}

void KisColorSmudgeStrategyBase::blendInBackgroundWithDulling(KisFixedPaintDeviceSP dst, KisColorSmudgeSourceSP src, const QRect &dstRect,
                                                              const KoColor &preparedDullingColor, const quint8 dullingRateOpacity,
                                                              const KoColor &currentPaintColor, const quint8 colorRateOpacity,
                                                              const quint8 smudgeOpacity)
{
    KIS_SAFE_ASSERT_RECOVER_RETURN(*currentPaintColor.colorSpace() == *m_colorRateOp->colorSpace());

    KoColor paintColor(currentPaintColor);
    KoColor dullingColor(preparedDullingColor);

    if (dullingColor.opacityU8() == OPACITY_TRANSPARENT_U8) {
        dullingColor = paintColor;
    }

    paintColor.setOpacity(OPACITY_OPAQUE_U8);
    dullingColor.setOpacity(OPACITY_OPAQUE_U8);

    m_colorRateOp->composite(dullingColor.data(), 1, paintColor.data(), 1, 0, 0, 1, 1, colorRateOpacity);
    dullingColor.setOpacity(dullingRateOpacity);

    if ((m_smearOp->id() == COMPOSITE_COPY || m_smearOp->id() == COMPOSITE_COPY_SPECTRAL) && smudgeOpacity == OPACITY_OPAQUE_U8) {
        dst->fill(dst->bounds(), dullingColor);
    } else {
        src->readBytes(dst->data(), dstRect);
        m_smearOp->composite(dst->data(), dstRect.width() * dst->pixelSize(),
                             dullingColor.data(), 0,
                             0, 0,
                             1, dstRect.width() * dstRect.height(),
                             smudgeOpacity);
    }
}

Regarding Persistence:

    const qreal smudgeLength = m_smudgeLengthOption.isChecked() ? m_smudgeLengthOption.computeSizeLikeValue(info) : 0.0;

    QPointF newCenterPos = QRectF(m_dstDabRect).center();
    /**
     * Save the center of the current dab to know where to read the
     * data during the next pass. We do not save scatteredPos here,
     * because it may differ slightly from the real center of the
     * brush (due to rounding effects), which will result in a
     * really weird quality.
     */
    QRect srcDabRect = (smudgeLength == 0.0) ? m_dstDabRect : m_dstDabRect.translated(((m_lastPaintPos - newCenterPos) * smudgeLength).toPoint());
    m_lastPaintPos = newCenterPos;

    if (m_firstRun && (smudgeLength != 0.0)) {
        m_firstRun = false;
        return spacingInfo;
    }

Of course, the effect may not be exactly the same as SAI’s, because the SAI code itself sets blending and dilution coefficients, and the sampled color is influenced by brush tip spacing, tip shape, and stroke direction. However, since these three factors vary minimally in SAI’s brushes, fixed coefficients can be set to achieve the desired final effect. SAI’s sampling radius is fixed to the entire brush tip area. That said, I believe not setting fixed coefficients might be the better choice. However, for those who want to control the final color and transparency, you can refer to the following method:

For example, if the blend ratio between the paint color and the color on the canvas (not the sampled color, i.e., the color already present on the canvas before blending) is 50%, and the final transparency after blending with the transparent areas of the canvas is 50%:

Step 1: Set dilution to the target value of 50%.
Step 2: Then, use the mouse (without pressure sensitivity) to draw a straight line.
Step 3: Use the color picker to check the opacity at the end of the line.
Step 4: Set dilution to the opacity displayed in the color picker, and set blending to 1 minus the opacity shown in the color picker.

Using this method, you can achieve an effect almost identical to SAI’s default. If you want to adjust the final effect of blending separately, you can also use the above steps to calculate the required parameters based on dilution.

After some attempts, I have revised the code and now the effect is closer to CSP and SAI:

blending: 50% dilution: 50%

void KisColorSmudgeStrategyBase::blendBrush(const QVector<KisPainter *> &dstPainters, KisColorSmudgeSourceSP srcSampleDevice,
                                       KisFixedPaintDeviceSP maskDab, bool preserveMaskDab, const QRect &neededRect,
                                       const QRect &srcRect, const QRect &dstRect, const KoColor &currentPaintColor,
                                       qreal opacity, qreal dilution,
                                       qreal colorRate, qreal smudgeRadius)
{
    if (m_smudgeMode == KisSmudgeLengthOptionData::DULLING_MODE) {
        this->sampleDullingColor(srcRect,
                                 smudgeRadius,
                                 srcSampleDevice, m_blendDevice,
                                 maskDab, &m_preparedDullingColor);

        KIS_SAFE_ASSERT_RECOVER(*m_preparedDullingColor.colorSpace() == *m_colorRateOp->colorSpace()) {
            m_preparedDullingColor.convertTo(m_colorRateOp->colorSpace());
        }
    }

    m_blendDevice->setRect(dstRect);
    m_blendDevice->lazyGrowBufferWithoutInitialization();

    const quint8 smudgeOpacity = qRound(opacity * 255.0);

    if (m_smudgeMode == KisSmudgeLengthOptionData::DULLING_MODE) {
        blendInBackgroundWithDulling(m_blendDevice, srcSampleDevice,
                                     dstRect,
                                     m_preparedDullingColor, dilution,
                                     currentPaintColor, colorRate,
                                     smudgeOpacity);
    } else if (m_smudgeMode == KisSmudgeLengthOptionData::BLURRING_MODE) {
        blendInBackgroundWithBlurring(m_blendDevice, srcSampleDevice,
                                      neededRect, srcRect, dstRect,
                                      smudgeOpacity, smudgeRadius);
    } else {
        blendInBackgroundWithSmearing(m_blendDevice, srcSampleDevice,
                                      srcRect, dstRect,
                                      smudgeOpacity);
    }

    const bool preserveDab = preserveMaskDab && dstPainters.size() > 1;

    Q_FOREACH (KisPainter *dstPainter, dstPainters) {
        dstPainter->setOpacity(OPACITY_OPAQUE_U8);

        dstPainter->bltFixedWithFixedSelection(dstRect.x(), dstRect.y(),
                                               m_blendDevice, maskDab,
                                               maskDab->bounds().x(), maskDab->bounds().y(),
                                               m_blendDevice->bounds().x(), m_blendDevice->bounds().y(),
                                               dstRect.width(), dstRect.height());
        dstPainter->renderMirrorMaskSafe(dstRect, m_blendDevice, maskDab, preserveDab);
    }
}


void KisColorSmudgeStrategyBase::blendInBackgroundWithDulling(KisFixedPaintDeviceSP dst, KisColorSmudgeSourceSP src, const QRect &dstRect,
                                                              const KoColor &preparedDullingColor, const qreal dilution,
                                                              const KoColor &currentPaintColor, const qreal colorRate,
                                                              const quint8 smudgeOpacity)
{
    KIS_SAFE_ASSERT_RECOVER_RETURN(*currentPaintColor.colorSpace() == *m_colorRateOp->colorSpace());

    KoColor paintColor(currentPaintColor);
    KoColor dullingColor(preparedDullingColor);

    if (dullingColor.opacityU8() == OPACITY_TRANSPARENT_U8) {
        dullingColor = paintColor;
    }

    const quint8 colorRateOpacity = qRound(colorRate * colorRate * 255.0);
    m_colorRateOp->composite(dullingColor.data(), 1, paintColor.data(), 1, 0, 0, 1, 1, colorRateOpacity);

    const qreal dullingRate = (dullingColor.opacityF() - paintColor.opacityF()) * dilution + paintColor.opacityF();
    const quint8 dullingRateOpacity = qRound(dullingRate * dullingRate * 255.0);
    dullingColor.setOpacity(dullingRateOpacity);

    KisFixedPaintDevice tempDevice(src->colorSpace(), m_memoryAllocator);
    tempDevice.setRect(dstRect);
    tempDevice.lazyGrowBufferWithoutInitialization();
    src->readBytes(tempDevice.data(), dstRect);

    m_smearOp->composite(tempDevice.data(), dstRect.width() * dst->pixelSize(),
                       dullingColor.data(), 0,
                       0, 0,
                       1, dstRect.width() * dstRect.height(),
                       quint8(127));

    const bool opaqueCopy = (m_smearOp->id() == COMPOSITE_COPY || m_smearOp->id() == COMPOSITE_COPY_SPECTRAL) && smudgeOpacity == OPACITY_OPAQUE_U8;
    if (opaqueCopy){
        tempDevice.readBytes(dst->data(), dstRect.x(), dstRect.y(), dstRect.width(), dstRect.height());
    } else {
        src->readBytes(dst->data(), dstRect);
        m_smearOp->composite(dst->data(), dstRect.width() * dst->pixelSize(),
                             tempDevice.data(), dstRect.width() * tempDevice.pixelSize(),
                             0, 0,
                             1, dstRect.width() * dstRect.height(),
                             smudgeOpacity);
    }
}

10 Likes

Are there more people paying attention to it? This involves a new blend algorithm that may require opening a new post

2 Likes

This isn’t a topic where the average user can talk with, I find it interesting, read even every post, but it is beyond the abilities left to me after my strokes, so I can’t discuss in such topics anymore. Before the strokes I would have been part of the discussion, possibly provided code, but that time is over/will never come back. So I would like to see it coming, but don’t expect useful input from my side.

Michelist

1 Like

This feature may not be easy to implement. Currently, almost all the smudge brush engines in painting software do not distinguish between flow and opacity. To be precise, they only have flow. This includes Paint Storm. If you use the smudge brush in Paint Storm to smear and then export the file as a PSD to Krita, you’ll find that it smears the color rather than the opacity channel. Moreover, both the sampling and drawing of the smudge brush engine are done on the same layer. I think this is the key limitation.

4 Likes

Hi,killy, I’m not referring to the content of the post, but to the code section you released. It looks somewhat different from the theme of this post, so I am asking if a separate post is needed.

1 Like