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:

  1. Read inputs — the source texture handle plus any parameters (radius, amount, degrees).
  2. Call ctx.run_shader() with the WGSL source and a list of named ShaderInput values.
  3. 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.

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

← Back to devlog