Filament Docs
Player

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

PlatformPath
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 KB

No 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.

DirectiveRequiredNotes
@effect <id>yesExactly 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"noDisplay name. Defaults to a title-cased id.
@category <name>noGroups the effect in the browser. Id-shaped, max 32 chars. Defaults to misc. Your own categories sit alongside the built-in ones.
@description "…"noOne line, shown in the tile tooltip.
@param …noOne knob. Same grammar as animations — see below.
@pass <name>noAn ordered internal stage. Up to 8.
@state <name>noA texture that survives to the next frame. Up to 2.
@requires palettenoBinds 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.

TypeControl the user seesWGSL type
floatsliderf32
intstepperi32
booltoggleu32 (0 / 1)
colorswatch pickervec3<f32>
enum:a|b|cdropdownu32, 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/wet

There 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:

HelperWhat 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 scale on a @pass to 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.wgsl max 256 KB, thumbnail.png max 1 MB, LICENSE.txt max 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, @category max 32 chars, @label max 80 chars, @description max 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. Optional thumbnail.png, LICENSE.txt. No manifest, no samples/.
  • Directory name must equal the @effect id.

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 / int require min and max. color default 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 palette

Type mapping: floatf32, inti32, boolu32, enumu32 (zero-based index), colorvec3<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: define fn main(uv: vec2<f32>) -> vec4<f32>.
  • With @pass: define fn pass_<name>(uv: vec2<f32>) -> vec4<f32> for each, run in declaration order.
  • Never define a vertex stage; the prelude supplies VertexOut and vs_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

ConditionResult
Missing effect.wgslImport rejected
Unknown // @ key, bad id, missing min/maxImport rejected with the header error
Id collides with a built-in or installed effectImport rejected; no silent replace
Disallowed archive entry, traversal, symlink, size or ratio limitImport rejected
WGSL fails to compile at runtimeEffect renders passthrough, tile badged with the error
Validation failure after installMoved 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.

On this page