Ollin

Mixing

the same two colors mixed in RGB, HSB, OKLab, OKLCH, OKHSL, and paint (Kubelka-Munk over spectra), one band per space, with each band's midpoint read back through hue, saturation, and brightness

Run
swift run --package-path Examples Example-Color-Mixing
Source
Examples/Color/Mixing/Sketch.swift
import Ollin

/// The same two colors mixed band by band in each interpolation space, so the
/// differences sit side by side: RGB dips through gray, HSB detours around
/// the hue wheel, the OK family stays perceptually even, and PAINT mixes the
/// two as scattering pigments (so complementary pairs meet in a real mixed
/// hue, and every mix darkens the way paint does). The endpoints drift around
/// the hue wheel over time, picked in OKHSL so their perceived lightness holds
/// still while they travel. A readout at each band's right edge reports its
/// midpoint through the `hue` / `saturation` / `brightness` read-backs, so the
/// dips and detours have numbers.
@main
final class Mixing: Sketch {
    let spaces: [(label: String, space: ColorSpace)] = [
        ("RGB", .rgb),
        // The HSB band pops now and then, on purpose: hue mixes the shortest
        // way around the wheel, and when the drifting endpoints' HSB hue gap
        // crosses half a turn, the short way switches sides in one frame. The
        // OK bands hold a fixed gap in their own hue, so only HSB flips.
        ("HSB", .hsb),
        ("OKLAB", .oklab),
        ("OKLCH", .oklch),
        ("OKHSL", .okhsl),
        ("PAINT", .paint),
    ]

    override func setup() {
        noStroke()
        textSize(26)
    }

    override func draw() {
        background(Color(hex: 0x14171C))
        let a = Color(OKHSL(h: time * 0.04, s: 0.95, l: 0.55))
        let b = Color(OKHSL(h: time * 0.04 + 0.45, s: 0.95, l: 0.7))

        let margin = width * 0.08
        let bandHeight = height * 0.1
        let gap = (height - Double(spaces.count) * bandHeight) / Double(spaces.count + 1)
        // One band per space, with an even gap above, below, and between them.
        let g = grid(columns: 1, rows: spaces.count,
                     padding: .symmetric(horizontal: margin, vertical: gap), gutter: gap)
        let samples = 180
        for (cell, entry) in zip(g.cells, spaces) {
            let band = cell.frame
            for i in 0..<samples {
                let t = Double(i) / Double(samples - 1)
                fill(Color.mix(a, b, t, in: entry.space))
                drawRect(band.x + band.width * Double(i) / Double(samples), band.y,
                         band.width / Double(samples) + 1, band.height)
            }
            fill(Color(hex: 0x9AA3AD))
            drawText(entry.label, band.x, band.y - 14)
            // Each band's midpoint, read back through `hue`/`saturation`/
            // `brightness`, the inverse of `init(hue:saturation:brightness:)`:
            // RGB's dip through gray shows as the lowest S of the column, and
            // paint's darkening as the lowest B.
            let mid = Color.mix(a, b, 0.5, in: entry.space)
            drawText("H \(Int((mid.hue * 360).rounded()))°  S \(Int((mid.saturation * 100).rounded()))  B \(Int((mid.brightness * 100).rounded()))",
                     band.x + band.width, band.y - 14, size: 20, align: .right)
        }
    }
}

↑↓ move ↩ open