Filament Docs
Player

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:

  1. Open Filament.
  2. Drag the .filament-animation file onto the app window.
  3. Filament validates and installs it, then shows an Animation imported toast. 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

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

To 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):

FieldWGSL typeWhat it carries
beatf32Continuous beat count, growing with the tempo. Whole numbers land on beats.
barPhasef32Position within the current 4-beat bar, in [0, 1).
bpmf32Current tempo in beats per minute.
resolutionvec2<f32>The project render-target size in pixels.
phasef32A 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

FieldTypeRequiredNotes
schemaVersionintegeryesUse 1. This exists so future Filament versions can evolve the format without breaking older bundles.
idstringyesA 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.
labelstringyesThe human-readable name shown in the UI. Plain text, max 80 characters.
groupstringyesWhich section of the picker the animation appears in. See "Group values" below.
versionstringyesSemantic version like 1.0.0. Must parse as three-part semver.
authorstringyesFree-form attribution. Plain text, max 80 characters.
descriptionstringnoLonger description shown in the library. Plain text, max 1000 characters.
usesPalettebooleannoDefaults to false. Set to true if your shader reads the active palette. Must agree with @requires palette in the WGSL header.
thumbnailstringnoRelative path to a thumbnail image inside the bundle. Typically thumbnail.png.
samplesstring arraynoRelative 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:

