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

Tiles Through Function Pointers

~15 min · c, swift, ffi, unmanaged, memory

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

libmypaint Asks, the Store Answers

libmypaint never owns a pixel. Whenever it needs a tile (to lay the dabs it queued once a batch closes, or to sample color under the brush for smudge) it fills in a request with tile coordinates and a readonly flag, calls tile_request_start, and expects a 64×64 buffer of premultiplied 15-bit RGBA back in request->buffer. When it is done it calls tile_request_end. Those two are function pointers in MyPaintTiledSurface, the struct from the last lesson.

Ariandel's versions of them barely do anything. They forward to two more function pointers, acquire and release, which ard_surface_create stored in the ArdSurface beside one opaque void *context. On the far side of those pointers sits Swift's TileStore, the layer's sparse map of tiles and the only place its pixels live.

Two Tiles the Bridge Keeps for Itself

The store is sparse: an absent tile is transparent, and nothing is allocated for it. So a read of an absent tile (smudge sampling a blank patch of canvas, which libmypaint's get_color requests read-only) has a perfectly good answer and nothing to point at. The store returns nil, and the bridge lends empty, a zeroed tile it allocated at creation. Only read-only requests ever receive it, so nothing ever writes it, and it stays transparent for the life of the surface.

The second tile, scratch, covers a writable request the store could not satisfy. The bridge zeroes it, lends it, and then tile_request_end drops it without calling release, so its paint goes nowhere and the store never hears that a tile changed. Compare libmypaint's own MyPaintFixedTiledSurface: one null tile does both jobs and is wiped after each request. Ariandel splits the jobs, so the read path's guarantee ("this tile is transparent") never depends on a wipe having happened first.

To be honest about it: today's Swift store always satisfies a writable request, because writableTile creates a missing tile on the spot. The scratch tile is a guard for a store that cannot answer (or a nil context), not a path the app actually takes. The commit that introduced the bridge, f99f2b0 on 2026-10-01, names the intent: such a request "paints into scratch instead of corrupting it".

Why the Callbacks Cannot Capture

A C function pointer is one machine address: where the code starts. There is no room in it for state. A Swift closure that captures the store is code plus a box of captured context, and a C type like uint16_t *(*)(void *, int, int, int) has nowhere to put the box. Swift converts a function to @convention(c) only when it captures nothing, and otherwise refuses outright. Under Swift 6.4 the error reads "a C function pointer cannot be formed from a closure that captures context"; we compiled the failing case to quote it.

So BrushSurface's callbacks are top-level private funcs that capture nothing, and the state travels separately, through the parameter C designed for exactly this: void *context. C stores it, never looks inside, and hands it back on every call. Each callback rebuilds the store from it. It is the ordinary C callback idiom (sorting comparators with a context, thread entry points, run-loop observers, SQLite's exec callback all carry one), and Unmanaged is Swift's half of it.

passUnretained, and the Lifetime It Assumes

Unmanaged.passUnretained(store).toOpaque() turns a class reference into a raw pointer without touching the reference count. takeUnretainedValue() in each callback turns it back, again without a retain or a release. Nobody owns the store through that pointer. The design is correct only if something else keeps the store alive for as long as C can call back, and something does:

  • BrushSurface holds public let store: TileStore, a strong reference.
  • Its deinit calls ard_surface_destroy, which drops libmypaint's one reference so the C surface and the context it stored are freed. A deinit body runs before the object's stored properties are released, so the C side is gone while the store is still alive.
  • C only calls back synchronously, inside calls Swift makes on the surface: mypaint_brush_stroke_to (color sampling, shaped stamps) and ard_surface_end (the queued dabs, the held tiles). The tiled surface's threadsafe_tile_requests flag is false and OpenMP is compiled out, so the requests run on the calling thread. There is no "later" for a stray callback to arrive in.

What would break it: a weak or unowned(unsafe) store field; C keeping the context past ard_surface_destroy in a global or on a background queue; or, in the other direction, passRetained with no matching release, which never crashes and quietly leaks every layer's tiles. Ownership in practice is simple: each Layer creates its surface lazily over its own tiles (lazy var surface = BrushSurface(store: tiles)), and Clone and dual-brush strokes make surfaces over scratch stores of their own.

Release Is the Dirty Signal

Look at what the store does with the two calls. A writable acquire goes through writableTile, the only way to a writable buffer in the engine: it records the before-state for undo, marks the tile dirty, and copies the buffer first if history or a save is holding it frozen (track 3 is all about that). libmypaint therefore can never scribble on a tile someone else is reading. A writable release inserts the key into the dirty set, which is the renderer's cue to upload it again. A read-only acquire returns the live buffer as it is, frozen or not, typed as UnsafeMutablePointer; the type says mutable, and the readonly flag is the promise that C will not write. libmypaint keeps that promise, which is the only reason the bridge may lend frozen tiles at all.

