The shader convention
Every pons shader is a single, self-contained WGSL fragment shader that follows one convention: a fixed uniform layout, one entry point, and a coordinate system that needs one line of correction. Two checks enforce it before anything gets published.
The Params struct
A shader declares exactly one uniform block, named Params, bound at @group(0) @binding(0). Its first three fields are fixed, in this exact order, and every custom parameter the collection declares comes after them as its own f32 field:
struct Params {
time: f32,
seed: f32,
texel: vec2f,
// your seeded parameters, all f32, in any order after this
}
@group(0) @binding(0) var<uniform> params: Params;Entry point and coordinates
The fragment entry point has one fixed signature:
@fragment
fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
// ...
}uv is top-origin and runs from 0 to 1 on both axes, but it is not corrected for the canvas's aspect ratio. Almost every shader wants to fix that before doing anything else, using texel:
let aspect = params.texel.y / params.texel.x; let p = (uv - 0.5) * vec2f(aspect, 1.0);
From there, p is a square, centered coordinate space regardless of the viewport's shape, which is what almost every shader in pons actually draws in.
What the validator checks
A shader is checked twice before a collection can publish it, once structurally and once for real:
- It contains the text
struct Paramsand an@fragmententry point. - The
Paramsblock hastime,seed, andtexelas members. - Every parameter key declared for the collection is also a member of
Params. A declared key missing from the struct silently gets ignored at render time, so this is checked before publish rather than discovered later. - The shader is actually compiled and rendered for one small frame, server-side, with realistic derived parameter values. This catches anything the structural check can't: a typo in a function name, a type mismatch, a loop with a non-constant bound.
A full annotated example
This is the starter shader shown in the collection studio: a single pulsing ring, with the seed nudging its color and two collection params (scale, speed) tuning its rhythm.
struct Params {
time: f32, // seconds; drives the animation
seed: f32, // [0, 1), unique per token
texel: vec2f, // 1/width, 1/height, for aspect correction
scale: f32, // custom: how tightly the rings pack
speed: f32, // custom: how fast it drifts
}
@group(0) @binding(0) var<uniform> params: Params;
// A cheap way to turn one float into a moving RGB color.
fn spectrum(t: f32) -> vec3f {
return 0.5 + 0.5 * cos(6.28318 * (t + vec3f(0.0, 0.33, 0.67)));
}
@fragment
fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
// uv is top-origin, [0,1] on both axes, and NOT aspect-corrected.
// texel.y / texel.x gives you the aspect ratio to fix that:
let aspect = params.texel.y / params.texel.x;
let p = (uv - 0.5) * vec2f(aspect, 1.0);
let t = params.time * params.speed;
let d = length(p) * params.scale - t;
// seed shifts the color per token so identical params still look distinct
let col = spectrum(fract(d + params.seed))
* smoothstep(0.9, 0.2, length(p));
return vec4f(col * 0.85, 1.0);
}Writing shaders that hold up
- Use
params.seedsomewhere that visibly changes the image. Two tokens with similar param rolls should still read as different pieces. - Animate with
params.time, but keep the motion slow. Confident drift reads better than frantic movement on a page full of thumbnails. f32literals need a decimal point (1.0, not1), loops need constant bounds, andwhile(true)is not allowed. WGSL is stricter than GLSL about all three.- Near-black backgrounds and additive glow read well on the site's dark pages. Hard color bands and flat rainbow fills tend to look cheap in comparison.