Custom WGSL Shaders
The built-in filters cover the basics — blur, brightness, colour correction. But creative coding is about the non-basics. The weird. The custom. The “I found a shader on Shadertoy and I want to use it.” For that, you need to let users write their own GPU code.
PixelShader and ComputeShader are the two most complex nodes in Lux. Not because of what they do — they run a user-provided shader — but because of what they figure out automatically.
The magic: dynamic pins
Write a WGSL fragment shader with a uniform:
@group(0) @binding(0) var<uniform> params: Params;
struct Params {
speed: f32,
color: vec3f,
scale: vec2f,
}
The PixelShader node parses that source, finds the struct, expands its fields, and creates three input pins on itself: speed (Number), color (Vec3), scale (Vec2). Wire values to them and they flow through as shader uniforms. Change the shader source? The pins update.
No manual pin configuration. No type mapping. Write the shader, get the pins.
The WGSL parser
This is 538 lines of hand-rolled parsing. No regex crate, no WGSL grammar dependency. Three passes:
Pass 1 — Strip comments. Remove // line comments and /* */ block comments. Replaces with whitespace to preserve line structure. This prevents false positives on commented-out uniforms.
Pass 2 — Parse structs. Find struct Name { ... } blocks, extract field names and types. Supports both vec2f and vec2<f32> syntax. Unknown types get silently skipped.
Pass 3 — Parse uniforms. Find var<uniform> name: Type declarations. If the type is a struct, expand it into individual fields. If it’s a primitive (f32, i32, vec4f), emit it directly. Also finds texture_2d bindings for texture inputs.
The parser is intentionally tolerant. Malformed fields get skipped. Unknown types get ignored. The shader might not compile on the GPU, but the parser won’t crash trying to figure out the pins. Error reporting happens at the wgpu level where it belongs.
Struct expansion is the key feature. Most non-trivial shaders group their uniforms into a struct — it’s WGSL convention. Without expansion, you’d get one pin of type “Params” with no way to wire individual values. With expansion, each struct field becomes its own pin.
PixelShader
- Inputs:
wgsl_source(String),width(Number, 512),height(Number, 512), + dynamic pins from uniforms - Output:
out(TextureHandle)
The entry point convention is @fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f. You get UV coordinates (0–1), you return a colour. Everything else — the fullscreen vertex shader, the bind group layout, pipeline compilation, output texture allocation — is handled by the framework.
The node is always-dirty because the shader might depend on time or other per-frame values. It saves and restores the WGSL source through node state persistence, so your shader survives save/load.
When the source changes, the parser re-runs, the node requests a pin refresh via ctx.request_pin_refresh(), and the graph calls dynamic_info() to get the updated pin list. New pins appear, removed pins disappear, types update automatically.
ComputeShader
- Inputs:
wgsl_source(String),width/height(Number, 512),workgroup_x/workgroup_y(Number, 8), + dynamic pins - Output:
out(TextureHandle)
Same idea, different execution model. The entry point is @compute fn cs_main(@builtin(global_invocation_id) gid: vec3u) and the output is bound as a storage texture at @group(2) @binding(0) var output: texture_storage_2d<rgba8unorm, write>.
Compute shaders can write to arbitrary pixels — not just the one at the current UV. This makes them suitable for particle systems, physics simulations, cellular automata, or anything where the output location doesn’t map 1:1 to the input.
Dispatch is automatic: ceil(width / workgroup_x) × ceil(height / workgroup_y) × 1 workgroups. The default workgroup size of 8×8 means 64 threads per group, which is a reasonable occupancy target for most GPUs.
Dynamic pins in the node system
Supporting dynamic pins required changes to lux-core:
Node::dynamic_info()— optional method that returns additional pins based on node state.Graph::refresh_node_info()— rebuilds a node’s pin list by combining static and dynamic info.ProcessContext::request_pin_refresh()— signal from a node that its pins have changed.
The graph merges dynamic pins with the static ones from info(), preserving any existing wire connections when pins match by name and type. This means you can edit a shader, add a new uniform, and your existing wires stay connected.
Why this matters
PixelShader and ComputeShader are the escape hatch. The built-in nodes cover common operations, but creative coding is fundamentally about doing things that aren’t common. These nodes turn Lux into a WGSL development environment with visual wiring — you write the GPU code, Lux handles the plumbing.
Port a Shadertoy effect. Implement a custom blur kernel. Write a reaction-diffusion simulation. Build a ray marcher. The GPU is right there — 1,489 lines of infrastructure to make “write a shader, get a node” actually work.
But most users won’t write custom shaders for common operations. Next: seventeen more built-in nodes covering transforms, compositing, analysis, and post-FX.