How it works
01 Texture sampling + noise mix
A WebGL fragment shader holds both the source image (uploaded as a sampler2D after loading via new Image()) and a
hash-based noise field. At u_progress = 0 every pixel shows
pure animated grain; at u_progress = 1 every pixel shows the
clean image. Between those poles each pixel transitions individually based on
its own hash threshold, not as a uniform blend.
02 Dither reveal by progress
Each pixel gets a fixed threshold from hash21(uv), uniformly
distributed across [0, 1]. A smoothstep around that threshold
turns the per-pixel blend weight on as u_progress crosses it.
Pixels with low thresholds reveal early; high-threshold pixels stay static
until last. The noise animation rate also slows as progress grows: fast,
chattery static at the start, settling into film grain as the image
emerges.
// 1. Per-pixel hash threshold — each pixel has its own reveal moment float threshold = hash21(uv); float reveal = smoothstep(threshold - 0.05, threshold + 0.05, u_progress); // 2. Animated grain → image sample, mixed by per-pixel reveal weight float noiseRate = (1.0 - u_progress) * 20.0 + 1.0; float grain = hash21(uv * 1.1 + vec2(floor(u_time * noiseRate) * 0.073)); vec4 img = texture2D(u_image, uv); gl_FragColor = vec4(mix(vec3(grain), img.rgb, reveal), 1.0); // 3. JS: pointerdown → animStartMs = performance.now() // progress = ease((now − animStartMs) / 1400ms) → 0…1
03 Replay
On first entry into the viewport an IntersectionObserver records performance.now() as animStartMs (after
the texture is ready). A pointerdown listener on the canvas
does the same thing. Each RAF frame computes progress = ease((now − animStartMs) / 1400ms): resetting animStartMs is the entire replay mechanism. Under prefers-reduced-motion progress is pinned to 1 and drawn once.
A Svelte action, <canvas use:develop>, driving a single
WebGL fragment pass. Texture created outside glQuad and bound to TEXTURE0 each frame. DPR-aware, pauses off-screen, accessible
under reduced-motion.