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

Dual Brushes and Grain, Each Two Ways

~16 min · libmypaint, dual-brush, grain, brush-engines, c, architecture

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

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:

  1. The second tip is placed dual_count times, at least once and never more than DUAL_MOST, which is 16. Each copy is thrown to a uniform point inside a disc of the scatter's reach and turned at random.
  2. At each pixel, the copies' coverage accumulates the way paint does: second += t − second · t.
  3. The stamp keeps ard_combine_alpha(mode, mask, second). Photoshop's Normal becomes a multiply here: keep only where both tips are.
  4. 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 with ard_surface_set_pressure before every input, and the depth becomes min + (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's m, 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 · valley whatever 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

BehaviorLives inRuns
Procreate's CombinePainter: DualStroke, lay(_:on:), ard_fix15_dual_mergeafter each batch
Photoshop's Dual BrushC surface: stamp(), set by ard_surface_set_dualinside every stamp
Canvas-fixed Multiply grainPainter: BrushGrain.lay(over:on:), ard_fix15_grainafter each batch
Stamped grainC surface: stamp(), set by ard_surface_set_grain and ard_surface_set_grain_modeinside every stamp
Put a rule where the facts it needs exist. If it depends only on a pixel's canvas position and the batch's result, it can wait for the Painter. If it depends on something one stamp alone knows (its center, its coverage, the pressure at its moment), it has to live in the 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.

Code

Where the two dual brushes part ways (AriandelEngine/Painter.swift, beginStroke, verbatim)·swift
var dual: DualStroke?
// Procreate's combined brush paints two strokes; Photoshop's Dual Brush masks each stamp (the surface's).
if let settings = brush.extras.dual, !settings.perStamp, tool == .paint || tool == .erase,
   let secondary = secondary(for: settings) {
    dual = DualStroke(layer: layer, brush: secondary, settings: settings, erase: tool == .erase,
                      color: brush.color, radius: scaled, symmetry: symmetry, seed: shapeSeed)
    // The primary, too, lays plain coverage; the merge erases or keeps the alpha.
    brush.setBaseValue(.eraser, 0)
    brush.setBaseValue(.lockAlpha, 0)
}
(clone?.surface ?? dual?.primarySurface ?? layer.surface)
    .prepare(for: brush, shape: brush.extras.shape, grain: brush.extras.grain, seed: shapeSeed)
Photoshop's Dual Brush inside one stamp (CAriandelSurface/ariandel_surface.c, stamp(), verbatim, re-indented)·c
if (dual_n > 0) {
    /* The second tip's coverage here: its stamps over this one, overlapping as paint does. */
    float second = 0.0f;
    for (int j = 0; j < dual_n; j++) {
        const float ex = (float)px + 0.5f - dual_x[j], ey = (float)py + 0.5f - dual_y[j];
        const float su = (ex * dual_cs[j] + ey * dual_sn[j]) / dual_radius;
        const float sv = (-ex * dual_sn[j] + ey * dual_cs[j]) / dual_radius;
        if (su <= -1.0f || su >= 1.0f || sv <= -1.0f || sv >= 1.0f) {
            continue;
        }
        const float t = (float)sample_texels(self->dual[dual_level], self->dual_sides[dual_level],
                                             (su + 1.0f) * 0.5f, (sv + 1.0f) * 0.5f) / (float)one;
        second += t - second * t;
    }
    /* Photoshop's Normal keeps only where both are: a multiply. */
    const int mode = self->dual_mode == ARD_COMBINE_NORMAL ? ARD_COMBINE_MULTIPLY : self->dual_mode;
    mask = (uint32_t)(ard_combine_alpha(mode, (float)mask / (float)one, second) * (float)one);
    if (mask == 0) {
        continue;
    }
}
The four grain modes side by side (grained() copied verbatim from ariandel_surface.c; compiled and run with clang)·c
/* The four grain modes, side by side. grained() is copied verbatim from
   CAriandelSurface/ariandel_surface.c; the enum mirrors ariandel_surface.h.
   m is the stamp's coverage at a pixel (0...1), height the grain there
   (0 = valley, 1 = peak), depth the grain's depth (0...1). */
#include <math.h>
#include <stdio.h>

typedef enum {
    ARD_GRAIN_MULTIPLY = 0,
    ARD_GRAIN_HEIGHT = 1,
    ARD_GRAIN_LINEAR_HEIGHT = 2,
    ARD_GRAIN_SUBTRACT = 3,
} ArdGrainMode;

static float
grained(int mode, float m, float height, float depth)
{
    const float valley = 1.0f - height;
    switch (mode) {
    case ARD_GRAIN_HEIGHT:
        return fminf(1.0f, fmaxf(0.0f, m * 10.0f * depth - valley));
    case ARD_GRAIN_LINEAR_HEIGHT: {
        const float sunk = m * 10.0f * depth;
        return fminf(1.0f, fmaxf(0.0f, fmaxf(height * sunk, sunk - valley)));
    }
    case ARD_GRAIN_SUBTRACT:
        return fmaxf(0.0f, m - depth * valley);
    default:
        return m * (1.0f - depth * valley);
    }
}

int
main(void)
{
    const char *names[] = {"multiply", "height", "linear height", "subtract"};
    const float heights[] = {0.0f, 0.25f, 0.5f, 0.75f, 1.0f};
    const float cases[][2] = {{1.0f, 0.22f}, {1.0f, 0.05f}, {0.5f, 0.22f}}; /* {m, depth} */
    for (int c = 0; c < 3; c++) {
        printf("m = %.2f, depth = %.2f   height: 0.00  0.25  0.50  0.75  1.00\n", cases[c][0], cases[c][1]);
        for (int mode = 0; mode < 4; mode++) {
            printf("  %-28s", names[mode]);
            for (int h = 0; h < 5; h++) {
                printf("  %.2f", grained(mode, cases[c][0], heights[h], cases[c][1]));
            }
            printf("\n");
        }
    }
    return 0;
}

/* Output:
m = 1.00, depth = 0.22   height: 0.00  0.25  0.50  0.75  1.00
  multiply                      0.78  0.83  0.89  0.94  1.00
  height                        1.00  1.00  1.00  1.00  1.00
  linear height                 1.00  1.00  1.00  1.00  1.00
  subtract                      0.78  0.83  0.89  0.94  1.00
m = 1.00, depth = 0.05   height: 0.00  0.25  0.50  0.75  1.00
  multiply                      0.95  0.96  0.98  0.99  1.00
  height                        0.00  0.00  0.00  0.25  0.50
  linear height                 0.00  0.12  0.25  0.38  0.50
  subtract                      0.95  0.96  0.98  0.99  1.00
m = 0.50, depth = 0.22   height: 0.00  0.25  0.50  0.75  1.00
  multiply                      0.39  0.42  0.44  0.47  0.50
  height                        0.10  0.35  0.60  0.85  1.00
  linear height                 0.10  0.35  0.60  0.85  1.00
  subtract                      0.28  0.34  0.39  0.44  0.50
*/

External links

Exercise

Take a grayscale photo of paper or canvas and treat its brightness as grain height. Draw one soft round stamp (coverage 1 at the center falling to 0 at the edge) over it and render the four modes from the third code block at depths 0.05, 0.22 and 0.5: twelve small images. Then answer from your images: in which modes does turning depth up make the stamp fuller, and in which does it make it emptier?
Hint
Write grained() once and loop over modes and depths; the formula is the whole renderer. Look at the soft edge, not the center. That is where Subtract and Multiply part ways and where Linear Height's ramp shows.

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.