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

Taking Over draw_dab

~16 min · c, libmypaint, vtable, brushes, symmetry

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

One Slot in a Table of Function Pointers

Every dab libmypaint lays goes through one call: mypaint_surface_draw_dab, which does nothing but call self->draw_dab, the first slot of the MyPaintSurface virtual table from lesson 1. The brush computes where the dab goes, how big, what color, how opaque, whether it erases; the surface decides what that means in pixels. That division is exactly the seam Ariandel needed. Procreate and Photoshop brushes stamp a picture (a leaf, a bristle clump, a scanned chalk mark), and libmypaint only knows how to rasterize an ellipse.

Forking libmypaint to teach it pictures was off the table: the engine is vendored byte for byte, and the rule is that a missing behavior lives in CAriandelSurface or Swift, never in vendored files. So ard_surface_create does three things in a precise order. It calls mypaint_tiled_surface_init, which installs libmypaint's own draw_dab. It saves that pointer into its own field, round_dab. Then it writes shaped_draw_dab into the slot. Nothing vendored changes. The takeover is one assignment into a struct Ariandel owns.

No Shape: Straight Through

The first thing shaped_draw_dab checks is whether a tip is set. With none (levels == 0) it passes the surface and all seventeen dab arguments to round_dab unchanged and returns its answer. That call-through is what keeps the takeover honest: a plain MyPaint brush paints exactly as if Ariandel had never touched the slot. libmypaint's colorize, posterize and spectral pigment mixing, its operation queue, and its own symmetry pass (which lives inside its draw_dab) all still run.

With a Shape: A Stamp, and Everything round_dab Used to Do

With a tip set, stamp() lays the picture where libmypaint asked for a round dab. It takes libmypaint's size, opacity, color, eraser and alpha lock and blends them the way libmypaint's normal and lock-alpha modes do; colorize, posterize and pigment mixing stay the round dab's alone, as the code comment says. It samples the tip from a mip level whose texels are about one pixel across (level_for, over at most ARD_SHAPE_LEVELS = 8 levels, halving down to 4 texels), so a big tip shrunk to a small brush doesn't sparkle. And because it writes straight into tiles instead of queuing, it acquires each tile it touches once per batch and holds it (held_tile) until ard_surface_end releases them all.

