Stippling
Weighted-Voronoi stippling places a fixed number of dots so that their local density reproduces the tone of a picture. Dark areas pack the dots tight and bright areas spread them out, so from a small distance the scatter reads as the picture. That is the classic look of a hand-stippled illustration. The output is plain points, so it feeds dots, marks, Voronoi cells, and the pen-plotter and SVG paths.
The method behind it is Adrian Secord's weighted Voronoi stippling, from 2002. Dots of even weight were a hand discipline in scientific illustration long before that. A random scatter does not reproduce it, because random placement clumps, so the dots are settled instead. The call seeds them by rejection-sampling the darkness, then it moves every dot to the darkness-weighted centroid of its cell, over and over. Each pass evens out the spacing, while the weighting keeps the dots on the tone. After a few dozen passes the spread is even, dense in the shadows and sparse in the light. Close up the dots are as even as blue noise, and at full size they take the shape of the image.
Contents
stipple (an image)
stipple(_ image: Image,
count: Int,
in bounds: Rectangle? = nil,
iterations: Int = 40) -> [Vector2]
This call stipples image with count dots inside bounds, which is the whole canvas by default. Darkness is read as 1 − linear luminance, scaled by alpha, so transparent pixels carry no ink. The image is stretched to fill bounds. To keep the image's own aspect ratio, pass the shared letterbox helper:
let dots = stipple(of: picture, count: 4000,
in: Rectangle(fitting: picture.size, in: canvasRectangle))
noStroke(); fill(.black)
for d in dots { drawCircle(center: d, radius: 2) }
The call is driven by the seeded random, so the same seed reproduces the stipple exactly.
stipple (a density function)
stipple(count: Int,
in bounds: Rectangle? = nil,
iterations: Int = 40,
density: (Vector2) -> Double) -> [Vector2]
The same engine runs over any field you write as a closure. The call samples that closure once up front, in canvas coordinates. A value of 0 keeps the dots out of a spot, and a higher value packs them tighter there. Noise, a distance falloff, or plain math all work.
let dots = stipple(count: 3000) { p in
fbm(p.x * 0.004, p.y * 0.004) // dots pool in the noise's peaks
}
Practical notes
- Compute once, then hold the points. The iteration sweeps every pixel of a working grid
iterationstimes. That issetup()work, notdraw()work. Animate the size or the color of the dots, not their placement. iterationssets how even the dots get. The default of 40 has visually converged. A few passes instead keep more of the grain of the initial scatter.- Size the dots by darkness for extra tone. The density carries the tone on its own. The classic stippling technique also scales each dot a little by the darkness under it. Sample the image at the dot to get that number.
- No ink means no dots. An all-white image, a fully transparent image, and an all-zero density each return an empty array. A texture-backed
Imagereturns an empty array too, because it holds no CPU pixels. Callsnapshot()on it first. - The result is deterministic. The same input, count, and seed reproduce the same dots exactly. A stipple is therefore safe to use in a snapshot test and in a recipe.
Standalone (outside a sketch)
The free functions take the rectangle and the random number generator as explicit arguments:
var rng = SplitMix64(seed: 7)
let dots = stipple(of: picture, count: 4000, in: frame, using: &rng)
let field = stipple(count: 800, in: frame, using: &rng) { p in p.x / frame.width }
Example: Patterns/Stippling. Guide: Chapter 9.
Related: Blue noise (even scatter with no image), Low-discrepancy sampling (even scatter as a stream), Images (the pixel access stippling reads through), Dithering (tone from a fixed pixel grid instead of free dots).