Texture Filters
The texture pipeline exists. Sources exist. Now the question becomes: what can you do to a texture once you have one?
The answer, for this session, is eight things. Eight GPU filter nodes, each backed by a WGSL fragment shader, each following the same pattern: texture in, parameters in, texture out.
The pattern
Every filter node in Lux follows the same structure:
- Read inputs — the source texture handle plus any parameters (radius, amount, degrees).
- Call
ctx.run_shader()with the WGSL source and a list of namedShaderInputvalues. - Set the output to the returned texture handle.
That’s it. The node itself is about 35 lines of Rust. The shader does the actual work. run_shader() handles pipeline compilation (cached by source hash), bind group creation, fullscreen triangle dispatch, and output texture allocation. The node author writes a fragment shader and a few lines of plumbing.
Here’s the full Brightness node. They’re all this size:
fn process(&mut self, ctx: &mut ProcessContext) {
let tex = ctx.input_texture("texture");
let amount = ctx.input_float("amount").unwrap_or(1.0);
if tex.is_invalid() { return; }
let out = ctx.run_shader(
include_str!("shaders/brightness.wgsl"),
tex, &[("amount", ShaderInput::Float(amount))],
);
ctx.set_output("out", PinValue::Texture(out));
}
The shader is equally compact:
@group(0) @binding(0) var<uniform> amount: f32;
@group(1) @binding(0) var src: texture_2d<f32>;
@group(1) @binding(1) var src_sampler: sampler;
@fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
let color = textureSample(src, src_sampler, uv);
return vec4f(color.rgb * amount, color.a);
}
Multiply RGB by a scalar. Preserve alpha. Done. The framework handles everything else — the fullscreen vertex shader, the bind group layout, the pipeline cache, the output texture allocation.
The eight filters
Blur — box blur over an NxN neighbourhood. The radius parameter (0–32) controls kernel size. Not Gaussian — that would require two passes for separable filtering. Box blur is cheaper and looks fine for most creative applications. The shader samples (2r+1)² texels per pixel, which gets expensive past radius 16, but that’s a problem for the multi-pass blur coming in the extended processing phase.
Brightness — multiplies RGB by a scalar. Amount 1.0 is unchanged, 2.0 is double, 0.0 is black. Simple but fundamental — it’s the gain knob on every image chain.
Contrast — lerps between 50% gray and the original colour. Amount 1.0 is unchanged, 0.0 is flat gray, 2.0 is punchy. The shader computes mix(vec3f(0.5), color.rgb, amount) — a single GPU instruction.
Invert — 1.0 - color.rgb, alpha untouched. The simplest possible filter. Photographic negative. Useful as a creative effect, but more useful as a utility — invert a mask, flip a displacement map, negate a lookup.
Threshold — binary black/white based on luminance. The level parameter (0.0–1.0) sets the cutoff using the standard luminance formula (0.299R + 0.587G + 0.114B). Below the threshold: black. Above: white. Clean, hard edges. Essential for mask generation — connect a NoiseTexture to a Threshold and you’ve got a procedural stencil.
HueShift — rotates the hue by a number of degrees. The shader converts RGB to HSL, adds the rotation, converts back. At 180° you get complementary colours. Wire an LFO to the degrees input and the colours cycle continuously — instant psychedelic.
Saturate — lerps between grayscale (luminance-weighted) and the original colour. Amount 0.0 is monochrome, 1.0 is unchanged, 2.0 is oversaturated. The desaturation uses perceptual luminance weights, so it matches what your eye expects.
EdgeDetect — 3×3 Sobel filter. Samples 8 neighbours, computes horizontal and vertical gradients, outputs the magnitude as grayscale. Strength parameter scales the result. Edges glow white against black — feed it into a Blend node with the original for a sketch effect.
Why WGSL, why static
Every shader is embedded in the binary via include_str!(). No runtime file loading, no shader compilation on first run — the source is right there in the plugin crate’s shaders/ directory, baked into the compiled binary, and cached by hash on first use.
This is intentional. Built-in filters should be zero-config. You add a Blur node and it works. The custom shader nodes (coming in a later phase) are for users who want to write their own WGSL. These eight are the batteries-included set.
What it feels like
Connect a LoadImage → HueShift → Blur → output. Move the hue slider. The image shifts through the spectrum, soft and smooth. It’s running at 60fps because each filter is a single fullscreen fragment shader dispatch — the GPU eats this for breakfast.
Eight nodes, eight shaders, 652 lines of code. The texture pipeline has teeth now. But there’s a problem — these filters only work on textures, and most of Lux’s content is vector layers. Next: bridging the two worlds.