The tip turns by its own angle, plus how far it follows the stroke's heading, plus how far it follows the Pencil's lean, plus random scatter; it flips at random if asked; and a seed makes the randomness repeatable, so a stroke drawn again at the lift (track 5's taper) scatters the same way. It ignores libmypaint's dab angle on purpose: that angle is only known modulo half a turn, harmless for an ellipse and wrong for a leaf. Track 5 tells that story.

Symmetry, Re-implemented

Taking the slot has a price: whatever the old function did that you still want, you now do yourself. libmypaint's symmetry pass lives inside its draw_dab, so shaped dabs would lose mirror and rotational symmetry the moment the slot changed hands. shaped_draw_dab repeats it, reading the same symmetry_data matrices libmypaint keeps (set through ard_surface_set_symmetry, a one-line forward to mypaint_tiled_surface_set_symmetry_state), and every reflection passes a mirror flag so the tip itself flips. Two departures from upstream are deliberate:

  • The four-way case turns half a turn. In VERTHORZ the second matrix is two reflections, which is a rotation by 180°, and the third is a reflection across the other axis. Upstream hands the rotated dab its angle unchanged and gives the third the first reflection's angle. For an ellipse both are fine, because an ellipse turned half a turn is the same ellipse. A leaf is not, so Ariandel adds 180° to both.
  • No skip. Upstream skips the whole symmetry pass when the first dab painted nothing, and its own comment warns the shortcut "_must_ be removed" if selection masks ever arrive. The shortcut's premise (if the first dab paints nothing, none of its copies will) holds for a round dab. It does not hold for a stamp whose grain or second tip depends on where it lands. Ariandel's shaped path always repeats and ORs the results.

One honest gap: the engine suite pins a shaped tip under vertical mirror symmetry (symmetryRepeatsAShapedTip). The half-turn in the four-way case is reasoned from the matrices, not pinned by a test.

The Other Hooks, All Outside libmypaint

Once the slot was Ariandel's, more brush behavior moved in, every piece on 2026-10-03, and all of it through setters on the surface:

HookSetterSet when
Picture tip (Procreate's Shape Source), 139c64f, 00:16ard_surface_set_shapeeach stroke, with its seed
Grain that rolls with the dab (Procreate's moving grain), 9e23122, 12:06ard_surface_set_graineach stroke
Photoshop's Dual Brush, stamp by stamp, ad36a8c, 19:33ard_surface_set_dualeach stroke
Tip follows the Pencil's lean (Photoshop's angle from Pen Tilt), 7c548c9, 19:48ard_surface_set_tilt_follow, ard_surface_set_azimuththe follow with the shape; the azimuth before every input
Photoshop's grain modes (Height, Linear Height, Subtract) and depth by pen pressure, 7241359, 20:17ard_surface_set_grain_mode, ard_surface_set_pressurethe mode with the grain; the pressure before every input

The vendored CMyPaint directory has exactly one commit in its history, the one that added it. Five features landed in one day without a single vendored byte moving. What the record does not prove is feel: the tilt commit itself says the simulator has no tilt and its test feeds azimuths, so how a tip that follows the lean feels under a real Pencil waits on Dad's hand.

Wrap an engine's virtual table instead of forking the engine. Let the engine install its own function, keep it, take the slot, and call straight through whenever you have nothing to add. You own only the difference, and you inherit the duty to redo whatever the old slot did that you still need. Write that duty down where the next upstream update will trip over it.

Code

The takeover: ard_surface_create (verbatim) and the head of shaped_draw_dab with its call-through (trimmed)·c
/* CAriandelSurface/ariandel_surface.c (verbatim) */
ArdSurface *
ard_surface_create(void *context, ArdTileAcquire acquire, ArdTileRelease release)
{
    ArdSurface *self = calloc(1, sizeof(ArdSurface));
    if (self == NULL) {
        return NULL;
    }
    self->empty = calloc(ARD_TILE_VALUES, sizeof(uint16_t));
    self->scratch = calloc(ARD_TILE_VALUES, sizeof(uint16_t));
    if (self->empty == NULL || self->scratch == NULL) {
        free(self->empty);
        free(self->scratch);
        free(self);
        return NULL;
    }
    self->context = context;
    self->acquire = acquire;
    self->release = release;
    mypaint_tiled_surface_init(&self->parent, tile_request_start, tile_request_end);
    self->parent.parent.destroy = surface_destroy;
    self->round_dab = self->parent.parent.draw_dab;
    self->parent.parent.draw_dab = shaped_draw_dab;
    return self;
}

/* The same file, the head of the replacement (trimmed after the call-through) */
/* libmypaint's draw_dab with a shaped tip: the tip turns with the stroke and
   at random as asked, and symmetry repeats it as libmypaint repeats a round
   dab (reflections mirror the tip too). Without a shape, libmypaint's own. */
static int
shaped_draw_dab(MyPaintSurface *surface, float x, float y, float radius,
                float color_r, float color_g, float color_b,
                float opaque, float hardness, float softness, float color_a,
                float aspect_ratio, float angle, float lock_alpha, float colorize,
                float posterize, float posterize_num, float paint)
{
    ArdSurface *self = (ArdSurface *)surface;
    if (self->levels == 0) {
        return self->round_dab(surface, x, y, radius, color_r, color_g, color_b, opaque, hardness, softness,
                               color_a, aspect_ratio, angle, lock_alpha, colorize, posterize, posterize_num, paint);
    }
    /* ... the shaped path: heading, turn, flips, stamp(), symmetry ... */
}
Upstream's symmetry pass beside Ariandel's: the skip and the four-way case (both trimmed)·c
/* libmypaint, mypaint-tiled-surface.c, inside draw_dab (trimmed) */
    // Symmetry pass

    // OPTIMIZATION: skip the symmetry pass if surface was not modified by the initial dab;
    // at current if the initial dab does not modify the surface, none of the symmetry dabs
    // will either. If/when selection masks are added, this optimization _must_ be removed,
    // and `surface_modified` must be or'ed with the result of each call to draw_dab_internal.
    MyPaintSymmetryData *symm_data = &self->symmetry_data;
    if (surface_modified && symm_data->active && symm_data->num_symmetry_matrices) {
        /* ... */
        case MYPAINT_SYMMETRY_TYPE_VERTHORZ: {
            // Reflect across horizontal line
            mypaint_transform_point(&matrices[0], x, y, &x_out, &y_out);
            DDI(x_out, y_out, -2.0 * symm.angle - angle, 1);
            // Then across the vertical line (diagonal)
            mypaint_transform_point(&matrices[1], x, y, &x_out, &y_out);
            DDI(x_out, y_out, angle, 2);
            // Then back across the horizontal line
            mypaint_transform_point(&matrices[2], x, y, &x_out, &y_out);
            DDI(x_out, y_out, -2.0 * symm.angle - angle, 3);
            num_bboxes_used = 4;
            break;
        /* ... */

/* Ariandel, ariandel_surface.c, inside shaped_draw_dab (trimmed) */
    int painted = STAMP(x, y, turn, 0);

    MyPaintSymmetryData *symm_data = &self->parent.symmetry_data;
    if (symm_data->active && symm_data->num_symmetry_matrices) {
        /* ... */
        case MYPAINT_SYMMETRY_TYPE_VERTHORZ:
            mypaint_transform_point(&matrices[0], x, y, &x_out, &y_out);
            painted |= STAMP(x_out, y_out, -2.0f * symm.angle - turn, 1);
            mypaint_transform_point(&matrices[1], x, y, &x_out, &y_out);
            painted |= STAMP(x_out, y_out, turn + 180.0f, 0);
            mypaint_transform_point(&matrices[2], x, y, &x_out, &y_out);
            painted |= STAMP(x_out, y_out, -2.0f * symm.angle - turn + 180.0f, 1);
            break;
        /* ... */
A minimal sketch: save the engine's function, take the slot, call through when there is nothing to add (compiled and run with Apple clang, -std=c11)·c
// A minimal sketch of the draw_dab takeover: keep the engine's function,
// install your own, call through when you have nothing to add.
// clang -std=c11 -Wall -Wextra wrap.c -o wrap && ./wrap
#include <stdio.h>
#include <stdlib.h>

typedef struct Surface Surface;
typedef int (*DrawDab)(Surface *self, float x, float y, float radius);
struct Surface { DrawDab draw_dab; };              /* the engine's vtable slot */

/* ---- the engine, untouched ---- */
static int engine_round_dab(Surface *self, float x, float y, float radius) {
    (void)self;
    printf("  engine: round dab r=%.1f at (%.0f, %.0f)\n", radius, x, y);
    return 1;
}
static void engine_init(Surface *s) { s->draw_dab = engine_round_dab; }
static void engine_stroke(Surface *s) { s->draw_dab(s, 5, 5, 3); s->draw_dab(s, 9, 6, 3); }

/* ---- the host ---- */
typedef struct {
    Surface parent;      /* first, so Surface* -> Host* is valid */
    DrawDab round_dab;   /* the engine's own, saved */
    const char *shape;   /* NULL: no shape set */
} Host;

static int host_draw_dab(Surface *surface, float x, float y, float radius) {
    Host *self = (Host *)surface;
    if (self->shape == NULL) {
        return self->round_dab(surface, x, y, radius);   /* straight through */
    }
    printf("  host:   stamp '%s' r=%.1f at (%.0f, %.0f)\n", self->shape, radius, x, y);
    return 1;
}

static Host *host_create(void) {
    Host *self = calloc(1, sizeof *self);
    engine_init(&self->parent);                 /* let the engine install its own first */
    self->round_dab = self->parent.draw_dab;    /* then save it */
    self->parent.draw_dab = host_draw_dab;      /* and take the slot */
    return self;
}

int main(void) {
    Host *host = host_create();
    puts("no shape:");
    engine_stroke(&host->parent);
    host->shape = "leaf";
    puts("with a shape:");
    engine_stroke(&host->parent);
    free(host);
    return 0;
}
/* Output:
   no shape:
     engine: round dab r=3.0 at (5, 5)
     engine: round dab r=3.0 at (9, 6)
   with a shape:
     host:   stamp 'leaf' r=3.0 at (5, 5)
     host:   stamp 'leaf' r=3.0 at (9, 6) */

External links

Exercise

Find a library you depend on that exposes a table of function pointers or a protocol you can implement (a SQLite VFS, a custom allocator hook, a logging sink, a URLProtocol). Wrap one entry the way ard_surface_create does: let the library install its default, save it, install yours, and call through unchanged when your condition is off. Then write the list of everything the original entry did, and mark each item 'called through', 'redone' or 'dropped on purpose'.
Hint
The quickest proof that your call-through is honest is to run the library's own tests or your existing suite with your wrapper installed and its condition off. If anything changes, your wrapper is doing work when it claims to step aside.

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.