Custom Animations
Install community animations or write your own — Filament's .filament-animation bundle format, the WGSL phase-and-beat runtime contract, and live hot-reload.
Filament ships with a built-in animation catalog, and it's built so you can write a new one, drop it into the app, and have it sit right next to the built-ins.
The first few sections need no programming — installing a shared animation is drag-and-drop, and starting your own is one button. The later sections get technical for writing shaders by hand, and the very last one is a compact reference you can hand an AI assistant to generate an animation for you.
Starting one from inside Filament
Open the library drawer, go to the User animations tab, and hit New. Give it a name, pick a starter template, and Filament writes a working bundle, adds it to your library, and reveals the files so you can open them in any editor. Save the file and the change appears immediately — no restart, no reimport.
The dialog also offers to put an AI prompt on your clipboard. That prompt carries the whole authoring contract below plus the files it just created, so you can paste it into an assistant, say what you want, and paste the answer back over animation.wgsl. Every user animation's right-click menu has the same Copy AI prompt action for changing one you already have, and if a shader stops compiling it becomes Copy error for AI — the error, the source, and the rules, in one paste.
What an animation is
An animation is a tiny program that draws one frame of color. Filament runs it once per frame, many times a second, and the result is what you see in the preview and what reaches your LEDs.
That program is written in WGSL (WebGPU Shading Language) — roughly a stripped-down C with math conveniences for graphics. You don't need to learn it to use animations other people made; just drop their file in and pick it from the library. You only need it to write your own.
Each animation ships with a small description file (a "manifest") that tells Filament its name, author, the knobs it exposes, and which group it belongs to in the picker. The shader plus the manifest are wrapped into one shareable file ending in .filament-animation.
That's the whole idea: one .filament-animation file is one animation. Post it in a forum, email it, download one from a friend. Drag it onto Filament and it appears in your library, working exactly like a built-in.
Installing a shared animation
There are two ways in.
Drag it onto the app window. The normal path for a packaged .filament-animation file:
- Open Filament.
- Drag the
.filament-animationfile onto the app window. - Filament validates and installs it, then shows an
Animation importedtoast. It's in your library, ready to use.
Copy a folder into the user animations directory. Handy when you have an unzipped bundle — a folder with animation.wgsl and manifest.json. Hit Open folder in the user animations tab (or use the table below) to reach the directory, drop the folder inside, and restart Filament. It shows up on the next launch.
Installed animations persist across restarts. To uninstall one, right-click its tile in the User animations tab and choose Delete; that removes the bundle directory from disk and can't be undone.
Where they live on disk
| Platform | Path |
|---|---|
| macOS | ~/Library/Application Support/Filament/animations/user/<id>/ |
| Windows | %LOCALAPPDATA%\Filament\animations\user\<id>\ |
| Linux | ~/.local/share/Filament/animations/user/<id>/ |
If a bundle fails to compile on import — say, a typo in the shader — Filament shows an Animation import failed toast with the error, moves the bundle to a sibling disabled/<id>/ folder, and drops a plain-text compile-error.txt beside it with the exact message. Disabled bundles don't load until you fix them and move them back. On startup, any disabled bundles raise a User animation disabled (or User animations disabled) toast listing what was skipped.
The shape of an animation bundle
A bundle is a directory laid out like this:
my-animation/
├── animation.wgsl (required) the shader program
├── manifest.json (required) the description
├── thumbnail.png (optional) 256×144 image shown in the picker
├── samples/ (optional) up to 8 example renders, samples/*.png
│ ├── default.png
│ └── ...
└── LICENSE.txt (optional) plain-text license for the bundleTo share it, zip the directory's contents and rename the result to end in .filament-animation instead of .zip. That's the file format. The two required files are all Filament needs; everything else is optional polish.
Tempo and phase — the motion model
Before the examples, you need to know how Filament gives your shader a sense of time, because it does not hand you a raw seconds clock.
Old shaders multiplied a wall-clock time value by a speed knob. Filament dropped that uniform — time * speed makes the animation jump when you turn the speed knob, and the jump grows the bigger time gets. Instead, the runtime accumulates motion on the CPU and hands your shader a smooth, already-integrated phase.
Your shader reads motion from these runtime fields, which Filament appends to your Params struct automatically (see The runtime contract):
| Field | WGSL type | What it carries |
|---|---|---|
beat | f32 | Continuous beat count, growing with the tempo. Whole numbers land on beats. |
barPhase | f32 | Position within the current 4-beat bar, in [0, 1). |
bpm | f32 | Current tempo in beats per minute. |
resolution | vec2<f32> | The project render-target size in pixels. |
phase | f32 | A smooth, ever-increasing base phase — the flicker-free replacement for raw time. |
Use params.phase wherever an old shader would read params.time. Use params.beat and params.barPhase when you want motion locked to the music. The tempo behind beat/barPhase/bpm comes from Filament's clock, which can sync to an external source — see Tempo & Sync.
Self-animating rate knobs: @phase
For a knob that controls how fast something moves, declare it with @phase instead of @param. A @phase channel is a float rate knob, but its slot in your Params struct doesn't carry the knob value — it carries the runtime-accumulated phase for that rate. Read the field directly and it already grows over time at the knob's rate.
The built-in metaballs works this way. It declares rate knobs like noiseSpeed, wobbleRate, and hueDriftRate with @phase, then reads params.wobbleRate as an angle that grows on its own:
// @phase wobbleRate min=0 max=4 step=0.05 default=1.1 label="Wobble Rate" unit=x
// ...in the fragment shader, params.wobbleRate is the accumulated phase:
let orbit_phase = params.wobbleRate * (0.34 + index * 0.07) + index * 1.49;
let wobble = sin(orbit_phase);Because the motion is pre-integrated, turning a @phase knob speeds the animation up or down with no jump, freezes it cleanly at rate 0, and keeps it tempo-syncable. It's the recommended way to build any rate control. A @phase declaration takes the same numeric attributes as a float @param (min, max, default, and optional step / label / unit / valueFormat) but no type= — a phase channel is always a float.
Quick start — your first animation in ten lines
Best way to feel how this works is to build one. We'll make a "breathing palette" that smoothly cycles through the user's selected palette, with one rate knob for how fast it breathes.
1. Make a folder
Create a folder called breathingPalette and put two files inside.
2. Write manifest.json
{
"schemaVersion": 1,
"id": "breathingPalette",
"label": "Breathing Palette",
"group": "User",
"version": "1.0.0",
"author": "Your Name",
"description": "Slowly cycles the palette with a soft breathing pulse.",
"usesPalette": true
}3. Write animation.wgsl
A header, then one function. The runtime writes everything around it — the uniform struct, the bindings, the vertex stage — so an animation is just "given this point, what colour is it?".
// @animation breathingPalette
// @phase breathe min=0 max=4 default=1 label="Breathe Rate" unit=x
// @requires palette
fn main(uv: vec2<f32>) -> vec4<f32> {
// `breathe` is a @phase channel: it holds accumulated phase, not the knob value.
let hue = wrap01(params.breathe * 0.1);
let pulse = 0.5 + 0.5 * sin(params.breathe);
let color = palette_sample(hue) * pulse;
return vec4<f32>(color, 1.0);
}4. Try it
Drop the breathingPalette folder into the user animations directory above — hot reload picks it up without a restart. Select it from your library and the canvas should gently pulse through your active palette, with the Breathe Rate knob speeding it up or down jump-free.
To share it, zip the breathingPalette folder's contents and rename the zip to breathingPalette.filament-animation.
That's the whole loop. Everything below is reference detail.
manifest.json reference
The manifest is plain JSON. Filament refuses to load an animation if the manifest is missing required fields, holds invalid values, or disagrees with the shader.
Fields
| Field | Type | Required | Notes |
|---|---|---|---|
schemaVersion | integer | yes | Use 1. This exists so future Filament versions can evolve the format without breaking older bundles. |
id | string | yes | A stable identifier. Camel-case, must match ^[a-z][a-zA-Z0-9]*$, max 64 characters, no spaces or punctuation. Must match the @animation line in your .wgsl exactly. |
label | string | yes | The human-readable name shown in the UI. Plain text, max 80 characters. |
group | string | yes | Which section of the picker the animation appears in. See "Group values" below. |
version | string | yes | Semantic version like 1.0.0. Must parse as three-part semver. |
author | string | yes | Free-form attribution. Plain text, max 80 characters. |
description | string | no | Longer description shown in the library. Plain text, max 1000 characters. |
usesPalette | boolean | no | Defaults to false. Set to true if your shader reads the active palette. Must agree with @requires palette in the WGSL header. |
thumbnail | string | no | Relative path to a thumbnail image inside the bundle. Typically thumbnail.png. |
samples | string array | no | Relative paths to up to 8 sample renders inside the bundle. Each path must live under samples/. |
Group values
group must be exactly one of these four strings:
| Value | Use it for |
|---|---|
"Color + Cycle" | Palette-driven cycles, gradients, washes |
"Sweeps" | Moving bands and traveling waves |
"Textures" | Generative textures, noise fields, organic shapes |
"User" | Your own animations and ones imported from the community |
Not sure? Use "User" — the default home for community animations. Any other string is rejected.
Rules about text fields
label, author, description, and any parameter labels render as plain text. HTML, Markdown, and control characters (other than ordinary whitespace) are rejected. Use plain language.
Naming things well
idis internal and permanent — once people use your animation, don't change it, since their saved projects refer to it. Pick something descriptive and camel-case it:oceanRipple, notOceanRippleorocean_ripple.labelis what users see. Capitalize it nicely:"Ocean Ripple".versionfollows semantic versioning. Bump the third number for fixes (1.0.0→1.0.1), the middle for new parameters or features, the first for changes that alter the look enough to make an existing user's project look wrong.
animation.wgsl reference
The shader file has two layers:
- The header — a block of
//comments at the top, in a fixed format, that tells Filament about the shader's parameters and requirements. - The shader body — actual WGSL code that draws a frame.
The header
The header is the first contiguous run of //-prefixed lines at the top of the file. Filament stops reading at the first non-comment line, or the first comment line that doesn't start with // @. Every header line looks like:
// @key valueRecognized keys
| Key | Cardinality | Purpose |
|---|---|---|
@animation | exactly 1 | Declares the animation's id. Must match manifest.json exactly. |
@param | any number | Declares a knob the user can adjust. See parameters below. |
@phase | any number | Declares a self-animating float rate knob — its struct slot carries accumulated phase. See Self-animating rate knobs. |
@uniform | any number | Reserved for future use. Parsed and round-tripped; not bound at runtime. |
@requires | 0 or 1 | Comma-separated feature flags. palette is the only recognized one today. |
Any unknown @-key is an error, and a duplicate @animation or @requires line is rejected. @param and @phase ids share one namespace — they must all be unique within the file.
Minimal valid header
// @animation myAnimationThat's enough — zero parameters, no palette use. Most interesting animations declare at least one knob and use the palette.
Full example header
// @animation oceanRipple
// @phase waveSpeed min=0 max=4 default=1.0 label="Wave Speed" unit=x
// @param waveScale type=float min=0.1 max=8 default=2.0 label="Wave Scale"
// @param brightness type=float min=0 max=2 default=1.0 label="Brightness" valueFormat=multiplier
// @param paletteMix type=float min=0 max=1 default=0.5 label="Palette Mix" valueFormat=percent
// @param invert type=bool default=false label="Invert"
// @param iterations type=int min=1 max=8 default=3 label="Iterations"
// @param accent type=color default=#00aaff label="Accent"
// @param style type=enum:warm|cool|wild default=warm label="Style"
// @requires paletteParameters: types, ranges, and UI controls
Every @param declares one knob in Filament's UI. The declaration both validates the value the user picks and tells the UI how to draw the control. (@phase declares a float rate knob with the same numeric attributes but no type=.)
Parameter syntax
// @param <id> type=<type> [type-specific attributes] default=<default> [common attributes]Parameter types
| Type | UI control | Required attributes |
|---|---|---|
float | Slider | min, max, default |
int | Integer slider | min, max, default (all whole numbers) |
bool | Toggle | default=true or default=false |
color | Color picker | default=#rrggbb (exactly six hex digits) |
enum:a|b|c | Dropdown | default=a (one of the listed tags) |
Attributes
| Attribute | Applies to | Notes |
|---|---|---|
type | @param (all) | One of the type strings above. Omitted for @phase. |
min | float, int, @phase | Lower bound, inclusive. Required for these; rejected for bool, color, enum. |
max | float, int, @phase | Upper bound, inclusive. Must be greater than min. Rejected for bool, color, enum. |
default | all | Initial value. Must be valid for the type and within bounds. |
step | float, int, @phase (optional) | Slider step. Rejected for bool, color, enum. |
label | optional | Human-readable name on the control. Defaults to the id with each capital letter starting a new word (waveSpeed → Wave Speed). Max 80 characters. |
unit | optional | Short suffix shown next to the value, e.g. Hz, °, %, x. Max 16 characters. |
valueFormat | optional | One of fixed, percent, degrees, hertz, multiplier, integer. Controls how the number is displayed. When omitted it is inferred from unit (Hz→hertz, %→percent, °/deg→degrees, x→multiplier; otherwise integer for int, fixed for float). |
Declaring min, max, or step on a bool, color, or enum parameter is an error — those types take no numeric range.
Examples by type
// Float slider, 0.0 to 1.0, default 0.5, shown as a percentage
// @param mix type=float min=0 max=1 default=0.5 label="Mix" valueFormat=percent
// Integer slider, 1 to 8, default 3
// @param iterations type=int min=1 max=8 default=3 label="Iterations"
// Boolean toggle (no min/max/step)
// @param invert type=bool default=false label="Invert"
// Color picker (no min/max/step)
// @param accent type=color default=#ff8800 label="Accent"
// Enum dropdown (no min/max/step)
// @param style type=enum:warm|cool|wild default=warm label="Style"
// Self-animating rate knob (float, no type=)
// @phase spin min=0 max=4 default=1.2 label="Spin" unit=xParameter id rules
- Lowercase-camel, must match
^[a-z][a-zA-Z0-9]*$, max 64 characters. - Unique within the file (shared across
@paramand@phase). - Appears in your shader's
Paramsstruct under exactly this name. - An
enum's tags are pipe-separated, each at most 32 characters and free of control characters.
The user can wire any knob to a modulation source or a MIDI control in the player — see Modulation. Your shader does nothing special for that; the runtime feeds the modulated value into the same uniform slot.
The runtime contract
Filament's shader runtime writes everything around your shader — the uniform struct, the bindings, the vertex stage and the fragment entry point. Your bundle contributes one function. That's the same deal custom effects get.
What the runtime generates
struct Params { /* your @params and @phases, then the runtime tail */ }
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var palette_tex: texture_1d<f32>;
@group(0) @binding(2) var palette_sampler: sampler;
@group(0) @binding(3) var noise_tex: texture_2d<f32>; // shared noise lattice
@group(0) @binding(4) var noise_sampler: sampler;
struct VertexOut { … }
@vertex fn vs_main(…) -> VertexOut { … } // fullscreen triangle
@fragment fn fs_main(in: VertexOut) -> @location(0) vec4<f32> { return main(in.uv); }A bundle that declares struct Params, struct VertexOut, any @group(...) binding, @vertex / vs_main, or @fragment / fs_main is rejected at parse time — those names collide with the generated ones. Mentioning them in a comment is fine; the check keys off the start of a line.
The Params struct
You don't write it, but you read it, so it's worth knowing what's in it. It lists every @param and @phase you declared, in header order, followed by the fixed runtime tail. Your knob types map this way:
@param type | WGSL field type |
|---|---|
float | f32 |
int | i32 |
bool | u32 (0 for false, 1 for true) |
color | vec3<f32> (RGB in 0..1) |
enum:a|b|c | u32 (zero-based index of the chosen tag) |
@phase | f32 (carries accumulated phase, not the raw knob value) |
After your knobs comes the tail — always in this order:
beat: f32,
barPhase: f32,
bpm: f32,
resolution: vec2<f32>,
phase: f32,There is no time field. Field order and padding used to be yours to get right, and getting it wrong displayed nonsense rather than failing; now the layout and the struct come from the same place, so they cannot disagree.
The entry point
Write one function:
fn main(uv: vec2<f32>) -> vec4<f32> {
// ...your code...
return vec4<f32>(r, g, b, 1.0);
}uv ranges from (0,0) at the top-left to (1,1) at the bottom-right of the canvas. For radial work, aspect_correct_uv(uv, params.resolution) squares it up.
One WGSL rule bites here: you cannot shadow a function parameter, so let uv = aspect_correct_uv(uv, …) is a compile error. Either name the corrected value something else, or rename the parameter — it is positional, so the name is entirely yours. The built-ins take the second route:
fn main(in_uv: vec2<f32>) -> vec4<f32> {
let uv = aspect_correct_uv(in_uv, params.resolution);
// ...
}The render target format is Rgba16Float, so you can write values outside 0..1 without clipping. The downsample-and-output stage clamps to display range, but high-dynamic-range intermediate values are fine and sometimes useful for bloom-like effects.
What you cannot do
- No file includes or imports.
- No external network access.
- No reading or writing files.
- No persistent state across frames — derive motion from
phase,beat, and your@phasechannels. - No declaring your own bindings; the five above are all there are.
These aren't just policy. The bundle is validated before install and the WGSL is compiled with naga; anything that violates the contract fails to compile.
Using the global palette
The user picks a palette in Filament's main UI. If your shader sets @requires palette and the manifest has "usesPalette": true, your shader gets the active palette as a 1-D texture, plus a built-in helper:
let color = palette_sample(t);t is a float; palette_sample wraps it to [0, 1) and returns an RGB vec3. Use it any time you want to color something with the user's chosen scheme:
let hue_position = uv.x + params.phase * 0.2;
let color = palette_sample(hue_position);
return vec4<f32>(color, 1.0);The user can change the palette any time and your animation picks it up on the next frame. Per-cell palettes and the global default are covered in Palettes. Letting users dress your animation in their palette is what makes it feel like a member of Filament rather than a stranger glued on.
Opting out of the palette
For fixed colors that the user's palette shouldn't touch, omit @requires palette and set "usesPalette": false (or omit the field — it defaults to false). The manifest flag and the @requires palette line must agree, or the bundle is rejected.
The palette bindings are still there — the runtime declares them for every animation — they just point at a neutral fallback texture. palette_sample keeps working; it simply returns the fallback.
Master effects: what happens after your shader runs
Filament runs a master effects chain on top of every animation — a chain the user builds (trails and hue rotation among them), described on the Master & Preview page.
You don't implement them. Your shader writes a single frame of color, and the master-effects chain runs afterwards. Two practical consequences:
- Don't bake trails or hue rotation into your shader. The user can add those as master effects, and your own will fight with theirs.
- Your shader's output is linear RGB. The master-effects pass operates on that linear value before it reaches the LEDs or the preview.
Helper functions (the prelude)
Filament prepends a small set of helpers to every animation. Call them without declaring anything — they make common patterns like noise, rotation, and hue conversion painless.
| Function | What it does |
|---|---|
wrap01(value) | Wraps a float to [0, 1) (fract) |
smoothstep_unit(value) | Smooth Hermite interpolation, clamped to [0, 1] |
hash2d(p) | Cheap deterministic hash of a vec2<f32> |
value_noise(p) | Smooth 2D value noise |
fbm_noise(p) | Fractional Brownian motion (5-octave value noise) |
rotate_centered(uv, degrees) | Rotates a UV around (0.5, 0.5) |
aspect_correct_uv(uv, resolution) | Re-centers and scales uv so circles stay round on non-square canvases |
glow_band(distance, radius, softness) | Smooth glow falloff inside a radius |
wrapped_band(position, center, width, fuzziness) | A soft band that wraps around [0, 1) |
hsv_to_rgb(h, s, v) | HSV to RGB conversion |
checkerboard_secondary(baseColor, contrast) | Blends a contrasting secondary color toward the base |
distance_to_segment(p, a, b) | 2D point-to-line-segment distance |
add_color(a, b) | Saturating (clamped) color add |
palette_sample(t) | Reads the active palette (only meaningful with usesPalette: true) |
Both snake_case and camelCase spellings of each helper exist (fbm_noise and fbmNoise, palette_sample and paletteSample), so call whichever feels natural. The constant TAU (= 2π) is also available.
You can read the prelude in full at src-tauri/resources/animations/_prelude.wgsl in the source tree, or by opening any built-in animation that uses these helpers.
Sharing your animation
When you're ready to share:
- Make sure your folder has at least
animation.wgslandmanifest.json. - Optionally add
thumbnail.png(256×144 recommended, max 1024×576). - Optionally add a
samples/subfolder with up to 8 example renders (max 1280×720 each). - Optionally add
LICENSE.txt(max 16 KB) with your terms. - Zip the contents of the folder, not the folder itself —
manifest.jsonshould be at the root of the zip. - Rename the resulting
.zipto.filament-animation.
On macOS:
cd path/to/my-animation
zip -r ../my-animation.filament-animation .On Windows PowerShell:
Compress-Archive -Path .\my-animation\* -DestinationPath .\my-animation.zip
Rename-Item .\my-animation.zip .\my-animation.filament-animationIterating quickly with hot reload
Filament watches the user animation directory. While the app is running, edit animation.wgsl or manifest.json of an installed user animation and save — Filament re-parses and re-compiles the bundle within a fraction of a second. The preview swaps over without dropping any master-effects history: trails keep their decay state across the swap.
The natural development loop:
- Drop your bundle into the user animations directory once.
- Open it in Filament and select it.
- Edit
animation.wgslin your editor. - Save.
- Watch the preview update.
Filament distinguishes three kinds of change:
- WGSL or parameter-shape change — recompiles the shader pipeline and swaps it atomically. Since parameters are declared only in the WGSL header, any parameter add/remove or range change is a recompile.
- Manifest-only metadata change (label, description) — updates the library entry without rebuilding the pipeline, so it's essentially instant.
- Compile error — Filament keeps the previous version active and shows an
Animation compile errortoast with the WGSL message. The bundle is not disabled while you hot-reload; fix the error, save again, and the new version takes over.
In development builds, the status bar shows a Watching N animations indicator (with a "Last reload events" panel) so you can confirm a save was picked up. It doesn't appear in release builds.
Limits and safety rules
Filament treats every .filament-animation file as untrusted input, even one you wrote yourself. It validates a bundle before installing and rejects anything that breaks these rules.
Structural rules
- The only allowed entries are
animation.wgsl,manifest.json,thumbnail.png,samples/*.png, andLICENSE.txt. - No path traversal (
.., absolute paths, backslashes, Windows drive prefixes), no symlinks, no directory entries, no encrypted entries. - No more than 32 entries in the archive.
- No duplicate paths, including case-insensitive duplicates.
Size limits
| Item | Limit |
|---|---|
| Total uncompressed contents | 5 MB |
animation.wgsl | 256 KB |
manifest.json | 64 KB |
LICENSE.txt | 16 KB |
thumbnail.png | 1 MB, max 1024×576 |
Each samples/*.png | 1 MB, max 1280×720 |
| Total sample images | 8 |
| Compression ratio | Any entry whose uncompressed size is more than 100× its compressed size is rejected. |
Content rules
- Plain text everywhere: no HTML, no Markdown, no control characters other than ordinary whitespace.
label,author, and any parameterlabel: max 80 characters each.description: max 1000 characters.- Parameter
unit: max 16 characters. - Enum tag: max 32 characters.
- All images must be valid, bounded PNGs.
Shader rules
- WGSL only — no GLSL, no SPIR-V.
- No
#includeor external file references. - Must define
fn main(uv: vec2<f32>) -> vec4<f32>, and must not declare anything the runtime generates (struct Params,struct VertexOut, any@group(...),@vertex/vs_main,@fragment/fs_main). - Must compile with
naga(the WGSL frontend used by the Rust runtime). - If compilation fails at import or on startup, the bundle is moved to
disabled/<id>/with acompile-error.txtand is not loaded until you fix it.
Identity rules
- Your
idcannot match a built-in animation's id. If it does, the import is rejected withanimation {id} already ships with Filament— rename your bundle inmanifest.json(and the@animationline) and try again. - Your
idcannot match an existing user animation's id either. The import is rejected withanimation {id} already exists in your library— there is no "replace?" prompt. To swap in a new version, delete the existing bundle's folder on disk first, then import.
These limits are deliberately strict. If a real community bundle ever needs more, they'll be raised.
Troubleshooting
"animation X already ships with Filament"
Your id collides with a built-in. Open manifest.json, change id to something unique, and update the matching @animation line in the .wgsl. The two must always agree.
"animation X already exists in your library"
You imported a bundle whose id matches one already installed, and Filament won't overwrite it. Open the user animations folder (Open folder), delete the existing <id>/ directory, then import again.
"Animation compile error" / nothing draws
The WGSL has a syntax or semantic error. Hot-reloading an edit keeps the previous version active and shows the message in a toast; a freshly imported bundle that fails is moved to disabled/<id>/ with compile-error.txt. Common causes:
- Missing
fn main(uv: vec2<f32>) -> vec4<f32>, or it returns the wrong type. - Shadowing the
uvparameter with alet uv = …at the top ofmain. WGSL forbids it; rename the parameter toin_uvinstead. - Reading a
paramsfield that isn't declared — the struct only holds the ids in your header plusbeat,barPhase,bpm,resolution,phase. - Declaring something the runtime generates. That one is caught before install, with a message naming the offender.
"Manifest and shader disagree on palette"
Your manifest says "usesPalette": true but your shader doesn't declare // @requires palette (or vice versa). Make the two agree.
Preview is black or frozen
- If you read the palette but your active palette is all black, the output is black. Switch palettes.
- A
@phaseknob or rate at0freezes the motion by design. Raise the rate. - If you accidentally return
vec4<f32>(0.0, 0.0, 0.0, 1.0), fix the math.
The animation lags or stutters
Usually too much work per pixel in a long loop. A high iterations value runs a lot of work for every pixel — cap the count, or do the heavy work less often.
Worked example: a second animation
A complete, working bundle using several parameter types, a @phase rate, the palette, and a couple of prelude helpers. It draws a noisy field that warps and tints over time.
manifest.json:
{
"schemaVersion": 1,
"id": "warpField",
"label": "Warp Field",
"group": "Textures",
"version": "1.0.0",
"author": "Example",
"description": "Warping noise field tinted by the active palette.",
"usesPalette": true,
"thumbnail": "thumbnail.png"
}animation.wgsl:
// @animation warpField
// @phase flow min=0 max=4 default=1.0 label="Flow" unit=x
// @param scale type=float min=0.5 max=8 default=2.5 label="Scale"
// @param warpAmount type=float min=0 max=2 default=0.6 label="Warp"
// @param brightness type=float min=0 max=2 default=1.0 label="Brightness" valueFormat=multiplier
// @param invert type=bool default=false label="Invert"
// @requires palette
// `in_uv` rather than `uv`, because the body wants `uv` for the corrected
// coordinate and WGSL forbids shadowing a parameter.
fn main(in_uv: vec2<f32>) -> vec4<f32> {
let uv = aspect_correct_uv(in_uv, params.resolution);
let p = uv * params.scale;
let t = params.flow;
let warp = vec2<f32>(
fbm_noise(p + vec2<f32>(t * 0.1, 0.0)) - 0.5,
fbm_noise(p + vec2<f32>(0.0, t * 0.1)) - 0.5,
) * params.warpAmount;
let field = fbm_noise(p + warp + vec2<f32>(t * 0.05, -t * 0.07));
var sample_t = field;
if (params.invert == 1u) {
sample_t = 1.0 - field;
}
let color = palette_sample(sample_t) * params.brightness;
return vec4<f32>(color, 1.0);
}Save the two files into a folder called warpField, zip the contents, rename to warpField.filament-animation, and drop it on Filament.
Reference card for AI agents and frameworks
A compact spec for tools generating animation bundles programmatically. Everything here is also covered above in prose.
Bundle requirements
- Directory or zip with extension
.filament-animation. - Required files at the root:
animation.wgsl,manifest.json. - Optional:
thumbnail.png,samples/*.png(≤ 8),LICENSE.txt. - No other files, no subdirectories beyond
samples/, no traversal, no symlinks, no directory entries. - Hard limits: 32 entries, 5 MB total uncompressed, 256 KB WGSL, 64 KB manifest, 16 KB license, 1 MB per image, compression ratio ≤ 100:1.
Manifest schema (manifest.json)
{
"schemaVersion": 1,
"id": "string, ^[a-z][a-zA-Z0-9]*$, ≤64 chars, must equal @animation",
"label": "string, ≤80 chars, plain text",
"group": "Color + Cycle | Sweeps | Textures | User",
"version": "semver string, e.g. 1.0.0",
"author": "string, ≤80 chars",
"description": "string, optional, ≤1000 chars",
"usesPalette": false,
"thumbnail": "thumbnail.png",
"samples": ["samples/example.png"]
}WGSL header grammar
The first contiguous run of //-prefixed lines at the top of the file is the header. One annotation per line. Parsing stops at the first non-@ comment or non-comment line.
// @animation <id>
// @param <id> type=<type> [min=<n> max=<n>] default=<value> [step=<n>] [label="..."] [unit="..."] [valueFormat=<fmt>]
// @phase <id> min=<n> max=<n> default=<n> [step=<n>] [label="..."] [unit="..."] [valueFormat=<fmt>] (float rate knob, no type=)
// @uniform <id> type=<type> (reserved; round-trip only)
// @requires palette (allowlist; only "palette" today)<type> ∈ float | int | bool | color | enum:<tag>|<tag>....
<fmt> ∈ fixed | percent | degrees | hertz | multiplier | integer.
Parameter/phase id pattern: ^[a-z][a-zA-Z0-9]*$, ≤64 chars, unique within the file (shared namespace). min < max for numeric types. default must satisfy the type and bounds. bool, color, and enum must not declare min/max/step. int min/max/default must be whole numbers. Enum tags are pipe-separated, ≤32 chars each.
Generated by the runtime — never declare these
struct Params { /* @params and @phases in header order, then the tail below */ }
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var palette_tex: texture_1d<f32>;
@group(0) @binding(2) var palette_sampler: sampler;
@group(0) @binding(3) var noise_tex: texture_2d<f32>;
@group(0) @binding(4) var noise_sampler: sampler;
struct VertexOut { @builtin(position) position: vec4<f32>, @location(0) uv: vec2<f32> }
@vertex fn vs_main(@builtin(vertex_index) vi: u32) -> VertexOut { /* fullscreen triangle */ }
@fragment fn fs_main(in: VertexOut) -> @location(0) vec4<f32> { return main(in.uv); }A bundle declaring struct Params, struct VertexOut, any @group(, @vertex, fn vs_main, @fragment or fn fs_main at the start of a line is rejected at parse time. Comments mentioning them are fine.
Params contents
Fields are your @param / @phase entries in header declaration order, then the fixed tail. There is no time field. Type mapping:
| Header declaration | WGSL field |
|---|---|
float | f32 |
int | i32 |
bool | u32 |
color | vec3<f32> |
enum:... | u32 (zero-based tag index) |
@phase | f32 (runtime-accumulated phase) |
Fixed tail, always last and in this order:
beat: f32,
barPhase: f32,
bpm: f32,
resolution: vec2<f32>,
phase: f32,Entry-point contract
- Define exactly one function:
fn main(uv: vec2<f32>) -> vec4<f32>. uvis(0,0)top-left to(1,1)bottom-right. The parameter name is positional — rename it if the body needsuvfor something else, because WGSL forbids shadowing a parameter.- Render target format is
Rgba16Float; HDR intermediate values are allowed. Colour is linear RGB.
Prelude (auto-prepended to every shader)
Available without declaration:
- Constant:
TAU(= 2π). - Functions (snake_case and camelCase aliases for each):
wrap01,smoothstep_unit,hash2d,value_noise,fbm_noise,rotate_centered,aspect_correct_uv,glow_band,wrapped_band,hsv_to_rgb,checkerboard_secondary,distance_to_segment,add_color. - Palette helpers (always present):
palette_sample(t: f32) -> vec3<f32>andpaletteSample. Sampling useswrap01(t).
Failure modes
| Condition | Result |
|---|---|
| Manifest invalid / required field missing | Import rejected with an Animation import failed toast. |
id collides with a built-in | Import rejected with an Animation import failed toast: animation {id} already ships with Filament. |
id collides with an existing user bundle | Import rejected with an Animation import failed toast: animation {id} already exists in your library. No replace prompt. |
| Any security/limit violation | Import rejected, no files written. |
| Manifest disagrees with WGSL (id mismatch, palette mismatch) | Import rejected with an Animation import failed toast. |
| WGSL fails to compile on import | Bundle moved to disabled/<id>/ with compile-error.txt, and an Animation import failed toast shows the WGSL error. |
| WGSL fails to compile on startup | Bundle stays in disabled/<id>/ with compile-error.txt; a User animation disabled toast lists what was skipped. |
| WGSL fails to compile during hot reload | Previous pipeline stays active; Animation compile error toast. Bundle not disabled. |
Filesystem locations
| Platform | User animations |
|---|---|
| macOS | ~/Library/Application Support/Filament/animations/user/ |
| Windows | %LOCALAPPDATA%\Filament\animations\user\ |
| Linux | ~/.local/share/Filament/animations/user/ |
Each bundle lives under <root>/<id>/. Disabled bundles live under <root>/disabled/<id>/.
Minimal valid bundle
manifest.json:
{
"schemaVersion": 1,
"id": "minimal",
"label": "Minimal",
"group": "User",
"version": "1.0.0",
"author": "Author"
}animation.wgsl:
// @animation minimal
fn main(uv: vec2<f32>) -> vec4<f32> {
let r = 0.5 + 0.5 * sin(params.phase);
return vec4<f32>(r, uv.x, uv.y, 1.0);
}That bundle compiles, imports, and runs. Use it as a starting point for any generated animation.
Animations & Palettes
Browse Filament's built-in animation catalog, drop clips onto the grid, tune them live with presets and BPM-synced playback, and re-tint everything with color palettes.
Palettes
Build color palettes, switch them per animation cell during a show, and share them as a single .filament-palette file.