Name a site.

Material

Index

1.52

Steepest bend

41.1°

Light slows to

197,232 km/s

Drag it. Almost nothing happens in the middle, because the surface there is parallel to the page, and a ray crossing two parallel faces comes out going the way it went in. Everything you can see is the rim, which is the only part of the slab that is bent.

Real dispersion is smaller than this. Crown glass splits red from blue by about 0.01 of an index and diamond by 0.02, which is a fringe of a pixel or two at this size. The slider goes to 0.14 because the effect everyone actually wants to see is a lie, and it is worth knowing which part of it is the lie.

Glass

A slab of real glass, dragged across any website.

Every piece of glass on the web is a blur with a gradient on it. Glass does not blur. Glass slows light down, and a ray crossing into something slower bends, by an amount that depends on the angle it arrived at, and on the colour it happens to be.

Everything people paint on by hand falls out of that one law. The middle of a slab is nearly clear, because a ray crossing two parallel faces leaves going the way it came in. The rim is where all of it happens, because the rim is the only part of the surface that is tilted. The colour fringe is not a filter, it is the fact that a material's index is a function of wavelength, so blue comes out somewhere red does not. A thicker slab moves the page further, because the distance the bent ray travels before it lands is longer.

There is no three.js here, and there is no mesh either. The whole scene is one triangle covering the canvas, and the shape of the glass is a signed distance function with a height field over it, so the slab is arithmetic rather than geometry, and its rim is exact at any zoom.

The dispersion slider goes far past anything real. Crown glass separates red from blue by about a hundredth of an index, which at this size is a fringe you would have to be told about. The look everybody wants is roughly ten times the truth, and it seems worth knowing which part of it is the lie.

/*
 * A slab of glass, drawn by bending light through it.
 *
 * Every "liquid glass" on the web is a backdrop blur with a white gradient on
 * top. That is not what glass does. Glass does exactly one thing: it slows
 * light down, so a ray crossing into it bends by an amount that depends on the
 * angle it arrives at and on the wavelength it happens to be. Everything people
 * try to fake • the shove at the rim, the colour fringe, the way the middle is
 * almost untouched while the edge is a mess • falls out of that one law for
 * free, and none of it can be reached by blurring.
 *
 * So this is Snell's law and nothing else. There is no three.js and there is
 * not even a mesh: the whole scene is one triangle covering the canvas, and the
 * shape of the glass lives in a distance function rather than in vertices.
 *
 * The model, in full:
 *
 *   1. A rounded rectangle, as a signed distance in screen pixels.
 *   2. A height field over it • flat across the middle, rolling off to nothing
 *      at the edge along a quarter circle. That roll-off is the bevel, and it
 *      is where all of the interesting refraction happens, because it is the
 *      only part of the surface that is not parallel to the page.
 *   3. The surface normal, as the gradient of that height.
 *   4. A refraction per colour channel, at three slightly different indices.
 *   5. The exit point, which for a slab with a flat bottom sitting on the page
 *      is the refracted direction carried across the local thickness.
 *
 * Step five is the reason this reads as a solid object rather than as a filter.
 * The page is against the bottom face, so where a ray lands is a real distance,
 * and a thicker slab moves the page further • which is the thing your eye
 * actually uses to judge how heavy a piece of glass is.
 *
 * One rule for anything written into the two shader strings below: no
 * backticks, not even inside a GLSL comment. They are template literals, and a
 * backtick in a comment ends the shader half way through.
 */

const VERTEX = `#version 300 es
void main() {
  /*
   * One triangle, no buffers. Two of the three corners are off screen, which
   * covers the canvas with a single primitive and, unlike two triangles, has no
   * seam down the diagonal where the interpolators meet.
   */
  vec2 corner = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
  gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0);
}`;

