Two Programs With One Name
"Dual brush" sounds like one idea: a second tip that breaks up the first. In the files Ariandel reads, it is two different computations, and the difference shows up on the canvas. A Procreate .brushset can carry a combined brush, and a Photoshop .abr can carry a Dual Brush. Ariandel imports both, so it implements both, and one flag tells them apart: DualBrush.perStamp.
Procreate's Combine: Two Strokes, Merged Per Batch
With perStamp == false, beginStroke builds a DualStroke (first code block). It is Clone's pattern from the last lesson, doubled. Two scratch stores, two surfaces, and both brushes follow the same Pencil: every input sample goes to the primary and then to the secondary. Both lay plain coverage, with eraser and alpha lock off. The secondary is sized from the primary in libmypaint's logarithmic radius: its radius is the primary's plus log(sizeRatio), so the side bar sizes both at once.
After every batch, lay(_:on:) visits each tile either brush reached and runs ard_fix15_dual_merge. It combines the two alphas by the brush's mode: Normal (either, a + b − ab), Multiply (both), Darken, Lighten, Difference or Subtract. Then it lays the result over the layer as the stroke found it, or takes it out of the layer for the eraser. Exactly like Clone, the merge recomputes from the original and the coverages so far, so each batch's answer stands alone.
Photoshop's Dual Brush: A Mask on Every Stamp
With perStamp == true, there is no second stroke at all. The work moves into Ariandel's C surface, into stamp(), the function that lays every shaped dab (second code block). For each stamp:
- The second tip is placed
dual_counttimes, at least once and never more thanDUAL_MOST, which is 16. Each copy is thrown to a uniform point inside a disc of the scatter's reach and turned at random. - At each pixel, the copies' coverage accumulates the way paint does:
second += t − second · t. - The stamp keeps
ard_combine_alpha(mode, mask, second). Photoshop's Normal becomes a multiply here: keep only where both tips are. - The masked stamp then paints like any stamp. That is the whole point: masked stamps build up, layer on layer, as a dense stroke lays dozens of them over each pixel.
The first version of the importer ran Photoshop's dual brushes through the two-stroke path. Commit ad36a8c records what that did: Dad's imported brushes came out faint at 100% opacity, because combining the two strokes' accumulated coverages once left a sparse second tip's Multiply strokes only a fraction painted. Masking each stamp instead filled light and medium touches back in. It was also cheaper: one second of the heaviest dual brushes (radius 200) drew in 0.06 to 0.16 s in a release build, where the two-stroke path had taken 0.14 to 0.35 s.
A per-stamp mask needs stamps. A round libmypaint brush has none of its own; its dabs are libmypaint's. So BrushSurface.prepare gives such a brush a picture of its own round dab, BrushShape.round(hardness:), and Ariandel's surface stamps that.
Grain: Fixed to the Canvas, or Stamped
A BrushGrain also comes in two placements, and the rule that chooses between them is one line in BrushExtras.swift:
stamped = depth > 0 && (rolls || mode != .multiply || depthFollowsPressure)
A plain canvas-fixed grain (Multiply, its depth not following the pressure) is laid after each batch: ard_fix15_grain lets through share = 1 − depth · (1 − g) of the batch's change, with g read at the pixel's canvas position, so the texture stays put like paper's tooth. A stamped grain is laid by the surface into each dab's mask. Each of the three conditions is something only a stamp knows:
- Rolling needs the dab's position. The surface reads the grain at
(px − rolling · x, py − rolling · y), where(x, y)is the dab's center: at 1 every dab wears the grain alike, at 0 it stays on the canvas. - Depth from pressure needs the input's pressure.
Brush.stroke(to:on:)passes it in withard_surface_set_pressurebefore every input, and the depth becomesmin + (depth − min) · pressure. - Photoshop's modes need the stamp's own coverage, because they bend it nonlinearly. Height clamps
10 · depth · m − valley, a function of one stamp'sm, so there is no factor to apply to a batch's accumulated change. The canvas-fixed multiply is not a per-stamp multiply moved later, either: it is defined on the batch, letting through a share of the batch's whole change, and the two disagree. At a share of 0.5, two opaque dabs end at 0.75 alpha when each stamp is multiplied (0.5, then 0.5 + 0.5 · 0.5) and at 0.50 when the batch is. A grain that rolls or follows the pressure takes the per-stamp form even in Multiply; only the plain grain fixed to the canvas takes the batch form, because it needs nothing but the pixel's position and the batch's result.
Four Modes
The C enum ArdGrainMode has four values: MULTIPLY (Ariandel's and Procreate's), and Photoshop's HEIGHT, LINEAR_HEIGHT and SUBTRACT, reproduced by the formulas Krita uses for them (KisMaskingBrushCompositeOp). The third code block runs grained() verbatim over a few cases, and three things jump out:
- "Depth" means opposite things. In Multiply and Subtract, a deeper grain holds more paint back. In Height, the paint sinks ten times the depth into the paper, so a deeper grain fills more of the valleys. At depth 0.22 a full stamp in Height is solid everywhere; at depth 0.05 only the peaks take paint.
- Linear Height softens Height. At depth 0.05 Height gives 0, 0, 0, 0.25, 0.5 across the heights; Linear Height ramps 0, 0.12, 0.25, 0.38, 0.5.
- Multiply and Subtract agree on a full stamp and part ways on a soft one: Subtract removes a fixed
depth · valleywhatever the coverage, so a soft edge vanishes into the valleys first (0.28 against 0.39 at half coverage in the deepest valley).
Where Each Hook Lives
| Behavior | Lives in | Runs |
|---|---|---|
| Procreate's Combine | Painter: DualStroke, lay(_:on:), ard_fix15_dual_merge | after each batch |
| Photoshop's Dual Brush | C surface: stamp(), set by ard_surface_set_dual | inside every stamp |
| Canvas-fixed Multiply grain | Painter: BrushGrain.lay(over:on:), ard_fix15_grain | after each batch |
| Stamped grain | C surface: stamp(), set by ard_surface_set_grain and ard_surface_set_grain_mode | inside every stamp |
Neither column is libmypaint. The stamp is Ariandel's own draw_dab, which libmypaint calls without knowing whose it is (Track 2). The engine still never learns.