Kai
GitHubWorkBlogGallery

Caustic: liquid glass, derived

A glass library that solves refraction instead of blurring it: a real height profile, a real surface normal, Snell's law per pixel. The optics were the easy half. The part that took months was accepting that glass has to be handed the picture it bends, and deleting the backend that pretended otherwise.

260
38.0
0.28
1.00
150
1.00
1.15
0.070
274
0.85
0.45
0.0

The blur is not the effect

Frosted glass and a lens are different materials, and most web glass ships the wrong one. A blur scatters light, which is what frost does. A lens bends it: edges behind it compress toward the rim, straight lines curve, and the highlight travels as the curvature turns. The lens at the top of this page is doing that. Drag it over the wire behind the television and the wire bows. No blur radius produces that at any strength, because a blur moves no pixel anywhere.

The rim is where a pane does nearly all of its work. The flat middle of a sheet barely deviates the light crossing it. The few pixels where the surface curves away are what bend the image, catch the light, and say the thing has thickness. A blur treats every pixel the same and so says nothing about shape at all.

backdrop-filter: blur(12px)Softens the picture and says nothing about its own shape.
RefractionBends it toward the rim, keeps the middle sharp, lights the edge.
The same photograph under both. One softens it, the other bends it.

A shader samples a texture

Bending an image means reading it somewhere other than where you write it, and the only thing a program can read at an arbitrary place is a texture. That one fact decides the architecture. The document's own pixels are not a texture and never will be: there is no API that hands you the rendered page as an image, and there should not be, since that is a read of everything the user has on screen.

So glass has to be handed the picture it is meant to bend. That sounds like a worse API than floating over a backdrop and it is the opposite: it is the same information written down instead of guessed at. Everything downstream simplifies, because from that point the glass owns its pixels rather than negotiating for them, and the two figures on this page that spent a week going crooked were both cases of a pane bending a copy it did not own.

The alternative is to ask the browser to bend the page on your behalf through a CSS filter. I built that first and shipped it for months. It is the next three sections, and it is why this one is short.

Backdrop handed inThe picture is in the texture, so there is something to bend.
Backdrop left on the pageNothing in the texture to sample, so the pane comes out as a hole.
Handed the picture, the lens bends it. Left over the page, it comes out as a hole.

What the engine will do for you