A C callback carries code; the context carries state. Keep the code capture-free, give the state exactly one owner on the Swift side, and make sure the C object's lifetime nests inside that owner's. Then unretained is not a gamble, it is a description.

Code

The C side: the two callback types (verbatim) and the forwarders that lend the empty and scratch tiles (verbatim)·c
/* CAriandelSurface/include/ariandel_surface.h (verbatim) */
/// Lends the buffer libmypaint reads (readonly != 0) or writes for tile (tx, ty).
/// A readonly request may return NULL for an absent tile; the bridge then lends
/// a shared transparent tile. A writable request should always return a buffer
/// the store owns, allocating one if needed; if it cannot, the bridge lends a
/// scratch tile whose paint is discarded.
typedef uint16_t *_Nullable (*ArdTileAcquire)(void *_Nullable context, int tx, int ty, int readonly);

/// Returns a buffer previously lent by ArdTileAcquire. Writable releases are
/// the store's signal that the tile changed.
typedef void (*ArdTileRelease)(void *_Nullable context, int tx, int ty, uint16_t *buffer, int readonly);

/* CAriandelSurface/ariandel_surface.c (verbatim) */
static void
tile_request_start(MyPaintTiledSurface *tiled, MyPaintTileRequest *request)
{
    ArdSurface *self = (ArdSurface *)tiled;
    const int readonly = request->readonly ? 1 : 0;
    uint16_t *buffer = self->acquire(self->context, request->tx, request->ty, readonly);
    if (buffer == NULL) {
        if (readonly) {
            buffer = self->empty;
        } else {
            memset(self->scratch, 0, sizeof(uint16_t) * ARD_TILE_VALUES);
            buffer = self->scratch;
        }
    }
    request->buffer = buffer;
}

static void
tile_request_end(MyPaintTiledSurface *tiled, MyPaintTileRequest *request)
{
    ArdSurface *self = (ArdSurface *)tiled;
    uint16_t *buffer = request->buffer;
    if (buffer == NULL || buffer == self->empty || buffer == self->scratch) {
        return;
    }
    self->release(self->context, request->tx, request->ty, buffer, request->readonly ? 1 : 0);
}
The Swift side: BrushSurface's unretained context and capture-free callbacks (trimmed), and the store's two engine entry points (verbatim)·swift
// AriandelEngine/BrushSurface.swift (trimmed)
/// The libmypaint surface over one layer's tile store.
///
/// libmypaint asks for tiles through the C bridge (`ariandel_surface.c`);
/// these two callbacks answer from the store. The surface keeps the store
/// alive and is used on the thread that owns the artwork.
public final class BrushSurface {
    public let store: TileStore
    let handle: OpaquePointer

    public init(store: TileStore) {
        self.store = store
        let context = Unmanaged.passUnretained(store).toOpaque()
        guard let surface = ard_surface_create(context, brushSurfaceAcquire, brushSurfaceRelease) else {
            fatalError("Out of memory creating a libmypaint surface")
        }
        handle = surface
    }

    deinit {
        ard_surface_destroy(handle)
    }
    // ...
}

private func brushSurfaceAcquire(_ context: UnsafeMutableRawPointer?, _ tx: Int32, _ ty: Int32,
                                 _ readonly: Int32) -> UnsafeMutablePointer<UInt16>? {
    guard let context else { return nil }
    let store = Unmanaged<TileStore>.fromOpaque(context).takeUnretainedValue()
    return store.acquireForEngine(x: tx, y: ty, readonly: readonly != 0)
}

private func brushSurfaceRelease(_ context: UnsafeMutableRawPointer?, _ tx: Int32, _ ty: Int32,
                                 _ buffer: UnsafeMutablePointer<UInt16>, _ readonly: Int32) {
    guard let context else { return }
    let store = Unmanaged<TileStore>.fromOpaque(context).takeUnretainedValue()
    store.releaseFromEngine(x: tx, y: ty, readonly: readonly != 0)
}

// AriandelEngine/Tiles.swift, inside TileStore (verbatim)
    // MARK: libmypaint's tile requests (through BrushSurface)

    func acquireForEngine(x: Int32, y: Int32, readonly: Bool) -> UnsafeMutablePointer<UInt16>? {
        let key = TileKey(x: x, y: y)
        if readonly {
            return tiles[key]?.pixels
        }
        return writableTile(at: key).pixels
    }

    func releaseFromEngine(x: Int32, y: Int32, readonly: Bool) {
        guard !readonly else { return }
        dirty.insert(TileKey(x: x, y: y))
    }
