Custom Effects
Write your own Filament effects — the single-file effect.wgsl bundle format, the prelude and tempo contract, multi-pass and feedback, and live hot-reload.
An effect is a filter. It takes the image a clip, layer, group or the master output produced, and answers one question per pixel: given this input, what comes out? Effects covers the 29 built-ins and how chains work; this page is about writing your own.
Effects are the easier of Filament's two shader formats. An animation is a whole bundle — a shader, a manifest, a Params struct you declare yourself. An effect is one file, and the runtime generates the plumbing.
Starting one from inside Filament
Open the library drawer, go to the Effects tab, and hit New on the User effects row. Give it a name, pick a starter template, and Filament writes a working effect.wgsl, adds it to your library, and reveals the file so you can open it in any editor. Save and the change appears immediately.
The dialog also offers to put an AI prompt on your clipboard. It carries the whole contract on this page plus the file it just created, so you can paste it into an assistant, say what you want, and paste the answer back over effect.wgsl. Every user effect's right-click menu has the same Copy AI prompt action for changing one you already have, and if a shader stops loading it becomes Copy error for AI.
Installing a shared effect
Drag a .filament-effect archive anywhere onto the app window. Filament validates and installs it, and it appears in the User effects section of the Effects tab immediately.
For an unzipped bundle — a folder containing effect.wgsl — hit Open folder under User effects and drop the folder inside. The file watcher picks it up without a restart.
To uninstall one, right-click its tile and choose Delete.
Where they live on disk
| Platform | Path |
|---|---|
| macOS | ~/Library/Application Support/Filament/effects/user/<id>/ |
| Windows | %LOCALAPPDATA%\Filament\effects\user\<id>\ |
| Linux | ~/.local/share/Filament/effects/user/<id>/ |
An effect that fails validation is moved to disabled/<id>/ next to it, with the reason written to compile-error.txt. The Effects tab lists disabled bundles with their error so you can see what went wrong; fix the file and move the folder back up one level.
The shape of an effect bundle
myEffect/
effect.wgsl required — the header and the shader
thumbnail.png optional
LICENSE.txt optional, max 16 KBNo manifest.json: the header at the top of the WGSL is the metadata. No samples/ either — that's an animation concept. Anything else in a .filament-effect archive is rejected.
The header
The header is the first unbroken run of // @ comment lines and must start on line 1. The first line that isn't a // @ comment ends it, so never put another comment above or inside it. Any unrecognised // @ key is a hard error rather than a silent skip.
| Directive | Required | Notes |
|---|---|---|
@effect <id> | yes | Exactly one. Matches ^[a-z][a-zA-Z0-9]*$, max 64 chars. This is the folder name and the id the app stores in projects. |
@label "My Effect" | no | Display name. Defaults to a title-cased id. |
@category <name> | no | Groups the effect in the browser. Id-shaped, max 32 chars. Defaults to misc. Your own categories sit alongside the built-in ones. |
@description "…" | no | One line, shown in the tile tooltip. |
@param … | no | One knob. Same grammar as animations — see below. |
@pass <name> | no | An ordered internal stage. Up to 8. |
@state <name> | no | A texture that survives to the next frame. Up to 2. |
@requires palette | no | Binds the active palette. palette is the only recognised feature. |
Parameters
// @param <id> type=<float|int|bool|color|enum:a|b|c> default=<value> [min=<n> max=<n> step=<n>] [label="…"] [unit=<text>]Parameter ids match ^[a-z][a-zA-Z0-9]*$. float and int require min and max; color takes a hex default like default=#00ff88; enum lists its variants in the type.
| Type | Control the user sees | WGSL type |
|---|---|---|
float | slider | f32 |
int | stepper | i32 |
bool | toggle | u32 (0 / 1) |
color | swatch picker | vec3<f32> |
enum:a|b|c | dropdown | u32, the zero-based variant index |
Every parameter is automatable: the Modulation system can drive any of them from audio, an envelope, or an LFO, and the timeline can keyframe them. You get that for free by declaring the knob.
You don't declare a Params struct
This is the big difference from animations. The runtime generates the struct from your @param lines and binds it as params — just read params.<id>.
The same struct also carries the runtime's own values:
params.beat // continuously growing beat count
params.barPhase // 0..1 across the current bar
params.bpm
params.resolution
params.phase // the base accumulated phase
params.fxMix // the chain's dry/wetThere is no time uniform. Wall-clock time was removed from both shader formats: multiplying it by a speed knob makes motion jump whenever the knob moves. Drive animation from beat and barPhase instead and it stays locked to the project tempo, which is what you want on a dance floor anyway. fract(params.beat) is the position inside the current beat, so pow(1.0 - fract(params.beat), 4.0) is a clean downbeat spike.
fxMix is there because it's part of the struct, but the runtime already applies dry/wet for you — folding it into your own output would apply it twice.
Writing the shader
A single-pass effect is one function:
// @effect rgbShift
// @label "RGB Shift"
// @category glitch
// @param amount type=float min=0 max=0.1 default=0.01 label="Shift"
fn main(uv: vec2<f32>) -> vec4<f32> {
let o = vec2<f32>(params.amount, 0.0);
let src = fx_input(uv);
return vec4<f32>(fx_input(uv + o).r, src.g, fx_input(uv - o).b, src.a);
}uv runs 0..1 across the frame. fx_input(uv) samples what the effect is applied to — the chain input, or the previous effect's output. There's no vertex stage to write: the prelude supplies vs_main and the runtime wraps your function in the fragment entry point.
Alpha is straight, not premultiplied. Pass fx_input(uv).a through unless you mean to change coverage; the alpha-aware compositor honours what you return, so lowering it reveals the layers underneath. Colour is linear RGB.
The prelude
Concatenated ahead of your source, so don't define these yourself:
| Helper | What it does |
|---|---|
fx_input(uv) | vec4 sample of the chain input (or previous pass). Clamps uv. |
fx_input_size() | Input dimensions in pixels, as floats. |
fx_texel(uv_step) | Convert a pixel step into a uv step. |
rgb_to_hsv(c) | Linear RGB to HSV. |
hsv_to_rgb(c) | HSV to linear RGB. |
luma(c) | Rec.709 luminance. |
fx_hash12(p) | Cheap sine-free hash, for grain and jitter. |
Plus const TAU: f32.
Using the palette
Add // @requires palette and the runtime binds the seam's active palette plus fx_palette_sample(t) -> vec4<f32>, a 0..1 index into the user's colours. Map your input's luma to t and the effect re-tints itself the moment the user switches palettes — exactly how the built-in Recolour works.
// @effect myRecolour
// @label "My Recolour"
// @category color
// @requires palette
// @param amount type=float min=0 max=1 default=1 label="Amount"
fn main(uv: vec2<f32>) -> vec4<f32> {
let src = fx_input(uv);
let mapped = fx_palette_sample(luma(src.rgb)).rgb;
return vec4<f32>(mix(src.rgb, mapped, params.amount), src.a);
}Multiple passes
Declare @pass lines and write one fn pass_<name>(uv: vec2<f32>) -> vec4<f32> per pass. They run in declaration order and each samples the previous pass's output through fx_input. This is how separable blurs work: blur horizontally at reduced resolution, then vertically.
// @effect softBlur
// @label "Soft Blur"
// @category blur
// @pass horizontal scale=0.5
// @pass vertical
// @param radius type=float min=0 max=8 default=3 label="Radius"
fn pass_horizontal(uv: vec2<f32>) -> vec4<f32> { … }
fn pass_vertical(uv: vec2<f32>) -> vec4<f32> { … }scale is a fraction of the chain input resolution in (0, 1]; a half-scale pass costs a quarter of the pixels. format picks the intermediate target (rgba8unorm, rgba16float, rgba32float) and defaults to the chain's format.
Filament folds dry/wet into a single-pass effect's own shader when it can, which saves reading and writing a full extra target. That optimisation is off for multi-pass effects and for effects with @state, so prefer a single pass unless you genuinely need the stages.
Feedback and trails
A @state texture holds the pass output from the previous frame and binds as state_<name>: texture_2d<f32>. That's the basis for trails, feedback and motion-persistence effects. Two per effect, at most.
Hot reload
The watcher notices any change under the user effects directory. Save effect.wgsl and the next frame uses the new source. A shader that fails to compile renders passthrough and badges its tile rather than taking the app down, and the tile's Copy error for AI action gives you the error, the source and the contract in one paste.
Performance
Effects run per pixel, per frame, on every surface. Filament is live-performance software, so treat a dropped frame as a bug in your shader:
- No unbounded loops, and no loop whose bound comes from a parameter — a dynamic bound stops the compiler unrolling and costs far more than the same work with a constant bound.
- Texture fetches dominate. A blur that takes 33 taps costs roughly 33 times a simple colour tweak; use
scaleon a@passto do the expensive part at lower resolution. - Guard every divisor. A zero-length radius produces NaN across the whole surface, and NaN propagates through the rest of the chain.
Limits
effect.wgslmax 256 KB,thumbnail.pngmax 1 MB,LICENSE.txtmax 16 KB- Archive: allowed entries only, no path traversal, no symlinks, max 32 entries, max 5 MB total, max 100:1 compression ratio
- Max 8
@pass, max 2@state,@categorymax 32 chars,@labelmax 80 chars,@descriptionmax 1000 chars - No
#include, no imports, no network, no file access, no bindings you didn't declare through@pass/@state
An effect id that collides with a built-in or an already-installed effect is rejected rather than silently replacing it.
Reference card for AI agents and frameworks
A compact spec for tools generating effect bundles programmatically. Everything here is also covered above in prose.
Bundle requirements
- One file:
effect.wgsl. Optionalthumbnail.png,LICENSE.txt. No manifest, nosamples/. - Directory name must equal the
@effectid.
Header grammar
// @effect <id> exactly 1, ^[a-z][a-zA-Z0-9]*$, ≤64
// @label "<text>" 0..1, ≤80 chars, defaults to title-cased id
// @category <name> 0..1, ^[a-z][a-zA-Z0-9]*$, ≤32, defaults to "misc"
// @description "<text>" 0..1, ≤1000 chars
// @param <id> type=<type> default=<v> [min=<n>] [max=<n>] [step=<n>] [label="<text>"] [unit=<text>]
// @pass <name> [scale=<0..1>] [format=<fmt>] 0..8, ordered
// @state <name> [format=<fmt>] 0..2
// @requires palette 0..1, "palette" is the only feature<type> ∈ float | int | bool | color | enum:<tag>|<tag>...
<fmt> ∈ rgba8unorm | rgba16float | rgba32float- Header = first contiguous run of
// @lines, starting at line 1. First non-// @line ends it. - Unrecognised keys are errors.
float/intrequireminandmax.colordefault is#rrggbb. Parameter ids are^[a-z][a-zA-Z0-9]*$.
Generated bindings
The runtime emits these ahead of your source; the bundle declares none of them.
struct Params { /* your @params, then beat, barPhase, bpm, resolution, phase, fxMix */ }
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var fx_input_tex: texture_2d<f32>; // prelude
@group(0) @binding(2) var fx_sampler: sampler; // prelude
@group(0) @binding(3) var fx_palette: texture_1d<f32>; // only with @requires palette
@group(0) @binding(3+) var state_<name>: texture_2d<f32>; // one per @state, after the paletteType mapping: float→f32, int→i32, bool→u32, enum→u32 (zero-based index), color→vec3<f32>.
There is no time field. Motion comes from beat / barPhase / phase. fxMix is applied by the runtime — do not blend with it.
Entry points
- No
@pass: definefn main(uv: vec2<f32>) -> vec4<f32>. - With
@pass: definefn pass_<name>(uv: vec2<f32>) -> vec4<f32>for each, run in declaration order. - Never define a vertex stage; the prelude supplies
VertexOutandvs_main. - Straight (non-premultiplied) alpha, linear RGB.
Prelude
fx_input(uv), fx_input_size(), fx_texel(uv_step), rgb_to_hsv(c), hsv_to_rgb(c), luma(c), fx_hash12(p), const TAU: f32, and fx_palette_sample(t) -> vec4<f32> when @requires palette is set.
Failure modes
| Condition | Result |
|---|---|
Missing effect.wgsl | Import rejected |
Unknown // @ key, bad id, missing min/max | Import rejected with the header error |
| Id collides with a built-in or installed effect | Import rejected; no silent replace |
| Disallowed archive entry, traversal, symlink, size or ratio limit | Import rejected |
| WGSL fails to compile at runtime | Effect renders passthrough, tile badged with the error |
| Validation failure after install | Moved to disabled/<id>/ with compile-error.txt |
Filesystem locations
- macOS:
~/Library/Application Support/Filament/effects/user/<id>/ - Windows:
%LOCALAPPDATA%\Filament\effects\user\<id>\ - Linux:
~/.local/share/Filament/effects/user/<id>/
Minimal valid bundle
effect.wgsl:
// @effect myGain
// @label "My Gain"
// @category color
// @param gain type=float min=0 max=4 step=0.01 default=1 label="Gain"
fn main(uv: vec2<f32>) -> vec4<f32> {
let src = fx_input(uv);
return vec4<f32>(src.rgb * params.gain, src.a);
}That file, in a folder called myGain, installs and runs. Use it as a starting point for any generated effect.
Effects
Drop GPU effects onto a clip, a layer, or the master output, chain them, recall presets, and ride their parameters live — blur, glitch, color, trails, and your own shaders.
Modulation — Audio & Envelopes
Turn any numeric control into a live-driven one — make parameters react to sound with a draggable bandpass band, or draw beat-synced envelopes and LFOs that ride a parameter on their own.