Ollin

ContourWalkers

walkers that step along the contours of a surface fitted through nine pins, turned a little uphill so they spiral in to the peaks, steered by the fitted field's exact gradient(at:), or by central differences over noise with the field parameter switched

Run
swift run --package-path Examples Example-Motion-ContourWalkers
Source
Examples/Motion/ContourWalkers/Sketch.swift
import Ollin

/// Walkers that read the slope of a field: each one steps along the contour it
/// stands on, turned a little uphill, so it spirals in toward a peak, or
/// downhill into a hollow with `Climb` below zero.
///
/// The field is a `RadialBasis` surface fitted through nine pins whose heights
/// breathe slowly, refitted every frame (nine points are a tiny solve).
/// `gradient(at:)` gives each walker the way uphill, exactly, from the fitted
/// bumps' own derivatives, and the perpendicular to it runs along the contour.
/// The contour lines are `isolines` of the same field. Switch `Field` to noise
/// and the slope comes from `gradient(at:field:)` instead, the central
/// difference that works for any function a sketch writes.
///
/// A walker that reaches a peak or a hollow, where the slope runs out, or that
/// leaves the map starts again somewhere else.
@main
final class ContourWalkers: Sketch {
    enum Field: String, CaseIterable, ParamOption { case fitted, noise }

    @Param var field: Field = .fitted
    @Param("Climb", -0.4...0.4) var climb = 0.12
    @Param("Walkers", 50...800) var count = 420

    private let paper = Color(hex: 0x14161B)
    private let ink = Color(hex: 0x3A4150)
    private let colors = Ramp.shades(of: Color(hex: 0x3E8E9A), lightness: 0.35...0.95)

    private var pins: [Vector2] = []
    private var trails: [[Vector2]] = []
    private var surface: RadialBasis<Vector2, Double>?

    private var frame: Rectangle { canvasRectangle.inset(by: 70) }

    override func setup() {
        randomSeed(4)
        // Nine pins on a jittered three-by-three grid, so the hills and
        // hollows spread over the whole map.
        pins = (0 ..< 9).map { i in
            let cell = Vector2(Double(i % 3) + 0.5, Double(i / 3) + 0.5) / 3
            return Vector2(frame.x + frame.width * cell.x + random(-90, 90),
                           frame.y + frame.height * cell.y + random(-90, 90))
        }
        trails = (0 ..< 800).map { _ in [spot()] }
    }

    override func draw() {
        background(paper)
        let heights = pins.indices.map { i in 0.5 + 0.45 * sin(time * 0.25 + Double(i) * 1.7) }
        surface = RadialBasis(points: pins, values: heights)

        // The contour lines the walkers follow.
        noFill()
        stroke(ink)
        strokeWeight(1.2 * scale)
        let levels = Array(stride(from: -0.6, through: 1.6, by: 0.08))
        for group in isolines(at: levels, in: frame, resolution: 90, field: height) {
            for line in group { drawPolyline(line.points, closed: line.isClosed) }
        }

        // Each walker takes one step: along the contour, turned uphill by `climb`.
        let turn = climb * .pi / 2
        for i in 0 ..< count {
            var trail = trails[i]
            let p = trail.last!
            let slope = field == .fitted ? surface?.gradient(at: p) ?? .zero
                                         : gradient(at: p, field: height)
            if slope.length < 1e-5 || !frame.contains(p) || trail.count > 900 {
                trails[i] = [spot()]
                continue
            }
            let along = slope.perpendicular.normalized
            let up = slope.normalized
            trail.append(p + (along * cos(turn) + up * sin(turn)) * 2.2)
            if trail.count > 40 { trail.removeFirst(trail.count - 40) }
            trails[i] = trail

            stroke(colors.color(at: height(p)).withAlpha(0.9))
            strokeWeight(2.2 * scale)
            drawPolyline(trail)
        }

        // The pins the fitted surface passes through.
        if field == .fitted {
            stroke(Color(white: 0.9))
            strokeWeight(1.5 * scale)
            for pin in pins { drawCircle(center: pin, radius: 7) }
        }

        drawCaption(field == .fitted ? "a fitted surface, climbed by its exact gradient"
                                     : "a noise field, climbed by central differences")
    }

    /// The height at a point, in roughly 0...1, from whichever field is chosen.
    private func height(_ p: Vector2) -> Double {
        if field == .fitted { return surface?.value(at: p) ?? 0 }
        return fbm(p.x * 0.0028, p.y * 0.0028, time * 0.03, octaves: 3)
    }

    private func spot() -> Vector2 {
        Vector2(random(frame.x, frame.x + frame.width), random(frame.y, frame.y + frame.height))
    }
}

↑↓ move ↩ open