A minimal sketch you can reproduce: a C engine, a Swift store, capture-free callbacks and an unretained context (run in an empty directory; built with Apple clang and swiftc -swift-version 6)·bash
# A minimal sketch: C asks a Swift store for tiles through two capture-free
# function pointers and one unretained context. Run in an empty directory.
cat > engine.h <<'EOF'
#include <stdint.h>
typedef uint16_t *_Nullable (*TileAcquire)(void *_Nullable context, int tx, int ty, int readonly);
typedef void (*TileRelease)(void *_Nullable context, int tx, int ty, uint16_t *_Nonnull buffer, int readonly);
/* Reads tile (tx - 1, ty), then writes tile (tx, ty) one higher. */
void engine_paint(void *_Nullable context, TileAcquire _Nonnull acquire,
                  TileRelease _Nonnull release, int tx, int ty);
EOF
cat > engine.c <<'EOF'
#include "engine.h"
#include <stddef.h>
static uint16_t empty[4]; /* lent for a read of an absent tile; never written */
void engine_paint(void *context, TileAcquire acquire, TileRelease release, int tx, int ty) {
    uint16_t *left = acquire(context, tx - 1, ty, 1);
    uint16_t before = (left ? left : empty)[0];
    if (left) release(context, tx - 1, ty, left, 1);
    uint16_t *tile = acquire(context, tx, ty, 0);
    if (tile == NULL) return;
    tile[0] = (uint16_t)(before + 1);
    release(context, tx, ty, tile, 0);
}
EOF
cat > main.swift <<'EOF'
final class Store {
    var tiles: [SIMD2<Int32>: UnsafeMutablePointer<UInt16>] = [:]
    var dirty: Set<SIMD2<Int32>> = []
    func acquire(_ x: Int32, _ y: Int32, readonly: Bool) -> UnsafeMutablePointer<UInt16>? {
        let key = SIMD2(x, y)
        if readonly { return tiles[key] }          // absent: nil, and C lends its empty tile
        if let tile = tiles[key] { return tile }
        let tile = UnsafeMutablePointer<UInt16>.allocate(capacity: 4)
        tile.initialize(repeating: 0, count: 4)
        tiles[key] = tile
        return tile
    }
    deinit { tiles.values.forEach { $0.deallocate() } }
}

// Top-level, capture-free: these convert to @convention(c) pointers.
private func storeAcquire(_ context: UnsafeMutableRawPointer?, _ tx: Int32, _ ty: Int32,
                          _ readonly: Int32) -> UnsafeMutablePointer<UInt16>? {
    guard let context else { return nil }
    return Unmanaged<Store>.fromOpaque(context).takeUnretainedValue().acquire(tx, ty, readonly: readonly != 0)
}
private func storeRelease(_ context: UnsafeMutableRawPointer?, _ tx: Int32, _ ty: Int32,
                          _ buffer: UnsafeMutablePointer<UInt16>, _ readonly: Int32) {
    guard let context, readonly == 0 else { return }
    Unmanaged<Store>.fromOpaque(context).takeUnretainedValue().dirty.insert(SIMD2(tx, ty))
}

let store = Store()                                 // the strong reference lives here
let context = Unmanaged.passUnretained(store).toOpaque()
for x in Int32(0)...2 { engine_paint(context, storeAcquire, storeRelease, x, 0) }
print("tile (2,0) =", store.tiles[SIMD2(2, 0)]![0])  // 3
print("dirty:", store.dirty.map { "(\($0.x),\($0.y))" }.sorted())
EOF
clang -std=c11 -Wall -c engine.c -o engine.o
swiftc -swift-version 6 -import-objc-header engine.h main.swift engine.o -o bridge && ./bridge

External links

Exercise

Pick a C API your project already calls that takes a callback plus a context pointer (SQLite's sqlite3_exec, a Core Foundation observer, an audio render callback). Write the callback as a capture-free top-level function, pass your Swift object with Unmanaged.passUnretained, and then write one sentence naming the strong reference that keeps the object alive and the line that stops C from calling back before it is released. If you cannot write that sentence, switch to passRetained and a balancing release.
Hint
sqlite3_exec is the easiest to try: its callback's first parameter is your context pointer, and every call happens before sqlite3_exec returns, which is exactly the synchronous lifetime that makes unretained safe. A callback that fires on a timer or another thread is the harder case, and it is the one that needs a deliberate retain.

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.