Trying to figure out efficient anti-aliasing for a brush engine plugin

I’ve gotten stuck on the optimization of a project and hope to receive some pointers for where to look to learn about a solution. The project is a brush engine plugin. I have the functionality figured out, but adding anti-aliasing makes the brush noticeably laggy. Now I’m trying to figure where I should optimize it.

The brush engine is meant to emulate a broad edge calligraphy pen. I found the existing brush engines insufficient to create the angled brush strokes with very sharp corners. As an example, the letters BE drawn with it:

Without anti-aliasing and fully opaque, the code is simple:

void KisBroadEdgeOp::paintLine(const KisPaintInformation &pi1, const KisPaintInformation &pi2, KisDistanceInformation *currentDistance)
{
    Q_UNUSED(currentDistance);
    if (!painter()) return;

    if (!m_segmentBufferDevice)
        m_segmentBufferDevice = source()->createCompositionSourceDevice();
    else
        m_segmentBufferDevice->clear();

    if (!m_painter) {
        m_painter = new KisPainter(m_segmentBufferDevice);
        m_painter->setPaintColor(painter()->paintColor());
    }

    KisPaintDeviceSP bufferDevice = m_painter->device();
    if (!bufferDevice) return;

    const qreal radius1 = 0.5 * additionalScale * m_sizeOption.apply(pi1) * m_broadEdgeOpOption.diameter;
    const qreal radius2 = 0.5 * additionalScale * m_sizeOption.apply(pi2) * m_broadEdgeOpOption.diameter;
    const qreal angle1 = m_broadEdgeOpOption.angle + m_rotationOption.apply(pi1);
    const qreal angle2 = m_broadEdgeOpOption.angle + m_rotationOption.apply(pi2);
    const QPointF penWidth1 = QPointF(cos(angle1), -sin(angle1)) * radius1;
    const QPointF penWidth2 = QPointF(cos(angle2), -sin(angle2)) * radius2;
    const QPointF pos1 = pi1.pos();
    const QPointF pos2 = pi2.pos();

    QPainterPath path;
    path.moveTo(pos1 + penWidth1);
    path.lineTo(pos2 + penWidth2);
    path.lineTo(pos2 - penWidth2);
    path.lineTo(pos1 - penWidth1);
    path.setFillRule(Qt::WindingFill);

    const QRect alignedRect= kisGrowRect(path.boundingRect(), 1).toAlignedRect();
    m_painter->setAntiAliasPolygonFill(false);
    m_painter->setFillStyle(KisPainter::FillStyleForegroundColor);
    m_painter->fillPainterPath(path, alignedRect);

    painter()->bitBlt(alignedRect.topLeft(), m_segmentBufferDevice, alignedRect);
    painter()->renderMirrorMask(alignedRect, m_segmentBufferDevice);
}

To handle dynamic opacity, I unfortunately can’t use fillPainterPath and instead loop over the alignedRect with a KisSequentialIterator. On a 45° brush angle, this square contains a majority of pixels outisde of the painted quad. I could skip with some simple math to calculate the per line offset of the painted pixels, but I worry that such memory windows shifting at an inconsistent rate would mess with CPU caching and create a memory accessing bottleneck.

The algorithm for testing if a pixel is inside of the quad on the other hand should be more efficient than the generic polygon path test. The trick is to treat this as an interpolation of two lines by t, with two distinct approaches depending on if the lines are parallel or not:

qreal intersectionScalar(QPointF p0, QPointF v0, QPointF p1, QPointF v1) {
    //p0.x + t0 * v0.x = p1.x + t1 * v1.x
    //p0.y + t0 * v0.y = p1.y + t1 * v1.y
    //t0 * (v0.x * v1.y - v1.x * v0.y) = p1.x * v1.y - p0.x * v1.y - p1.y * v1.x + p0.y * v1.x

    // If v0 and v1 are parallel or either is 0, then this divides by 0 and returns positive or negative infinity.
    qreal t = (p1.x() * v1.y() - p0.x() * v1.y() - p1.y() * v1.x() + p0.y() * v1.x())
              / (v0.x() * v1.y() - v1.x() * v0.y());
    return t;
}
bool parallelAngles;
QPointF helperPoint;
if (penWidth1.x() * penWidth2.y() - penWidth2.x() * penWidth1.y()) {
    // The brush angles are parallel.
    parallelAngles = true;
    helperPoint = penWidth1;
} else {
    // The brush angles intersect.
    parallelAngles = false;
    qreal scalar = intersectionScalar(pos1, penWidth1, pos2, penWidth2);
    helperPoint = pos1 + scalar * penWidth1;
}

KoColor currentColor(painter()->paintColor());
KisSequentialIterator it(bufferDevice, alignedRect);
while (it.nextPixel()) {
    const QPointF pixelPoint = QPointF(it.x(), it.y());
    qreal t;
    if (parallelAngles)
        t = intersectionScalar(pos1, pos2 - pos1, pixelPoint, helperPoint);
    else
        t = intersectionScalar(pos1, pos2 - pos1, pixelPoint, pixelPoint - helperPoint);
    
    const QPointF localCenter = pos1 + t * (pos2 - pos1);
    const QPointF localWidth = penWidth1 + t * (penWidth2 - penWidth1);
    const qreal distance = (abs(pixelPoint.x() - localCenter.x()) + abs(pixelPoint.y() - localCenter.y()))
                           / (abs(localWidth .x()) + abs(localWidth .y()));
    const QPointF localOpacity = penOpacity1 + t * (penOpacity2 - penOpacity1);

    if (t > 0 && t <= 1 && distance <= 1) {
        currentColor.setOpacity(localOpacity);
        memcpy(it.rawData(), currentColor.data(), pixelSize);
    }
}

