Skip to content

Saqoosha/Spectral-Glass

Repository files navigation

English · 日本語

Spectral Glass

Live demo: https://saqoosha.github.io/Spectral-Glass/ (WebGPU: Chrome/Edge 120+ or Safari 18+. HTML-in-canvas background: Chrome with the CanvasDrawElement flag; 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.

Four glass cubes refracting the README page over a Picsum sunset photo

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.

Why not just shift R/G/B?

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.

3-sample vs 8-sample spectral — zoomed on cube edge

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.

Quick start

bun install
bun run dev        # http://localhost:5173
bun run test       # Vitest on the math modules
bun run build      # tsc --noEmit + vite build

Requires 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.

Controls

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), EnvironmentHDR 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.

Technical approach

  • 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) via cubeRotation(time)), tumbling wavy plate — a thick square slab whose midsurface bends in Z along waveAmp · 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 by transpose(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) with cameraZ = (height/2) / tan(fov/2) derived from the user-facing FOV.
  • Cauchy + Abbe IOR. Wavelength-dependent index via the glTF KHR_materials_dispersion formula.
  • 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 hash21 so 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 with diamondAnalyticExit, 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 silhouette bg (or envmap-backed reflSrc when the HDR map is on). Approx mode skips the chain and uses the shared hero reflSrc TIR path to avoid flicker when heroLambda jitters.
  • 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.copyElementImageToTexture can 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. rgba16float ping-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; see main.ts pausedFrames for 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's cubeRot / plateRot and 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 rgba16float into 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 ms at 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|)) · 8 on cube / plate to handle rounded-rim normal-turn aliasing. Clamped to [0, 6].
  • localStorage persistence. Validated load (rejects NaN / bogus enums), legacy taa: booleanaaMode migration for older payloads, trailing-edge debounced save, pagehide flush.

Project structure

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.

Design

  • Architecture notes — module map, frame path, uniform layout, proxy mesh + camera, per-wavelength loop (spatial stratification, per-λ Fresnel, TIR fallback), measured performance

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.

References

  1. Khronos. KHR_materials_dispersion — the Cauchy + Abbe formulation used here.
  2. Wyman, Sloan, Shirley (2013). Simple Analytic Approximations to the CIE XYZ Color Matching Functions. JCGT 2(2).
  3. Wilkie et al. (2014). Hero Wavelength Spectral Sampling. EGSR.
  4. Peters (2025). Spectral Rendering, Part 2.
  5. Heckel. Refraction, dispersion, and other shader light effects.

Status

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/.

Credits

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.

About

Realtime WebGPU demo of physically accurate spectral dispersion through glass pills, prisms, and rotating cubes. Per-wavelength refraction via Cauchy+Abbe IOR, spatial stratification, per-λ Fresnel.

Resources

License

Stars

Watchers

Forks

Contributors