Go native-only: remove the Python POC, move the Swift app to the top level

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>
This commit is contained in:
Rasyidan Akbar F. 2026-10-05 20:33:18 +07:00
commit a20d3310db
29 changed files with 160 additions and 775 deletions

123
EFFECTS.md Normal file
View file

@ -0,0 +1,123 @@
# 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.
- 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.
```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. Lower runs first; the default is 50. Existing effects use 10 to 90.
- `// 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`. 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:
```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. If one of yours is missing, its `// slider` line is malformed.
## 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.
- 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 --check` prints `ok` for it.