const FRAGMENT = `#version 300 es
precision highp float;

uniform vec2 u_res;
uniform sampler2D u_tex;

/* All in device pixels, y down, which is the frame the pointer arrives in. */
uniform vec2 u_centre;
uniform vec2 u_half;
uniform float u_radius;
uniform float u_thick;
uniform float u_bevel;

uniform float u_ior;
uniform float u_disp;
uniform float u_frost;
uniform vec2 u_light;

out vec4 outColor;

/* The rounded box, which is the shape of every panel on the web. */
float sdBox(vec2 p, vec2 b, float r) {
  vec2 q = abs(p) - b + r;
  return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
}

/*
 * The surface, as a height above the page.
 *
 * A quarter circle rather than a smoothstep. Both are flat in the middle and
 * zero at the rim, but the circle arrives at the rim vertically, so the surface
 * there stands nearly perpendicular to the page and bends light as hard as the
 * material can. A smoothstep eases into the edge instead, and gives the soft
 * plastic rim that reads as a blur no matter how much index is put behind it.
 */
float height(vec2 p) {
  float inside = -sdBox(p - u_centre, u_half, u_radius);
  float t = clamp(inside / u_bevel, 0.0, 1.0);
  float k = 1.0 - t;
  return u_thick * sqrt(max(0.0, 1.0 - k * k));
}

/*
 * The normal, by central difference.
 *
 * The analytic gradient exists • the distance function is differentiable almost
 * everywhere • but it is several terms of chain rule that would have to be
 * rederived every time the profile changes, to save two texture-free
 * evaluations of a function a dozen instructions long. This is the cheaper
 * thing to own.
 */
vec3 normalAt(vec2 p) {
  float e = 1.0;
  float dx = height(p + vec2(e, 0.0)) - height(p - vec2(e, 0.0));
  float dy = height(p + vec2(0.0, e)) - height(p - vec2(0.0, e));
  return normalize(vec3(-dx / (2.0 * e), -dy / (2.0 * e), 1.0));
}

/*
 * Where a ray that entered at p comes out on the page.
 *
 * The eye is at +z looking into the screen, so the incident direction is
 * straight back along it, and the ratio handed to refract is air over glass.
 * The guard is for the degenerate end of the index slider rather than for total
 * internal reflection, which cannot happen on the way *into* a denser material:
 * at an index of exactly 1 there is no glass, and the ray should pass straight
 * through rather than divide by a vanishing z.
 */
vec2 exitPoint(vec2 p, vec3 n, float h, float ior) {
  vec3 r = refract(vec3(0.0, 0.0, -1.0), n, 1.0 / ior);
  if (r.z >= -0.001) return p;
  return p + r.xy * (h / -r.z);
}

/*
 * Frost, as roughness at the surface rather than as a blur of the result.
 *
 * A blur applied afterwards smears the refraction, which is backwards: a
 * roughened surface scatters rays *before* they travel, so the further the page
 * is from the glass the more it dissolves. Spreading the taps by an amount that
 * grows with the local thickness is the cheap version of that, and it keeps the
 * rim crisp where the glass is thin.
 */
vec3 blurred(vec2 p, float spread) {
  vec2 uv = p / u_res;
  if (spread < 0.35) return texture(u_tex, uv).rgb;

  /* Six taps around a ring plus the centre. Enough to lose the text, few enough
     to stay inside a frame at full canvas size, and unrolled because a loop
     bounded by a uniform is a loop the compiler cannot flatten. */
  vec3 sum = texture(u_tex, uv).rgb;
  sum += texture(u_tex, uv + vec2(1.0, 0.0) * spread / u_res).rgb;
  sum += texture(u_tex, uv + vec2(0.5, 0.87) * spread / u_res).rgb;
  sum += texture(u_tex, uv + vec2(-0.5, 0.87) * spread / u_res).rgb;
  sum += texture(u_tex, uv + vec2(-1.0, 0.0) * spread / u_res).rgb;
  sum += texture(u_tex, uv + vec2(-0.5, -0.87) * spread / u_res).rgb;
  sum += texture(u_tex, uv + vec2(0.5, -0.87) * spread / u_res).rgb;
  return sum / 7.0;
}

void main() {
  /* gl_FragCoord counts up from the bottom. Everything else here counts down
     from the top, because that is where pointers and layout live. */
  vec2 p = vec2(gl_FragCoord.x, u_res.y - gl_FragCoord.y);

  vec3 page = texture(u_tex, p / u_res).rgb;
  float sd = sdBox(p - u_centre, u_half, u_radius);

  /*
   * The shadow, offset away from the light. Cast by the slab outline rather
   * than by anything traced • an occluder this simple over a flat page has an
   * analytic shadow, and a soft step across thirty pixels is indistinguishable
   * from one.
   */
  float shadowSd = sdBox(p - u_centre + u_light * 14.0, u_half, u_radius);
  vec3 outside = page * mix(0.72, 1.0, smoothstep(0.0, 34.0, shadowSd));

  /* One pixel of feather across the boundary. The distance function is exact,
     so this is a real antialiased edge, not a fringe of half-lit texels.
     Written the long way round because smoothstep is only defined for an
     increasing pair of edges • reversing them works everywhere and is undefined
     in the spec, which is the kind of thing that holds until one driver. */
  float mask = 1.0 - smoothstep(-1.0, 1.0, sd);
  if (mask <= 0.001) {
    outColor = vec4(outside, 1.0);
    return;
  }

  float h = height(p);
  vec3 n = normalAt(p);
  float spread = u_frost * h * 0.35;

  /*
   * Three indices, one per channel. Glass disperses because its index is a
   * function of wavelength: blue is slowed most and bends furthest, which is
   * the entire mechanism of a prism and the reason a real lens fringes towards
   * cyan on one side and orange on the other. Faking it by sliding the finished
   * image sideways gets the colours and misses the fact that the fringe is
   * widest exactly where the surface is steepest.
   */
  float r = blurred(exitPoint(p, n, h, u_ior - u_disp), spread).r;
  float g = blurred(exitPoint(p, n, h, u_ior), spread).g;
  float b = blurred(exitPoint(p, n, h, u_ior + u_disp), spread).b;
  vec3 glass = vec3(r, g, b);

  /* Fresnel: a surface turned away from you reflects more than it transmits.
     At the rim that is most of the light, which is why the edge of real glass
     is bright even against something dark. */
  float fresnel = pow(1.0 - clamp(n.z, 0.0, 1.0), 3.0);
  glass = mix(glass, vec3(1.0), fresnel * 0.10);

  /* One specular highlight, from the direction the shadow falls away from. The
     two agreeing is most of what makes this read as an object sitting on the
     page rather than a hole cut in it. */
  vec3 light = normalize(vec3(u_light, 0.8));
  vec3 halfway = normalize(light + vec3(0.0, 0.0, 1.0));
  glass += pow(max(dot(n, halfway), 0.0), 140.0) * 0.85;

  outColor = vec4(mix(outside, glass, mask), 1.0);
}`;

