Scattered
fitting: a value known at a few scattered points becomes a value defined everywhere, because each known point contributes a bump (RadialBasis)
- Run
swift run --package-path Examples Example-Shapes-Scattered- Source
- Examples/Shapes/Scattered/Sketch.swift
import Ollin
/// **Fitting**: two ways of getting from a handful of numbers to a whole picture.
///
/// `RadialBasis` goes outward. You know a value at a few scattered places and want one
/// everywhere, so every known point contributes a bump and the bumps are weighted to pass
/// exactly through the values you gave. `Fit.minimize` goes inward: you have a few parameters
/// and a way of saying how wrong a setting is, and it walks them downhill.
///
/// - **field**: a color at six drifting points, read back over the whole canvas. Nothing
/// here is a gradient. Every pixel is a weighted sum of the six, and the six are the only
/// thing that moves.
/// - **warp**: the same fit carrying `Vector2`s instead of colors. Six places are pinned to
/// where they should move to, and the grid between them follows.
/// - **fit**: marks scattered around a circle that is never told to the sketch. Three
/// numbers get walked downhill every frame until they describe the circle the marks came
/// from, and the ring is drawn from those three.
///
/// Try it: pull `smoothing` up in the `field` reading and watch the field stop passing
/// through its own points, or pull `wobble` up in `fit` until the marks stop being a circle
/// at all and watch the answer get less sure of itself.
@main
final class Scattered_Example: Sketch {
enum Reading: String, CaseIterable, ParamOption { case field, warp, fit }
@Param(style: .segmented, icon: "circle.grid.cross") var reading: Reading = .field
@Param(0 ... 0.6, icon: "wind") var smoothing = 0.0
@Param(6 ... 40, icon: "square.grid.3x3") var step = 12.0
@Param(0 ... 90, icon: "waveform") var wobble = 26.0
let ground = Color(hex: 0x0E1216)
let inks = [Color(hex: 0xE8734A), Color(hex: 0x49B0A5), Color(hex: 0xE0C25C),
Color(hex: 0xC85A7C), Color(hex: 0x6E8FD4), Color(hex: 0x8FC46B)]
override func draw() {
background(ground)
switch reading {
case .field: drawColorField()
case .warp: drawWarpedGrid()
case .fit: drawRecoveredCircle()
}
}
/// Six drifting points, each holding a color, read back over the whole canvas.
private func drawColorField() {
let anchors = drifting()
guard let field = RadialBasis(points: anchors, values: inks, smoothing: smoothing) else {
return
}
noStroke()
let side = step
for y in stride(from: 0.0, to: height, by: side) {
for x in stride(from: 0.0, to: width, by: side) {
fill(field.value(at: Vector2(x + side / 2, y + side / 2)))
drawRect(x, y, side + 1, side + 1)
}
}
// The points themselves, ringed rather than filled, so it is clear how few of them
// there are. What shows inside each ring is the field's own color, and it matches
// the point's exactly, because passing through the values it was given is what
// makes this an interpolation rather than a blur of them.
noFill()
stroke(Color(hex: 0x101820, alpha: 0.85))
strokeWeight(4)
for anchor in anchors { drawCircle(center: anchor, radius: 12) }
noStroke()
}
/// The same six points carrying displacements instead of colors, which makes the fit a
/// warp: pin a few places to where they should go, and everything between them follows.
private func drawWarpedGrid() {
let anchors = drifting()
let pulls = anchors.enumerated().map { i, a -> Vector2 in
let phase = time * 0.5 + Double(i) * 1.9
return a + Vector2(cos(phase), sin(phase * 1.3)) * 130
}
guard let warp = RadialBasis(points: anchors, values: pulls, smoothing: smoothing) else {
return
}
noFill()
stroke(Color(hex: 0x9FB3C8, alpha: 0.75))
strokeWeight(1.5)
let side = max(step * 2.5, 24)
// Each grid line is drawn through the warp point by point, so a straight line comes
// out bent by however the six pins are pulling where it passes.
for x in stride(from: 0.0, through: width, by: side) {
drawPolyline(stride(from: 0.0, through: height, by: 10).map {
warp.value(at: Vector2(x, $0))
})
}
for y in stride(from: 0.0, through: height, by: side) {
drawPolyline(stride(from: 0.0, through: width, by: 10).map {
warp.value(at: Vector2($0, y))
})
}
noStroke()
for (anchor, pull) in zip(anchors, pulls) {
stroke(Color(hex: 0xE8734A, alpha: 0.6))
strokeWeight(2)
drawLine(anchor, pull)
noStroke()
fill(Color(hex: 0xE8734A))
drawCircle(center: pull, radius: 7)
}
}
/// Marks scattered around a circle the sketch is never told, and the three numbers a
/// downhill walk recovers from them.
private func drawRecoveredCircle() {
let truth = Vector2(width * 0.5 + cos(time * 0.4) * 120,
height * 0.5 + sin(time * 0.31) * 90)
let radius = 250 + sin(time * 0.6) * 60
let marks = (0 ..< 120).map { i -> Vector2 in
let a = Double(i) / 120 * .tau
// A fixed pattern of noise rather than a random one, so the marks wander with
// the shape instead of flickering.
let off = noise(cos(a) * 2 + 11, sin(a) * 2 + 7, time * 0.2) - 0.5
return truth + Vector2(angle: a) * (radius + off * 2 * wobble)
}
noStroke()
fill(Color(hex: 0x9FB3C8, alpha: 0.8))
for mark in marks { drawCircle(center: mark, radius: 3.5) }
// Three parameters: where the middle is, and how far out the ring sits. The cost is how
// badly the marks miss a ring with those three numbers.
let found = Fit.minimize(from: [width / 2, height / 2, 200], steps: 260, rate: 6) { p in
let center = Vector2(p[0], p[1])
return marks.reduce(0.0) { total, mark in
let off = center.distance(to: mark) - p[2]
return total + off * off
}
}
noFill()
stroke(Color(hex: 0xE0C25C))
strokeWeight(3)
drawCircle(found.values[0], found.values[1], found.values[2])
noStroke()
fill(Color(hex: 0xE0C25C))
drawCircle(found.values[0], found.values[1], 6)
}
/// Six points wandering slowly, well inside the canvas.
private func drifting() -> [Vector2] {
(0 ..< 6).map { i in
let phase = Double(i) * 2.4
return Vector2(width * (0.5 + cos(time * 0.17 + phase) * 0.34),
height * (0.5 + sin(time * 0.23 + phase * 1.4) * 0.34))
}
}
}