Engineering6 min read
The gradient behind this page
It's a WebGL plane, it took a weekend to look right, and then about three weeks of small ugly problems that had nothing to do with how it looks.
Written by OZP Studios, Seattle, WA
The purple behind this text is a mesh in a WebGL canvas, fixed to the viewport, sitting under everything. Getting it to look the way we wanted took a weekend. Everything after that was the boring part, and the boring part is most of the story, so here it is.
Knowing when it has actually painted
The site holds a preloader until the background is on screen, otherwise you get a black page that turns purple a half second later and the whole intro looks like a mistake. The problem is there's no reliable event for “the canvas has drawn its first frame.” Mount doesn't mean painted. Ready doesn't mean visible.
What we do is poll. Every 50ms we look for a canvas with a non-zero width and height, and when we find one we wait a single additional animation frame before telling the loader to go. If we haven't seen pixels after eighty tries, roughly four seconds, we give up and reveal anyway, because a slow GPU shouldn't be able to hold someone on a loading screen forever. It's a polling loop with a timeout. We'd rather it were a promise. It isn't.
The black-to-purple pop
We shipped a bug twice where navigating between pages produced a flash: black for a frame, then the gradient punching back in at full strength. Both times the cause was the same, which is why it's now written in the file as a comment instead of living in someone's head. On home the opacity follows scroll, on inner pages it eases to a fixed intensity, and we'd been swapping which value drove the style depending on the route. Swapping the source restarts the animation from wherever the new one happens to be.
One motion value owns the opacity for the life of the app. Routes change the target it's springing toward and nothing else touches it. Obvious in retrospect, invisible in code review, plainly wrong once you watch it at quarter speed in a screen recording.
Things that were just numbers
A few of the fixes were only a matter of finding the right constant:
- The stock plane is 10 units across at 192 segments. At our camera angle the ripples at the far edge ran off the mesh, so ours is 16 units with the subdivisions scaled to keep the same vertices per unit, capped at 640. Past that cap you're paying for triangles smaller than a pixel.
- Device pixel ratio is clamped to 2. On a 3x phone the gradient looks the same and costs roughly twice as much to draw.
- Under prefers-reduced-motion the opacity jumps instead of springing. The gradient is still there, it just doesn't ease anywhere.
- Touch detection uses (pointer: coarse), not ontouchstart or maxTouchPoints. Windows laptops with touchscreens report touch capability while the person is very much using a mouse, and we were handing those people the phone path for weeks.
The line we're not proud of
The library logs the string “material (onInit)” to the console on every mount. There is now a five-line patch at the top of our scene file that wraps console.log and drops exactly that one message. Patching a global to silence someone else's debug line is not good practice and we did it anyway, because a console that's noisy by default is a console people stop reading, and we'd rather keep ours useful. It's still there. We look at it occasionally.
The lab route
Tuning this thing by editing parameters inside a query string in a constant is miserable, so there's an internal route with the parameters as sliders. It's noindex, it's not in the nav, and it exists purely because comparing two values of uSpeed shouldn't require a rebuild. Most of the settings we ship came out of ten minutes on that page.
The part we haven't resolved: on a mid-range Android this is still the most expensive thing on the page by a wide margin. We fade it out over the first 900 pixels of scroll, which is a taste decision that is also, conveniently, a performance decision. Whether the honest version is to not ship the canvas to those devices at all is a conversation we keep having and keep not finishing.