
Free React pricing table: three plans with features and a button each, a monthly/yearly switch, and glass price blocks lit by one shaft of sun in WebGL.
components/we-inc/photoreal-pricing-table-with-monthly-yearly-switch-and-three-glass-blocks-of-different-heights-in-one-shaft-of-window-light.tsx
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "framer-motion";
/** The three plans. Both prices are per month: `yearly` is the monthly rate when paid once a year. Example prices. */
const plans = [
{
name: "Starter",
monthly: 9,
yearly: 7,
pitch: "For one person shipping their first forms.",
features: ["3 live forms", "1,000 responses a month", "Email alerts on every reply", "Export to CSV"],
cta: "Start with Starter",
height: 8, // height of the glass block, in centimetres
},
{
name: "Team",
monthly: 24,
yearly: 19,
pitch: "Shared forms and answers for a small team.",
features: ["Unlimited forms", "10,000 responses a month", "5 seats with roles", "Logic, branching and file uploads", "Remove the footer badge"],
cta: "Start with Team",
height: 12,
},
{
name: "Scale",
monthly: 59,
yearly: 47,
pitch: "Volume, control and a person to call.",
features: ["Everything in Team", "100,000 responses a month", "25 seats and single sign-on", "Audit log and data residency", "Priority support"],
cta: "Talk to us",
height: 17,
},
];
const PITCH = 10.5; // centimetres between block centres
const BW = 3.4; // half the width of a block (cm)
const BD = 2.4; // half its depth (cm)
const POOL_SPRING = 70; // pull of the pool of sun toward the chosen block
const POOL_DAMPING = 13; // lower lets the light overshoot
const DEMO = true; // walks through yearly and the plans by itself until somebody touches it
const KEY: [number, number, number] = [-0.42, 0.5, -0.76]; // toward the window: behind and to the left
const GROTESK = '"Inter Tight", "Inter", ui-sans-serif, system-ui, sans-serif';
/* ---- photoreal kit (pasted, see PHOTOREAL-KIT.md) ---- */
const FULLSCREEN_VS = `#version 300 es
out vec2 vUv;
void main() { vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); vUv = p; gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); }`;
const GLSL_HEADER = `#version 300 es
precision highp float;
uniform vec2 uRes; // drawing buffer size in pixels
uniform float uFrame; // sample index since the last reset (drives every random number)
uniform float uTime; // seconds, for things that really move
uniform vec3 uCamPos, uCamRt, uCamUp, uCamFw;
uniform float uTan, uAspect, uAperture, uFocus;
uniform vec2 uShift; // lens shift (architectural framing): move the subject in frame without tilting verticals
out vec4 o;`;
const GLSL_COMMON = `
#define PI 3.14159265359
float saturate(float x) { return clamp(x, 0.0, 1.0); }
float hash12(vec2 p) { vec3 p3 = fract(vec3(p.xyx) * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); }
float hash13(vec3 p3) { p3 = fract(p3 * 0.1031); p3 += dot(p3, p3.zyx + 31.32); return fract((p3.x + p3.y) * p3.z); }
float ign(vec2 p) { return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); }
float vnoise(vec2 p) {
vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash12(i), hash12(i + vec2(1, 0)), f.x), mix(hash12(i + vec2(0, 1)), hash12(i + vec2(1, 1)), f.x), f.y);
}
float vnoise3(vec3 p) {
vec3 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
return mix(mix(mix(hash13(i), hash13(i + vec3(1, 0, 0)), f.x), mix(hash13(i + vec3(0, 1, 0)), hash13(i + vec3(1, 1, 0)), f.x), f.y),
mix(mix(hash13(i + vec3(0, 0, 1)), hash13(i + vec3(1, 0, 1)), f.x), mix(hash13(i + vec3(0, 1, 1)), hash13(i + vec3(1, 1, 1)), f.x), f.y), f.z);
}
float fbm(vec2 p) { float a = 0.5, s = 0.0; for (int i = 0; i < 5; i++) { s += a * vnoise(p); p = mat2(1.6, 1.2, -1.2, 1.6) * p + 3.1; a *= 0.5; } return s; }
float fbm3(vec3 p) { float a = 0.5, s = 0.0; for (int i = 0; i < 4; i++) { s += a * vnoise3(p); p = p * 2.03 + vec3(1.7, 9.2, 3.1); a *= 0.5; } return s; }
// Clamp one sample before it is averaged: a single lucky glossy ray hitting the window would otherwise leave
// a bright speck ("firefly") that takes hundreds of frames to fade. Call on the final colour in main().
vec3 clampSample(vec3 c, float m) { float l = max(c.r, max(c.g, c.b)); return l > m ? c * (m / l) : c; }
// A new well-spread 2D random point per pixel AND per frame: the R2 low-discrepancy sequence over frames,
// rotated by a per-pixel white-noise offset (structured offsets such as ign() show diagonal hatching while moving).
// Use a different salt for every decision (pixel jitter, lens, sun disc, gloss...). Accumulation averages them.
vec2 rand2(float salt) {
vec2 f = gl_FragCoord.xy + salt * 17.13;
return fract(vec2(hash12(f), hash12(f + 71.7)) + uFrame * vec2(0.7548776662, 0.5698402910));
}`;
const GLSL_SAMPLING = `
mat3 basisOf(vec3 n) { vec3 t = abs(n.y) < 0.999 ? normalize(cross(vec3(0.0, 1.0, 0.0), n)) : vec3(1.0, 0.0, 0.0); return mat3(t, cross(n, t), n); }
vec2 diskSample(vec2 u) { float r = sqrt(u.x), a = 6.2831853 * u.y; return r * vec2(cos(a), sin(a)); }
vec3 cosineSample(vec3 n, vec2 u) { vec2 d = diskSample(u); return basisOf(n) * vec3(d, sqrt(max(0.0, 1.0 - dot(d, d)))); }
// a microfacet normal from the GGX distribution: reflect about it for a rough reflection, refract through it for frosted glass
vec3 ggxSample(vec3 n, float rough, vec2 u) {
float a = rough * rough;
float ct = sqrt((1.0 - u.x) / (1.0 + (a * a - 1.0) * u.x)), st = sqrt(max(0.0, 1.0 - ct * ct)), ph = 6.2831853 * u.y;
return normalize(basisOf(n) * vec3(st * cos(ph), st * sin(ph), ct));
}`;
const GLSL_CAMERA = `
// Thin-lens camera. Sub-pixel jitter = supersampling; a random point on the lens = true depth of field.
// uAperture is the lens radius in scene units (0 = pinhole). uFocus is the distance along the view axis that is sharp.
void cameraRay(out vec3 ro, out vec3 rd) {
vec2 px = rand2(1.0) - 0.5;
vec2 ndc = (gl_FragCoord.xy + px) / uRes * 2.0 - 1.0 - uShift;
vec3 dir = normalize(uCamFw + uCamRt * (ndc.x * uTan * uAspect) + uCamUp * (ndc.y * uTan));
vec3 fp = uCamPos + dir * (uFocus / dot(dir, uCamFw));
vec2 lens = diskSample(rand2(2.0)) * uAperture;
ro = uCamPos + uCamRt * lens.x + uCamUp * lens.y;
rd = normalize(fp - ro);
}`;
// the light rig, edited for this scene: a tall narrow window behind the blocks; the pool of sun follows the chosen plan
const GLSL_RIG_SETTINGS = `
uniform vec2 uWin;
const vec3 KEY_DIR = normalize(vec3(${KEY.join(", ")}));
const vec3 KEY_COL = vec3(1.0, 0.84, 0.65) * 3.8;
const float KEY_SPREAD = 0.006;
const float WIN_DIST = 160.0;
const vec2 WIN_HALF = vec2(6.0, 40.0);
#define WIN_OFF uWin
const float BAR = 0.4;
const float BAR_Y = -9.0;
const vec3 SKY_COL = vec3(0.55, 0.64, 0.8) * 0.14;
const vec3 FILL_DIR = normalize(vec3(0.3, 0.42, 1.0));
const vec3 FILL_COL = vec3(1.0, 0.95, 0.88) * 0.1;
const vec3 CEIL_COL = vec3(0.95, 0.9, 0.84) * 0.45;`;
const GLSL_LIGHT_RIG = `
// the window opening with one vertical bar and one transom, in window-plane units
float paneMask(vec2 q, float pen) {
vec2 e = WIN_HALF - abs(q);
float open = smoothstep(-pen, pen, e.x) * smoothstep(-pen, pen, e.y);
float bars = smoothstep(-pen, pen, abs(q.x) - BAR) * smoothstep(-pen, pen, abs(q.y - BAR_Y) - BAR);
return open * bars;
}
// The gobo: how much of the window a point sees along KEY_DIR. This is what makes light look like it came
// from a real room instead of a CG directional light: a pool with straight edges and bar shadows.
float keyGobo(vec3 p) {
mat3 B = basisOf(KEY_DIR);
vec2 q = vec2(dot(p, B[0]), dot(p, B[1])) - WIN_OFF;
float d = max(WIN_DIST - dot(p, KEY_DIR), 1.0);
return paneMask(q, 0.25 + d * KEY_SPREAD);
}
// What a mirror sees. The window appears as a bright shape with bars: that shape in every reflection is the
// single strongest "this is a photograph" cue. Everything else is dark so reflections have contrast.
// envStudioRough: the same room seen in a ROUGH surface. Instead of firing random glossy rays at a small, very
// bright window (the source of sand-like speckle and sparkling metal), the window and card are blurred
// analytically by the roughness. Deterministic: zero noise, converges in one frame.
vec3 envStudioRough(vec3 rd, float rough) {
float a = rough * rough; // GGX alpha: roughly the angular blur in radians
vec3 c = mix(vec3(0.028, 0.025, 0.022), CEIL_COL, smoothstep(0.1 - a, 0.95 + a, rd.y));
c = mix(c, vec3(0.09, 0.075, 0.06), smoothstep(0.0, -0.5 - a, rd.y));
float k = dot(rd, KEY_DIR);
if (k > 0.0) {
mat3 B = basisOf(KEY_DIR);
vec2 q = vec2(dot(rd, B[0]), dot(rd, B[1])) / k * WIN_DIST - WIN_OFF;
float pen = 1.2 + a * WIN_DIST;
// a blurred window spreads the same light over a larger area: keep its energy, so the peak drops
float spread = (WIN_HALF.x * WIN_HALF.y) / ((WIN_HALF.x + pen) * (WIN_HALF.y + pen));
c += vec3(1.0, 0.96, 0.9) * 26.0 * paneMask(q, pen) * mix(1.0, spread, saturate(a * 4.0));
}
float f = dot(rd, FILL_DIR);
if (f > 0.0) {
mat3 B = basisOf(FILL_DIR);
vec2 e = vec2(0.6, 0.36) - abs(vec2(dot(rd, B[0]), dot(rd, B[1])) / f);
float w = 0.18 + a;
c += FILL_COL * 7.0 * smoothstep(-w, w, e.x) * smoothstep(-w, w, e.y);
}
return c;
}
vec3 envStudio(vec3 rd) { return envStudioRough(rd, 0.0); }`;
const GLSL_MATERIALS = `
// q = position in the object's own frame, in CENTIMETRES (scale noise accordingly if your units differ).
// n = geometric normal. Every material returns a perturbed normal: real surfaces are never perfect.
struct Mat { vec3 albedo; float rough; float metal; float coat; vec3 n; };
Mat mk(vec3 a, float r, float m, float c, vec3 n) { Mat x; x.albedo = a; x.rough = r; x.metal = m; x.coat = c; x.n = n; return x; }
// Metals: these are reflectance (F0) colours in linear light, not paint colours.
const vec3 BRASS = vec3(0.88, 0.69, 0.37);
const vec3 GOLD = vec3(1.0, 0.76, 0.33);
const vec3 STEEL = vec3(0.56, 0.56, 0.54);
const vec3 ALUMINIUM = vec3(0.91, 0.92, 0.92);
const vec3 COPPER = vec3(0.95, 0.62, 0.50);
// Brushed: fine parallel scratches along axis. Tilting the normal ACROSS the brushing stretches highlights
// across it, which is how brushed metal really looks.
Mat matBrushed(vec3 q, vec3 n, vec3 f0, vec3 axis) {
vec3 b = normalize(cross(n, axis) + 1e-4);
float a = dot(q, axis), c = dot(q, b);
float s = 0.6 * vnoise(vec2(a * 0.5, c * 240.0)) + 0.4 * vnoise(vec2(a * 0.1, c * 700.0));
return mk(f0 * (0.96 + 0.04 * s), 0.24 + 0.04 * s, 1.0, 0.0, normalize(n + b * (s - 0.5) * 0.012));
}
// Polished: near mirror, with faint handling haze in patches (no real polished object is uniform).
Mat matPolished(vec3 q, vec3 n, vec3 f0) {
float h = fbm(q.xz * 0.9 + q.y);
return mk(f0, 0.04 + 0.09 * smoothstep(0.45, 0.75, h), 1.0, 0.0, n);
}
// Glazed ceramic: coloured body under a glass coat; glaze pools and thins, a few pinholes, gentle waviness.
Mat matCeramic(vec3 q, vec3 n, vec3 glaze) {
float pool = fbm(q.xz * 0.6 + q.y * 0.4);
float pin = step(0.996, hash13(floor(q * 22.0)));
vec3 w = vec3(fbm(q.xy * 1.3) - 0.5, 0.0, fbm(q.zy * 1.3 + 4.0) - 0.5);
return mk(glaze * (0.78 + 0.4 * pool) * (1.0 - 0.6 * pin), 0.35, 0.0, 1.0, normalize(n + w * 0.025));
}
// Matte paper or card: fibres, never flat white.
Mat matPaper(vec3 q, vec3 n, vec3 tint) {
float f = fbm(q.xz * 9.0) * 0.6 + vnoise(q.xz * 60.0) * 0.4;
return mk(tint * (0.93 + 0.08 * f), 0.88, 0.0, 0.0, normalize(n + vec3(f - 0.5, 0.0, f - 0.5) * 0.02));
}
// Linen: plain weave with slubs (thicker uneven threads), about 18 threads per cm.
Mat matLinen(vec3 q, vec3 n, vec3 tint) {
vec2 u = q.xz * 18.0;
float wx = 0.5 + 0.5 * sin(u.x * PI), wy = 0.5 + 0.5 * sin(u.y * PI);
float over = mod(floor(u.x) + floor(u.y), 2.0);
float th = mix(wx, wy, over);
float slub = vnoise(vec2(floor(u.x) * 3.1, u.y * 0.08)) * vnoise(vec2(u.x * 0.08, floor(u.y) * 2.7));
vec3 a = tint * (0.8 + 0.2 * th) * (0.92 + 0.16 * slub);
return mk(a, 0.9, 0.0, 0.0, normalize(n + vec3(cos(u.x * PI) * (1.0 - over), 0.0, cos(u.y * PI) * over) * 0.08));
}
// Plaster / limewash: broad trowel clouds and fine pores.
Mat matPlaster(vec3 q, vec3 n, vec3 tint) {
float c = fbm(q.xy * 0.18 + q.z * 0.1), p = vnoise(q.xy * 35.0 + q.z);
return mk(tint * (0.86 + 0.22 * c) * (0.97 + 0.04 * p), 0.92, 0.0, 0.0, normalize(n + vec3(p - 0.5, c - 0.5, 0.0) * 0.03));
}
// Honed travertine: cream with soft bands along one direction and long shallow pits.
Mat matTravertine(vec3 q, vec3 n) {
float band = fbm(vec2(q.z * 0.35 + fbm(q.xz * 0.08) * 2.0, q.x * 0.03));
vec3 a = mix(vec3(0.66, 0.58, 0.47), vec3(0.78, 0.71, 0.6), band);
float pitN = vnoise(vec2(q.x * 1.6, q.z * 9.0) + 7.0) * vnoise(vec2(q.x * 0.5, q.z * 3.0));
float pit = smoothstep(0.5, 0.58, pitN);
a = mix(a, vec3(0.52, 0.44, 0.34), pit * 0.3);
a *= 0.96 + 0.08 * vnoise(q.xz * 30.0);
return mk(a, mix(0.42, 0.9, pit), 0.0, 0.0, normalize(n + vec3(0.0, 0.0, pit * 0.08)));
}
// Marble: soft warped veins, polished.
Mat matMarble(vec3 q, vec3 n, vec3 base, vec3 vein) {
// thin veins that wander but do not curl: low warp, a main vein family and a fainter hairline family
float w = fbm3(q * 0.15) * 3.2;
float v = 1.0 - smoothstep(0.0, 0.05, abs(sin(q.x * 0.35 + q.z * 0.18 + w)));
float hair = 1.0 - smoothstep(0.0, 0.03, abs(sin(q.x * 0.9 - q.z * 0.6 + w * 1.7)));
vec3 a = mix(base, vein, v * 0.6 + hair * 0.2) * (0.96 + 0.08 * fbm(q.xz * 1.5));
return mk(a, 0.12, 0.0, 0.6, n);
}
// Wood, grain running along x. Photographic wood is LOW contrast: rings within 25% brightness, not stripes.
const vec3 OAK_LIGHT = vec3(0.52, 0.36, 0.20), OAK_DARK = vec3(0.36, 0.23, 0.12);
const vec3 WALNUT_LIGHT = vec3(0.20, 0.11, 0.06), WALNUT_DARK = vec3(0.11, 0.055, 0.03);
Mat matWood(vec3 q, vec3 n, vec3 light, vec3 dark, float pores) {
float r = length(q.yz + vec2(9.0, 4.0)) * 3.2 + fbm3(q * vec3(0.05, 0.7, 0.7)) * 3.0;
float ring = smoothstep(0.4, 0.95, fract(r));
float streak = vnoise(vec2(q.x * 0.05, q.z * 11.0 + q.y * 11.0)) * 0.6 + vnoise(vec2(q.x * 0.2, q.z * 40.0)) * 0.4;
vec3 a = mix(light, dark, ring * 0.3 + streak * 0.35);
float pore = smoothstep(0.78, 0.86, vnoise(vec2(q.x * 1.4, (q.z + q.y) * 55.0))) * pores;
a *= 1.0 - pore * 0.35;
return mk(a, 0.45 + 0.2 * pore, 0.0, 0.35, normalize(n + vec3(0.0, 0.0, (streak - 0.5) * 0.03)));
}
// Dielectrics (glass, acrylic, resin). You trace the refracted ray yourself; these do the physics around it.
float fresnelD(float c, float ior) { float r = (1.0 - ior) / (1.0 + ior); r *= r; return r + (1.0 - r) * pow(1.0 - saturate(c), 5.0); }
// Light lost to the colour of the body over a path of length len. absorb is per unit length per channel:
// clear soda glass vec3(0.05, 0.012, 0.03) per cm (green edges); resin uses strong values for deep colour.
vec3 beer(vec3 absorb, float len) { return exp(-absorb * len); }`;
const GLSL_SDF = `
float sdRoundBox(vec3 p, vec3 b, float r) { vec3 q = abs(p) - b + r; return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0) - r; }
float smax(float a, float b, float k) { float h = max(k - abs(a - b), 0.0); return max(a, b) + h * h * 0.25 / k; }
float smin(float a, float b, float k) { float h = max(k - abs(a - b), 0.0); return min(a, b) - h * h * 0.25 / k; }
// ray vs box centred at the origin: (t near, t far)
vec2 boxI(vec3 ro, vec3 rd, vec3 b) {
rd = mix(rd, vec3(1e-5), lessThan(abs(rd), vec3(1e-5)));
vec3 m = 1.0 / rd, n = m * ro, k = abs(m) * b;
vec3 t1 = -n - k, t2 = -n + k;
return vec2(max(max(t1.x, t1.y), t1.z), min(min(t2.x, t2.y), t2.z));
}
vec3 boxFaceNormal(vec3 p, vec3 b) { vec3 d = abs(p) / b; return d.x > d.y && d.x > d.z ? vec3(sign(p.x), 0, 0) : d.y > d.z ? vec3(0, sign(p.y), 0) : vec3(0, 0, sign(p.z)); }`;
const GLSL_SHADOW_AO = `
// needs: const float SCENE_TOP, AO_RADIUS; float map(vec3); float mapShadow(vec3); vec3 lightFilter(vec3 p, vec3 l)
// Deterministic penumbra (no random sun points: random shadow rays are the main source of floor speckle).
// SHADOW_SOFT is the penumbra angle in radians: sun-sharp is ~0.01, overcast-soft ~0.08.
const float SHADOW_SOFT = 0.03;
float softShadow(vec3 ro, vec3 rd, float tmin, float tmax) {
float res = 1.0, t = tmin, ph = 1e10;
for (int i = 0; i < 48; i++) {
vec3 p = ro + rd * t;
if (p.y > SCENE_TOP && rd.y > 0.0) break;
float h = mapShadow(p);
if (h < 0.0004) return 0.0;
float y = h * h / (2.0 * ph), d = sqrt(max(h * h - y * y, 0.0));
res = min(res, d / (SHADOW_SOFT * max(1e-3, t - y)));
ph = h;
t += clamp(h, 0.01, 3.0);
if (t > tmax) break;
}
res = saturate(res);
return res * res * (3.0 - 2.0 * res);
}
vec3 shadowCol(vec3 ro, vec3 rd) { return softShadow(ro, rd, 0.01, 200.0) * lightFilter(ro, rd); }
float calcAO(vec3 p, vec3 n) {
float occ = 0.0, s = 1.0;
for (int i = 0; i < 5; i++) { float h = AO_RADIUS * (0.06 + 0.94 * float(i) / 4.0); occ += (h - map(p + n * h)) * s; s *= 0.72; }
return saturate(1.0 - 1.4 * occ / AO_RADIUS);
}
vec3 calcNormal(vec3 p, float e) {
vec2 k = vec2(1.0, -1.0);
return normalize(k.xyy * map(p + k.xyy * e) + k.yyx * map(p + k.yyx * e) + k.yxy * map(p + k.yxy * e) + k.xxx * map(p + k.xxx * e));
}`;
const GLSL_LIGHTING = `
// One opaque surface under the rig. Diffuse from the key (through the window gobo, with smooth analytic
// shadows), cool sky and the card; specular reflects the room along the mirror direction with the window
// pre-blurred by roughness (envStudioRough). Everything here is deterministic, so it adds NO per-frame noise:
// accumulation only has to clean up edges, depth of field and any glass you trace yourself.
// coat adds a sharp glaze/lacquer layer.
vec3 litOpaque(vec3 p, vec3 v, Mat m, float ao) {
vec3 n = m.n;
float NoV = max(dot(n, v), 1e-3);
vec3 L = KEY_DIR;
float NoL = max(dot(n, L), 0.0);
vec3 sun = NoL > 0.0 ? KEY_COL * NoL * keyGobo(p) * shadowCol(p + n * 0.015, L) : vec3(0.0);
vec3 f0 = mix(vec3(0.04), m.albedo, m.metal);
vec3 F = f0 + (1.0 - f0) * pow(1.0 - NoV, 5.0);
vec3 amb = SKY_COL * (0.55 + 0.45 * n.y) + FILL_COL * 1.6 * max(dot(n, FILL_DIR) + 0.25, 0.0);
vec3 col = (1.0 - F) * (1.0 - m.metal) * m.albedo * (sun + amb * ao);
vec3 r = reflect(-v, n);
// rough surfaces lose some of the grazing Fresnel boost
vec3 Fr = f0 + (max(vec3(1.0 - m.rough), f0) - f0) * pow(1.0 - NoV, 5.0);
col += Fr * envStudioRough(r, m.rough) * mix(vec3(1.0), shadowCol(p + n * 0.015, r), 1.0 - m.rough * 0.6) * mix(1.0, ao, 0.6);
if (m.coat > 0.0) {
vec3 rc = reflect(-v, n);
float Fc = (0.04 + 0.96 * pow(1.0 - NoV, 5.0)) * m.coat;
col = col * (1.0 - Fc) + Fc * envStudio(rc) * shadowCol(p + n * 0.015, rc);
}
return col;
}
// Light that a lit translucent body gives off from inside (resin, wax, jade, frosted acrylic).
vec3 subsurface(vec3 p, vec3 n, vec3 scatterCol, float strength) {
return scatterCol * strength * (KEY_COL * keyGobo(p) * (0.35 + 0.65 * max(dot(n, KEY_DIR), 0.0)) + SKY_COL);
}`;
const OUTPUT_FS = `#version 300 es
precision highp float;
uniform sampler2D uAccum;
uniform vec2 uPx;
uniform float uExposure, uGrain, uVignette, uSeed, uHalation;
in vec2 vUv;
out vec4 o;
float ign(vec2 p) { return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); }
float hash(vec2 p) { vec3 p3 = fract(vec3(p.xyx) * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); }
// ACES filmic fit: highlights roll off instead of clipping, which is most of the "camera" look
vec3 aces(vec3 x) { return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0); }
void main() {
vec3 c = texture(uAccum, vUv).rgb;
// halation: the brightest highlights bleed a warm glow, like light scattering in film
vec3 g = vec3(0.0);
float rot = 6.2831853 * ign(gl_FragCoord.xy);
for (int k = 0; k < 12; k++) {
float a = float(k) * 2.39996 + rot, r = 2.0 + 24.0 * sqrt(float(k) / 12.0);
g += max(texture(uAccum, vUv + vec2(cos(a), sin(a)) * r * uPx).rgb - 2.0, 0.0);
}
c += g / 12.0 * vec3(0.42, 0.3, 0.22) * uHalation;
c = aces(c * uExposure);
vec2 q = vUv - 0.5;
c *= 1.0 - uVignette * dot(q, q) * 2.0;
c = pow(c, vec3(1.0 / 2.2));
// grain lives in the midtones, never in pure black or white
float l = dot(c, vec3(0.299, 0.587, 0.114));
// static grain (it does not crawl), meant to be barely visible: default amplitude 0.012
c += (hash(gl_FragCoord.xy + uSeed) - 0.5) * uGrain * (0.25 + 3.0 * l * (1.0 - l));
o = vec4(c, 1.0);
}`;
type Vec3 = [number, number, number];
type Camera = { pos: Vec3; rt: Vec3; up: Vec3; fw: Vec3; tan: number; aspect: number; shift: [number, number]; aperture: number; focus: number };
const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const dot = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const norm = (a: Vec3): Vec3 => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; };
const cross = (a: Vec3, b: Vec3): Vec3 => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
function lookAt(pos: Vec3, target: Vec3, fovYdeg: number, aspect: number, aperture = 0, shift: [number, number] = [0, 0]): Camera {
const fw = norm(sub(target, pos));
const rt = norm(cross(fw, [0, 1, 0]));
const up = cross(rt, fw);
return { pos, rt, up, fw, tan: Math.tan((fovYdeg * Math.PI) / 360), aspect, shift, aperture, focus: Math.hypot(...sub(target, pos)) };
}
function project(cam: Camera, p: Vec3, cssW: number, cssH: number): [number, number, number] {
const d = sub(p, cam.pos);
const z = dot(d, cam.fw);
const x = dot(d, cam.rt) / (z * cam.tan * cam.aspect) + cam.shift[0];
const y = dot(d, cam.up) / (z * cam.tan) + cam.shift[1];
return [(x * 0.5 + 0.5) * cssW, (0.5 - y * 0.5) * cssH, z];
}
function overlayRect(cam: Camera, center: Vec3, half: Vec3, cssW: number, cssH: number) {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (let i = 0; i < 8; i++) {
const [x, y] = project(cam, [center[0] + (i & 1 ? half[0] : -half[0]), center[1] + (i & 2 ? half[1] : -half[1]), center[2] + (i & 4 ? half[2] : -half[2])], cssW, cssH);
x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y);
}
return { left: x0, top: y0, width: x1 - x0, height: y1 - y0 };
}
function compileProgram(gl: WebGL2RenderingContext, vs: string, fs: string) {
const p = gl.createProgram()!;
for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]] as const) {
const s = gl.createShader(type)!;
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) || "shader");
gl.attachShader(p, s);
}
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p) || "link");
return p;
}
type StageOptions = {
canvas: HTMLCanvasElement;
wrap: HTMLElement;
/** full fragment shader: GLSL_HEADER + chunks + your scene. Writes linear HDR to `o`. */
sceneFS: string;
/** called once per CSS resize, before drawing: build your camera here and return it */
camera: (cssW: number, cssH: number) => Camera;
/** your uniforms, every rendered frame (program is in use) */
uniforms?: (gl: WebGL2RenderingContext, loc: (name: string) => WebGLUniformLocation | null, t: number) => void;
/** advance your simulation; return true while anything visible is still moving */
step?: (dt: number, t: number) => boolean;
/** samples per pixel when at rest (then drawing stops). Default 128; never below 96 */
samples?: number;
/** blend weight of each new frame while moving: 0.25 (default) keeps ~4 frames of history; raise for fast motion */
motionBlend?: number;
maxPixels?: number; // cap on drawing buffer pixels, default 2.2e6
maxDpr?: number; // default 2
exposure?: number; grain?: number; vignette?: number; halation?: number;
onFail?: () => void; // WebGL 2 missing, shader error or context lost: show your CSS still
};
function createPhotoStage(o: StageOptions) {
const { canvas, wrap } = o;
const fail = () => { o.onFail?.(); return null; };
const gl = canvas.getContext("webgl2", { antialias: false, alpha: false, depth: false, powerPreference: "high-performance" });
if (!gl) return fail();
const float = !!gl.getExtension("EXT_color_buffer_float");
let scene: WebGLProgram, out: WebGLProgram;
try {
scene = compileProgram(gl, FULLSCREEN_VS, o.sceneFS);
out = compileProgram(gl, FULLSCREEN_VS, OUTPUT_FS);
} catch (e) {
console.error(e);
return fail();
}
const SAMPLES = o.samples ?? 128;
const MOTION = o.motionBlend ?? 0.25;
let frameNo = 0;
const maxPixels = o.maxPixels ?? 2.2e6;
let scale = Math.min(window.devicePixelRatio || 1, o.maxDpr ?? 2);
const vao = gl.createVertexArray();
const fbo = gl.createFramebuffer();
const tex = gl.createTexture();
const locS = new Map<string, WebGLUniformLocation | null>();
const loc = (n: string) => { if (!locS.has(n)) locS.set(n, gl.getUniformLocation(scene, n)); return locS.get(n)!; };
const locO = (n: string) => gl.getUniformLocation(out, n);
let W = 0, H = 0, cssW = 1, cssH = 1, n = 0, cam: Camera | null = null;
const resize = () => {
cssW = Math.max(1, wrap.clientWidth); cssH = Math.max(1, wrap.clientHeight);
let k = scale;
if (cssW * cssH * k * k > maxPixels) k = Math.sqrt(maxPixels / (cssW * cssH));
cam = o.camera(cssW, cssH);
n = 0;
const w = Math.max(2, Math.round(cssW * k)), h = Math.max(2, Math.round(cssH * k));
if (w === W && h === H) return;
W = w; H = h; canvas.width = W; canvas.height = H;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, float ? gl.RGBA16F : gl.RGBA8, W, H, 0, gl.RGBA, float ? gl.HALF_FLOAT : gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
};
const draw = (t: number, moving: boolean) => {
const c = cam!;
gl.bindVertexArray(vao);
gl.viewport(0, 0, W, H);
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.useProgram(scene);
gl.uniform2f(loc("uRes"), W, H);
gl.uniform1f(loc("uFrame"), frameNo++); // never repeats, so moving frames keep exploring new samples
gl.uniform1f(loc("uTime"), t);
gl.uniform3fv(loc("uCamPos"), c.pos); gl.uniform3fv(loc("uCamRt"), c.rt); gl.uniform3fv(loc("uCamUp"), c.up); gl.uniform3fv(loc("uCamFw"), c.fw);
gl.uniform1f(loc("uTan"), c.tan); gl.uniform1f(loc("uAspect"), c.aspect); gl.uniform1f(loc("uAperture"), c.aperture); gl.uniform1f(loc("uFocus"), c.focus);
gl.uniform2fv(loc("uShift"), c.shift);
o.uniforms?.(gl, loc, t);
// blend the new jittered frame into the running average: alpha 1/(n+1) is an exact mean;
// while moving keep a SHORT history instead of restarting: alpha 0.25 is about 4 frames (a touch of motion
// blur, no speckle). When it stops, carry on averaging from there: n continues at 1/MOTION samples.
const alpha = moving ? MOTION : 1 / (n + 1);
gl.enable(gl.BLEND);
gl.blendFunc(gl.CONSTANT_ALPHA, gl.ONE_MINUS_CONSTANT_ALPHA);
gl.blendColor(0, 0, 0, n === 0 ? 1 : alpha);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.disable(gl.BLEND);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.useProgram(out);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.uniform1i(locO("uAccum"), 0);
gl.uniform2f(locO("uPx"), 1 / W, 1 / H);
gl.uniform1f(locO("uExposure"), o.exposure ?? 1);
gl.uniform1f(locO("uGrain"), o.grain ?? 0.012);
gl.uniform1f(locO("uVignette"), o.vignette ?? 0.35);
gl.uniform1f(locO("uHalation"), o.halation ?? 0.6);
gl.uniform1f(locO("uSeed"), 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
n = moving ? Math.round(1 / MOTION) : n + 1;
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(wrap);
let raf = 0, last = performance.now(), slow = 0, onScreen = true, dead = false;
const t0 = last;
const tick = (now: number) => {
raf = 0;
const real = now - last;
last = now;
const dt = Math.min(real / 1000, 0.05);
const t = (now - t0) / 1000;
const moving = o.step ? o.step(dt, t) : false;
if (moving || n < SAMPLES) {
if (moving) {
// measure, do not guess: if moving frames run long, draw fewer pixels
slow = slow * 0.9 + (real > 28 ? 0.1 : 0);
if (slow > 0.55 && scale > 0.45) { scale *= 0.85; slow = 0; W = 0; resize(); }
}
draw(t, moving);
}
if (onScreen && !document.hidden) raf = requestAnimationFrame(tick);
};
const wake = () => { if (!raf && !dead && onScreen && !document.hidden) { last = performance.now(); raf = requestAnimationFrame(tick); } };
const io = new IntersectionObserver(([e]) => { onScreen = e.isIntersecting; wake(); });
io.observe(wrap);
document.addEventListener("visibilitychange", wake);
const lost = (e: Event) => { e.preventDefault(); dead = true; o.onFail?.(); };
canvas.addEventListener("webglcontextlost", lost);
wake();
return {
/** something changed that is not animated (theme, text, reduced-motion snap): restart accumulation */
invalidate() { n = 0; wake(); },
/** call after step() starts moving from rest (input), so the loop is running */
wake,
camera: () => cam!,
size: () => [cssW, cssH] as const,
dispose() {
dead = true;
cancelAnimationFrame(raf);
ro.disconnect(); io.disconnect();
document.removeEventListener("visibilitychange", wake);
canvas.removeEventListener("webglcontextlost", lost);
gl.getExtension("WEBGL_lose_context")?.loseContext();
},
};
}
function springStep(s: { x: number; v: number }, target: number, dt: number, k = 900, damping = 38) {
const sub = Math.max(1, Math.ceil(dt / 0.004));
for (let i = 0; i < sub; i++) {
const h = dt / sub;
s.v += (k * (target - s.x) - damping * s.v) * h;
s.x += s.v * h;
}
if (Math.abs(s.v) < 0.02 && Math.abs(target - s.x) < 0.002) { s.x = target; s.v = 0; return false; }
return true;
}
function stillObject(face: string, opts: { radius?: number; depth?: number } = {}) {
const r = opts.radius ?? 14, d = opts.depth ?? 10;
return {
background: face,
borderRadius: r,
boxShadow: [
"inset 0 1px 0 rgba(255,255,255,0.35)",
"inset 0 -2px 3px rgba(0,0,0,0.22)",
`0 ${d * 0.4}px ${d * 0.3}px rgba(0,0,0,0.28)`,
`${d * 1.6}px ${d * 2.2}px ${d * 3}px rgba(20,12,4,0.30)`,
].join(", "),
};
}
const STILL_GRAIN =
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0.9 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>\")";
function stillWindowLight(warm = "255,236,205") {
return {
background: `linear-gradient(115deg, transparent 18%, rgba(${warm},0.55) 30%, rgba(${warm},0.55) 47%, transparent 48.5%, transparent 50%, rgba(${warm},0.5) 51.5%, rgba(${warm},0.45) 70%, transparent 82%)`,
filter: "blur(6px)",
mixBlendMode: "soft-light" as const,
};
}
/* ---- end of kit ---- */
// The scene: three blocks of thick low-iron glass on a honed marble floor in front of a cobalt limewashed wall. Centimetres.
const SCENE = `
const float SCENE_TOP = 19.0;
const float AO_RADIUS = 2.4;
uniform float uYear;
uniform float uPoolX;
uniform sampler2D uLegend;
const float BW = ${BW.toFixed(2)}, BD = ${BD.toFixed(2)}, BR = 0.3, WZ = -34.0, PITCH = ${PITCH.toFixed(2)};
const float HS[3] = float[3](${plans.map((p) => p.height.toFixed(1)).join(", ")});
const vec3 GLASS_ABS = vec3(0.022, 0.005, 0.014);
vec3 bc(int i) { return vec3((float(i) - 1.0) * PITCH, HS[i] * 0.5, 0.0); }
vec3 bh(int i) { return vec3(BW, HS[i] * 0.5, BD); }
vec2 scene(vec3 p) {
vec2 r = p.y < p.z - WZ ? vec2(p.y, 0.0) : vec2(p.z - WZ, 1.0);
for (int i = 0; i < 3; i++) {
float d = sdRoundBox(p - bc(i), bh(i), BR);
if (d < r.x) r = vec2(d, float(i + 2));
}
return r;
}
float map(vec3 p) { return scene(p).x; }
// glass never blocks the sun: lightFilter tints it, and bends it into bright bands near the edges
float mapShadow(vec3 p) { return 50.0; }
float edgeDist(vec3 e) { return e.x + e.y + e.z - max(e.x, max(e.y, e.z)) - min(e.x, min(e.y, e.z)); }
vec3 lightFilter(vec3 p, vec3 l) {
vec3 T = vec3(1.0);
for (int i = 0; i < 3; i++) {
vec3 b = bh(i) - 0.03, q = p - bc(i);
vec2 tb = boxI(q, l, b);
if (tb.y <= max(tb.x, 0.0)) continue;
float t0 = max(tb.x, 0.0), len = tb.y - t0;
float rim = min(edgeDist(b - abs(q + l * t0)), edgeDist(b - abs(q + l * tb.y)));
float band = exp(-pow((rim - 0.75) / 0.32, 2.0));
T *= beer(GLASS_ABS, len) * 0.9 * (0.12 + 0.88 * smoothstep(0.05, 0.4, rim)) * (0.95 + 1.5 * band);
}
return T;
}
`;
const SCENE_MAIN = `
float legendAt(int i, vec2 uv) {
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) return 0.0;
vec2 a = vec2((float(i) + uv.x) / 3.0, uv.y * 0.5);
return mix(texture(uLegend, a).r, texture(uLegend, a + vec2(0.0, 0.5)).r, uYear);
}
// the sandblasted lettering on a front face (value, slope across, slope down)
vec3 legend(int i, vec3 q, vec3 n) {
float front = smoothstep(0.85, 0.98, n.z);
if (front <= 0.0) return vec3(0.0);
float s = 2.0 * (BW - 0.45);
vec2 uv = vec2(q.x / s + 0.5, (HS[i] * 0.5 - 1.0 - q.y) / s);
float e = 1.0 / 512.0;
return front * vec3(legendAt(i, uv), legendAt(i, uv + vec2(e, 0.0)) - legendAt(i, uv - vec2(e, 0.0)), legendAt(i, uv + vec2(0.0, e)) - legendAt(i, uv - vec2(0.0, e)));
}
// floor and wall are planes, so their normals are exact; occlusion comes from the blocks and the corner
float aoAt(vec3 p) {
float ao = 0.55 + 0.45 * smoothstep(0.0, 9.0, min(p.y, p.z - WZ));
for (int i = 0; i < 3; i++) {
float k = 1.0 - smoothstep(0.0, 3.0, max(sdRoundBox(p - bc(i), bh(i), BR), 0.0));
ao *= 1.0 - 0.5 * k * k;
}
return ao;
}
vec3 shadeOpaque(int id, vec3 p, vec3 v) {
float ao = aoAt(p);
if (id == 0) {
// honed limestone: broad cloudy variation, a fine sanded tooth and a few tiny shell flecks, no polish
vec2 u = p.xz;
float cloud = fbm(u * 0.06 + fbm(u * 0.02) * 1.5);
vec3 a = mix(vec3(0.58, 0.52, 0.44), vec3(0.71, 0.65, 0.56), cloud);
a *= 0.95 + 0.07 * vnoise(u * 22.0) + 0.03 * vnoise(u * 3.0);
float fleck = smoothstep(0.82, 0.9, vnoise(u * 2.6 + 11.0) * vnoise(u * 5.3 - 4.0) * 1.9);
a = mix(a, vec3(0.45, 0.41, 0.36), fleck * 0.35);
Mat m = mk(a, 0.6 + 0.1 * cloud, 0.0, 0.0, normalize(vec3((vnoise(u * 22.0) - 0.5) * 0.04, 1.0, (vnoise(u * 22.0 + 5.0) - 0.5) * 0.04)));
return litOpaque(p, v, m, ao);
}
Mat m = matPlaster(p, vec3(0.0, 0.0, 1.0), vec3(0.03, 0.065, 0.33));
// sun off the floor bounces up onto the wall around the pool of light
vec3 bounce = vec3(1.0, 0.85, 0.66) * 0.55 * exp(-p.y * 0.045) * exp(-pow((p.x - uPoolX + 6.0) / 15.0, 2.0));
return litOpaque(p, v, m, ao) + m.albedo * bounce;
}
vec3 traceOpaque(vec3 ro, vec3 rd);
// beyond the glass there is only the floor and the wall, so the bent ray finds them in closed form
vec3 traceBehind(vec3 ro, vec3 rd) {
// a neighbouring block on the way: you see through it (tinted, with its own glint) to whatever lies beyond
vec3 thr = vec3(1.0), add = vec3(0.0);
for (int j = 0; j < 3; j++) {
vec2 tb = boxI(ro - bc(j), rd, bh(j));
if (tb.x > 0.05 && tb.y > tb.x) {
vec3 nj = boxFaceNormal(ro + rd * tb.x - bc(j), bh(j));
float Fj = fresnelD(max(dot(-rd, nj), 0.0), 1.5);
add += thr * Fj * envStudio(reflect(rd, nj));
thr *= (1.0 - Fj) * beer(GLASS_ABS, tb.y - tb.x) * 0.92;
}
}
return add + thr * traceOpaque(ro, rd);
}
vec3 traceOpaque(vec3 ro, vec3 rd) {
float t = 1e9; int id = -1;
if (rd.y < -1e-4) { t = max(-ro.y / rd.y, 0.0); id = 0; }
if (rd.z < -1e-4) { float tw = (WZ - ro.z) / rd.z; if (tw < t) { t = tw; id = 1; } }
if (id < 0) return envStudio(rd);
return shadeOpaque(id, ro + rd * t, -rd);
}
vec3 shadeGlass(int i, vec3 p, vec3 v, vec3 n) {
vec3 c = bc(i), q = p - c, b = bh(i);
vec3 lg = legend(i, q, n);
vec3 nb = normalize(n + vec3(lg.y, -lg.z, 0.0) * 0.5);
// polished faces, sandblasted lettering: the blast is just a far rougher patch of the same glass
float frost = mix(0.025, 0.55, lg.x);
// clear body: no random normals at all; only the blasted letters sample a rough lobe
vec3 h = lg.x > 0.02 ? ggxSample(nb, frost, rand2(5.0)) : nb;
float F = fresnelD(max(dot(v, nb), 0.0), 1.5);
vec3 r = reflect(-v, nb);
vec3 col = F * traceBehind(p + n * 0.03, r);
// blasted letters scatter most light back: little of the scene behind shows through them, so they read on any background
vec3 dir = refract(-v, h, 1.0 / 1.5), pos = q, thr = vec3(1.0 - F) * (1.0 - 0.7 * lg.x);
for (int k = 0; k < 4; k++) {
float len = max(boxI(pos, dir, b).y, 0.0);
thr *= beer(GLASS_ABS, len);
pos += dir * len;
vec3 ne = boxFaceNormal(pos, b);
// the base sits on the stone in optical contact: no mirror there, you look straight onto the floor
if (ne.y < -0.5) { col += thr * 0.92 * shadeOpaque(0, vec3(c.x + pos.x, 0.0, c.z + pos.z), -dir); break; }
vec3 od = refract(dir, -ne, 1.5);
if (dot(od, od) > 1e-4) {
thr *= 1.0 - fresnelD(dot(od, ne), 1.5);
col += thr * traceBehind(c + pos + od * 0.03, od);
break;
}
dir = reflect(dir, ne);
pos -= ne * 0.003;
}
// light that came in through the back and top: the frosted letters catch it and glow, the body barely does
vec3 inner = KEY_COL * keyGobo(p) * beer(GLASS_ABS, 2.0 * BD);
col += subsurface(p, n, vec3(0.75, 0.88, 0.84), 0.015);
col += lg.x * (inner * 0.4 + SKY_COL * 2.2 + FILL_COL * 3.0 + vec3(0.12)) * vec3(0.9, 0.95, 0.93);
return col;
}
void main() {
vec3 ro, rd;
cameraRay(ro, rd);
float t = 0.0;
vec2 h = vec2(1e9, 0.0);
bool hit = false;
for (int s = 0; s < 200; s++) {
h = scene(ro + rd * t);
if (h.x < 0.0002 * t) { hit = true; break; }
t += h.x;
if (t > 1200.0) break;
}
vec3 p = ro + rd * t, col;
int id = int(h.y + 0.5);
if (!hit) col = envStudio(rd);
else if (id >= 2) col = shadeGlass(id - 2, p, -rd, calcNormal(p, 0.002));
else col = shadeOpaque(id, p, -rd);
o = vec4(clampSample(col, 5.0), 1.0);
}
`;
const SCENE_FS = [GLSL_HEADER, GLSL_COMMON, GLSL_SAMPLING, GLSL_CAMERA, GLSL_RIG_SETTINGS, GLSL_LIGHT_RIG, GLSL_MATERIALS, GLSL_SDF, SCENE, GLSL_SHADOW_AO, GLSL_LIGHTING, SCENE_MAIN].join("\n");
/** Lettering blasted into the front of each block: white on black, one 512px tile per plan, monthly row then yearly row. */
function legendArt() {
const cv = document.createElement("canvas");
cv.width = 1536;
cv.height = 1024;
const ctx = cv.getContext("2d")!;
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, cv.width, cv.height);
ctx.fillStyle = "#fff";
ctx.textBaseline = "alphabetic";
ctx.filter = "blur(1.2px)";
plans.forEach((p, i) => {
for (let row = 0; row < 2; row++) {
const x = i * 512 + 40, y = row * 512;
ctx.textAlign = "left";
ctx.font = `600 40px ${GROTESK}`;
ctx.letterSpacing = "8px";
ctx.fillText(p.name.toUpperCase(), x, y + 70);
ctx.letterSpacing = "-10px";
ctx.font = `500 90px ${GROTESK}`;
ctx.fillText("$", x - 4, y + 190);
ctx.font = `600 230px ${GROTESK}`;
ctx.fillText(String(row ? p.yearly : p.monthly), x + 58, y + 292);
ctx.letterSpacing = "6px";
ctx.font = `600 34px ${GROTESK}`;
ctx.fillText(row ? "A MONTH, YEARLY" : "A MONTH", x, y + 368);
}
});
return cv;
}
// the window basis the shader uses, so the pool of sun can be slid onto a block
const KN = norm(KEY);
const KT = norm([KN[2], 0, -KN[0]]);
const KB = cross(KN, KT);
const blockAt = (i: number): Vec3 => [(i - 1) * PITCH, plans[i].height * 0.5, 0];
type Sim = { pool: { x: number; v: number }; year: { x: number; v: number }; plan: number; yearly: boolean; touched: boolean };
export default function GlassBlockPricingTable() {
const reduce = useReducedMotion();
const wrapRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [mode, setMode] = useState<"pending" | "gl" | "still">("pending");
const [plan, setPlan] = useState(1);
const [yearly, setYearly] = useState(false);
const [rects, setRects] = useState<{ left: number; top: number; width: number; height: number }[]>([]);
const sim = useRef<Sim>({ pool: { x: 1, v: 0 }, year: { x: 0, v: 0 }, plan: 1, yearly: false, touched: false });
const stageRef = useRef<{ wake: () => void } | null>(null);
useEffect(() => {
sim.current.plan = plan;
sim.current.yearly = yearly;
stageRef.current?.wake();
}, [plan, yearly]);
useEffect(() => {
const wrap = wrapRef.current, canvas = canvasRef.current;
if (!wrap || !canvas) return;
const s = sim.current;
s.pool.x = s.plan;
s.year.x = s.yearly ? 1 : 0;
let legendTex: WebGLTexture | null = null;
let demoAt = 3.4, step = 0;
const poolPoint = (k: number): Vec3 => {
// between two blocks the light travels in a straight line along the row
const i = Math.min(1, Math.max(0, Math.floor(k))), f = Math.min(1, Math.max(0, k - i));
const a = blockAt(i), b = blockAt(i + 1);
return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, 0];
};
const stage = createPhotoStage({
canvas,
wrap,
sceneFS: SCENE_FS,
samples: 128,
exposure: 1.0,
vignette: 0.4,
halation: 0.5,
onFail: () => setMode("still"),
camera: (w, h) => {
const aspect = w / h;
const wide = w >= 768;
const fov = 17;
const half = wide ? 17 : 17.5;
const dist = Math.max(wide ? 96 : 104, half / (Math.tan((fov * Math.PI) / 360) * aspect));
const el = 0.46;
const target: Vec3 = [0, 6.5, 1.5];
const cam = lookAt([0, target[1] + Math.sin(el) * dist, target[2] + Math.cos(el) * dist], target, fov, aspect, dist * 0.012, wide ? [0.2, -0.16] : [0, -0.42]);
setRects(plans.map((_, i) => overlayRect(cam, blockAt(i), [BW, plans[i].height * 0.5, BD], w, h)));
return cam;
},
uniforms: (gl, loc) => {
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, legendTex);
gl.uniform1i(loc("uLegend"), 1);
const P = poolPoint(s.pool.x);
gl.uniform2f(loc("uWin"), dot(P, KT), dot(P, KB));
gl.uniform1f(loc("uPoolX"), P[0]);
gl.uniform1f(loc("uYear"), Math.min(1, Math.max(0, s.year.x)));
},
step: (dt, t) => {
if (DEMO && !reduce && !s.touched && t > demoAt) {
const script = [() => setYearly(true), () => setPlan(2), () => setPlan(0), () => setYearly(false), () => setPlan(1)];
script[step % script.length]();
step++;
demoAt = t + 3.4;
}
const goalYear = s.yearly ? 1 : 0;
if (reduce) {
const moved = s.pool.x !== s.plan || s.year.x !== goalYear;
s.pool.x = s.plan; s.year.x = goalYear; s.pool.v = s.year.v = 0;
return moved;
}
const a = springStep(s.pool, s.plan, dt, POOL_SPRING, POOL_DAMPING);
const b = springStep(s.year, goalYear, dt, 140, 24);
return a || b;
},
});
if (!stage) return;
stageRef.current = stage;
const gl = canvas.getContext("webgl2")!;
legendTex = gl.createTexture();
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, legendTex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, legendArt());
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.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
stage.invalidate();
setMode("gl");
return () => { stageRef.current = null; stage.dispose(); };
}, [reduce]);
const touch = () => { sim.current.touched = true; };
const pick = (i: number) => { touch(); setPlan(i); };
const chosen = plans[plan];
return (
<section className="relative overflow-hidden bg-[#0d1a4a] text-[#f3ecdf]" style={{ fontFamily: GROTESK }} onPointerDown={touch} onKeyDown={touch}>
<div ref={wrapRef} className="relative h-[540px] w-full sm:h-[600px]">
<canvas ref={canvasRef} aria-hidden="true" className={`absolute inset-0 h-full w-full ${mode === "still" ? "hidden" : ""}`} />
{mode === "still" && (
<div aria-hidden="true" className="absolute inset-0" style={{ background: "linear-gradient(180deg, #0f1f5c 0%, #172b78 58%, #8a8073 58%, #625a50 100%)" }}>
<div className="absolute inset-0" style={stillWindowLight()} />
<div className="absolute inset-x-0 bottom-[14%] flex items-end justify-center gap-[6%] sm:left-[30%]">
{plans.map((p, i) => (
<div key={p.name} className="w-[16%] max-w-[120px]" style={{ height: `${p.height * 13}px`, ...stillObject(i === plan ? "rgba(214,238,228,0.55)" : "rgba(190,215,210,0.3)", { radius: 4, depth: 12 }) }} />
))}
</div>
<div className="absolute inset-0 opacity-30 mix-blend-overlay" style={{ backgroundImage: STILL_GRAIN }} />
</div>
)}
{mode === "gl" &&
rects.map((r, i) => (
<button key={plans[i].name} type="button" tabIndex={-1} aria-hidden="true" onClick={() => pick(i)} className="absolute cursor-pointer rounded-sm outline-none" style={{ left: r.left, top: r.top, width: r.width, height: r.height }} />
))}
<div className="pointer-events-none absolute inset-x-0 top-0 flex flex-col gap-5 p-5 sm:p-10 md:max-w-[46%]">
<p className="text-[11px] font-semibold uppercase tracking-[0.26em] text-[#f3ecdf]/75">Pricing</p>
<h2 className="text-[34px] font-semibold leading-[0.95] tracking-[-0.035em] sm:text-[56px]">One price, whatever you build.</h2>
<div className="pointer-events-auto flex flex-wrap items-center gap-3 text-[11px] font-semibold uppercase tracking-[0.2em]">
<span className={yearly ? "text-[#f3ecdf]/60" : ""}>Monthly</span>
<button
type="button"
role="switch"
aria-checked={yearly}
aria-label="Bill yearly"
onClick={() => { touch(); setYearly((v) => !v); }}
className="relative h-9 w-[62px] rounded-full border border-[#f3ecdf]/35 bg-black/25 backdrop-blur-sm transition-colors hover:bg-black/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f3ecdf]"
>
<span className="absolute left-1 top-1 h-[26px] w-[26px] rounded-full bg-[#f3ecdf] shadow-[0_2px_6px_rgba(0,0,0,0.4)] transition-transform duration-300 ease-out motion-reduce:transition-none" style={{ transform: yearly ? "translateX(26px)" : "none" }} />
</button>
<span className={yearly ? "" : "text-[#f3ecdf]/60"}>Yearly</span>
<span className="rounded-full bg-[#f3ecdf]/15 whitespace-nowrap px-2.5 py-1 tracking-[0.12em] normal-case">Save about 20%</span>
</div>
</div>
<p className="pointer-events-none absolute bottom-4 left-5 text-[11px] text-[#f3ecdf]/60 sm:left-10">Example prices in USD.</p>
</div>
<div className="mx-auto max-w-6xl px-5 pb-12 pt-8 sm:px-10">
<fieldset>
<legend className="sr-only">Choose a plan</legend>
<div className="grid gap-4 md:grid-cols-3">
{plans.map((p, i) => {
const on = plan === i;
const price = yearly ? p.yearly : p.monthly;
return (
<div key={p.name} className={`flex flex-col rounded-2xl p-6 transition-colors duration-300 ${on ? "bg-[#f3ecdf] text-[#0d1a4a]" : "bg-white/[0.06] text-[#f3ecdf]"}`}>
<label className="cursor-pointer">
<input type="radio" name="glass-block-plan" aria-label={`${p.name} plan`} value={p.name} checked={on} onChange={() => pick(i)} className="peer sr-only" />
<span className="flex items-center justify-between rounded-md text-[12px] font-semibold uppercase tracking-[0.22em] peer-focus-visible:ring-2 peer-focus-visible:ring-current peer-focus-visible:ring-offset-4 peer-focus-visible:ring-offset-transparent">
{p.name}
<span className={`grid h-5 w-5 place-items-center rounded-full border-[1.5px] ${on ? "border-[#0d1a4a]" : "border-[#f3ecdf]/50"}`}>
<span className={`h-2.5 w-2.5 rounded-full bg-current transition-transform ${on ? "scale-100" : "scale-0"}`} />
</span>
</span>
<span className="mt-4 flex items-start font-semibold leading-none tracking-[-0.05em]">
<span className="mt-1 text-[22px] tracking-normal">$</span>
<span className="text-[56px] tabular-nums">{price}</span>
<span className={`ml-2 mt-2 text-[12px] font-medium leading-snug tracking-normal ${on ? "text-[#0d1a4a]/70" : "text-[#f3ecdf]/70"}`}>
a month
<span className="block">{yearly ? `$${p.yearly * 12} billed yearly` : "billed monthly"}</span>
</span>
</span>
<span className={`mt-3 block text-[15px] leading-snug ${on ? "text-[#0d1a4a]/80" : "text-[#f3ecdf]/80"}`}>{p.pitch}</span>
</label>
<ul className={`mt-5 flex-1 space-y-2.5 border-t pt-5 text-[14px] leading-snug ${on ? "border-[#0d1a4a]/15" : "border-[#f3ecdf]/15"}`}>
{p.features.map((f) => (
<li key={f} className="flex gap-3">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mt-0.5 shrink-0">
<path d="M4 10.5l4 4 8-9" />
</svg>
{f}
</li>
))}
</ul>
<a
href="#"
onClick={(e) => e.preventDefault()}
className={`mt-6 inline-flex h-11 items-center justify-center rounded-full px-5 text-[14px] font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ${on ? "bg-[#0d1a4a] text-[#f3ecdf] hover:bg-[#162a6e] focus-visible:ring-[#0d1a4a] focus-visible:ring-offset-[#f3ecdf]" : "bg-[#f3ecdf]/10 text-[#f3ecdf] hover:bg-[#f3ecdf]/20 focus-visible:ring-[#f3ecdf] focus-visible:ring-offset-[#0d1a4a]"}`}
>
{p.cta}
</a>
</div>
);
})}
</div>
</fieldset>
<p aria-live="polite" className="sr-only">{`${chosen.name}, $${yearly ? chosen.yearly : chosen.monthly} a month, billed ${yearly ? "yearly" : "monthly"}`}</p>
</div>
</section>
);
}
