Engineering notes · 8 min read
Making three shaders behave like one instrument
A shader demo gets interesting when its effects inherit state and the runtime has to release what it creates.
Paper's shader library already has a strong gallery. It shows what each effect can do, exposes the parameters, and gives developers code they can reuse. A second gallery would have repeated work that was already done well.
We built Atlas of Unstable Matter around a different question: what happens when several shaders have to share one object, one history, and one runtime budget?
The page turns a single specimen into an exposed plate, an annealed mark, and a numbered CMYK proof. This post explains the decisions that made those stages feel connected, the WebGL constraints that changed the design, and the tests that kept a visual experiment from becoming a fragile demo.
The specimen gave every shader the same job
The first concepts treated God Rays, Heatmap, and Halftone CMYK as separate demonstrations. They looked good in isolation and fell apart as a page. A visitor could change one effect without that choice mattering anywhere else.
The fix was a small state machine:
latent → exposed → annealed → printed
Exposure duration and aperture choose a palette. The visitor's word is written into the same asymmetric specimen. Annealing converts that combined mask into a thermal field. The press inherits the image, palette, exposure value, mark, incision, dot, and edition number.
The controls follow the same rule. Later acts stay locked until the specimen has the state they require. A visitor can inspect the next station, but cannot produce a valid proof by skipping the work upstream.
That dependency did more for the experience than another dozen shader parameters would have. Each effect now has a reason to exist.
Removing the flashiest option improved the system
Liquid Metal was one of the early finalists. It is immediately impressive, which made it an easy choice during ideation. Its preprocessing and likely context churn made it a poor fit for a mobile sequence with several live graphics.
We cut it.
God Rays became the exposure source. Heatmap became the material transformation. Halftone CMYK became the output process. Those three effects have different visual grammars, but each can operate on the same specimen without pretending that an unrelated animation is part of the story.
The cut also clarified the mobile interaction. Act I uses a hold gesture and an aperture control rather than spatial dragging that could trap scrolling. Act II asks for a short word. Act III uses an ordered set of physical-looking plate buttons. The mechanics change because the job changes.
WebGL lifecycle became a product constraint
A page can remove a canvas and still leave its graphics context allocated inside the browser. Three sections mounting and unmounting during a long scroll can quietly accumulate contexts, especially when retries and failure recovery create new mounts.
The atlas allows one active ShaderMount at a time. An intersection arbiter chooses the dominant act. Before another shader mounts, the current mount disposes its resources and requests explicit context loss through WEBGL_lose_context when the browser exposes that extension.
Mounting also runs as an ownership-scoped transaction. Every attempt receives a generation number. After asynchronous work, it checks that it still owns the scene before changing shared state. A superseded attempt releases only its own canvas and context. A 2.5 second readiness timeout falls back to the authored poster instead of leaving a blank stage.
Some browsers do not expose explicit context loss. In that case, the application permits at most one context creation per act. Reopening an exhausted act stays in poster mode and asks for a reload. That limit is conservative on purpose. A visual extra should not degrade the rest of the page.
Heatmap processing moved off the main thread
The annealing step needs more than a word. It rasterizes the specimen silhouette, the branch-like incision, the dot, and the visitor's mark into a source mask. Several blurred distance bands are then packed into image channels for the Heatmap shader.
The first implementation ran the library's preprocessing path on the main thread. It worked, but an explicit commit could pause interaction for hundreds of milliseconds. Automatic prewarming also created races between scrolling, committing a mark, and mounting the next scene.
The released version performs that work in a dedicated OffscreenCanvas worker. Its processing surface is capped at 624,400 pixels. The worker uses summed-area tables to produce three blur bands, then transfers the packed PNG back to the page. Processing measured 69 to 229 ms in repeated local release runs and 187 to 431 ms during the final live run. The verifier observed no main-thread long task during that processing window.
The worker starts only when the visitor commits the anneal action. Resetting the specimen increments the processing generation, so a late worker result cannot advance a specimen that no longer exists.
There is still a limit here. Browsers without workers or OffscreenCanvas receive the designed poster state. We chose a truthful fallback over a second, slower code path that would be harder to reason about and test.
The proof needed two layers
The first CMYK proof covered the paper with dense dots and grain. It had print character, but the title, border, exposure notation, and edition metadata were hard to read on a phone. It also dropped the incision and dot that made the specimen recognizable.
The final proof keeps a clean source plate underneath a restrained shader layer. The source preserves typography, border, metadata, and the full specimen. The live Halftone CMYK canvas uses screen blending to add plate texture and misregistration without turning the paper into noise.
Before the fourth plate, the source remains faint and grayscale. Cyan, magenta, yellow, and key each produce a distinct rendered state. After the key plate lands, the full-color source resolves underneath the halftone layer.
This was a useful reminder: an effect can be technically correct and still be the wrong compositor. The final image matters more than shader purity.
The verifier tests the story, not only the route
The first acceptance run failed because /unstable-matter/ did not exist. That gave the project a clean baseline before implementation.
The finished verifier drives the complete journey at 320×568, 390×844, and 1440×1000. It checks that the mobile plate and exposure control appear in the first viewport, interactive targets meet the 44 pixel minimum, and later actions remain unavailable until their prerequisites are complete.
It also checks the parts that visual smoke tests tend to miss:
- pointer cancellation restores a latent specimen, and a second pointer cannot finish the first pointer's hold;
- a keyboard can complete the entire sequence;
- reduced-motion changes take effect while a shader is mounted;
- orientation changes do not create overflow or a second canvas;
- stale Heatmap work cannot advance a reset specimen;
- failed mount construction cleans up and can retry;
- missing
WEBGL_lose_contextsupport stays within the three-context lifetime bound; - 10 complete expose, anneal, print, and reset cycles end with one active context;
- the three palettes and different marks produce different rendered pixels;
- all four printing plates produce distinct proof states.
The main bundle is 37,321 bytes gzipped. Active canvases measured between 149,776 and 667,368 pixels in the final release suite. Local release verification passed twice before deployment, and the same journey passed against the canonical URL.
What remains unproven
The automated suite uses real Chrome and mobile viewport dimensions, but it is not a physical-phone thermal test. A five-minute run on an older phone could still reveal power use, driver behavior, or memory pressure that desktop Chrome emulation will not.
The worker blur is also specialized for this specimen pipeline. It is not a general replacement for Paper's Heatmap preprocessing, and the application deliberately falls back when the required browser primitives are missing.
Those boundaries are part of the result. The atlas treats GPU allocation, cancellation, motion preferences, and failure states as visible design inputs. The shaders become more convincing because the surrounding system knows when to stop rendering them.
Build the dependency before polishing the effect
The best decision in this project was giving every shader responsibility for the same artifact. That forced narrative continuity, exposed lifecycle costs, and made downstream verification possible.
Start with the state that must survive. Give each effect one transformation of that state. Then measure the browser behavior that the composition creates. The visual polish has something solid to attach to after those pieces work.