Authoring a shader plugin
Drop in a shader file, declare it in plugin.toml, and it becomes a node with generated sliders and colour pickers.
A shader plugin needs no code beyond the shader itself, and no build step. Write a WGSL or GLSL effect, name your parameters in comments, point a small text file at it, and a new node appears in the search palette.
The shape of a shader plugin
A shader plugin is a folder under ~/.dna/plugins/ containing a plugin.toml manifest that declares your shader nodes, and one or more shader files (.wgsl or .glsl) sitting next to it. A plugin is exactly one kind, so a shader plugin cannot also be a script or native plugin. One kind per folder.
A minimal manifest:
[plugin]
name = "My Effects"
version = "0.1.0"
api_version = "2.0.0"
[plugin.shader]
[[shader]]
id = "custom.chromatic_aberration"
display_name = "Chromatic Aberration"
description = "Splits the colour channels outward from centre"
category = "Effects"
file = "chromatic.wgsl"
The [plugin.shader] line marks the whole plugin as a shader plugin. Each [[shader]] block becomes one node. The id must be unique, since it is the node's registry name; display_name is what you see in the palette; file points at the shader sitting beside the manifest.
Declaring a shader node
Each [[shader]] entry accepts these fields:
id(required). Unique node id, e.g.custom.vignette.display_name(required). The name shown in the palette.description. A short line about what it does.category. Which palette group it lands in.keywords. Comma-separated search terms.file(required). Relative path to the shader, ending in.wgslor.glsl.language.WGSL(default) orGLSL.shader_type.Fragment(default) orCompute.inputs. Named image inputs, up to four. The default is a singlecontentinput.
The file path has to stay inside your plugin folder: no .. and no absolute paths.
For a node that mixes two images, give it inputs = ["content", "overlay"]. Each name becomes an input port on the node and a texture you can sample in the shader.
Writing a fragment shader
A fragment shader is a complete WGSL module. You declare the bindings, the vertex stage and the fragment entry point; nothing is wrapped around it.
// @param float brightness -1.0 1.0 0.0 "Brightness"
// @param float contrast 0.0 2.0 1.0 "Contrast"
struct ShaderUniforms {
resolution: vec2<f32>,
time: f32,
frame: i32,
params: array<vec4<f32>, 8>,
color_0: vec4<f32>,
color_1: vec4<f32>,
color_2: vec4<f32>,
color_3: vec4<f32>,
}
@group(0) @binding(0) var input_content: texture_2d<f32>;
@group(0) @binding(1) var tex_sampler: sampler;
@group(0) @binding(2) var<uniform> uniforms: ShaderUniforms;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex
fn vs_main(@builtin(vertex_index) i: u32) -> VertexOutput {
var out: VertexOutput;
let x = f32((i & 1u) << 2u) - 1.0;
let y = f32((i & 2u) << 1u) - 1.0;
out.position = vec4<f32>(x, -y, 0.0, 1.0);
out.uv = vec2<f32>((x + 1.0) * 0.5, (y + 1.0) * 0.5);
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let brightness = uniforms.params[0].x;
let contrast = uniforms.params[0].y;
let color = textureSample(input_content, tex_sampler, in.uv);
let rgb = (color.rgb - 0.5) * contrast + 0.5 + brightness;
return vec4<f32>(rgb, color.a);
}
Whatever you return is passed on with the alpha convention and colour space of the image that came in, since that is the image your pixels are made from. A node with no image input is treated as a fresh render in linear colour with premultiplied alpha, the same as everything else the renderer draws. Nothing re-reads your output to check, so return colour already multiplied by its alpha rather than straight colour, or it will composite bright at the edges.
Three rules make that boilerplate work rather than merely compile.
The entry points must be named
vs_mainandfs_main.Textures bind at 0 upwards in the order your
inputslist names them, asinput_<name>. The sampler takes the next slot and the uniform block the one after, so two inputs push the sampler to 2 and the uniforms to 3.ShaderUniformsmust match the layout above field for field.resolutionis the output size in pixels,timeis seconds andframeis a frame count; the last two drive animation with no keyframing.
Writing a compute shader
Set shader_type = "Compute" and write a body instead of a module. Leave @compute out of your file, define compute_pixel(coord), and the bindings, helpers and entry point are generated around it.
// @param float intensity 0.0 2.0 1.0 "Intensity"
fn compute_pixel(coord: vec2<i32>) -> vec4<f32> {
let uv = get_uv(coord);
let color = load_content(coord);
return vec4<f32>(color.rgb * get_intensity(), color.a);
}
The generated names are load_<name>(coord) per input, get_uv(coord) for normalised coordinates, get_<param>() per declared parameter, _param(i) for a float slot by index, and uniforms with the same layout as above.
Parameters become controls
The lines starting with // @param give the node its sliders, checkboxes and colour pickers. The format is the type first, then the name, then its range and default:
// @param float strength 0.0 0.05 0.01 "Aberration strength"
// @param color tint #ff0000 "Tint colour"
// @param bool invert false "Invert output"
Each type maps to a control: float to a slider (min max default), vec2 / vec3 / vec4 to per-component sliders, int to an integer slider, bool to a checkbox, and color to a colour picker (#rrggbb).
Float slots fill in declaration order, and a vec3 takes three of them. There are 32 float slots and 4 colour slots; anything past that is dropped without a message. params is packed as eight vec4s, so slot n is uniforms.params[n / 4][n % 4], and colours are uniforms.color_0 through color_3. In a compute shader _param(n) does that arithmetic for you.
Each parameter becomes an ordinary input pin named sp_<name>, so you can keyframe it or drive it with an expression. Binding one to live MIDI is not wired up yet: see The parameter bridge.
Live editing and errors
Save the shader file and press Reload in the Plugin Manager. Nothing watches the file, so an edit takes effect on that click and not before.
A compile error turns the node into an error node and puts the compiler's message in the Console. There is no line-and-column pointer, and the node stops producing output rather than falling back to the last build that worked. A broken shader never crashes the app.
Shaders are checked against what your specific graphics card supports. A shader that leans on a hardware feature your GPU lacks is refused at compile time rather than misbehaving. A node that will not compile on one machine but works on another is usually hitting this.
The trust check that refuses a capability-requesting plugin in a restricted project runs on the native and Rust load paths only. A shader plugin's [capabilities] block is parsed and then not checked against trust, so declare it accurately rather than relying on it to stop anything. See Capabilities & trust.