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)
)
}
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
- Coordinate transforms
- Color adjustments
- Combining two chains
- Modulation: color drives coordinates
- Reading layers, images, and feedback
- Drawing and realizing a chain
- How it renders
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 aBlendMode, 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
visualmodule of the shader library, so a hand-writtenShadercan 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.