export type Slab = {
  /** Centre, in CSS pixels of the canvas, y down. */
  x: number;
  y: number;
  width: number;
  height: number;
  radius: number;
  /** How deep the glass is at its flat middle, in CSS pixels. */
  thickness: number;
  /** How far in from the rim the surface takes to flatten out. */
  bevel: number;
  ior: number;
  dispersion: number;
  frost: number;
};

export type Glass = {
  setTexture(source: TexImageSource): void;
  resize(width: number, height: number, dpr: number): void;
  draw(slab: Slab): void;
  destroy(): void;
};

/* Up and to the left, which is where light comes from in every interface ever
   drawn. y is negative because y counts down here. */
const LIGHT: [number, number] = [-0.52, -0.48];

function compile(gl: WebGL2RenderingContext, type: number, source: string) {
  const shader = gl.createShader(type);
  if (!shader) throw new Error("Could not create a shader.");
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    const log = gl.getShaderInfoLog(shader);
    gl.deleteShader(shader);
    throw new Error(log ?? "The shader did not compile.");
  }
  return shader;
}

export function createGlass(canvas: HTMLCanvasElement): Glass | null {
  const gl = canvas.getContext("webgl2", {
    alpha: false,
    antialias: false,
    preserveDrawingBuffer: false,
  });
  if (!gl) return null;

  const program = gl.createProgram();
  const vs = compile(gl, gl.VERTEX_SHADER, VERTEX);
  const fs = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);
  gl.attachShader(program, vs);
  gl.attachShader(program, fs);
  gl.linkProgram(program);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    throw new Error(
      gl.getProgramInfoLog(program) ?? "The program did not link.",
    );
  }
  gl.deleteShader(vs);
  gl.deleteShader(fs);

  /*
   * A vertex array is still required even with nothing in it. WebGL2 refuses to
   * draw from the default one, and the vertex shader reads gl_VertexID rather
   * than any buffer, so this exists purely to be bound.
   */
  const vao = gl.createVertexArray();

  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, texture);
  /* CLAMP_TO_EDGE, because refraction at the rim routinely asks for a pixel
     just off the side of the page. Repeating would wrap the far edge of the
     screenshot into the near one, which looks like a bug and is one. */
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);

  const uniform = (name: string) => gl.getUniformLocation(program, name);
  const uRes = uniform("u_res");
  const uTex = uniform("u_tex");
  const uCentre = uniform("u_centre");
  const uHalf = uniform("u_half");
  const uRadius = uniform("u_radius");
  const uThick = uniform("u_thick");
  const uBevel = uniform("u_bevel");
  const uIor = uniform("u_ior");
  const uDisp = uniform("u_disp");
  const uFrost = uniform("u_frost");
  const uLight = uniform("u_light");

  // Aliased for the same reason the teardown's renderer aliases it: the React
  // hooks lint rule matches on a name beginning "use" alone, in a file that has
  // no React in it at all.
  const activateProgram = gl.useProgram.bind(gl);

  let scale = 1;
  let hasTexture = false;

  return {
    setTexture(source) {
      gl.bindTexture(gl.TEXTURE_2D, texture);
      gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
      gl.texImage2D(
        gl.TEXTURE_2D,
        0,
        gl.RGBA,
        gl.RGBA,
        gl.UNSIGNED_BYTE,
        source,
      );
      hasTexture = true;
    },

    resize(width, height, dpr) {
      scale = dpr;
      canvas.width = Math.max(1, Math.round(width * dpr));
      canvas.height = Math.max(1, Math.round(height * dpr));
      gl.viewport(0, 0, canvas.width, canvas.height);
    },

    draw(slab) {
      gl.clearColor(0, 0, 0, 1);
      gl.clear(gl.COLOR_BUFFER_BIT);
      if (!hasTexture) return;

      activateProgram(program);
      gl.bindVertexArray(vao);

      gl.uniform2f(uRes, canvas.width, canvas.height);
      gl.uniform2f(uCentre, slab.x * scale, slab.y * scale);
      gl.uniform2f(uHalf, (slab.width / 2) * scale, (slab.height / 2) * scale);
      gl.uniform1f(uRadius, slab.radius * scale);
      gl.uniform1f(uThick, slab.thickness * scale);
      /* Never zero: the bevel is a divisor, and a slab with no roll-off is a
         pane lying flat on the page, which refracts nothing at all. */
      gl.uniform1f(uBevel, Math.max(1, slab.bevel * scale));
      gl.uniform1f(uIor, slab.ior);
      gl.uniform1f(uDisp, slab.dispersion);
      gl.uniform1f(uFrost, slab.frost);
      gl.uniform2f(uLight, LIGHT[0], LIGHT[1]);

      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, texture);
      gl.uniform1i(uTex, 0);

      gl.drawArrays(gl.TRIANGLES, 0, 3);
      gl.bindVertexArray(null);
    },

    destroy() {
      gl.deleteVertexArray(vao);
      gl.deleteTexture(texture);
      gl.deleteProgram(program);
    },
  };
}