click to replay

·Playground

Develop from noise.

An image that resolves out of dithered static, like a photograph developing in a darkroom. Click to replay.

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.