There are three ways to ask CSS for glass and one property name covers two of them. backdrop-filter: blur() is supported everywhere and is a blur, not refraction. backdrop-filter: url(#id) points at an SVG filter and can genuinely bend, in Chrome only: Safari and Firefox parse the declaration, decline to resolve the reference, and report nothing. No error, no warning, no visible failure. That silence is how the most installed glass library on npm serves a plain blur to a large share of its users without anyone noticing.

The third asks for a filter on the content rather than on the backdrop, which every engine supports. It works, and it carries the constraint people assume they are escaping by using the first two: the pixels being bent have to live inside the filtered element, so you hand it the background either way. Once the picture is being handed over regardless, the only question left is who reads it, and a shader reads it better in every respect that follows.

ApproachPropertyChromeSafariFirefox
Blur the backdropbackdrop-filter: blur()
Refract the backdropbackdrop-filter: url()
Refract the contentfilter: url()
Sample a textureWebGL
Three ways to ask the engine for glass, and the one the library does instead.

From geometry to pixels

A caustic is the pattern light casts after refracting through a curved surface, which is what the library computes, so that is what it is called. The chain has nothing faked in it. The rounded rectangle is a signed distance field. The bezel is a height profile, differentiated numerically for its slope. The bend is Snell's law in vector form. The rim highlight is Fresnel reflectance, which is the physical reason a glass edge catches light at all.

Every step is a function of the one before it. Change the profile and the normals change, so the refraction changes, so the highlight moves. None of those are separate settings that have to be kept in agreement by hand, which is the practical difference between deriving an effect and assembling one.

bevelnθ₁θ₂Distance fieldHeight profileSurface normalSnell's lawDisplacement map
Distance field, height profile, surface normal, Snell's law, one map.

One map, four channels

All of it resolves to one RGBA image. Red and green carry the displacement vector, blue carries the baked specular highlight, and alpha is the shape mask, so refraction clips to the silhouette instead of the bounding box. The shader reads that image and bends whatever is under it.

Two decisions paid for most of the performance. The generator evaluates one quadrant and mirrors it into the other three, so the per-pixel maths runs a quarter as often, and the specular term is composed to survive that mirroring. Baking the highlight into blue avoids a second pass, which Aave measured as expensive on Safari.

Eight bits a channel is not enough to hold a displacement, and it took me a long time to see why. One step of a byte moves the sampled point by two 255ths of the peak bend, about a pixel and a half on these controls, so the refracted image lands on a coarse lattice and every gradient across it comes out as a staircase. It looks exactly like a resolution problem. It is not: quadrupling the map changes 0.39% of the rendered pixels and leaves every step precisely where it was, because sixteen times as many samples of 256 levels is still 256 levels. Carried to the GPU as half float, one step near neutral is eight times finer and safely sub-pixel. A filter cannot be given that map at all, since feDisplacementMap reads bytes, so on that route the staircase is not a bug anyone can fix.

The map
R displacement x
G displacement y
B specular
A shape mask
The map this page generated, and what each channel carries.

What the edge is made of

Five profiles ship and the choice between them is most of what a pane looks like. The squircle packs the bend into a narrow hard rim that melts into the flat centre with no seam. The circle spreads the same bend wider and softer. The convex profile is a parabola, the only one with a finite rim slope, so it bends gently throughout. The concave profile pushes the backdrop outward where the others pull it in, so its rim behaves like the inside of a bubble. The bead is the only one that does not simply climb to a flat centre: it crests just inside the rim and falls away to nothing, which is the shape a real capsule of glass has.

Thickness and bezel width stay separate numbers, because a pane can be thicker than it is wide. Thickness defaults to 2.4 times the bezel, a ratio picked by comparing candidates side by side, so setting the bezel alone gives a slab rather than a sheet. Pull them apart for a narrow hard edge with deep glass behind it, or a soft wide edge on a thin one.

The figure above cost me an afternoon and taught me the most useful thing in this post: a lens only shows what it bends. Centred on that photograph the four panes sat over a wall of near-uniform forest and rendered as four identical empty rectangles, so I went looking for a bug in the optics. There was no bug. The crop is aimed at the cabin and the waterline now and the cross-sections are obvious. The same mistake, in the same week, had the lens at the top of this page drifting over empty sky and looking broken.

squircleNarrow, hard rim.
circleThe same bend, wider and softer.
convexA parabola: gentle throughout.
concaveHollowed, so the bend runs the other way.
Four of the five. Same picture, same numbers, only the cross-section changes.

Moving it has to be free

Moving a lens must never regenerate its displacement map. That is the rule the library is arranged around and it is what makes the lens at the top of this page draggable at all. The map is a function of the pane's shape and not of where the pane is, so a move costs a viewport and a uniform.

A filter cannot keep that promise, because of a WebKit bug. Setting any subregion on feImage makes WebKit render nothing for the entire filter, not merely misplace the map, regardless of units, filter region, or whether the map arrives as a blob URL or a document fragment. The only way round it is to stop asking the filter to place anything and bake the position into the map's own pixels, which turns every move into a layer-sized rasterise and a PNG encode. On a large lens that is seconds rather than frames.

Baked-in position costs a second thing that surfaces much later. Those pixels are laid out in device pixels, and a browser zoom changes the device pixel ratio under them. The map stays rasterised at the old ratio, the copy of the backdrop slides out of register with the picture around it, and the pane goes visibly crooked while its optics are perfectly correct. That was the last argument I needed. Once every figure here had moved to sampling a texture, nothing was left using the filter, and I deleted it rather than keep a second and worse way to do the same job.

One surface, many panes

A WebGL context is not free, and a browser keeps somewhere between eight and sixteen before it silently drops the oldest. A component that quietly opens one each is a component that breaks any page using nine of them. The surface is the answer: one context, one uploaded frame per tick, and as many panes on it as the layout wants. The player below has nine panes on one surface, which is the case that would have exhausted the budget without it.

Video is where sampling a texture stops being a preference. Every engine composites a live video element on the GPU, outside the CSS filter pipeline, so a filter over one renders a flat panel in Safari however correct the optics are. Reading the frame as a texture is the only way to bend it at all.

Three tiers are defined and the library tells you which one a page is on. One renders: the shader, wherever WebGL is available. The other two are honest labels on work not done. Under prefers-reduced-transparency, and on an engine with no WebGL, the tier is reported and warned about, and nothing yet paints the opaque fill or the plain blur it names.

RefractionThe shader, on any surface that can be uploaded.
Opaqueprefers-reduced-transparency. Detected, not yet painted.
BlurNo WebGL at all. Detected, not yet painted.
What each tier looks like. One renders today, two are specified. A page gets one, never three.
12.0
64
0.14
1.00
1.00
0.10
0.80
0.30
0.0
274
96
147
69
93
7.0
120
0.16
4.00
1.10
0.88
0.00
274
56
86
17
1.5
8
0.16
4.00
1.10
0.88
0.00
0.118
274
84
47
0.27
-0.45
240
24.7
0.55
0.74
0.90
0.40
0.040
274
96
66
2.5
120
0.16
1.00
1.00
1.00
0.88
0.00
0.040
274
68
68
19
5.0
48
0.10
1.00
1.00
0.80
0.30
0.050
274

Where it stands

Working today: the optics core, map generation, the WebGL backend and its surface, spring motion, capability detection, the development warnings, and 162 tests over the maths and the map. Every control above is built on it, and they are one object cut to different shapes rather than a set of lookalikes: one light, one bezel profile, one rim.

The layering rule that keeps it honest is that nothing in the core imports React, and nothing under the maths or map directories touches the DOM. The second half of that was aspirational until recently. The map held one call to document.createElement, in the rasteriser that turned a map into a PNG for the filter, and deleting the filter took the rasteriser with it. The optics are testable without a browser because of that rule, and the shader backend could be written underneath them without the core noticing.

Running two backends for a while taught me the thing I would keep from it: a shared parameter name is a promise, and nobody finds a broken one without a second implementation to measure against. frost is spent as a Gaussian standard deviation. The shader was sampling a nine-tap kernel at one sigma, whose variance is half of that, so it blurred at 0.707 of what every caller asked for. Spacing the taps at sigma root two matched it and cost nothing.

Not built yet: the opaque tier, and adaptive scrims for text contrast. Zero runtime dependencies, ESM, MIT.

GitHubX.comInstagram