Ollin

Stippling

weighted-Voronoi stippling: a painted, lit sphere rebuilt from 5,200 dots, packed more densely where the picture is darker (stipple)

Run
swift run --package-path Examples Example-Patterns-Stippling
Source
Examples/Patterns/Stippling/Sketch.swift
import Ollin

/// A shaded sphere rebuilt out of nothing but dots.
///
/// `stipple(_:count:in:)` places a fixed budget of points so their local
/// density follows a picture's darkness: shadow packs them tight, highlight
/// spreads them out, and from arm's length the scatter *is* the picture. The
/// image here is painted in `setup()` (a lit sphere with a mottled surface),
/// so the sketch carries no asset, and the stipple is computed once and held;
/// the relaxation that evens out the spacing is per-pixel CPU work, not
/// something to redo every frame.
///
/// Each dot is also sized a little by the darkness under it, the classic
/// stippling move: density carries the tone, size sharpens the shadows.
/// What animates is only a gentle shimmer; the points never move, so the
/// image holds still the way an ink drawing does.
@main
final class Stippling: Sketch {
    private var dots: [(point: Vector2, darkness: Double)] = []

    override func setup() {
        seed(21)
        let picture = paint()
        let frame = canvasRectangle.inset(by: 80)
        let points = stipple(of: picture, count: 5200, in: frame, iterations: 45)

        // Sample the darkness back under every dot, for the size pass.
        dots = points.map { p in
            let x = Int((p.x - frame.x) / frame.width * Double(picture.width))
            let y = Int((p.y - frame.y) / frame.height * Double(picture.height))
            let c = picture[clamp(x, 0, picture.width - 1),
                            clamp(y, 0, picture.height - 1)]
            return (p, 1 - c.luminance)
        }
    }

    override func draw() {
        background(Color(hex: 0xF5F2EA))

        noStroke()
        fill(Color(hex: 0x1A1B26))
        for (p, darkness) in dots {
            // A slow shimmer in the dot sizes; the positions stay put.
            let breathe = 1 + signedNoise(p.x * 0.004, p.y * 0.004, time * 0.35) * 0.18
            drawCircle(center: p, radius: (1.1 + darkness * 2.6) * breathe * scale)
        }

        drawCaption("5,200 dots; darkness sets how tightly they pack")
    }

    /// The picture the dots reproduce: a sphere lit from the upper left, its
    /// surface mottled by fbm, on a paper-white ground the stipple leaves
    /// empty. Painted small; the stipple's working grid does the refining.
    private func paint() -> Image {
        let n = 360
        let image = Image(width: n, height: n, color: .white)
        let light = Vector3(-0.5, -0.55, 0.67).normalized
        let radius = 0.82
        for y in 0 ..< n {
            for x in 0 ..< n {
                let u = (Double(x) + 0.5) / Double(n) * 2 - 1
                let v = (Double(y) + 0.5) / Double(n) * 2 - 1
                let d2 = u * u + v * v
                guard d2 < radius * radius else { continue }
                let normal = Vector3(u / radius, v / radius,
                                     (1 - d2 / (radius * radius)).squareRoot())
                let diffuse = max(normal.dot(light), 0)
                let mottle = signedNoise(u * 2.6 + 5, v * 2.6 + 5) * 0.16
                let tone = clamp(0.08 + diffuse * 0.92 + mottle * diffuse, 0, 1)
                image[x, y] = Color(white: tone)
            }
        }
        return image
    }
}

↑↓ move ↩ open