Skip to content

Make

Updated

LFX effects

An LFX effect is an effect an extension carries: a WGSL shader and an optional interface page. Lumit checks the shader before it runs, then runs it on the GPU in its own pipeline. The same files work on Windows, macOS and Linux.

Blinds darkens every other band of rows. It is two files in a folder called blinds.

extension.json:

{
"format": 1,
"id": "blinds",
"name": "Blinds",
"version": "1.0.0",
"effects": [
{
"id": "blinds",
"name": "Blinds",
"category": "Stylise",
"version": 1,
"api": 1,
"shader": "blinds.wgsl"
}
]
}

blinds.wgsl:

struct Params {
/// @slider(2, 20) @default(6) @unit(px) Spacing
spacing: f32,
/// @slider(0, 1) @default(0.5) Strength
strength: f32,
};
fn shade(uv: vec2<f32>) -> vec4<f32> {
let colour = lumit_sample(uv);
let row = fract(lumit_px(uv).y / p.spacing);
let dark = step(0.5, row) * p.strength;
return colour * (1.0 - dark);
}

1. Add the folder that holds blinds under Settings ▸ Addons ▸ Extension folders.

2. Pick a layer and choose Effect ▸ Stylise ▸ Blinds. Effect controls shows Spacing and Strength.

3. Edit the shader and save it. The change is drawn when Settings ▸ Addons next opens, or at the next start. A shader that does not compile leaves the picture alone, and Effect controls gives the reason and the line.

4. Make a package when it is done, as in Making a package.

The larger example is Tape, in the repository at examples/extensions/tape: a worn video tape in two passes, with a cassette deck for its interface.

tape/
extension.json
tape.wgsl
ui/
index.html
deck.js

An extension lists its effects under effects. entry is optional, so an extension can hold effects, a panel, or both. The other fields are as in Make an extension.

{
"format": 1,
"id": "tape",
"name": "Tape",
"version": "1.0.0",
"effects": [
{
"id": "tape",
"name": "Tape",
"category": "Stylise",
"version": 1,
"api": 1,
"shader": "tape.wgsl",
"passes": [{ "name": "bleed", "scale": 0.5 }, { "name": "shade" }],
"interface": { "entry": "ui/index.html", "width": 420, "height": 260 }
}
]
}
FieldWhat it is
idLower-case letters, digits and hyphens, unique in the extension. A project saves the effect as lfx:<extension id>/<effect id>.
nameThe effect’s name in the Effects menu and Effect controls.
categoryBlur & sharpen, Colour, Distortion, Generate, Stylise, Temporal, Transition or Utility.
versionA whole number, raised when the pixels change.
apiThe shader contract, 1.
shaderThe .wgsl file, inside the folder.
passesOptional. Up to 8, each {name, scale}, drawn in order. scale is 1, 0.5 or 0.25, and the last pass is 1. With none, there is one pass called shade.
inputOptional. {label, kind}: a second picture, as a layer row called label. kind is image or depth. A depth input is a Depth socket in the node graph.
backdropOptional, false when left out. true runs the effect over the composite of everything below, even on a layer with a picture of its own. That layer is drawn as an adjustment layer, covering the whole frame, and every other effect on it works on the composite too. Its own picture, placed as it sits in the composition, is the second input wherever the input row names no other layer.
interfaceOptional. {entry, width, height}: the interface page, and the panel’s opening size in pixels, 100 to 4096 each way.

A manifest with an unknown category, a higher api, a bad pass or a file outside the folder is refused before anything is copied. An extension carries at most 64 effects.

The file has one function per pass, named after it:

fn bleed(uv: vec2<f32>) -> vec4<f32> { ... }
fn shade(uv: vec2<f32>) -> vec4<f32> { ... }

uv runs from 0 to 1 across the frame, whatever size the pass is drawn at. A function returns a premultiplied, scene-linear colour. The last pass is the effect’s result, mixed with its input by the Mix row.

NameWhat it is
lumit_sample(uv)The effect’s input.
lumit_sample2(uv)The second input.
lumit_orig(uv)The layer before any of its effects.
lumit_read(pass_<name>, uv)A pass’s picture, at any scale. Transparent until that pass has run.
lumit_load(xy)One pixel of the input, xy in the frame’s pixels.
lumit_size()The frame’s size in pixels.
lumit_px(uv)uv in composition pixels, the same at every preview resolution.
lumit_premult(c), lumit_unpremult(c)Premultiply or unpremultiply a colour.
lumit.timeThe layer’s time in seconds.
lumit.seedA number of the instance’s own, fixed for its life.
lumit.comp_scaleFrame pixels per composition pixel: 1 at Full, 0.5 at Half.

lumit.time and lumit.seed are the only values that vary. A shader may not declare @group or @binding, the names src, orig, dst, matte, input2, lumit and p, or a name starting lumit_ or pass_.

A struct Params in the shader becomes the effect’s rows in Effect controls, in the order declared, and the shader reads them as p.<field>. The /// comment above a field sets the row’s kind, default and label:

