FlowStreaks
a grainy picture streaked along a direction field, three ways: a noise layer read as an angle, drifting blobs read along their contours, and their normal map read as vectors (.streaked(along:) / combined(with:.lineIntegralConvolution))
- Run
swift run --package-path Examples Example-Effects-FlowStreaks- Source
- Examples/Effects/FlowStreaks/Sketch.swift
import Ollin
/// **Line integral convolution** paints a direction field as brushed streaks.
/// Every pixel becomes the average of the picture along the streamline through
/// it, walked both ways, so fine grain turns into hair-thin strokes that follow
/// the field wherever it bends. The field is an `aside { }` layer, drawn only to
/// steer the streaks and never composited; `field:` says how its colors encode a
/// direction. Three readings, on a parameter:
///
/// • **swirl**: a noise layer read as an **angle** (`.angle(turns:)`), the
/// classic flow-field look; `turns` winds the field tighter.
/// • **contours**: drifting soft blobs read along their **contour lines**
/// (`.contour`), so the streaks circle each blob like grain around a knot.
/// • **bump**: the blobs' normal map read as a **vector** field (`.vector`, the
/// reading `displace` makes), so the streaks radiate from every bump.
///
/// Try it: raise `length` until the picture melts into strokes, feed a photo in
/// as the base, or replace the aside with a `.normalMap` of any picture.
@main
final class FlowStreaks: Sketch {
enum Field: String, CaseIterable, ParamOption { case swirl, contours, bump }
@Param(icon: "wind", group: "Field") var field: Field = .swirl
/// The streak from end to end, as a fraction of the canvas.
@Param("Length", 0.01 ... 0.2, icon: "ruler", group: "Field") var length = 0.06
/// How many turns the swirl's brightness sweeps through, black to white.
@Param("Turns", 0.5 ... 4, icon: "arrow.triangle.2.circlepath", group: "Field") var turns = 2.0
/// How much grain the streaks are made of.
@Param("Grain", 0 ... 1, icon: "circle.dotted", group: "Base") var grain = 0.8
override func draw() {
background(Color(white: 0.05))
compose {
// The base: a gray sheet with three discs following their orbits,
// grained so the streaks have something to be made of.
layer {
background(Color(white: 0.5))
noStroke()
for i in 0 ..< 3 {
let t = time * 0.2 + Double(i) * .tau / 3
fill(Color(hue: Double(i) / 3 + 0.05, saturation: 0.75, brightness: 0.95))
drawCircle(width * 0.5 + cos(t) * width * 0.22,
height * 0.5 + sin(t * 1.3) * height * 0.22, 190)
}
fill(.white)
drawCircle(width * 0.5, height * 0.5, 60)
}
.post(.grain(amount: grain))
.streaked(along: steer(), length: length, field: encoding)
}
drawCaption("line integral convolution · \(field.rawValue) · the streaks follow the aside's field")
}
/// The aside that steers the streaks, one per field reading.
private func steer() -> ComposeLayer {
switch field {
case .swirl:
// A noise layer, drifting: the walk reads its gray as an angle.
return aside {
withState {
translate(width * 0.5, height * 0.5)
rotate(time * 0.05)
scale(1.4)
drawImage(generate(.noise(scale: 3)).image, -width * 0.5, -height * 0.5)
}
}
case .contours, .bump:
// Soft blobs on their own orbits: read along their contours, or as
// the normals of the hills they make.
let blobs = aside {
background(.black)
noStroke()
fill(.white)
for i in 0 ..< 4 {
let t = time * 0.3 + Double(i) * 1.7
drawCircle(width * 0.5 + cos(t * 0.9) * width * 0.3,
height * 0.5 + sin(t) * height * 0.3, 150)
}
}.post(.gaussianBlur(radius: 60))
return field == .bump ? blobs.post(.normalMap(amount: 4)) : blobs
}
}
private var encoding: Combine.FieldEncoding {
switch field {
case .swirl: return .angle(turns: turns)
case .contours: return .contour
case .bump: return .vector
}
}
}