Ollin

Visual chains

You compose animated imagery by chaining: start from a source, warp it, color it, and mix chains into one another. A Visual is a value that describes the whole expression. The chain compiles into a single GPU pass however deep it grows, so combining costs almost nothing.

override func draw() {
    drawVisual(
        .oscillator(frequency: 40, colorShift: 0.15)
            .rotated(0.4)
            .kaleidoscope(segments: 6)
            .displaced(by: .noise(scale: 3), amount: 0.08)
    )
}
A chain shown as a graph of real renders: striped oscillator bands, folded into a hexagonal kaleidoscope, then organically warped by a noise driver patched in from below

Everything moves by default, because sources drift with time. Every number can also change per frame without recompiling anything: .rotated(time * 0.2), an amount driven by a beat, or an @Param parameter. This works because the chain's structure decides the shader, which is compiled once and cached, while its numbers travel in a uniform buffer.

Contents

Sources

Every chain starts at a source, and each source is a static factory:

.oscillator(frequency: 40, speed: 2, colorShift: 0)   // sine bands, drifting; colorShift fringes the channels
.noise(scale: 8, speed: 0.3)          // an evolving noise field (signed: dark alone, ideal for modulation)
.voronoi(scale: 5, speed: 0.3, blending: 0.3)   // animated cells, each a random gray
.shape(sides: 3, radius: 0.3, smoothing: 0.01)  // a soft-edged polygon (60+ sides reads as a circle)
.gradient(speed: 0)                   // red = x, green = y, blue breathes with time
.solid(.indigo)                       // a flat color
.layer(target)                        // read a RenderTarget (see below)

.noise is signed (-1…1), so a displacement driven by it wobbles about zero. To view the noise as a mid-gray cloud, chain .brightness(0.5).

Coordinate transforms

These transforms warp where the chain beneath them samples. They compose in the order you read them, so .rotated(a).repeated(x: 3) tiles the rotated image.

.rotated(0.4)                         // radians about the center; .rotated(0.1, speed: 0.5) spins on its own
.scaled(1.5)                          // zoom about the center; x:/y: multiply per axis (negative mirrors)
.pixelated(20)                        // snap sampling to a coarse grid; or .pixelated(x: 40, y: 8)
.repeated(x: 3, y: 3)                 // tile; offsetX/offsetY stagger alternate rows (a brick layout)
.kaleidoscope(segments: 6)            // fold into n mirrored wedges about the center
.scrolled(x: 0.25, speedX: 0.1)       // slide, drifting per second, wrapping at the edges

Coordinates are aspect-corrected, so shapes stay round, rotation keeps its true angle, and pattern cells stay square at any canvas size.

Color adjustments

These adjustments change the color a chain produced, after the chain has sampled:

.brightness(0.2)     .contrast(1.6)      .saturation(2)      .inverted()
.posterized(levels: 4, gamma: 0.6)         // quantized levels
.thresholded(0.5)                        // hard black/white split about a luminance
.luma(threshold: 0.5, tolerance: 0.1)    // keying: the dark side turns transparent
.hueShifted(0.3)                         // a fraction of the color wheel
.tinted(.orange)                         // multiply by a color
.colorCycled(time * 0.05)                // endless HSV crawl (feed it a growing value)
.channel(.red, scale: 1, offset: 0)      // broadcast one channel (or .luminance) as grayscale

.channel turns a colorful chain into a scalar signal. So when a modulation needs a sharper driver, pass it .channel(.luminance) of that driver.

Combining two chains

Any chain can plug into any other. The combine ops blend the colors of the two chains per pixel:

a.blended(with: b)                    // b over a by b's alpha (BlendMode .normal)
a.blended(with: b, .add, amount: 0.5) // any canvas BlendMode: .add, .subtract, .multiply, .screen, .lightest, .darkest
a.mixed(with: b, amount: 0.5)         // cross-dissolve
a.differenced(with: b)                // absolute per-channel difference
a.masked(by: b)                       // keep a where b reads bright and opaque

amount on .blended fades the whole effect, so a beat or a parameter can drive a blend.

Modulation: color drives coordinates

In modulation, one chain's color moves another chain's sampling coordinate, per pixel. Any chain can act as the driver, and any of the inputs below can take one.

.displaced(by: .noise(scale: 3), amount: 0.1)      // the general form: red/green push x/y
.rotated(by: driver, amount: 1, offset: 0)          // per-pixel rotation angle from the driver
.scaled(by: driver, amount: 0.3, offset: 1)         // per-pixel zoom
.pixelated(by: driver, amount: 10, offset: 3)       // per-pixel grid density
.kaleidoscope(by: driver, segments: 4, amount: 0.1)    // the fold radius warps organically

The driver is itself a full chain, so a source displaced by .layer(feed).channel(.luminance) warps according to the live image you drew into the feed layer.

Reading layers, images, and feedback

.layer(_:) reads a RenderTarget, which can hold anything you drew, generated, or filtered. It samples the target wherever the coordinate lands, warped or not, and wraps at the edges:

let scene = makeRenderTarget()
withTarget(scene) { /* draw anything */ }
drawVisual(.layer(scene).kaleidoscope(segments: 8).hueShifted(time * 0.1))

A chain may read up to two distinct layers, so any layer past the second samples as transparent black. To mix more than two, flatten a sub-chain with generate(_:) and read the result.

.layer(_ feedback:) reads the previous frame of a Feedback layer, which is how you build a video-feedback loop:

var trail: Feedback!
override func setup() { trail = makeFeedback() }

override func draw() {
    withFeedback(trail) { _ in
        drawVisual(.layer(trail)                     // last frame…
            .scaled(1.01).rotated(0.002)             // …zoomed and spun…
            .tinted(Color(white: 1, alpha: 0.97))    // …decaying…
            .blended(with: .shape(sides: 5, radius: 0.1)
                .scrolled(x: 0.2 * sin(time), y: 0.2 * cos(time))))   // …plus new content
    }
    drawImage(trail.image, 0, 0)
}

Drawing and realizing a chain

drawVisual(chain)                     // fill the canvas (honors transform, tint, blend mode)
let layer = generate(chain)           // realize as a RenderTarget instead

generate(_:) returns the chain as a layer, so you can use it anywhere else in the effect graph. You can filter that layer (generate(chain).filtered(.bloom())), feed it to a combine, or read it from another chain.

How it renders

  • The whole chain, drivers included, compiles to one fragment shader and runs as one pass. There are no intermediate layers inside a chain.
  • The generated source depends only on the chain's structure. Identical structures share one cached pipeline. So a chain rebuilt every draw(), which is the normal pattern, costs one hash lookup, and animating its values costs nothing. Changing the structure, for example adding a step or switching a BlendMode, triggers one new compile.
  • Values travel in the shader params buffer, which holds 64 floats. A chain with more animatable numbers than that still renders, because the extras are baked into the source as constants. Changing those numbers recompiles, and Ollin prints a note once to tell you.
  • Inside a chain, colors are straight (non-premultiplied) sRGB, the same as in a user Shader. When drawn, they composite back into Ollin's linear-light pipeline.
  • The ops are plain shader-library functions in the visual module of the shader library, so a hand-written Shader can call them too.

Two examples show chains in use. swift run --package-path Examples Example-Shaders-VisualSynth plays one deep chain. Example-Shaders-VisualCatalog shows every family on a switchable contact sheet, and each tile is labeled with the calls it makes.

↑↓ move ↩ open