How it works
01 Stateless per-pixel hash
No texture lookups, no noise libraries: just a two-component hash of each pixel's screen
coordinate. hash21(xy) costs one dot, one sin, one fract per fragment. Entirely stateless: any pixel at any time is deterministic
from its coordinate alone.
02 Film-speed flicker
Real celluloid grain shifts with every frame of film. To match that, wall-clock time is
quantised to 24 fps (floor(u_time × 24)) and added as a large XY
offset into the hash. The entire grain field reseeds 24 times per second, film-like, not
monitor-locked. Under prefers-reduced-motion the field freezes on a single frame.
// Fragment shader — stateless hash noise, reseeded at ~24fps
float t = floor(u_time * 24.0); // film-speed frame index
vec2 co = gl_FragCoord.xy // pixel coord …
+ vec2(t * 1291.0, t * 1871.0); // … shifted per frame to reseed
float n = hash21(co); // fast stateless hash (no texture)
// centre at neutral grey → overlay brightens AND darkens symmetrically
float g = 0.5 + (n - 0.5) * u_intensity;
gl_FragColor = vec4(g, g, g, 1.0); 03 Neutral-grey overlay
The shader outputs grey centred on 0.5, scaled by u_intensity. The CSS mix-blend-mode: overlay on the canvas means values above 0.5 brighten the content
beneath and values below 0.5 darken it, symmetrically, on both dark and light surfaces. At
intensity zero the grain disappears; drag it up and the texture becomes unmistakable. The
slider above drives a reactive u_intensity uniform directly: no rebuild, just a
uniform update per frame.
A Svelte action, <canvas use:grain={{ intensity }}>.
Wrap any element with <Grain /> to fill it. DPR-aware, pauses off-screen,
honors reduced-motion. Open to borrow.