Ollin

Textures

material textures by name, one picker across them: a rust bloom and a verdigris patina over a Patina the sketch holds (the blooms growing in one after another), a terrazzo floor laid chip by chip over a Terrazzo, bubble wrap over a drawing, and a sheet creased by Heightfield.creased lit from its own slope; the ink switch strokes the scatters' geometry over the paint

Run
swift run --package-path Examples Example-Effects-Textures
Source
Examples/Effects/Textures/Sketch.swift
import Ollin

/// Material textures by name, the pixel kind: a rust bloom, a verdigris
/// patina, a terrazzo floor, bubble wrap over a drawing, and a crease folded
/// into a heightfield, one picker across them. Each texture is a generator or
/// a filter the sketch asks for with a few parameters and a seed, and the two
/// scatters are geometry the sketch holds (`Patina`, `Terrazzo`): the GPU
/// paints the very blobs and chips the sketch can stroke, which the ink switch
/// shows. The blooms grow in one after another and the chips are laid largest
/// first, the light drifting around the sheet, so the textures move.
@main
final class Textures: Sketch {
    enum Texture: String, CaseIterable, ParamOption { case rust, verdigris, terrazzo, bubbleWrap, crease }

    @Param(style: .segmented, icon: "square.grid.2x2") var texture = Texture.rust
    @Param("Seed", 1 ... 99, icon: "dice") var seed = 7
    @Param("Size", 0.4 ... 2, icon: "circle.dashed") var size = 1.0
    @Param("Density", 0.2 ... 1, icon: "circle.hexagongrid") var density = 0.6
    @Param("Light drift", 0 ... 1, icon: "sun.max") var lightDrift = 0.15
    @Param("Outlines in ink", icon: "pencil.line") var drawsOutlines = false

    override var loopDuration: Double? { 16 }

    private var patina: Patina?
    private var floor: Terrazzo?
    private var builtFor: (seed: Int, size: Double, density: Double)?

    private func rebuildIfNeeded() {
        if let builtFor, builtFor == (seed, size, density) { return }
        let region = canvasRectangle
        let count = Int((8 + 14 * density).rounded())
        patina = Patina.scatter(in: region, count: count, radius: (40 * size) ... (150 * size), seed: seed)
        floor = Terrazzo.scatter(in: region, chipRadius: 44 * size, density: 0.3 + 0.5 * density,
                                 gap: 3 * size, tones: 5, seed: seed)
        builtFor = (seed, size, density)
    }

    /// The blooms grown in one after another over the loop: each blob's
    /// radius and blisters scaled by its own ease, the geometry rebuilt so the
    /// GPU paints the grown bloom.
    private func grownPatina(_ t: Double) -> Patina {
        guard let patina else { return Patina(blobs: []) }
        let blobs = patina.blobs.enumerated().map { i, blob -> Patina.Blob in
            let start = Double(i) / Double(max(patina.blobs.count, 1)) * 0.6
            let grow = smoothstep(start, start + 0.3, t)
            let scale = 0.15 + 0.85 * grow
            return Patina.Blob(center: blob.center, radius: blob.radius * scale,
                               amplitudes: blob.amplitudes, phases: blob.phases,
                               massOffset: blob.massOffset,
                               blisters: blob.blisters.map {
                                   Circle(center: blob.center + ($0.center - blob.center) * scale,
                                          radius: $0.radius * scale)
                               },
                               tone: blob.tone)
        }
        return Patina(blobs: blobs)
    }