struct Params {
/// @slider(0, 20) @default(3) @unit(px) Wobble
wobble: f32,
/// @bounded(0, 100) @default(25) Noise
noise: f32,
/// @choice("PAL", "NTSC") @default("PAL") Style
style: u32,
};
TypeAnnotationRow
f32@slider(lo, hi)A number, its slider from lo to hi. 0 to 1 without one.
f32@bounded(lo, hi)A number held between lo and hi.
f32@dialAn angle in degrees.
i32@counter(lo, hi)A whole number.
u32@toggleA checkbox: 0 or 1.
u32@choice("A", "B", …)A menu. The shader reads the option’s index.
u32@seedA seed.
u32noneA whole number from 0.
vec4<f32>A colour. @default(r, g, b, a).
vec2<f32>A point: two rows, <field>_x and <field>_y, in pixels.

@default(v) is the starting value, and @unit(px), @unit(deg) or @unit(s) the unit. The rest of the comment is the label, or the field’s name when there is none. A vec3<f32> field is refused. An annotation that cannot be read loses its own row only.

@enabled_if(…) greys a field’s rows in Effect controls. Its terms are input and !input, whether the second input gives a picture, and field=N and field!=N, a choice on option N or a toggle at 0 or 1. The second input gives a picture when its row names a layer, or when a backdrop effect sits on a layer with a picture of its own. The rows stay live while any term holds, and every @enabled_if on a field must hold:

/// @choice("Shape layer", "Rounded rectangle") Shape
shape: u32,
/// @enabled_if(shape != 0, !input) @unit(px) Width
width: f32,

A rule with a term that cannot be read is dropped, and its rows stay live.

  • A shader that is refused or does not compile leaves the picture as it came in. Effect controls says why, with line numbers in the shader’s own file.
  • A project naming an LFX effect that is not installed opens, leaves the picture alone and keeps every value. The effect’s heading names the extension to install.
  • An effect running when the graphics device was lost twice is switched off until Lumit restarts.

Installing an extension again reads its shaders again, and an open project draws with the new ones straight away. An extension another Lumit installed is picked up when Settings ▸ Addons opens, or at the next start. Double-clicking a package while Lumit is open installs it in the open Lumit.

An effect with an interface has an Interface button on its heading in Effect controls. It opens the page in a floating panel of its own, which follows the instance picked in Effect controls, or the selected layer’s first one. With none, the page stays open and draws its own empty state. The page is an extension page, with window.lumit as in Make an extension, and these calls besides. Any other page is refused them with not-allowed.

CallWhat it does
effect.rowsEvery row at the playhead, as [{id, label, kind, min, max, choices, value}]. kind is float, int, angle, slider, bool, choice, seed or colour.
effect.begin {id}A drag on the row starts. What effect.set writes is previewed until effect.end.
effect.set {id, value}Writes a value. Without effect.begin, it is kept at once.
effect.endThe drag ends, and what it wrote is kept as one undo step.
EventWhen
effect.changedValues change: an undo, keyframes at a new playhead, Effect controls. {values: {id: value}}.
effect.targetThe panel follows another instance, or none. {id}, null for none.

A value is in the row’s own units: a number, true or false for a checkbox, the option’s index for a menu, [r, g, b, a] for a colour. A point is its _x and _y rows. A keyframed row is set at the playhead. A row an expression drives reads null, and writing it is refused. A row with a hard range is held to it. Layer, file and text rows are not listed.

A drag still open when the panel follows another instance is dropped, and effect.set for it is refused until effect.end or the next effect.begin. A reload drops it too.

Tape’s deck turns a knob like this:

const rows = await lumit.call("effect.rows");
lumit.call("effect.begin", { id: "bleed" });
lumit.call("effect.set", { id: "bleed", value: 20 });
lumit.call("effect.end");
lumit.on("effect.changed", ({ values }) => redraw(values));

A package is one .lfx file: the extension’s folder as a zip, with extension.json at its root. Settings ▸ Addons ▸ Extensions ▸ Make a package takes the folder, checks it as an install would, and saves <id>.lfx. A package is held to the same limits as a folder. One holding a link, or a path outside its root, is refused.

In this order. Of two with the same id, the first is used.

FolderWhere
Lumit’s ownWhere installs go unless another folder is chosen. Windows %LOCALAPPDATA%\Lumit\Lumit\data\extensions, macOS ~/Library/Application Support/dev.Lumit.Lumit/extensions, Linux ~/.local/share/lumit/extensions, Flatpak ~/.var/app/io.github.luminalmvm.Lumit/data/lumit/extensions.
Machine-wideWindows %CommonProgramFiles%\Lumit\Extensions, macOS /Library/Application Support/Lumit/Extensions, Linux /usr/share/lumit/extensions. For an author’s own installer. The Flatpak cannot see it; add the folder instead.
AddedFolders added under Settings ▸ Addons ▸ Extension folders, or chosen when installing.

A script sets an effect’s rows by name with effect.set, as in Scripts.