ValueUse 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

  • id is 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, not OceanRipple or ocean_ripple.
  • label is what users see. Capitalize it nicely: "Ocean Ripple".
  • version follows semantic versioning. Bump the third number for fixes (1.0.01.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:

  1. The header — a block of // comments at the top, in a fixed format, that tells Filament about the shader's parameters and requirements.
  2. 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 value

Recognized keys

KeyCardinalityPurpose
@animationexactly 1Declares the animation's id. Must match manifest.json exactly.
@paramany numberDeclares a knob the user can adjust. See parameters below.
@phaseany numberDeclares a self-animating float rate knob — its struct slot carries accumulated phase. See Self-animating rate knobs.
@uniformany numberReserved for future use. Parsed and round-tripped; not bound at runtime.
@requires0 or 1Comma-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 myAnimation

That'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 palette

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

TypeUI controlRequired attributes
floatSlidermin, max, default
intInteger slidermin, max, default (all whole numbers)
boolToggledefault=true or default=false
colorColor pickerdefault=#rrggbb (exactly six hex digits)
enum:a|b|cDropdowndefault=a (one of the listed tags)

Attributes

AttributeApplies toNotes
type@param (all)One of the type strings above. Omitted for @phase.
minfloat, int, @phaseLower bound, inclusive. Required for these; rejected for bool, color, enum.
maxfloat, int, @phaseUpper bound, inclusive. Must be greater than min. Rejected for bool, color, enum.
defaultallInitial value. Must be valid for the type and within bounds.
stepfloat, int, @phase (optional)Slider step. Rejected for bool, color, enum.
labeloptionalHuman-readable name on the control. Defaults to the id with each capital letter starting a new word (waveSpeedWave Speed). Max 80 characters.
unitoptionalShort suffix shown next to the value, e.g. Hz, °, %, x. Max 16 characters.
valueFormatoptionalOne 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=x

Parameter id rules

  • Lowercase-camel, must match ^[a-z][a-zA-Z0-9]*$, max 64 characters.
  • Unique within the file (shared across @param and @phase).
  • Appears in your shader's Params struct 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 typeWGSL field type
floatf32
inti32
boolu32 (0 for false, 1 for true)
colorvec3<f32> (RGB in 0..1)
enum:a|b|cu32 (zero-based index of the chosen tag)
@phasef32 (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 @phase channels.
  • 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:

  1. 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.
  2. 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.

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

  1. Make sure your folder has at least animation.wgsl and manifest.json.
  2. Optionally add thumbnail.png (256×144 recommended, max 1024×576).
  3. Optionally add a samples/ subfolder with up to 8 example renders (max 1280×720 each).
  4. Optionally add LICENSE.txt (max 16 KB) with your terms.
  5. Zip the contents of the folder, not the folder itself — manifest.json should be at the root of the zip.
  6. Rename the resulting .zip to .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-animation

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

  1. Drop your bundle into the user animations directory once.
  2. Open it in Filament and select it.
  3. Edit animation.wgsl in your editor.
  4. Save.
  5. 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 error toast 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, and LICENSE.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

ItemLimit
Total uncompressed contents5 MB
animation.wgsl256 KB
manifest.json64 KB
LICENSE.txt16 KB
thumbnail.png1 MB, max 1024×576
Each samples/*.png1 MB, max 1280×720
Total sample images8
Compression ratioAny 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 parameter label: 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 #include or 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 a compile-error.txt and is not loaded until you fix it.

Identity rules

  • Your id cannot match a built-in animation's id. If it does, the import is rejected with animation {id} already ships with Filament — rename your bundle in manifest.json (and the @animation line) and try again.
  • Your id cannot match an existing user animation's id either. The import is rejected with animation {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 uv parameter with a let uv = … at the top of main. WGSL forbids it; rename the parameter to in_uv instead.
  • Reading a params field that isn't declared — the struct only holds the ids in your header plus beat, 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 @phase knob or rate at 0 freezes 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 declarationWGSL field
floatf32
inti32
boolu32
colorvec3<f32>
enum:...u32 (zero-based tag index)
@phasef32 (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>.
  • uv is (0,0) top-left to (1,1) bottom-right. The parameter name is positional — rename it if the body needs uv for 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> and paletteSample. Sampling uses wrap01(t).

Failure modes

ConditionResult
Manifest invalid / required field missingImport rejected with an Animation import failed toast.
id collides with a built-inImport rejected with an Animation import failed toast: animation {id} already ships with Filament.
id collides with an existing user bundleImport rejected with an Animation import failed toast: animation {id} already exists in your library. No replace prompt.
Any security/limit violationImport 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 importBundle moved to disabled/<id>/ with compile-error.txt, and an Animation import failed toast shows the WGSL error.
WGSL fails to compile on startupBundle stays in disabled/<id>/ with compile-error.txt; a User animation disabled toast lists what was skipped.
WGSL fails to compile during hot reloadPrevious pipeline stays active; Animation compile error toast. Bundle not disabled.

Filesystem locations

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

On this page

Starting one from inside FilamentWhat an animation isInstalling a shared animationWhere they live on diskThe shape of an animation bundleTempo and phase — the motion modelSelf-animating rate knobs: @phaseQuick start — your first animation in ten lines1. Make a folder2. Write manifest.json3. Write animation.wgsl4. Try itmanifest.json referenceFieldsGroup valuesRules about text fieldsNaming things wellanimation.wgsl referenceThe headerRecognized keysMinimal valid headerFull example headerParameters: types, ranges, and UI controlsParameter syntaxParameter typesAttributesExamples by typeParameter id rulesThe runtime contractWhat the runtime generatesThe Params structThe entry pointWhat you cannot doUsing the global paletteOpting out of the paletteMaster effects: what happens after your shader runsHelper functions (the prelude)Sharing your animationIterating quickly with hot reloadLimits and safety rulesStructural rulesSize limitsContent rulesShader rulesIdentity rulesTroubleshooting"animation X already ships with Filament""animation X already exists in your library""Animation compile error" / nothing draws"Manifest and shader disagree on palette"Preview is black or frozenThe animation lags or stuttersWorked example: a second animationReference card for AI agents and frameworksBundle requirementsManifest schema (manifest.json)WGSL header grammarGenerated by the runtime — never declare theseParams contentsEntry-point contractPrelude (auto-prepended to every shader)Failure modesFilesystem locationsMinimal valid bundle