back home

blog

a pane in the glass — part 3: webgl, the real thing

seriesa pane in the glasssnell's law to webgl, one pane at a time
  1. ○part 0the setup
  2. ○part 1why light slows down
  3. ○part 2apple's beautiful lie
  4. →part 3webgl: the real thing

We've covered the physics. We've seen what Apple fakes and why. Now we build the real thing: a WebGL shader that implements Snell's law, the Fresnel equations, and dispersion per fragment, in real time.

Every line of shader code below maps to a concept from Part 1. By the end, the slider labels in the demo won't be opaque parameter names — they'll be physical quantities you understand.

The architecture

The setup is a full-screen quad — two triangles covering the viewport — with a single fragment shader running on every pixel. The background scene is rendered to a 2D canvas each frame and uploaded as a WebGL texture. The shader reads from that texture, but not at the fragment's own UV coordinate. It computes where a refracted ray would land in the background, and samples there instead. That offset sample is the refraction.

Three passes, slightly different parameters each time: one for red, one for green, one for blue. The offsets differ because the refractive index differs per wavelength (dispersion). The results are composited into a single color.

Step 1: the lens shape and surface normal

We need to know, at every fragment, which direction the glass surface is pointing — its normal vector. For a flat sheet of glass the normal always points straight at the viewer, which means zero refraction (Snell's law gives zero deviation at normal incidence). To get a visible effect we need a curved surface — a lens.

We define the lens shape with a signed distance function and compute the normal analytically from the sphere-cap geometry. At the optical centre the surface faces you directly; at the rim it tilts outward — exactly where Snell's law produces maximum angular deviation.

// Signed distance function for a rounded rectangle
// Returns negative inside the shape, positive outside
float sdRoundBox(vec2 p, vec2 b, float r) {
vec2 q = abs(p) - b + r;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
// Surface normal from sphere-cap geometry
// At the rim: normal tilts outward → large Snell deviation
// At the centre: normal faces viewer → zero deviation
vec3 lensNormal(vec2 uv, vec2 center, vec2 halfSize, float thick) {
vec2 local = uv - center;
vec2 n2d = local / halfSize; // normalise to [-1,1]
float r = length(n2d);
// z-component: 1 at centre (flat face), 0 at rim (fully tilted)
float z = sqrt(max(0.0, 1.0 - r * r * thick * thick));
return normalize(vec3(n2d * thick, z));
}

Step 2: Snell's law in one GLSL call

GLSL has a built-in refract(I, N, eta) that implements Snell's law exactly. I is the incident ray direction (straight at the screen: vec3(0, 0, -1)), N is the surface normal we just computed, and eta is n₁ / n₂ — the ratio of refractive indices. With air outside (n₁=1) and glass inside, eta = 1.0 / n.

The refracted ray's XY components tell us how much to offset the background sample — the larger the tilt of the surface normal, the larger the offset, exactly as the Snell's law demo in Part 1 showed.

vec2 refractedUV(vec2 uv, vec3 normal, float eta, float thick) {
vec3 incident = vec3(0.0, 0.0, -1.0); // viewer looking at screen
// This line IS Snell's law: n₁ sinθ₁ = n₂ sinθ₂
vec3 refracted = refract(incident, normal, eta);
// XY deviation → UV offset
// thin-lens approximation: we don't raytrace the exit surface
vec2 offset = refracted.xy * thick * 0.5;
return uv + offset;
}
// For each channel, eta differs slightly (dispersion)
float eta_r = 1.0 / (u_n - u_disp); // red bends least
float eta_g = 1.0 / u_n; // green: base index
float eta_b = 1.0 / (u_n + u_disp); // blue bends most
vec2 uv_r = refractedUV(uv, N, eta_r, u_thick);
vec2 uv_g = refractedUV(uv, N, eta_g, u_thick);
vec2 uv_b = refractedUV(uv, N, eta_b, u_thick);
The thin-lens approximation skips tracing the refracted ray to the back surface of the glass and computing the exit refraction there. A full thick-lens model would do two Snell calculations per channel — entry and exit. For a UI element the difference is visually negligible, and single-surface saves three more texture lookups per fragment per channel.

Step 3: Fresnel reflectance

The Fresnel equations give us the fraction of light reflected vs. transmitted at the surface. We use the Schlick approximation — accurate to within a few percent, and trivially cheap to compute. cosθ is just the z-component of the surface normal, since the viewer is looking straight at the screen. This is exactly the curve from the Fresnel demo in Part 1: low at the centre (normal incidence, ~4%), rising steeply toward the rim (grazing incidence, approaching 100%).

// Schlick approximation of the Fresnel equations
// R₀ = ((n₁ - n₂) / (n₁ + n₂))² = reflectance at normal incidence
// R(θ) ≈ R₀ + (1 - R₀)(1 - cosθ)⁵
float fresnelSchlick(float cosTheta, float n) {
float r0 = (1.0 - n) / (1.0 + n);
r0 = r0 * r0; // ~0.04 for n=1.5 (standard glass)
return r0 + (1.0 - r0) * pow(1.0 - cosTheta, 5.0);
}
// cosθ = N.z because the view direction is (0,0,-1)
float cosTheta = N.z;
float F = fresnelSchlick(cosTheta, u_n) * u_fresnel;
// Rim light: pow(1-cosθ, 3) peaks sharply at grazing angles
// This is what makes the button edge bright — same physics as
// why a pool looks like a mirror when viewed at a shallow angle
float rimLight = pow(1.0 - cosTheta, 3.0) * 0.9;

Step 4: compositing it together

Sample the background texture at each refracted UV, extract one channel from each sample, combine them into an RGB color. Then blend in the Fresnel specular reflection — brighter toward the rim, near-zero at the centre.

// One channel from each refracted sample
float scene_r = texture2D(u_scene, uv_r).r;
float scene_g = texture2D(u_scene, uv_g).g;
float scene_b = texture2D(u_scene, uv_b).b;
vec3 refracted_color = vec3(scene_r, scene_g, scene_b);
// Specular: directional light + Fresnel rim
vec3 lightDir = normalize(vec3(-0.4, 0.8, 1.0));
float specular = pow(max(dot(N, lightDir), 0.0), 32.0) * 0.7;
vec3 specColor = vec3(1.0) * (rimLight + specular);
// Fresnel blend: at the rim, show reflection instead of refraction
// At the centre, show refraction (F is near zero there)
vec3 color = mix(refracted_color, specColor, F) + specColor * 0.15;
gl_FragColor = vec4(color, mask); // mask = 0 outside the lens

What this still doesn't do

Multi-bounce refraction. A real thick lens refracts at entry, propagates through the glass, and refracts again at exit. We compute only the entry surface. For a UI element this is fine — the exit deviation is small and in the opposite direction, partially cancelling the entry deviation. For a thick glass block or a sphere you'd need both surfaces.

Internal reflections. Light that hits the back surface past the critical angle bounces internally. Real glass jewellery owes much of its character to this. Implementing it requires at least one more render pass.

Caustics. The bright patterns a glass object casts on surfaces behind it are a consequence of refraction concentrating light into certain regions. These require path tracing or dedicated caustic algorithms — not feasible in a real-time UI shader.

For a glass button or panel in a UI, none of that matters. The single-surface thin-lens model with Fresnel and dispersion is already more physically correct than anything shipped in a production UI framework. The slider labelled "n" in the demo above is a real refractive index. Set it to 1.33 and you get water. Set it to 2.42 and you get diamond. The physics is real; only the geometry is simplified.