For anti-aliasing, nothing feels as straight forward.


Anti-aliasing the first quad like this is straight forward. Each pixel receives an alpha value matching the percentage of it’s surface, which is inside of the quad. The blue pixels are neither entirely covered or uncovered. The problem arises with subsequent quads. There are three scenarios that need to be handled.

  1. A straight line split into multiple paintLine calls. Each quad shares one edge with the previous quad, but no quad areas overlap. The anti-aliasing of both quads should cancel each other out along that edge. With conventional blending, that’s newAlpha = 1 - lastAlpha which means blendedAlpha = newAlpha + (1 - newAlpha) * (1 - newAlpha). It should be 1 instead.
  2. After one quad, the paint stroke reverses in direction. The quads fully overlap and should be blended in the conventional way.
  3. The pen position changes only slightly for the second quad, but the pen angle changes significantly. This causes the pen edges of the two positions to intersect within their widths. One one side of the intersection, blending should be done conventionally, while on the other the anti-aliasing should cancel each other out.

One idea I had was to redraw the entire brush stroke with every paintLine. The Shape brush engine seems to work similarly, but I assume redrawing the whole buffer is unnecessary here. Only pixels that are at least partially covered by the quad need to be changed. And of those, any pixels fully covered can be blended with the conventional method. Pixels containing an edge however, would need to be rendered from scratch by using supersampling anti-aliasing to count the number of quad intersections for each subpixel.

The next idea was to hold pixels intersected by the leading edge in limbo. For each of those pixels I iterate an individual counter. Then for all other pixels I check if that counter is greater than 0. The counter determines how many quads in the stored quad history need to be processed together with the current one, to determine the coverage of the current pixel. Once processed, the counter is reset to 0. This makes brush strokes parallel to the pen angle especially laggy.

At this point it started to feel like I’m just adding more and more complexity to the algorithm, to handle all those edge cases. Surely someone else must have already gone through all of this and I’m just reinventing the wheel over here. Though, there are still some avenues I consider pursuing.

Instead of checking every pixel for a non zero counter, I can check only pixels intersected by the trailing edge. That alone would have no benefit, but it would allow for swapping between two buffers that scale proportional to pen width, instead of canvas size.

The other idea is to remove the need for a quad history by considering their directionality. When subsequent quads are on opposite sides of an edge, their coverage can be added together. When they’re on the same side of an edge, the the previous sum can be blended with the output and a new sum can be started. But there’s the unfortunate third case, where two edges intersect within one pixel. I’m not sure how to handle the case, where the quads are both, on the same side and opposite sides.

But at some point, all the flow control will create more overhead than it optimizes the code and I’m not sure how to tell when that happens. Either way, if anyone did read through all of this, I want to thank you for having interest in my problem. Nobody has an obligation to read all this and I truly appreciate that Krita has a community of people willing to help out others!

Have you tried just plain super-sampling? That is, rendering without anti-aliasing to a 4x-sized buffer and then painting the scaled-down version of the image onto the target surface.

I haven’t even considered that option yet! That sounds like a clean solution too. So far I just relied on a CompositionSourceDevice to buffer pixel data, without even considering the layout of that buffer. I could even make sampling factor an option. That’s a helpful idea, thank you very much!

Though, there are still some things I need to figure out before I can test an implementation. For example how to retrieve the current layer size to set the buffer size. But that shouldn’t be too difficult to figure out on my own. It just means can’t tell you how well it works until then.

Allocating the entire layer as a buffer is probably a bit much, you’d probably get significantly better performance by sizing it on demand. It’s also probably not quite correct to restrict it to the canvas size, since Krita lets you draw outside of of its bounds… at least sometimes, it’s not very consistent about it. I’ve used that approach to implement a triangle fan fill anyway and that was fine performance-wise.

That makes this a bit more complicated. I probably should map coordinates to memory page sized chunks, which I then allocate on demand. And that would in turn involve setting up something like a look up table. Is that why the KisSequentialIterator class looks so complicated, because it handles chunking as well? I gotta read through those implementations again and see what I can learn. Anti-aliasing is a lot more complicated than I expected!

I haven’t dealt with that part of Krita, but I’d imagine that the iterator exists to map between e.g. contiguous and tiled surfaces.

I want to give an update on this one. Using super-sampling was a great solution. And the various iterators already implemented are easy to understand too. I got an implementation working that works fast enough and gives clean anti-aliasing. I then also made a version that gets around the dynamic memory allocation, by only storing subpixel data for pixels intersected by the leading and trailing edge of the quad. That unfortunately has some artifacting in very inconsistent and rare situations, which I’ve been unable to pin down. Probably some kind of floating-point rounding error. I would have been happy with how this project is progressing, if I didn’t try to handle opacity and the build up painting mode next. That has left me stumped for a week now. But that’s out of scope for this topic.

All that’s left to say is, thank you very much for giving such helpful advice again. I’ll close the topic now, by marking the super-sampling advice as the helpful answer.