Building liquid glass with WebGL
Liquid glass is not a translucent rectangle with blur. It is a sampling problem. A convincing surface changes where the scene is read, how that displacement grows across its volume, and how light reveals the boundary of an otherwise invisible material.
Start with an optical model, not a material preset
The experiment below is a single full-screen WebGL draw call. Every pixel decides whether it belongs to the lens, reconstructs a curved surface from its distance to the lens center, and samples a scene texture at a displaced coordinate.
Drag the lens across the scene, then isolate the variables. Refraction controls displacement, depth changes the curvature response, dispersion separates the color channels, and the light viewfinder rotates the specular term.
A 2D field can describe a 3D-looking surface
The lens begins as a radial distance field in normalized device-independent coordinates. Multiplying the horizontal axis by the canvas aspect ratio keeps the field circular when the canvas is not square. Without that correction, the same radius becomes an ellipse.
For every covered fragment, d is the normalized distance from the center. The radial direction is the screen-space normal. The missing depth component comes from the upper half of a unit sphere: z = sqrt(1 - d²). No sphere mesh, perspective camera, or depth buffer is required.
vec2 aspect = vec2(u_resolution.x / u_resolution.y, 1.0);
vec2 point = (v_uv - u_center) * aspect;
float radius = mix(0.24, 0.43, u_size);
float d = length(point) / radius;
float mask = 1.0 - smoothstep(0.985, 1.005, d);
vec2 normal = point / max(length(point), 0.0001);
float z = sqrt(max(0.0, 1.0 - d * d));
float edge = pow(clamp(d, 0.0, 1.0), 3.0);The shader needs pixels before it can bend them
WebGL cannot sample arbitrary HTML behind a canvas. The scene must be rasterized into a texture first. In this study a Canvas 2D surface supplies the typography and base lighting. In a production renderer, the input would usually be an image, a video frame, or the color attachment of an offscreen framebuffer.
The scene is sampled through an analytic surface and written into the final framebuffer.
Scene texture
Surface field
Composite
A full-screen primitive keeps geometry trivial
Two triangles cover clip space and generate interpolated UV coordinates. The scene is uploaded once after resize; only the small uniform values change during animation. This keeps CPU-to-GPU traffic predictable and leaves the optical work in the fragment shader.
// Two triangles cover clip space. The fragment shader does the work.
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 1, -1, -1, 1,
-1, 1, 1, -1, 1, 1,
]), gl.STATIC_DRAW);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, sceneTexture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA,
gl.UNSIGNED_BYTE, sourceCanvas
);Refraction is controlled UV displacement
A real refractive interface bends a ray according to the ratio between two indices of refraction. A single screen-space texture does not contain enough geometry to trace that ray correctly, so the shader uses an intentional approximation: displace the sample along the surface normal and increase the offset toward the rim.
The center stays readable because its offset approaches zero. The boundary bends aggressively, which creates the compressed ring that the visual system reads as thickness. The approximation is not physically complete, but it is stable, inexpensive, and controllable at interface scale.
float bend = (0.018 + u_refraction * 0.095)
* edge
* (0.35 + u_depth * 0.95);
// Divide by aspect to return to texture space.
vec2 refractedUv = v_uv - normal * bend / aspect;
vec3 background = texture2D(u_scene, v_uv).rgb;
vec3 glass = texture2D(u_scene, refractedUv).rgb;
vec3 color = mix(background, glass, mask);Dispersion and light restore the material cues
One displaced texture sample produces distortion, but it still reads like a software warp. Glass needs boundary cues. Dispersion samples red, green, and blue at slightly different offsets. The separation is multiplied by the edge term so text stays sharp through the center and color only appears where curvature is strongest.
The highlight is independent of the scene. A two-dimensional light direction is dotted with the radial normal, raised to a high power, and masked to the rim. The result behaves like a narrow specular lobe. It does not simulate Fresnel reflectance, but it plays the same perceptual role: the grazing boundary becomes brighter than the face.
float spread = u_dispersion * 0.013 * edge;
vec3 refracted;
refracted.r = scene(refractedUv + normal * spread / aspect).r;
refracted.g = scene(refractedUv).g;
refracted.b = scene(refractedUv - normal * spread / aspect).b;
float rim = smoothstep(0.72, 0.995, d) * mask;
vec2 light = vec2(cos(u_light_angle), sin(u_light_angle));
float specular = pow(max(dot(normal, light), 0.0), 12.0) * rim;
refracted += vec3(0.09) * rim;
refracted += vec3(0.12 + u_exposure * 0.42) * specular;Coverage and optics must remain separate
The distance field answers whether a fragment belongs to the surface. The optical functions answer how that fragment samples and reflects light. Keeping those concerns separate makes the edge antialiasing independent from refraction strength.
This distinction also prevents a common artifact: using displacement as opacity. When the two are coupled, increasing refraction makes the object disappear and creates a dark halo at the boundary. The final composite should mix the untouched scene with the fully shaded glass using only the coverage mask.
refraction, depth, and dispersion may change sampled color, but only the distance field controls lens coverage.
The render lifecycle is part of the effect
Optical quality is limited by the backing buffer, not the CSS size of the canvas. The implementation observes layout, scales the buffer by device pixel ratio, and caps that ratio at two. A 3× phone would otherwise shade nine physical pixels for every CSS pixel.
The animation clock stops affecting the image when reduced motion is requested, and draw calls are skipped while the experiment is outside the viewport. These constraints do not change the shader model; they keep a small visual detail from becoming a permanent GPU workload.
const ratio = Math.min(devicePixelRatio, 2);
canvas.width = Math.round(bounds.width * ratio);
canvas.height = Math.round(bounds.height * ratio);
gl.viewport(0, 0, canvas.width, canvas.height);
const visibility = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
});
function frame(time) {
if (visible) {
uploadUniforms(time);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
requestAnimationFrame(frame);
}What usually breaks the illusion
- 01Sampling outside the texture
Use
CLAMP_TO_EDGEor explicitly clamp UVs. Repeated edge pixels expose the render boundary immediately. - 02Ignoring aspect ratio
Distance calculations in raw UV space stretch the lens whenever width and height differ.
- 03Uniform displacement
A constant offset translates the scene. Curvature needs a non-linear response that is quiet at the center and strong at the rim.
- 04Too much dispersion
Large RGB offsets look like a glitch. Keep them sub-pixel through most of the surface and reveal them near the boundary.
- 05Rendering without a fallback
Context creation and shader compilation can fail. The component keeps semantic content outside the canvas and displays a static optical surface when WebGL is unavailable.