Recreating Apple's Liquid Glass

Real-Time Frosted Glass with Physically-Based Light Transport

glassrefractionsdfwebglcaustics

Apple's Liquid Glass design language makes UI elements feel like physical objects. The secret isn't just blur - it's the subtle interplay of refraction, specular highlights, and caustics that emerges from treating the panel as real curved glass. This article breaks down each technique, starting from the foundation: the squircle.

Ingredients

Three elements combine to create the Liquid Glass illusion: a smooth squircle shape with continuous curvature, beveled edges that create 3D geometry, and an optimized blur that simulates frosted glass. Each layer builds on the previous.

Initializing WebGL...
4
Squircle SDF - Adjust the Exponent

The Squircle: Apple's Signature Shape

Apple doesn't use rounded rectangles. They use squircles - superellipses defined by |x/a|ⁿ + |y/b|ⁿ = 1. When n=2, you get an ellipse. When n=4, you get that distinctively smooth Apple corner. As n approaches infinity, it becomes a sharp rectangle. The key difference: rounded rectangles have an abrupt curvature change where the arc meets the straight edge. Squircles have continuous curvature throughout.

Squircle Signed Distance Field glsl
// Squircle SDF: |x/a|^n + |y/b|^n = 1
// n=2: ellipse, n=4: squircle (Apple), n→∞: rectangle
float sdSquircle(vec2 p, vec2 size, float n) {
    vec2 d = abs(p) / size;
    float dist = pow(pow(d.x, n) + pow(d.y, n), 1.0 / n);
    return (dist - 1.0) * min(size.x, size.y);
}

// Gradient for surface normals
vec2 sdSquircleGrad(vec2 p, vec2 size, float n) {
    vec2 d = abs(p) / size;
    float sum = pow(d.x, n) + pow(d.y, n);
    float sumPow = pow(sum, 1.0/n - 1.0);
    vec2 grad = sign(p) * pow(d, vec2(n-1.0)) / size * sumPow;
    return normalize(grad);
}

Bevel: From Flat to 3D

A flat shape looks like a sticker. To make it feel like real glass, we need depth. The bevel creates a chamfered edge that catches light differently than the flat center. We use the SDF gradient to determine the edge direction, then compute lighting based on a virtual surface normal that tilts outward in the bevel zone.

Initializing WebGL...
40
1
Bevel Geometry - Adjust Width

The bevel width determines how far the chamfer extends inward from the edge. The SDF gives us the signed distance - negative values are inside the shape. We use smoothstep to create a smooth transition zone where the surface normal gradually tilts from facing outward (at the edge) to facing forward (in the flat center).

Bevel Geometry Function glsl
// Returns bevel geometry: XY = bevel direction, Z = bevel amount (0-1)
vec3 computeBevel(float sdf, vec2 grad, float bevelWidth) {
    // Bevel amount: 1 at edge, 0 at center
    float bevelAmount = 1.0 - smoothstep(-bevelWidth, 0.0, sdf);
    // Direction points outward (toward edge)
    return vec3(grad, bevelAmount);
}

// Usage:
vec3 bevel = computeBevel(d, grad, bevelWidth);
vec2 bevelDir = bevel.xy;    // Direction of the chamfer
float bevelAmount = bevel.z; // 0 = flat center, 1 = edge

// Construct surface normal from bevel
vec3 normal = normalize(vec3(bevelDir * bevelAmount, 1.0 - bevelAmount * 0.5));

Frosted Glass Blur

The frosted glass effect requires a fast, high-quality blur. We downsample the scene to half resolution first - this doubles the effective blur radius for free. Then we apply separable Gaussian passes: horizontal first, then vertical.

Bilinear Sampling Optimization

By sampling between texel centers, the GPU's bilinear filtering blends two texels in one fetch - cutting 9 samples down to 5.

Enlighten Us

With the geometry in place, we can simulate how light interacts with the glass. The bevel acts as a lens - its curved surface bends light according to Snell's law. This section covers refraction, specular highlights, and the caustic patterns that emerge from cylindrical lens focusing.

Refraction & Chromatic Dispersion