    override func draw() {
        rebuildIfNeeded()
        let t = loopProgress(over: 16)
        let light = -2.2 + time * lightDrift

        switch texture {
        case .rust, .verdigris:
            background(Color(hex: 0x1A1B1E))
            let blooms = grownPatina(t)
            let layer = texture == .rust
                ? generate(.rust(blobs: blooms, lightAngle: light))
                : generate(.verdigris(blobs: blooms, lightAngle: light))
            drawImage(layer.image, 0, 0)
            if drawsOutlines {
                noFill()
                stroke(Color(white: 0.05, alpha: 0.7))
                strokeWeight(1.2 * scale)
                for blob in blooms.blobs {
                    drawPolygon(blob.outline.points)
                    for mass in blob.masses { drawPolygon(mass.points) }
                }
            }
        case .terrazzo:
            background(Color(hex: 0x1A1B1E))
            guard let floor else { return }
            // The chips laid largest first over the first half of the loop.
            let laid = Int(Double(floor.chips.count) * smoothstep(0, 0.55, t))
            let poured = Terrazzo(chips: Array(floor.chips.prefix(laid)), gap: floor.gap)
            drawImage(generate(.terrazzo(chips: poured)).image, 0, 0)
            if drawsOutlines {
                noFill()
                stroke(Color(white: 0.1, alpha: 0.8))
                strokeWeight(1.2 * scale)
                for chip in poured.chips { drawShape(chip.shape) }
            }
        case .bubbleWrap:
            // A drawing first, then the sheet over everything drawn.
            background(Color(hex: 0xF2EEE6))
            noStroke()
            let palette = [Color(hex: 0xE4572E), Color(hex: 0x2B6C8C), Color(hex: 0xF2B36A), Color(hex: 0x1F7A6B)]
            for i in 0 ..< 7 {
                let a = Double(i) / 7 * .tau + time * 0.25
                let r = width * (0.22 + 0.1 * sin(time * 0.4 + Double(i)))
                fill(palette[i % palette.count])
                drawCircle(width / 2 + cos(a) * r, height / 2 + sin(a) * r, width * 0.11)
            }
            fill(Color(hex: 0x1A1B1E))
            textFont(.systemBold)
            textSize(width * 0.16)
            textAlign(.center, .middle)
            drawText("WRAP", width / 2, height / 2)
            postProcess(.bubbleWrap(radius: 26 * size * scale, gap: 6 * size * scale, lightAngle: light))
        case .crease:
            // A sheet with three creases whose ends drift, lit from the
            // field's own slope so the folds read as folds.
            background(Color(hex: 0x1A1B1E))
            if baseSheet == nil || baseSeed != seed {
                baseSheet = Heightfield(columns: 257, rows: 257) { u, v in
                    0.5 + 0.06 * (fbm(u * 3 + Double(seed), v * 3, octaves: 3) - 0.5)
                }
                baseSeed = seed
            }
            guard var sheet = baseSheet else { return }
            for i in 0 ..< 3 {
                let swing = 0.12 * sin(time * 0.3 + Double(i) * 2.1)
                let from = Vector2(0.1 + 0.3 * Double(i) + swing, i == 1 ? 0.95 : 0.05)
                let to = Vector2(0.9 - 0.3 * Double(i) - swing, i == 1 ? 0.05 : 0.95)
                sheet = sheet.creased(along: [from, to], width: 0.02 * size, amplitude: 0.28 * density)
            }
            drawImage(shaded(sheet, lightAngle: light), in: canvasRectangle)
        }
    }

    private var baseSheet: Heightfield?
    private var baseSeed = 0

    /// The sheet lit as paper: each sample's normal from the field's own
    /// slope (central differences of the heights), one Lambert term against
    /// a light at `lightAngle`, painted through a paper ramp.
    private func shaded(_ field: Heightfield, lightAngle: Double) -> Image {
        let paper = Ramp([Color(hex: 0x8F8779), Color(hex: 0xE6DFD0), Color(hex: 0xFFFBF2)])
        let light = Vector3(cos(lightAngle) * 0.7, sin(lightAngle) * 0.7, 0.72).normalized
        let relief = 26.0
        var pixels = [UInt8]()
        pixels.reserveCapacity(field.columns * field.rows * 4)
        for y in 0 ..< field.rows {
            for x in 0 ..< field.columns {
                let left = field[max(x - 1, 0), y], right = field[min(x + 1, field.columns - 1), y]
                let up = field[x, max(y - 1, 0)], down = field[x, min(y + 1, field.rows - 1)]
                let normal = Vector3(-(right - left) * relief, -(down - up) * relief, 1).normalized
                let lit = 0.25 + 0.75 * max(0, normal.dot(light))
                let c = paper.color(at: min(max(lit, 0), 1))
                pixels.append(contentsOf: [UInt8((c.red * 255).rounded()), UInt8((c.green * 255).rounded()),
                                           UInt8((c.blue * 255).rounded()), 255])
            }
        }
        return Image(width: field.columns, height: field.rows, premultipliedRGBA: pixels)
            ?? Image(width: field.columns, height: field.rows, color: .white)
    }
}

↑↓ move ↩ open