WebGLFragment shaderInteractive study

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.

DRAG / REFRACTION
Glass
LightViewfinder
Drag inside the preview. Every control writes directly to a shader uniform; reset restores the reference surface.

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.

glsl
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);
Surface profileAnalytic unit hemisphere
z (d) = 1−d2
Domain0 ≤ d ≤ 1
Centerz(0) = 1
Boundaryz(1) = 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.

Fragment pipelineFrom scene texture to refracted output

The scene is sampled through an analytic surface and written into the final framebuffer.

01 · INPUT

Scene texture

sampler2D u_scene
02 · RECONSTRUCT

Surface field

normal.xy · depth z
03 · OUTPUT

Composite

gl_FragColor
The optical center travels through the surface field while refraction strength oscillates independently. Only uniforms change between frames; the source texture stays fixed.

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.

javascript
// 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.

glsl
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);
A refractive surface only becomes visible when the pixels underneath it move.
Move the pointer across the sentence. The lens magnifies the source, bends samples toward its optical center, and separates color channels near the rim.

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.

glsl
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.

Invariant

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.

javascript
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

  • 01
    Sampling outside the texture

    Use CLAMP_TO_EDGE or explicitly clamp UVs. Repeated edge pixels expose the render boundary immediately.

  • 02
    Ignoring aspect ratio

    Distance calculations in raw UV space stretch the lens whenever width and height differ.

  • 03
    Uniform displacement

    A constant offset translates the scene. Curvature needs a non-linear response that is quiet at the center and strong at the rim.

  • 04
    Too much dispersion

    Large RGB offsets look like a glitch. Keep them sub-pixel through most of the surface and reveal them near the boundary.

  • 05
    Rendering 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.