English · 日本語
Live demo: https://saqoosha.github.io/Spectral-Glass/ (WebGPU: Chrome/Edge 120+ or Safari 18+. HTML-in-canvas background: Chrome with the
CanvasDrawElementflag; otherwise the app falls back to Picsum-only.)
A realtime WebGPU demo of physically accurate spectral dispersion through
Apple "Liquid Glass"-style floating pills, triangular prisms, rotating cubes,
tumbling wavy plates, and round brilliant cut diamonds. Unlike the common
"shift R/G/B IORs" hack that most web implementations use (including Three.js's
MeshPhysicalMaterial.dispersion), this samples the full visible spectrum
per-wavelength and reconstructs the final color via CIE 1931 color matching
functions.
Above: the default opening scene: four rotating glass cubes (n_d = 1.272,
V_d = 2.0, refraction strength 0.15, perspective FOV 45°, N = 16) over a
composite background — this README page (live HTML, via Chrome's
CanvasDrawElement trial) layered on top of a Picsum photo when the browser
supports HTML-in-canvas, or the same Picsum photo by itself when it does not.
The bright spectral fringes along every cube edge are the shader splitting
the composite background by wavelength in real time at 60 fps; on busier,
high-chroma photos like the sunset above the dispersion mixes with the
photo's own colour, and on a flatter / monochrome Picsum draw the per-
wavelength split reads as a pure rainbow instead.
A 3-sample RGB IOR is visibly a three-band rainbow. Real glass is a continuous spectrum. When dispersion is strong you can see the difference — the 3-sample version produces hard R/G/B fringing along every refraction edge, while the 8-sample spectral version resolves into a continuous rainbow.
Left: N = 3, with per-pixel jitter and temporal accumulation disabled so the
underlying 3-band structure is visible. Right: N = 8 with the spectral pipeline
fully on (stratified jitter + CIE reconstruction + EMA history). Same rotating
glass cube (n_d = 1.7, V_d = 4), same grayscale background photo, same
frame — only the per-wavelength sample count and the smoothing pipeline differ.
In the live demo, press and hold Z to force N = 3 AND pin temporal
jitter off — the 3-band RGB structure only shows up when both are flipped, so
the hotkey toggles them together. Release to restore the configured sample
count (the default opening scene is N = 16) and the jitter setting from
Tweakpane. With jitter on, even N = 3 looks close to N = 8 at typical
dispersion strengths because the per-pixel hash and EMA history smooth the
bands back into a continuous rainbow.
bun install
bun run dev # http://localhost:5173
bun run test # Vitest on the math modules
bun run build # tsc --noEmit + vite buildRequires a WebGPU-capable browser (Chrome / Edge 120+, Safari 18+). The
scrollable HTML text background uses Chrome's HTML-in-canvas
CanvasDrawElement path; browsers without it automatically switch the
Background control to Picsum only.
| Input | Action |
|---|---|
| Drag a shape | Move it around the canvas (cube / diamond use a circular hit radius) |
Z (hold) |
Force N = 3 AND pin temporal jitter off so the 3-band RGB structure is actually visible (release to restore both) |
| Space | Shuffle the active shape's instances to random positions (4 for pill / prism / cube / plate, 1 for diamond) |
R |
Load a new random Picsum photo (same as Random photo; only when HDR env is off) |
T / S / B / F |
Diamond view presets — Top (table toward camera) / Side (girdle profile) / Bottom (culet toward camera) / Free (tumble). No-op for other shapes. |
| Tweakpane | IOR, Abbe, sample count, shape (pill / prism / cube / plate / diamond), dimensions, wave amp + wavelength (plate only), diamond size + view preset + Wireframe / Facet color + TIR debug (pink = bounce budget exhausted with refract still TIR; orange = analytic exit miss) + TIR max bounces 1…32 (default 6, higher = more work on TIR pixels) (diamond only), refraction strength, projection (ortho / perspective), FOV, temporal jitter, refraction mode, Stop the world (freeze rotation/wave while AA keeps converging), AA mode selector — None / FXAA (single-frame spatial filter) / TAA (sub-pixel jitter + motion-vector history reprojection), Environment — HDR env on: Poly Haven panorama (1K/2K/4K) + exposure + rotation + random; off: those hidden + Random photo (Picsum background; legacy reflSrc path for reflections). In Chrome with HTML-in-canvas, Background switches between Picsum only and Picsum + text (HTML). |
| Presets | Subtle pill · Prism rainbow · Rotating cube · Wavy plate · Diamond |
| Materials | 10 real-world glasses (water → BK7 → SF flints → diamond → moissanite) + 4 fantasy (n_d up to 3.5, V_d down to 2) |
Preset values are intentionally opinionated snapshots:
| Preset | Instances | Key values |
|---|---|---|
| Subtle pill | 4 | N=8, n_d=1.517, V_d=6.5, refraction 0.035, pill 400×115×62, edge 100 |
| Prism rainbow | 4 | N=16, n_d=1.600, V_d=12, refraction 0.155, prism 393×149×117 |
| Rotating cube | 4 | N=16, n_d=1.272, V_d=2.0, refraction 0.150, cube 230, edge 44 |
| Wavy plate | 4 | N=16, n_d=1.272, V_d=2.0, refraction 0.200, plate 346×346×60, edge 10.5, wave 17 / 535 |
| Diamond | 1 | N=16, n_d=2.418, V_d=55, refraction 0.200, size 400, free tumble, TIR bounces 6, Brown Studio 2 2K, exposure 0.75, rotation -1.0995 |
All presets use perspective FOV 45°, Exact refraction, temporal jitter on,
AA None, history alpha 0.5, and clear paused / debugProxy. Non-diamond
presets disable HDR env (so the spectral split lands on the photo); the Diamond
preset turns it on with the Brown Studio 2 panorama configured in the table
above.
The Perf panel shows GPU ms by default when the browser exposes WebGPU
timestamp-query (if the adapter does not, the GPU line stays empty — that is
expected). Add ?perf=1 (or ?perf) to also log samples on
window._perf for benchmarks, only in builds where timestamp queries are
available; otherwise the logging hook is not installed. Check Show proxy
in the UI to tint every proxy fragment pink and see the rasterised silhouette.
- WebGPU + WGSL, two-pass. Cheap fullscreen bg pass (photo + history) followed by an instanced 3D-cube mesh proxy per pill. The heavy per-pixel refraction shader only runs on fragments inside the proxy silhouette. Back-face culling (CCW-outward 3D → CW NDC after Y-flip) gives exactly one invocation per covered pixel.
- 3D SDFs, five shapes. Pill (stadium XY + rounded Z), prism
(isosceles triangle in YZ extruded in X), rotating cube (rounded box +
per-frame
rot * (p - center)viacubeRotation(time)), tumbling wavy plate — a thick square slab whose midsurface bends in Z alongwaveAmp · sin(kx+t) · sin(ky+t)while both faces ride that midsurface together, keeping thickness uniform — and a round brilliant cut diamond (58-facet Tolkowsky-ideal polytope, D_8-folded to 5 plane evaluations + table cap + girdle cylinder, with an analytical back-exit and a configurable TIR bounce chain (1–32 internal reflections, default 6) to reproduce the characteristic sparkle). Cube, plate, and diamond proxy corners are transformed bytranspose(rot)so the rasterised silhouette tracks the shader's rotation exactly — no √3 bounding-box slack. Diamond ships its own 46-triangle exact-hull proxy instead of the cube AABB the other shapes use, so sharp-facet silhouettes don't waste fragments on AABB slack. - Ortho or perspective projection. UI toggle. Ortho keeps the flat Liquid
Glass aesthetic; perspective uses a pinhole camera at
(w/2, h/2, cameraZ)withcameraZ = (height/2) / tan(fov/2)derived from the user-facing FOV. - Cauchy + Abbe IOR. Wavelength-dependent index via the glTF
KHR_materials_dispersionformula. - Wyman-Sloan-Shirley CIE XYZ (JCGT 2013) analytic approximation — no lookup tables.
- Two-surface refraction. Front hit via primary sphere-trace, back exit via per-wavelength inside-trace (Exact mode) or shared hero-wavelength trace (Approx mode, Wilkie 2014). Cube, plate, and diamond skip the inside-trace entirely — their back-face exit is analytical (slab intersection for cube/plate, ≈ 10× fewer SDF evals per wavelength than pill/prism; ray-polytope test against all 57 facet planes + girdle cylinder for diamond — no SDF evals, just ≈ 60 dot products, which is comparable in op count to an inside-trace but with NO march loop, NO NaN-prone gradient, and access to the exact facet normal). Diamond's exact facet normal eliminates the finite-diff degeneracy at facet edges that caused TIR pixels to flicker during tumble.
- Per-wavelength Fresnel. Blue λ has higher IOR → higher Schlick Fresnel → visible blue-tinged rim on diamonds and prisms (the classic "fire" of high-index crystals).
- Per-wavelength sRGB weighting. Each sampled photo pixel is weighted by
xyzToSrgb(cmf(λ))— short-wavelength samples contribute to blue, long to red. This preserves photo color when refraction UVs coincide and produces real chromatic fringing where they diverge. - Spatial + temporal jitter. Per-pixel wavelength phase via
hash21so neighbouring pixels sample different λ — the eye and history accumulation average the noise, so N=8 stratified looks like N=16 uniform. Turning Temporal jitter off now pins the spectral phase and hero wavelength, so the on/off difference is visible instead of being hidden by a still-jittered shader path. - TIR fallback. When
refract()returns zero at the back face, the wavelength contributes the external reflection instead of dropping — no black holes inside the cube. For diamond (exact refraction only), a bounce chain runs first: up to N internal reflections (UI “TIR max bounces”, 1…32, default 6), each time reflecting off the current facet, finding the next exit withdiamondAnalyticExit, and trying to refract out. This is where a brilliant cut’s sparkle comes from. If the chain exhausts without a valid exit direction, the sample blends to silhouettebg(or envmap-backedreflSrcwhen the HDR map is on). Approx mode skips the chain and uses the shared heroreflSrcTIR path to avoid flicker whenheroLambdajitters. - HDR environment map (unified scene). Background, refraction, AND reflection all sample a real linear-HDR panorama (Poly Haven CC0 HDRIs, curated across studio / indoor / outdoor / sunset / night categories at 1K / 2K / 4K resolution — 2K default). bg samples at the view direction (skybox-style in perspective, flat in ortho); refracted rays sample at their exit direction (classic IBL refraction, avoids the UV-parallax approximation); reflections at the reflection direction. Bright HDR highlights drive the Fresnel rim — top-view diamonds show the characteristic "light gathered + returned through the crown" sparkle that a flat photo can't produce. Toggleable for A/B with the legacy Picsum photo bg + UV-offset refraction path; exposure + yaw sliders let users tune without re-downloading.
- HTML-in-canvas background. Chrome builds exposing
GPUQueue.copyElementImageToTexturecan rasterise the scrollable DOM text panel into the same texture the glass refracts. If the API is missing or repeated copies fail, the app falls back to Picsum only while staying scrollable and interactive. - Temporal accumulation.
rgba16floatping-pong history with EMA blend (α defaults to 0.5 in steady state — user-tunable via the History α slider — and 1.0 for one frame after a scene change so cube tail doesn't ghost in). When Stop the world freezes the scene, the blend switches to progressive averaging α = max(1/n, 1/256) — noise drops as 1/√n in the convergence ramp and bottoms out at a 256-sample sliding window (~6 % residual). The 1/256 floor is required by fp16 precision so that small new-sample contributions don't round to zero and slowly fade silhouettes to black; seemain.ts pausedFramesfor the full derivation. - Temporal AA with motion-vector reprojection. Each frame's primary
ray is sub-pixel-jittered by a per-pixel hash; history is read at
fragCoord + (projected_prev_world − projected_curr_world)so the jitter cancels and only the rotation-driven world motion shifts the read. Stationary scenes read history at exactly the pixel centre — no iterated bilinear blur — while tumbling cubes and plates keep their refracted texture sharp under motion. The host pre-computes both the current and previous frame'scubeRot/plateRotand uploads them as uniforms; cube and plate get analytic-exit reprojection, pill / prism fall back to the unreprojected read. - Post-process pass. Scene writes linear
rgba16floatinto a canvas-sized intermediate; a second pass copies or FXAA-filters it to the swapchain depending on the AA mode. The sRGB OETF is applied once there (identity when the swapchain is already*-srgb), so both FXAA and the scene share the same linear pixels and the encoding isn't scattered across shaders. - FXAA (optional). 9-tap FXAA 3.x in the post pass — luma computed in
perceptual (sRGB) space for edge detection, color blended in linear space.
Alternative to TAA: no temporal jitter, zero ghosting, slightly softer
edges.
~0.3 msat 1080p. - Photo mipmaps. Uploaded photo carries a full mip chain (fullscreen-blit
downsample). Per-wavelength refraction sample picks an LOD from two terms:
-log2(cosT) - 1(grazing-angle minification) plus(1 - max(|nLocal|)) · 8on cube / plate to handle rounded-rim normal-turn aliasing. Clamped to[0, 6]. - localStorage persistence. Validated load (rejects NaN / bogus enums),
legacy
taa: boolean→aaModemigration for older payloads, trailing-edge debounced save, pagehide flush.
src/
├── main.ts Frame loop + glue (+ T/S/B/F diamond-view hotkeys)
├── math/ Pure math modules (unit-tested)
│ ├── cauchy.ts Wavelength → IOR (glTF formulation)
│ ├── wyman.ts Wyman CIE XYZ approximation
│ ├── srgb.ts XYZ → linear sRGB matrix + OETF
│ ├── sdfPill.ts 3D pill SDF (mirrors WGSL version)
│ ├── sdfPrism.ts Triangular prism SDF (mirrors WGSL version)
│ ├── sdfCube.ts Rounded box / cube SDF (mirrors WGSL version)
│ ├── cube.ts rz·rx rotation columns for the tumbling cube
│ ├── plate.ts rx·ry rotation columns for the tumbling plate
│ ├── diamond.ts Tolkowsky-ideal brilliant-cut proportions,
│ │ facet-plane derivations, WGSL `const` emitter,
│ │ tumble + fixed-view rotation matrices
│ └── diamondExit.ts JS mirror of the analytical ray-polytope
│ back-exit (Phase B regression reference)
├── hdr.ts Minimal Radiance .hdr decoder (Phase C)
├── envmap.ts HDR envmap texture loader + GPU uploader
├── envmapList.ts Curated Poly Haven HDRI slugs + random picker
├── htmlBgTexture.ts HTML-in-canvas texture copy + support checks
├── persistence.ts localStorage: validated load, debounced save, pagehide flush
├── photo.ts Picsum fetch → GPU texture (w/ gradient fallback)
├── pills.ts Pill state + shape-aware pointer drag
├── perfStats.ts Rolling CPU/GPU HUD stats
├── shapeParams.ts Shape-specific params → frame fields
├── spectralSampling.ts Temporal-jitter / hero-wavelength field builder
├── ui.ts Tweakpane bindings (shape selector, presets, materials)
├── webgpu/
│ ├── device.ts Adapter + device + error handlers
│ ├── history.ts Ping-pong history textures
│ ├── pipeline.ts Bg + proxy pipelines + shared bind groups + encodeScene
│ ├── postprocess.ts Intermediate rgba16f target + passthrough/FXAA pipelines + encodePost
│ ├── mipmap.ts Fullscreen-blit mipmap generator (used by photo.ts)
│ ├── perf.ts GPU timestamp harness (default when supported)
│ └── uniforms.ts Typed uniform buffer writer
└── shaders/
├── fullscreen.wgsl Fullscreen triangle vertex shader
├── postprocess.wgsl Passthrough + FXAA fragment shaders + sRGB OETF
├── dispersion/
│ ├── frame.wgsl Uniforms + envmap sampling helpers
│ ├── sdf_primitives.wgsl Pill/prism/cube/plate SDFs
│ ├── scene.wgsl Scene SDF aggregate + shape dispatch
│ ├── trace.wgsl Sphere trace + analytic exits
│ ├── spectral.wgsl Cauchy IOR + CIE/Wyman spectral helpers
│ ├── proxy.wgsl Instanced proxy vertex path
│ └── fragment.wgsl Background + dispersion fragment shaders
└── diamond.wgsl Diamond-specific WGSL: `sdfDiamond` (D_8 folded),
`diamondAnalyticExit` (ray-polytope back-exit
used by the TIR bounce chain), wireframe + facet-
colour + TIR debug overlays, exact convex-hull proxy
mesh, TAA pill-index picker
tests/ Vitest unit tests for each math module
docs/
└── ARCHITECTURE.md Frame path, uniform layout, SDF & tracing details
Math modules in src/math/ are mirrored 1:1 by functions in
src/shaders/dispersion/*.wgsl and src/shaders/diamond.wgsl — the vitest
suite (run bun run test — currently 200 cases, count drifts as cases are added) acts as the
reference implementation for the shader. The diamond plane coefficients
are injected from diamond.ts into the shader source at pipeline build
time so the host-side math and GPU-side constants can't drift.
- Architecture notes — module map, frame path, uniform layout, proxy mesh + camera, per-wavelength loop (spatial stratification, per-λ Fresnel, TIR fallback), measured performance
Apple Silicon (1292×1073, 4 instances on screen, WebGPU timestamp-query, p50 of ≥ 30
samples):
| Config | GPU time |
|---|---|
| pill N=8 | 1.70 ms |
| pill N=32 | 6.42 ms |
| cube N=8 | 1.05 ms |
| cube N=16 | 1.38 ms |
| cube N=32 | 1.97 ms |
| cube N=64 | 3.21 ms |
All within the 16.67 ms vsync budget. Background pixels cost ~nothing; the
per-λ loop dominates on pill / prism / cube / plate pixels. Cube and plate
are noticeably cheaper than pill at the same N because their back-face
exits are analytical slab intersections instead of the per-wavelength
sphere-trace pill/prism still pay — plate adds 3 Newton iterations on top
to land on its wavy surface. Apple's TBDR already culls background
efficiently, but discrete GPUs gain more from the proxy pass.
- Khronos. KHR_materials_dispersion — the Cauchy + Abbe formulation used here.
- Wyman, Sloan, Shirley (2013). Simple Analytic Approximations to the CIE XYZ Color Matching Functions. JCGT 2(2).
- Wilkie et al. (2014). Hero Wavelength Spectral Sampling. EGSR.
- Peters (2025). Spectral Rendering, Part 2.
- Heckel. Refraction, dispersion, and other shader light effects.
Tech demo / proof of technique. Not a library. No production website
integration. If you want to pull the spectral-refraction technique into your
own project, the interesting files are src/shaders/dispersion/,
src/shaders/diamond.wgsl, and the mirrored helpers in src/math/.
HDR environment maps are sourced on demand from Poly Haven and individually authored by their contributing photographers (primarily Greg Zaal, Dimitrios Savva, Sergej Majboroda, et al.). All Poly Haven HDRIs are released under CC0 — no attribution required for use, but listed here in gratitude. Consider supporting Poly Haven if you use a lot of their bandwidth.
Picsum background photos are fetched from picsum.photos under the Unsplash license.
Project code itself is MIT-licensed.

