Skip to content
C.W.K.
Stream
Lesson 05 of 05 · published

Taper by Redrawing

~13 min · libmypaint, taper, stabilization, performance, architecture

Level 0Blank Canvas
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

You Cannot Thin an End You Have Not Reached

Pressure Taper makes a stroke thin in from its start and out to its end, like a line that sharpens at both tips. The start is easy: you know how far the pen has traveled, so the first stretch can grow from thin to full as it is drawn. The end is impossible to draw live. The end is wherever the pen lifts, and while the pen is down nobody knows where that will be. A painting app cannot paint the future.

Ariandel's answer, from commit 2240bbb: thin the start live, and when the pen lifts and the whole length is finally known, put the stroke back and draw it again, whole, so the end thins too.

While Drawing: Taper Is Just Input

libmypaint never hears the word "taper". StrokeTaper works on the two numbers libmypaint already reads. For every input, continueStroke advances the taper by the distance the input moved, then:

  • sets the brush's base radius to radius(at:), so size tapers even on a brush with no pressure mapping, and
  • multiplies the sample's pressure by pressure(at:), which is how opacity tapers.

How far each end thins is a share of a reach of forty radii: start × 40 × exp(baseRadius) pixels, and the same for the end. At a tip, Tip Size scales the radius by max(0.02, 1 − size · (1 − fullness)). While the pen is down, total is unknown, so only the start can thin. The first row of the third code block shows it: a 5-pixel brush on a 400-pixel stroke grows from 0.75 px to full over the first 100 px, then stays full to the very end.

At the Lift: Draw It Again

endStroke (first code block) first lets the stroke catch up: a smoothed stroke trails the pen, so it is carried on to where the pen lifted, and a last input with zero force is laid. Then, if the taper's end is above zero and the stroke had more than one input:

  1. taper.restart(total: taper.length): back to distance zero, now knowing the total. Each end is also capped at 45% of the stroke, so a short stroke never thins away entirely.
  2. startAgain puts back every tile the stroke touched, starts recording afresh, and calls recordBefore with the first try's before-states, so the undo step still begins from before the stroke. A Clone or a dual brush is rebuilt over the same original.
  3. continueStroke(drawn.fed, …) replays fed, the inputs as libmypaint was given them after smoothing and Drawing Assist, with replaying = true so neither runs twice.

The second row of the sketch is that redraw: the same stroke, now 0.75 px at both ends. The engine suite pins the real thing in taperedStrokesThinTowardBothEnds: thin at the start while drawn, full under the pen, thin at both ends after the lift with the middle still full, the brush's own size back, and one undo removing it all.

The redraw restarts Ariandel's randomness from the same place. The stroke's shapeSeed survives startAgain, so the surface's generator hands out the same sequence again, and the first dab is laid again where the pen landed. The same sequence is not the same stroke, though. A shaped tip's scatter, turns and flips repeat only while libmypaint asks for the same dabs in the same places, and two things change that. The taper itself does: the end now thins, a thinner dab is spaced closer, so near the end the redraw lays more dabs in new places, and every stamp after the first extra one draws different numbers. And libmypaint's own random generator is not reseeded by a reset, so its position and size jitter are drawn afresh, moving a shaped tip's dabs as surely as a round brush's.

What Bounds the Redraw

The whole stroke is replayed in a single continueStroke call, which is one batch, on the main actor, at the instant the pen lifts. A long stroke with a big, finely spaced brush is exactly where that hurts. Commit 7241359 measured the brush that forced the issue: Photoshop's 1% spacing is fifty dabs per radius, and in a release build one second of a 300-pixel brush at that spacing took 1 to 2 s to draw.

So beginStroke caps one setting, the base value of the brush's own dabs_per_actual_radius, so that the stroke's base radius in canvas pixels times it stays within Painter.dabBudget = 3000 (second code block). A 100-pixel brush lays up to 30 per radius, a 300-pixel one 10, and the cap never pushes a brush below 2. The stamps still overlap heavily, and the brush's own setting comes back when the stroke ends.

The cap is narrow on purpose. It leaves libmypaint's other density settings alone (dabs_per_basic_radius, where the importer puts Photoshop's Count, and dabs_per_second), along with a Procreate Combine's secondary brush and symmetry's extra copies. And it bounds density, not length: the redraw at the lift is still the biggest batch there is, and it grows with the stroke. What the cap removes is the blow-up commit 7241359 measured, a fine spacing on a large tip.

The Rest of the List

Taper is the last "afterward" in this track. The rule's first place, "before the mapping", is quieter:

  • Stabilization and Motion Filtering live in StrokeSmoothing and touch only the pen's positions. Motion Filtering is a One Euro filter whose cutoff falls from 20 Hz toward 0.5 Hz as the setting rises; Stabilization averages the last 1 to 49 positions. At the lift they are carried on to where the pen actually lifted.
  • Drawing Assist moves each input onto the guide line that the stroke's first ten screen points chose.
  • StreamLine is the exception. It is libmypaint's own slow tracking (up to 8 at 100%), set on the brush. The engine does know this one.
