Spec fixes come from a test where pi + deepseek-v4.1-flash wrote effects from EFFECTS.md alone: glitch-blocks and ghost-stamps both compiled on the first try. The agent's feedback led to in.dt (frame time), documented limits of in.prev as memory, and a --check warning for ignored slider lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
125 lines
6.6 KiB
Markdown
125 lines
6.6 KiB
Markdown
# 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:
|
|
|
|
```metal
|
|
float4 effect(float2 uv, Inputs in) {
|
|
return in.image.sample(in.smp, uv); // this one changes nothing
|
|
}
|
|
```
|
|
|
|
- `uv` is 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. Alpha is not shown on screen; return 1.0 unless you use it as memory (see below).
|
|
- 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): `length(flow) * 50.0` is a rough 0..1 "how fast" value. |
|
|
| `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.dt` | float | Seconds since the previous frame. Effects run once per camera frame, so this is about 1/30. Use it to make fades and timers last the same in real time whatever the frame rate: `value *= exp(-in.dt / seconds)`. |
|
|
| `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.
|
|
|
|
```metal
|
|
// slider <name> <min> <max> <default>
|
|
// order <number>
|
|
// on
|
|
```
|
|
|
|
- `// slider glow 0.0 4.0 1.5` adds a slider; read its value as `in.p.glow`. Up to 16 per effect.
|
|
- `// order 60` sets the position in the chain. Any whole number; only the relative order matters, lower runs first, and the default is 50. Existing effects use 10 to 90. The motion inputs (`flow`, `particles`) are computed from the camera before any effect runs.
|
|
- `// on` makes 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`, not `2`. Convert loop counters with `float(i)`.
|
|
- Helper functions go above `effect`, must be marked `static`, and take `Inputs in` as 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`. Its `.rgb` is the picture you showed last frame; its alpha channel (`.a`) is free for your own use because it is not shown on screen. That gives one private number per pixel, stored with 8-bit precision (256 steps between 0 and 1), which is enough for a glow or heat level but not for a timestamp or a full-colour image.
|
|
- To do something every N seconds, test where `in.time` is inside the current period: `fract(in.time / N) < in.dt / N` is true for exactly one frame per period.
|
|
|
|
## Two complete examples
|
|
|
|
A wavy colour split with two sliders:
|
|
|
|
```metal
|
|
// 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:
|
|
|
|
```metal
|
|
// 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.
|
|
|
|
```bash
|
|
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, and warns when a `// slider` line was ignored because it is malformed or over the limit of 16.
|
|
|
|
## Checklist
|
|
|
|
- The file is `effects/<name>.metal` and defines `float4 effect(float2 uv, Inputs in)`.
|
|
- It reads its input picture from `in.image`, not `in.camera`, so it works in a chain.
|
|
- The few numbers a user would most want to tune are sliders with a sensible range and default. Internal constants can stay in the code.
|
|
- The first line is a comment saying what the effect does.
|
|
- `.build/release/MotionLab --check` prints `ok` for it.
|