Experiment 10
3D Liquid Glass
A three.js render as the displacement map — 3D forms refracting live HTML
Published: Aug 12, 2026
Experiment 10
A three.js render as the displacement map — 3D forms refracting live HTML
Published: Aug 12, 2026
Open to new projects, ideas, and conversations
Experiment 10
A three.js render as the displacement map — 3D forms refracting live HTML
Published: Aug 12, 2026
This picks up where Liquid Glass left off. That experiment refracts the live DOM through an SVG feDisplacementMap, but its map is a rounded rectangle rasterised on the CPU from a signed-distance field — the shape is baked into the algorithm, so the lens can never be anything else. Here a GPU draws the map instead, which means any form can be the glass, including one that moves.
One shader pass, no lights. For every pixel of the model it refracts the view ray through the surface normal and writes the resulting screen-space offset into red and green, a specular term into blue, and the silhouette into alpha. A second, earlier pass renders the back faces so the offset can scale with how much glass the ray actually crosses — without it a hollow shell and a solid billet look identical, and the model reads as a decal rather than an object.
A plain view-normal map carries the same information and was the obvious thing to try first. The refraction shader wins because it gets there in a single instruction and hands you an index of refraction to turn.
Blue never reaches the filter. feDisplacementMap reads only the two channels named by its xChannelSelector and yChannelSelector, which are red and green — the specular travels separately, as a white image with the highlight in its alpha. The displacement map's blue channel is therefore free, and it is held at neutral, which is what makes the map beside the lens read as a map. Putting the real specular there instead is informative and changes not one pixel of the output, but it drops most of the surface to nearly zero blue and turns the whole thing olive.
The lens is exactly the panel, and the map next to it is exactly the same square, so the form is the same size in both — the map is not a diagram of the effect, it is the effect at 1:1. Where the model sits inside that square is the camera's business rather than the box's: Object scale and the two Offsets move it within the frame, and dragging either panel spins it.
The frustum is fitted to the radius of the smallest sphere around the model that is centred on the point the camera orbits. That is deliberately not the tightest possible fit. Projecting the bounding box's eight corners into view space and fitting to those extracts considerably more of the map out of a form that spreads sideways — but it is a different answer at every angle, so the model grew and shrank as you spun it and jumped the moment the fit was recomputed. A radius measured from the orbit centre cannot change with orientation, which is the entire point. What that costs in map coverage, Object scale gives back on purpose.
Three settings do all the work of keeping it that way: no tone mapping, no sRGB output transfer, and no premultiplied alpha. Any one of them left at its default silently rewrites every value — an sRGB encode alone turns a neutral 0.5 into 0.735, and the lens then bends hardest exactly where it should sit perfectly still.
An SVG filter cannot read a canvas. It reads an image, by URL — so a live map has to be encoded to a PNG and decoded again every frame, which costs several milliseconds before the filter has done anything at all. The animation is a fixed two-and-a-third second loop, though, so that cost only ever has to be paid once: every frame is rendered and encoded up front, and playback is a single href assignment. The clip runs at thirty frames a second, so on a sixty-hertz display half of those assignments do not even happen.
Live is what runs by default, and the Map source select opts into the baked loop. The millisecond readout below the panels is the difference: live, the map pipeline costs a few milliseconds a frame; baked, it costs hundredths of one, because on most frames it does nothing whatsoever and on the rest it assigns a single string. The frame rate next to it barely moves between the two — it is capped by the display, so it only reacts once the budget is properly blown, which is exactly why the millisecond figure is there at all. Baking is opt-in rather than automatic because it is not free: every frame has to be rendered and encoded before any of them can be shown, and paying that on the release of every drag made the demo lurch for frames nobody was going to look at.
Both modes run the same encoder over the same readback, so what the toggle changes is when the work happens, not what it produces. That is deliberate — it makes “baked matches live” something you can actually check rather than take on trust. They also share one amplitude. The map is normalised so it spans as much of the eight-bit channel range as it can without clipping, and that gain is measured once, by a short probe sweep, whenever a parameter that changes the map's magnitude is committed. It is emphatically not measured when the camera moves: it used to be folded into the bake, so letting go of the model re-measured it at the new angle and the refraction changed amplitude at a viewing angle you had been holding still for seconds.
Experiment 05 runs thirteen full-region filter passes per frame at its defaults, and seventeen with frost on. Each one processes the entire filtered box at device pixel density, so the count is the cost. Eight of the thirteen are chromatic aberration, which needs a separate displacement gather per colour channel plus the masks and blends to reassemble them. Two more are the specular, which does not need to be in the filter at all — it is a white image with the highlight in its alpha, so it screen-blends over the top as an ordinary element. Four more are the frost blur, which a CSS backdrop-filter on the same overlay does over the lens box alone rather than over everything.
What is left is a flood, an image, a merge and one displacement. The flood and merge look removable and are not: a displacement map reads the whole region, and outside the lens an unflooded graph is transparent black, which is not a neutral offset but a very large one. The specular and the frost are out of the filter for good; the chromatic aberration is still a slider, and turning it up puts its eight passes back and takes the counter under the panels from four to twelve.
The blue channel needed a Fresnel weight before it was usable. A Blinn-Phong highlight on a sphere resolves to a small bright spot, but on a flat face the highlight condition is either satisfied across the whole face or nowhere on it — so a cube came out with one side solid white and stopped reading as glass entirely. Weighting it by the viewing angle takes camera-facing geometry down to a few percent and leaves the grazing angles bright, which is both what glass does and, not coincidentally, the bright-rim look experiment 05 arrived at by hand.
This is the part that took the longest. Experiment 05 established that a soft alpha in the displacement map is fatal: the rim ends up carrying a nearly-neutral value, the filter samples the undisplaced pixel there, and a dark ring appears around the lens. A rendered 3D silhouette hands you that soft alpha by default — and worse, an antialiased edge averages the colour across samples too, so a thirty-percent-covered pixel carries thirty percent of the displacement it should.
The fix is three things at once: supersample so the edge is located accurately, divide the colour back out by the coverage so the boundary pixel carries full strength, then threshold the alpha hard. Take any one of the three away and the dark ring comes straight back — all three are still in the resolve pass, on a switch that no longer has a control attached to it.
Experiment 05 found that WebKit does not re-evaluate a filter when an feImage merely moves, and rebuilds the filter's id every frame to force it. The obvious hope was that swapping the image's href — a change of content rather than position — would be treated differently. It is not. Measured in a real WKWebView: replacing the map with a completely different one, uniform maximum displacement, while holding the filter id fixed, changed zero pixels out of six hundred thousand.
So rebuilding the filter's id on every frame is not a precaution, it is the mechanism: without it the lens stops dead on an Apple device while the map beside it carries on. On Chromium the rebuild is pure overhead, so it is neither on nor off but decided by the engine.
Everything about how the map reaches the screen. The filter region is the element's own box in object-bounding-box units, the colour interpolation is forced to sRGB, and the stage carries an identity transform whose only job is to be the coordinate system WebKit resolves the filter against — without it the refraction lands wherever the nearest transformed ancestor happens to be. Those were expensive to find the first time and none of them changed here.