BehaviorWhen it happensWhat libmypaint sees
Stabilization, Motion Filtering, Drawing Assistbefore the mappingmoved inputs
Taper while drawingper inputa radius and a pressure
Shaped tip, Photoshop dual, stamped graininside every stampits own draw_dab, which is not its own
Clone, Procreate's Combine, plain canvas-fixed grain, selectionafter each batchnothing
Taper's endat the lift, by redrawinga second stroke
When the fact you need arrives too late, keep everything you need to do the work again. Ariandel keeps the fed inputs, the seed and the before-states, so "later" costs one replay instead of a wrong stroke.

Code

Drawing the stroke again at the lift (AriandelEngine/Painter.swift, endStroke, verbatim)·swift
// Its whole length known now, a tapered stroke is drawn again so its end thins too.
if let drawn = stroke, var taper = drawn.taper, taper.redrawsAtLift, drawn.fed.count > 1 {
    taper.restart(total: taper.length)
    var again = startAgain(drawn)
    again.taper = taper
    again.replaying = true
    again.inputs = drawn.inputs
    stroke = again
    continueStroke(drawn.fed, viewZoom: viewZoom, viewRotationDegrees: viewRotationDegrees)
}
The dab budget (AriandelEngine/Painter.swift, beginStroke and the constant, verbatim)·swift
    // A stamp costs its area, so a stroke's cost grows with its radius times its dabs a radius:
    // past the budget, a large brush lays fewer, still overlapping heavily (Photoshop's 1% spacing
    // on a 300-pixel brush took twice the stroke's own time to draw on office).
    let densitySetting = BrushAttribute.setting("dabs_per_actual_radius")
    let density = brush.baseValue(densitySetting)
    brush.setBaseValue(densitySetting, Swift.min(density, Swift.max(2, Float(Self.dabBudget) / exp(scaled))))
/// A stroke's radius (canvas pixels) times its dabs a radius, at most: a
/// 100-pixel brush lays up to 30 a radius, a 300-pixel one 10.
static let dabBudget = 3000.0
A minimal sketch of StrokeTaper's math and the dab budget (trimmed from BrushExtras.swift; compiled and run with swiftc in Swift 6 mode)·swift
import Foundation

// A minimal sketch of StrokeTaper (BrushExtras.swift), trimmed to size: the
// same reach (40 radii), the same 45% cap, the same fullness and radius math.
// The first pass cannot thin the end; the redraw at the lift can.

struct Taper {
    let start: Double, end: Double, size: Double
    let baseRadius: Float  // log of the radius in pixels, as libmypaint keeps it
    var total: Double?     // the stroke's whole length, known only at the lift

    init(start: Double, end: Double, size: Double, baseRadius: Float) {
        let reach = 40 * Double(exp(baseRadius))
        self.start = start * reach
        self.end = end * reach
        self.size = size
        self.baseRadius = baseRadius
    }

    func fullness(at distance: Double) -> Double {
        var full = 1.0
        let cap = total.map { $0 * 0.45 } ?? .infinity
        let startLength = min(start, cap)
        if startLength > 0 { full = min(full, distance / startLength) }
        if let total, end > 0 {
            let endLength = min(end, cap)
            if endLength > 0 { full = min(full, (total - distance) / endLength) }
        }
        return min(1, max(0, full))
    }

    func radius(at distance: Double) -> Float {
        baseRadius + Float(log(max(0.02, 1 - size * (1 - fullness(at: distance)))))
    }
}

let length = 400.0  // a 400-pixel stroke
var taper = Taper(start: 0.5, end: 0.5, size: 0.85, baseRadius: Float(log(5.0)))  // a 5-pixel brush
let stops: [Double] = [0, 50, 100, 200, 300, 350, 400]

func row(_ label: String, _ taper: Taper, _ stops: [Double]) {
    let radii = stops.map { String(format: "%5.2f", Double(exp(taper.radius(at: $0)))) }
    print(label, radii.joined(separator: " "))
}
print("distance (px):       ", stops.map { String(format: "%5.0f", $0) }.joined(separator: " "))
row("while drawing (px):  ", taper, stops)
taper.total = length  // the pen lifts: the whole length is known
row("redrawn at lift (px):", taper, stops)

// Painter.dabBudget: a stroke's radius times its dabs a radius, at most.
let dabBudget = 3000.0
print("\nradius  dabs a radius (brush asks 50)")
for radius in [10.0, 60.0, 100.0, 300.0, 1000.0] {
    let held = min(50, max(2, dabBudget / radius))
    print(String(format: "%6.0f  %5.1f", radius, held))
}

// Output:
// distance (px):            0    50   100   200   300   350   400
// while drawing (px):    0.75  2.88  5.00  5.00  5.00  5.00  5.00
// redrawn at lift (px):  0.75  2.88  5.00  5.00  5.00  2.88  0.75
//
// radius  dabs a radius (brush asks 50)
//     10   50.0
//     60   50.0
//    100   30.0
//    300   10.0
//   1000    3.0

External links

Exercise

In any drawing toy (an HTML canvas is enough), record each stroke's points as they arrive. Taper the start live by scaling width with distance traveled. On pointer-up, clear the stroke's area back to how it was before the stroke, and redraw the whole recorded stroke with both ends tapered. Then time the redraw for a long stroke at a large width and a fine spacing, and add a budget like Ariandel's: radius × dabs per radius ≤ some constant. How low can the constant go before you can see the spacing?
Hint
Snapshot the pixels the stroke covers when it begins, not when it ends; by pointer-up they are already painted. Cap each end's taper at under half the stroke's length, or a short flick will taper away to nothing.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.