When light crosses the glass-air boundary, it bends according to Snell's law. But here's the key: different wavelengths bend at different angles. Blue light has a higher effective IOR than red, so it refracts more. This chromatic dispersion creates those subtle rainbow fringes at the edges - the hallmark of quality glass rendering.

Initializing WebGL...
1
1.5
60
1
Refraction with Chromatic Dispersion

Crank up the dispersion to see the color separation. Notice how the fringes only appear at the edges where the bevel curves - the flat center has no dispersion because there's no refraction there. Real glass has subtle dispersion; diamond's famous 'fire' comes from its extreme chromatic dispersion.

Chromatic Dispersion glsl
// Each RGB channel samples at a different offset
// Blue bends more than red (higher effective IOR)
float dispersionAmount = uDispersion * 0.003 * bevel.edgeFactor;
vec2 dispersionDir = bevel.gradDir;

vec2 uvR = baseUV - dispersionDir * dispersionAmount; // Red bends least
vec2 uvG = baseUV;                                     // Green in middle
vec2 uvB = baseUV + dispersionDir * dispersionAmount;  // Blue bends most

vec3 color = vec3(
    texture2D(uBlurredScene, uvR).r,
    texture2D(uBlurredScene, uvG).g,
    texture2D(uBlurredScene, uvB).b
);

Specular & Fresnel Reflection

Glass both reflects and transmits light. The Fresnel equations describe the split: at normal incidence, glass reflects ~4%. At grazing angles, it becomes a near-perfect mirror. Meanwhile, specular highlights appear where the bevel's surface normal aligns with the light reflection direction. Together, these effects make the glass feel physically present.

Initializing WebGL...
0.5
128
1
60
Specular Highlights + Fresnel Reflection

Drag the sun angle to see how specular and reflection work together. The specular highlight is a focused glint from the light source, while Fresnel reflection shows the entire environment. At grazing angles (the bevel edges), both effects intensify - the glass becomes almost mirror-like. The specular sharpness controls highlight size; reflection intensity scales the environment contribution.

Specular + Fresnel Combined glsl
// Fresnel using Schlick approximation
float fresnelSchlick(float cosTheta, float F0) {
    return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}

// Blinn-Phong specular with energy normalization
vec3 halfVec = normalize(viewDir + lightDir);
float NdotH = max(dot(normal, halfVec), 0.0);
float F = fresnelSchlick(NdotH, 0.04);
float spec = pow(NdotH, shininess) * (shininess + 2.0) / 6.28;

// Fresnel reflection blend
float NdotV = max(dot(normal, viewDir), 0.0);
float fresnel = fresnelSchlick(NdotV, 0.04);
vec3 reflected = sampleEnvironment(reflect(-viewDir, normal));
vec3 glassColor = mix(refracted, reflected, fresnel);
glassColor += lightColor * F * spec; // Add specular on top

Caustics

Caustics are the bright bands of light that form when a curved surface focuses rays like a lens. Think of sunlight through a wine glass creating patterns on a tablecloth. When light passes through our glass panel from behind, it refracts at the back surface's bevel and focuses on our side. The cylindrical lens shape concentrates light perpendicular to the bevel axis.

Initializing WebGL...
1.5
1.5
60
Caustics - Cylindrical Lens Focusing

Watch the sun orbit around the glass. Caustics appear when light comes from behind - notice how the bright band traces the bevel contour as the sun passes through the back half of its orbit. Higher IOR creates stronger focusing - diamond (IOR 2.4) produces brilliant fire, while water (IOR 1.33) creates subtle pools.

Caustics from Cylindrical Lens glsl
vec3 computeCaustics(BevelGeometry bevel) {
    vec3 backNormal = vec3(0.0, 0.0, -1.0); // Back surface
    
    // Caustics appear when light comes from BEHIND (lightDir.z < 0)
    float backFacing = max(dot(lightDir, backNormal), 0.0);
    if (backFacing < 0.01) return vec3(0.0);
    
    // Back bevel is inverted - focus direction flips
    vec3 focusDir = vec3(-bevel.gradDir.x, -bevel.gradDir.y, 0.0);
    
    // Band shape: peaks within the bevel zone
    float bandShape = pow(edgeFactor * (1.0 - edgeFactor * 0.5), 0.625);
    
    // Focusing power from curvature squared × IOR factor
    float iorFactor = (uIOR - 1.0) / 0.5;
    float focusingPower = bevelAmount * bevelAmount * 2.0 * iorFactor;
    
    // Light alignment with focus direction
    float rawAlignment = dot(lightDir.xy, focusDir.xy);
    float focusAlignment = 0.3 + 0.7 * rawAlignment * rawAlignment;
    
    return lightColor * focusingPower * focusAlignment * backFacing * bandShape;
}

