Every drawing app starts the same way. You collect touch points, build a path, stroke it with a round cap and a width driven by pressure, and ship. It works. It also looks like plastic, and no amount of tuning fixes that, because the failure is structural rather than cosmetic.
This is a write-up of the rendering engine behind a chalkboard app, and specifically of three decisions that turned out to be load-bearing:
- A stroke is a sequence of stamps deposited at fixed spacing, not a path.
- The document stores input points, never pixels.
- Undo is deterministic replay driven by a seeded RNG.
Each one buys something. Each one also has a bill, and the bill is the interesting part.
Why stroking a path fails
The obvious approach to "make it look like chalk" is: stroke the path, then add noise. Multiply by a grain texture, jitter the width, lower the alpha at high speed.
It never lands, for a reason that is easy to state once you see it. A stroked path is a continuous region with a single alpha value per pixel. Real chalk is not a region — it is a population of particles that either landed or did not. The visual signature of chalk (the broken edges, the dry-brush gaps when you move fast, the way repeated passes reinforce into a solid line) comes from discrete deposition events on a rough surface. If you start from a continuous region and multiply noise on top, you get a continuous region with noise on top. The eye reads it instantly as texture-over-shape.
So the model is inverted. Instead of "draw the shape, then roughen it," walk the path and drop individual grain stamps.
The baker
The whole thing lives in one function that turns a line segment between two input samples into a list of GPU instances:
static func bake(_ s: Stroke, from p0: InkPoint, to p1: InkPoint,
rng: inout SeededRNG, into out: inout [StampInstance],
scale: Float = 1) {
let b = s.brush
let dx = p1.x - p0.x, dy = p1.y - p0.y
let dist = sqrt(dx * dx + dy * dy)
let speed = min(1, dist / max(0.001, s.size * 2.9))
let step = max(s.size * b.spacing, s.size * 0.02, 0.00005)
let n = max(1, min(4000, Int((dist / step).rounded())))
Note that speed is normalized by pen diameter, not by an absolute point distance. This is not aesthetic pedantry; it is the fix for a real bug. The original code used a hardcoded 26pt as full speed. Once the canvas supports deep zoom, a fast gesture at 800× covers a tiny distance in canvas space, speed pins to zero, and the dry-brush effect silently disappears at high magnification. Scaling by pen diameter — which is itself already divided by zoom — makes the entire speed semantics zoom-invariant.
Per stamp, three families of parameters are applied: pressure, speed, and stylus tilt.
let elong = 1 + tilt * b.tiltElongation
let broaden = 1 + tilt * b.tiltBroaden
let tintByTilt = 1 + tilt * (b.tiltAlpha - 1)
let size = s.size * broaden
* (1 - speed * b.speedThin)
* (0.72 + pr * 0.42)
let alpha = b.alpha * tintByTilt
* (1 - speed * b.speedFade)
* (0.55 + pr * 0.65)
elong becomes a non-uniform scale on the stamp quad, rotated to the stylus azimuth — that is the flat-side stroke. Everything a "brush" is, in this engine, is a struct of numbers: spacing, jitter, alpha, speedFade, speedThin, tiltElongation, tiltBroaden, tiltAlpha, tooth, toothSoftness. Chalk, pencil and ink are the same code with different constants. Adding a material means adding numbers, not code paths.
Deposition happens in the fragment shader
Jitter alone still is not chalk. The missing piece: chalk dust only sticks where the pressure beats the surface roughness. So the stamp texture supplies coverage and a second, canvas-anchored texture supplies tooth:
fragment float4 stamp_fragment(StampOut in, ...)
{
float mask = atlas.sample(smp, in.uv, in.stamp).r;
float cov = mask * in.color.a; // coverage 0..1
float tooth = grain.sample(grainSmp, in.canvasUV).r;
float deposit = smoothstep(1.0 - cov, 1.0 - cov + bu.softness, tooth);
float a = mix(cov, deposit, bu.tooth);
return float4(in.color.rgb * a, a);
}
The threshold moves down as coverage rises. Press hard and even the valleys of the board take dust, so the line goes solid; move fast and coverage drops, so only the peaks catch anything — which is dry brush. You never author the dry-brush effect; it emerges from the same expression that produces a solid line.
The single most important line is in the vertex stage:
o.canvasUV = pt * bu.grainScale;
pt is the canvas position of this fragment, not a stamp-local coordinate. If grain UVs were stamp-local, every stamp would sample a different random patch, and overlapping stamps would average toward flat gray. Anchoring grain to canvas space means repeated passes hit the same peaks and valleys and reinforce each other. That single substitution is the difference between "grainy smear" and "chalk."
The eraser reuses the identical stamping pipeline with a blend of (zero, oneMinusSrcAlpha). The consequence is free and correct: erased edges are dusty, not razor-cut.
Store points, not pixels
The document model is almost embarrassingly small:
struct Stroke: Codable {
let brushKind: Material.BrushKind
let color: SIMD4<Float>
let size: Float // diameter, in points
let erasing: Bool
let seed: UInt32
var points: [InkPoint]
var layer: Int = 0
}
That decision pays three times.
Zoom stays sharp. Strokes are stored in canvas coordinates. Zooming does not magnify a bitmap; it re-bakes the same points at a larger scale. Grain stays crisp at any magnification because it is regenerated, not stretched.
Layers are free. Ordering is a stable sort:
let sorted = list.enumerated()
.sorted { ($0.element.layer, $0.offset) < ($1.element.layer, $1.offset) }
.map(\.element)
A bitmap architecture needs one full-screen texture per layer. Here a layer costs one Int per stroke. The sort must be stable — Swift's sort is not — hence the enumeration offset as tiebreaker; without it, strokes within a layer reshuffle their overlap on every replay.
Undo is replay. No snapshots, no history of textures:
func undo() {
if let backup = clearedBackup, strokes.isEmpty {
strokes = backup; clearedBackup = nil; needsReplay = true; return
}
guard !strokes.isEmpty else { return }
clearedBackup = nil
redoStack.append(strokes.removeLast())
needsReplay = true
}
For that to be pixel-identical, randomness must be reproducible. Each stroke carries a seed and replay reconstructs the same generator:
static func bakeAll(_ s: Stroke, into out: inout [StampInstance], scale: Float = 1) {
guard s.points.count > 1 else { return }
var rng = SeededRNG(seed: s.seed)
for i in 1..<s.points.count {
bake(s, from: s.points[i - 1], to: s.points[i], rng: &rng, into: &out, scale: scale)
}
}
Rendering itself is incremental: the canvas texture is persistent with loadAction = .load, and only undo / clear / page change trigger a full replay. Redrawing every stroke per frame is fine on a whiteboard demo and dies under 120 Hz coalesced touches.
The failure modes — the part worth your time
RNG desync is the nastiest bug in the design. The baker skips stamps that are too small or too faint. The skip must happen after the RNG has been consumed:
let idx = UInt32(rng.nextInt(StampAtlas.count))
let jx = rng.signed() * s.size * b.jitter
let jy = rng.signed() * s.size * b.jitter
let grain = rng.next() * .pi * 2
guard size * scale > 0.4, alpha > 0.004 else { continue }
Move that guard three lines up and the code is still correct-looking, still renders fine live — and every replay after an undo shifts every grain in the drawing. It reproduces only after an undo, which is exactly when nobody is watching for it. Any edit to this function needs the RNG call count and order re-verified first.
Screen-scale constants inside canvas-scale math. 0.4 above is a screen threshold ("a stamp under half a point is not worth drawing"), but size is in canvas units. At high zoom a stroke's canvas size is a fraction of a point, so without the * scale conversion every single stamp gets skipped: nothing renders, and the stroke is still persisted with that tiny size, so it stays invisible when you zoom back out. Silent data loss with no error anywhere. The same class of bug bit the minimum spacing, the jitter-filter distance, and the brush-size floor. If you build a deep-zoom canvas, audit every constant for which space it lives in.
A one-encoder buffer aliasing trap. An earlier version memcpy'd each stroke's instances to buffer offset 0 before its draw call. The copies happen on the CPU at encode time; the GPU executes after commit. So every draw in that encoder read the last batch written. Live drawing goes through the incremental path and looked perfect; only replay produced garbage, so the bug hid for a long time. The fix is one upload for the whole batch, with per-span offsets.
Empty is not failure. The upload helper returns early on an empty instance array — and it must return success, because "clear the board" is exactly the zero-stamp case. Treating it as failure skips the loadAction = .clear pass and the clear button appears to do nothing.
Real costs. CPU baking is O(total stamps), and a full replay re-bakes everything; a dense drawing has a visible hitch on undo. There is a hard cap of 4000 stamps per segment to stop a pathological segment from exploding. Overdraw is high by construction — every stamp is a blended quad. Editing an old stroke's geometry is not supported, because the model is append-only history. And the aspects that matter most (dry brush, tilt, finger-vs-pencil) cannot be validated in the simulator at all; they need a real device and a stylus, which makes the CI story weak.
One API landmine. altitudeAngle returns 0 for finger touches, and 0 means "stylus lying flat." Without a type == .pencil check, every finger stroke renders as an extremely elongated smear.
This engine powers Flying Chalk.
Top comments (0)