Adds --check (compile every effect without camera or window), EFFECTS.md (the contract for writing effects) and AGENTS.md. The POC stays available at the 'poc' tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5.6 KiB
Writing a MotionLab effect
An effect is one text file: effects/<name>.metal. The app picks it up automatically (also while it is running) and shows it in the panel with a checkbox and its sliders. No other file needs to change.
The contract
The file defines exactly one function, written in Metal Shading Language. It is called once per pixel, every frame, and returns that pixel's colour:
float4 effect(float2 uv, Inputs in) {
return in.image.sample(in.smp, uv); // this one changes nothing
}
uvis the pixel's position:(0,0)is top-left,(1,1)is bottom-right.- Return
float4(r, g, b, 1.0)with components in 0..1. - Do not write
#include,using namespace, a vertex function or a fragment function. The app adds those.
What in gives you
| Field | Type | Meaning |
|---|---|---|
in.image |
texture | The picture so far: the output of the effect above this one in the chain, or the camera if it is the first. Read this for your input picture. |
in.camera |
texture | The untouched camera frame. |
in.last |
texture | The camera frame before this one. length(camera - last) at a pixel tells you how much it changed. |
in.prev |
texture | This effect's own output from the previous frame. Read it to build trails and feedback. |
in.flow |
texture | .xy = which way and how far the picture moved at this spot since the last frame, in uv units. Typically tiny (0.001 to 0.02). |
in.particles |
texture | .r = brightness of particles that spawn where something moves and drift along with the motion. |
in.smp |
sampler | Pass it to every .sample() call. |
in.time |
float | Seconds since the app started. |
in.resolution |
float2 | Output size in pixels. One pixel is 1.0 / in.resolution in uv units. |
in.p.<name> |
float | Your sliders (see below). |
Reading a texture: in.image.sample(in.smp, uv) returns a float4; take .rgb, .r, .xy and so on. Reading outside 0..1 repeats the edge pixel.
Comment lines the app reads
Put these at the top of the file. Spacing matters: one space between the parts, nothing after the last value.
// slider <name> <min> <max> <default>
// order <number>
// on
// slider glow 0.0 4.0 1.5adds a slider; read its value asin.p.glow. Up to 16 per effect.// order 60sets the position in the chain. Lower runs first; the default is 50. Existing effects use 10 to 90.// onmakes the effect enabled when the app starts. Leave it out for new effects.
Slider names must be plain identifiers and must not be Metal built-ins or keywords (mix, length, in, float, step, sample and so on). amount, speed, glow, hue are fine.
Metal Shading Language, for people who know GLSL
| GLSL | Metal |
|---|---|
vec2, vec3, vec4 |
float2, float3, float4 |
texture(tex, uv) |
tex.sample(in.smp, uv) |
mod(a, b) |
fmod(a, b) (keeps the sign of a; use a - b * floor(a / b) for a positive result) |
atan(y, x) |
atan2(y, x) |
mix, clamp, smoothstep, fract, floor, length, dot, sin, cos, pow, abs, min, max |
the same |
- Write float numbers with a decimal point:
2.0, not2. Convert loop counters withfloat(i). - Helper functions go above
effect, must be markedstatic, and takeInputs inas a parameter if they read textures. - There is no global state. To remember something between frames, write it into your output and read it back from
in.prev. The alpha channel (.a) of your output is free for this: it is not shown on screen.
Two complete examples
A wavy colour split with two sliders:
// Splits red and blue apart in a wave that scrolls down the picture.
// order 60
// slider amount 0.0 0.05 0.012
// slider waves 1.0 80.0 24.0
float4 effect(float2 uv, Inputs in) {
float2 d = float2(in.p.amount * sin(uv.y * in.p.waves + in.time * 3.0), 0.0);
return float4(in.image.sample(in.smp, uv + d).r,
in.image.sample(in.smp, uv).g,
in.image.sample(in.smp, uv - d).b, 1.0);
}
Things that move light up and the glow fades slowly, using in.prev alpha as memory:
// Whatever moves lights up, and the glow lingers.
// order 50
// slider sensitivity 1.0 30.0 10.0
// slider linger 0.5 0.99 0.93
float4 effect(float2 uv, Inputs in) {
float moved = length(in.camera.sample(in.smp, uv).rgb - in.last.sample(in.smp, uv).rgb);
float fresh = smoothstep(0.3, 1.0, moved * in.p.sensitivity);
float heat = max(in.prev.sample(in.smp, uv).a * in.p.linger, fresh);
float3 base = in.image.sample(in.smp, uv).rgb * 0.5;
return float4(base + float3(1.0, 0.5, 0.1) * heat, heat);
}
More examples are in effects/.
Checking your work
Build once, then check. Both commands print compiler errors with the line number in your file and exit non-zero on failure.
swift build -c release
# compiles every effect; needs no camera and opens no window
.build/release/MotionLab --check
# runs only your effect on the live camera for 90 frames, saves the last frame, exits
.build/release/MotionLab --only <name> --snapshot /tmp/<name>.png
--check also lists the sliders it found for each effect. If one of yours is missing, its // slider line is malformed.
Checklist
- The file is
effects/<name>.metaland definesfloat4 effect(float2 uv, Inputs in). - It reads its input picture from
in.image, notin.camera, so it works in a chain. - Every number a user might want to tune is a slider with a sensible range and default.
- The first line is a comment saying what the effect does.
.build/release/MotionLab --checkprintsokfor it.