Absorption

Real glass isn't perfectly transparent. As light travels through, some wavelengths are absorbed more than others. This is Beer's Law: intensity decays exponentially with distance. Common soda-lime glass (window glass) has iron oxide impurities that absorb red and blue more than green - giving thick glass panels their characteristic green tint at the edges.

Initializing WebGL...
5
60
Beer's Law Absorption

Increase the thickness to see the green tint emerge. The center is thicker than the beveled edges, so it absorbs more - creating a gradient from green center to clearer edges. At 20cm, even the sky takes on that aquarium-glass look. Real architectural glass is typically 0.5-2cm.

Beer's Law Absorption glsl
vec3 computeAbsorption(BevelGeometry bevel, vec3 color) {
    // Beer&#39;s Law: I = I₀ × e^(-α × d)
    // α = absorption coefficient (per cm), d = path length(cm)
    
    // Soda-lime glass absorption coefficients (cm⁻¹)
    // Iron oxide impurities cause the green tint
    vec3 absorptionCoeff = vec3(0.02, 0.005, 0.015); // R, G, B
    
    // Path length: thicker in center, thinner at edges
    float pathLength = (1.0 - bevel.edgeFactor * 0.5) * uThickness;
    
    // Exponential falloff
    vec3 transmission = exp(-absorptionCoeff * pathLength);
    
    return color * transmission;
}

Putting It All Together

Real scenes have multiple light sources, each casting its own specular highlights, caustics, and coloring the environment. The final demo combines all effects with three independent lights. Each light has a direction (controlled by horizontal angle and elevation) and a color that tints both the sky and the glass effects.

Initializing WebGL...
50
1
1.5
0.5
128
0.8
1
2
4
Multi-Light Glass - Drag to Position Lights

The three lights create a classic three-point setup: warm sun as key light, cool blue fill for shadow detail, and orange rim for edge definition. Each light contributes independently to specular highlights, caustics, and environment coloring. Drag the position controls to orbit lights around the glass - notice how caustics only appear when a light is behind the panel.

Multi-Light Accumulation glsl
// Convert spherical coordinates to 3D direction
vec3 getLightDirFromAngles(float angle, float elevation) {
    float cosElev = cos(elevation);
    return normalize(vec3(
        sin(angle) * cosElev,  // X: horizontal position
        sin(elevation),         // Y: vertical position
        cos(angle) * cosElev    // Z: depth (front/back)
    ));
}

// Accumulate specular from all lights
vec3 computeSpecularHighlights(BevelGeometry bevel) {
    vec3 specular = vec3(0.0);
    
    // Each light contributes independently
    specular += computeSpecularFromLight(normal, viewDir,
        light0.direction, light0.color, light0.intensity);
    specular += computeSpecularFromLight(normal, viewDir,
        light1.direction, light1.color, light1.intensity);
    specular += computeSpecularFromLight(normal, viewDir,
        light2.direction, light2.color, light2.intensity);
    
    return specular;
}

// Environment also samples all lights for sky coloring
vec3 sampleEnvironmentMultiLight(vec3 dir) {
    float influence0 = computeLightInfluence(dir, light0.direction);
    float influence1 = computeLightInfluence(dir, light1.direction);
    float influence2 = computeLightInfluence(dir, light2.direction);
    
    vec3 combinedColor = light0.color * influence0 * light0.intensity
                       + light1.color * influence1 * light1.intensity
                       + light2.color * influence2 * light2.intensity;
    // ... apply to sky gradient and clouds
}

Want to see more?

Check out my interactive portfolio with live shader demos