/*
* 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);
},
};
}