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:
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.startAgainputs back every tile the stroke touched, starts recording afresh, and callsrecordBeforewith 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.continueStroke(drawn.fed, …)replaysfed, the inputs as libmypaint was given them after smoothing and Drawing Assist, withreplaying = trueso 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
StrokeSmoothingand 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.
| Behavior | When it happens | What libmypaint sees |
|---|---|---|
| Stabilization, Motion Filtering, Drawing Assist | before the mapping | moved inputs |
| Taper while drawing | per input | a radius and a pressure |
| Shaped tip, Photoshop dual, stamped grain | inside every stamp | its own draw_dab, which is not its own |
| Clone, Procreate's Combine, plain canvas-fixed grain, selection | after each batch | nothing |
| Taper's end | at the lift, by redrawing | a second stroke |