Ollin

User-supplied shaders

Write your own fragment shader and run it through Ollin's effect graph, or hand it to fill(_:) and stroke(_:) as the paint a shape is drawn with. You supply a small Metal function, and Ollin wraps it into a full GPU pass. You get the speed of a hand-written shader without writing the pipeline boilerplate, and you get clear errors when something doesn't compile.

let plasma = Shader("""
float4 shade(float2 uv, ShaderInfo info) {
    float2 p = (uv * 2.0 - 1.0) * 6.0;
    float fx = cos(p.x) * cos(p.y);
    float fy = sin(p.x) * sin(p.y);
    float v = 0.5 + 0.5 * sin((fx * fx + fy * fy) * 6.28318 + info.time);
    float3 col = palette(v, float3(0.5), float3(0.5),
                         float3(1.0), float3(0.0, 0.33, 0.67));   // a library helper
    return float4(col, 1.0);
}
""")

override func draw() {
    drawImage(generate(plasma).image, 0, 0)   // run it as a full-canvas source layer
}

Contents


The contract

Your shader defines one function:

float4 shade(float2 uv, ShaderInfo info) { ... }
  • uv runs 0…1 across the layer from a top-left origin, so (0,0) is the top left and (1,1) is the bottom right. That is the same orientation as the rest of the canvas. A shader ported from a bottom-left convention needs uv.y = 1.0 - uv.y.
  • info carries the per-frame values: info.time, info.deltaTime, info.frame, info.resolution (the layer size in pixels), info.mouse (in points), and your params (see below).
  • Return a straight (non-premultiplied) sRGB color in 0…1. Ollin converts it to the premultiplied linear color that a layer composites in, so float4(0.5, 0.5, 0.5, 1.0) reads as mid-gray on screen.
The uv gradient annotated: (0,0) at the top left, (1,0) top right, (0,1) bottom left, (1,1) bottom right, with the center marked (0.5, 0.5)

Ollin generates the surrounding Metal fragment for you, along with a fullscreen vertex, and calls shade once per pixel.

Two panels evaluating the same glow function: coarsely on the left, where each grid cell shows one answer, and at full pixel resolution on the right where the answers fuse into a smooth image

Generator, filter, combine

How many input layers your shader reads decides what kind of pass it is. Each kind is a different entry point into the same effect graph:

InputsKindHow you run itRead inputs with
nonegeneratorgenerate(shader) or generate(.shader(shader))(no input)
onefilterlayer.filtered(.shader(shader))sample(info, uv)
twocombinea.combined(with: b, .shader(shader))sample(info, uv), sampleAux(info, uv)

A generator paints from math alone, like the plasma above. A filter transforms a layer you drew. A combine reads two layers at once, which is how you write a blend or a warp of your own in the shader. A fourth use is not a pass in the graph at all: a Shader handed to Sim.shader is the step of a simulation field, run over the field's own state every frame with readers of its own (cell, mark, input) and no color conversion on either side. All three return a RenderTarget, so you can draw it with .image, filter it again, or feed it into another effect.

sample and sampleAux read a layer as a straight sRGB color, which is the space your shader works in. When a layer holds data rather than a picture, read it with sampleRaw(info, uv) instead, and use sampleAuxRaw for a combine's second input. Both hand back the stored values with no color conversion at all. A measured distance field is the case to reach for them. Its red channel is a distance in pixels and runs negative, and its green and blue are the two halves of a direction. None of that survives being read as a color.

A data layer of your own, one texel per cell of a grid, wants its texels exact, and how you draw it decides that. An explicit-size layer, makeRenderTarget(width:height:), is exactly that many texels on every path, live or exported, so a read at (i + 0.5) / width lands on one texel's center and blends nothing. What can go wrong is the drawing. A one-texel drawRect lays its anti-aliasing halo outside its edge, where the next texel's center is, so every texel ends up carrying most of whichever neighbor was drawn after it (63 percent, measured). Draw data as an image instead, one byte per channel per texel, at its native size:

let sync = Kuramoto(columns: 30, rows: 38, layout: .hex, coupling: 2, range: 1, seed: 4)
var bytes: [UInt8] = []
for phase in sync.phases {
    let c = UInt8(((cos(phase) + 1) / 2 * 255).rounded())
    let s = UInt8(((sin(phase) + 1) / 2 * 255).rounded())
    bytes += [c, s, 0, 255]
}
let data = makeRenderTarget(width: 30, height: 38)
withTarget(data) {
    if let cells = Image(width: 30, height: 38, premultipliedRGBA: bytes) { drawImage(cells, 0, 0) }
}

Read that layer with sampleAux, not the raw form: a byte image is stored the way a picture is, and sampleAux undoes that, so each channel comes back as its byte over 255. A byte is eight bits, so store an angle as its cosine and sine rather than the angle itself, which would wrap at the byte's rounding. A value that needs more than a byte goes through a mark several texels wide, read at the mark's center, or through a simulation field, which keeps its cells as floats.


A shader as paint

The same Shader is paint. fill(shader) and stroke(shader) run shade at every pixel a shape covers, with uv across the shape's own bounding box rather than the layer, and three more fields in info: position (the pixel in canvas points), distance (the signed distance to the shape's edge in its own units, negative inside; across a stroke, zero on its centerline), and along (the 0...1 fraction along a stroke). Coverage is applied after shade returns, so the edge stays sharp, and a mesh takes the shader as its surface texture. The contract is the same: return straight sRGB, read params with param(info, i), and the library is spliced in.

fill(Shader("""
float4 shade(float2 uv, ShaderInfo info) {
    float edge = smoothstep(0.0, -6.0, info.distance);          // a soft rim, 6 units in
    return float4(palette(uv.x, float3(0.5), float3(0.5), float3(1.0), float3(0.0, 0.33, 0.67)) * edge, 1.0);
}
"""))
drawStar(300, 300, 120, 50, points: 6)

Color § A shader as paint has the whole account: the box, the three fields path by path, what shares a run, and what a vector or web export does with one.

ShaderInfo and params

As well as the per-frame fields (and, under a paint, info.position, info.distance, and info.along), you can pass your own floats and read them with param(info, i):

let warp = Shader(metalSource, params: [0.3, 1.2, 6.0])
float4 shade(float2 uv, ShaderInfo info) {
    float strength = param(info, 0);   // 0.3
    float scale    = param(info, 1);   // 1.2
    ...
}

You can pass up to 64 floats, and info.paramCount is how many you passed. Drive them from a @Param parameter to tune a shader while it runs.


The shader library

Ollin's shader library is spliced into every user shader, so you can call these helpers inside shade with no import. They are the same helpers Ollin's own shaders use, written from the published techniques and credited in the README's Techniques list.

GroupHelpers
always theresrgbToLinear / linearToSrgb / unipolar / bipolar / perceptualCoverage. These are spliced whatever you ask for, because the generated fragment reads them.
colorluma, palette(t, a, b, c, d) (cosine gradient), linearToOklab / oklabToLinear / oklabToOklch / oklchToOklab
hashhash12, hash22, hash33
noisevalueNoise, fbm, gradientNoise, simplexNoise, worley / worley2, ridgedFbm, turbulence, warpedFbm, curlNoise
sdfsmin(a, b, k) (smooth minimum) plus the 2D distance catalog (sdEllipse, sdRoundBox, sdSegment, sdStar, sdHeart, sdBezier, …)
domainrotate2D, pmod / pmod2 (repeat), mirror, pmodPolar (radial fold)
complexa float2 as a complex number: cmul, cdiv, cexp, clog, cpow, csqrt, the trigonometric set, complexPlane to frame the layer as the plane, and domainColor to paint a value (pulls in color); the CPU twin is Complex

See the shader library reference for every function with its full signature.

By default the whole library is spliced. Unused helpers are removed as dead code, so on the GPU the choice costs nothing. It only affects compile time, which matters when many shaders compile or hot-reload at once. To trim the set, pass an explicit Modules set:

let s = Shader(metalSource, using: [.noise, .domain])   // splice only these sections

Inline source or a .metal file

The body can be an inline Swift string, which is the simplest form. It hot-reloads with the sketch under OllinLive, so you edit the string, save, and the sketch recompiles with the new shader. The body can also be a .metal resource file beside your sketch:

let s = Shader(resource: "warp", in: .module)   // reads warp.metal from the sketch's bundle

in: is required, because it can't default to Ollin's own bundle. Under OllinLive the file hot-reloads too: edit the .metal, save, and it recompiles in place without restarting the sketch. The file is re-read on every change, so a line-accurate error in the .metal shows in the overlay just as an inline one does.


Pulling in another file

Once two shaders want the same helper, write it once and include it:

#include "helpers.metal"

float4 shade(float2 uv, ShaderInfo info) {
    return sample(info, swirl(uv, param(info, 0)));   // swirl lives in helpers.metal
}

The path is relative to the file that names it, so a helper sitting beside the shader is named by its file name alone. An inline Swift string resolves the same way, against the folder of the .swift file it is written in. A compute kernel can include a file too, so one helper file serves both.

The rules are short:

  • A file is read once, however many times it is named. Two shaders that both include the same helper therefore do not define it twice.
  • A mistake inside an included file is reported against that file, at its own line. An include is only worth having if the error still points at the file you would edit.
  • A file that is not there, or a loop of files that include each other, is reported before the compiler is asked. Otherwise the compiler would answer with a pile of undeclared identifiers that point away from the real mistake.

Ollin's own shader library needs no include, because it is already spliced into every shader (see The shader library above). #include <angle> forms are left for the compiler, so <metal_stdlib> still works if you want a file that also compiles on its own.

Under OllinLive, an edit to the helper reloads the shaders that read it. Working on the helper is the same loop as working on the shader.

Examples/Shaders/BlackHole splits one shader this way. Its picture, blackhole.metal, includes physics.metal, which holds the light paths and the disk's physics and knows nothing of the screen. That split is what lets a test compile the physics into a compute kernel of its own and check it against closed forms.

Reading a library from somewhere else

A path that starts with / or ~ is taken as it is. A shader can therefore read a shader library cloned anywhere on the machine:

#include "~/Developer/References/lygia/generative/snoise.msl"

Whatever that file includes then resolves from where the file sits. Most shader libraries use that form between their own files, so one line brings in the whole chain. Two things stay yours to check. First, the file has to be Metal, since a .glsl will not compile whatever it is called. Second, the terms of that library travel with the files you include. Ollin neither ships nor depends on any of them.


Errors

A shader that doesn't compile is reported at the file and line you wrote it in. That is the sketch's own .swift for an inline string, or the .metal file for a resource. The report prints the offending line with a caret under it, so the location is real and an IDE can jump straight to it. The frame keeps running with the broken pass skipped, so a typo never crashes the sketch.

/Users/you/Sketches/Plasma.swift:23:5: error: use of undeclared identifier 'vec4'; did you mean 'vec'?
    vec4 v = 0.5 + 0.5 * sin(...);
    ^~~~

For an inline string, the line number assumes the canonical form, with the opening """ on the Shader( call line. A one-line shader may read one line off, which still lands within sight of the mistake.

In a plain swift run, the message goes to the terminal. In OllinLive, it appears in the on-screen error overlay and clears when you fix it.

To try a shader without launching a sketch at all, hand the file to ollin check. It compiles the shader on this machine's GPU and prints the same errors at the same lines.


See also

  • A shader as paint: fill(shader) / stroke(shader), the shape-box uv, and info.position / info.distance / info.along
  • Checking a shader: compile a .metal file from the command line and see what it reads
  • Layered effects: the off-screen layers, filters, and compose { } your shader plugs into
  • Compute & GPU particles: runtime-compiled compute kernels, the sibling page for buffer and texture work
  • SDF combinators: compose signed-distance fields without writing raw shader code
  • Parameters: @Param parameters to drive a shader's params live

↑↓ move ↩ open