Scenic Prism: Splitting Light with a WebGPU Path Tracer

scenic-prism is a sibling of scenic-draft. Scenes are written the same way, as a tree of signed-distance shapes, materials and a background. The difference is underneath: it compiles to a WGSL compute shader and path-traces on WebGPU, and it is built around one thing scenic-draft cannot do. Glass splits white light into colour.

Unlike the scenic-draft posts, every picture here is traced live in your browser, with scenic-prism-react. They need WebGPU, and start when they scroll into view. The counter in the corner is the number of samples per pixel so far.

0/1000

Dispersion

Real glass bends short wavelengths more than long ones. White light passing through a prism therefore leaves as a fan of colours, each at a slightly different angle.

scenic-draft traces one ray per sample, carrying red, green and blue together, so all three bend by the same amount. Glass refracts but never splits light. scenic-prism instead traces several bands per sample, each a separate path from the same camera ray with the same random numbers. Each band refracts with its own index of refraction, through every bounce. The bands are recombined, weighted by their colours, before tone mapping.

Where nothing refracts, the paths stay together and add back up to the ordinary image. At a glass surface they part:

0/1000
dispersion: false
0/1000
dispersion: { strength: 0.08 }

Dispersion is on by default. Any refractive material, one with transmission or an ior other than the default, splits light without further setup:

'use client'

import {
  backgrounds,
  camera,
  draft,
  materials,
  SceneRenderer,
  sphere,
} from 'scenic-prism-react'

// Module scope: a new spec object would recompile and restart the render.
const BALL = draft(
  sphere(1.1).paint(materials.glass),
  backgrounds.slats,
).withCamera(camera([0, 0.6, -4]).focalLength(42))

export function Ball() {
  return <SceneRenderer spec={BALL} size={720} bounces={14} lazy />
}
0/1000
One path
0/1000
Red, green and blue paths (the default)

At the default settings the effect is subtle: thin blue and orange edges on the slats seen through the ball.

The default is three bands, red, green and blue, at 650, 550 and 450 nanometres. Each band's index is:

index = 1 + (ior - 1) * iorScale
      + strength * ((referenceWavelength / wavelength)^2 - 1)
      + iorOffset

ior is the material's own. The wavelength term is an artistic dispersion law rather than a fit to real glass. strength (default 0.04) sets how far apart the bands are pulled. Zero turns the effect off, and a negative value reverses the order of the colours. iorScale and iorOffset adjust a single band's index directly.

Turning strength up pulls the bands further apart:

0/1000
strength: 0.04 (default)
0/1000
strength: 0.2

Palettes

The scene-level dispersion field takes a list of channels, from one to sixteen. Each has a color, the RGB weight it contributes, and optionally a wavelength, iorScale and iorOffset.

A band's colour is independent of its wavelength. Seven bands from red to violet give a smoother rainbow than three. Equally, a band can be any colour, with any index:

// Seven bands, closer to a real spectrum.
const rainbow = {
  strength: 0.12,
  channels: [
    { color: [1, 0, 0], wavelength: 680 },
    { color: [1, 0.5, 0], wavelength: 620 },
    { color: [0.8, 0.9, 0], wavelength: 580 },
    { color: [0, 1, 0], wavelength: 530 },
    { color: [0, 0.6, 1], wavelength: 490 },
    { color: [0, 0, 1], wavelength: 460 },
    { color: [0.5, 0, 1], wavelength: 420 },
  ],
}

// Two bands, with wavelength ignored and each index set directly.
const warmCool = {
  strength: 0,
  channels: [
    { color: [1, 0.45, 0.1], iorScale: 0.4 },
    { color: [0.1, 0.55, 1], iorScale: 2.2 },
  ],
}

// Cyan, magenta and yellow, in reverse order.
const cmy = {
  strength: -0.12,
  channels: [
    { color: [0, 1, 1], wavelength: 640 },
    { color: [1, 0, 1], wavelength: 540 },
    { color: [1, 1, 0], wavelength: 440 },
  ],
}

draft(scene, backgrounds.slats).withDispersion(rainbow)

Each choice below is a different spec, so the shader is recompiled and the image restarts:

0/1000

The weights are normalised per component across the palette, so overlapping bands add no brightness. A palette that covers red, green and blue reproduces the plain image wherever nothing refracts. The warm/cool pair has no dispersion law at all: on glass, one band refracts with an index of about 1.2, less than water, and the other about 2.1, close to diamond, so the prism shows two separate images of the slats.

Seeing it, and paying for it

Dispersion is easiest to see at sharp changes in what a refracted ray picks up. The bands() background, bright stripes with dark gaps, is made for this. backgrounds.slats, backgrounds.spectrum and backgrounds.duotone are ready-made versions. Flat-faced solids with sharp edges split light more visibly than a sphere, as long as the light leaves through a face at an angle to the one it entered by. Light crossing two parallel faces, as through a window pane or between opposite faces of an octahedron, comes out travelling the way it went in, and its colours recombine. That is why the examples use a prism. There is no prism primitive, so it is an intersect of five half-space planes:

const s = Math.sqrt(3) / 2

// Three long faces around the y axis (one edge towards the camera) and two ends.
const prism = intersect(
  plane([0, 0, 1], 0.375),
  plane([s, 0, -0.5], 0.375),
  plane([-s, 0, -0.5], 0.375),
  plane([0, 1, 0], 1.3),
  plane([0, -1, 0], 1.3),
).paint(materials.glass)

Each sample traces every band, so the cost grows with the number of bands: three paths for the default, seven for the rainbow above. You may notice the rainbow converging more slowly. Scenes without a refractive material compile to a single path regardless. Glass also needs a larger bounce budget, since every surface crossing spends one. These examples use bounces={14}.

It is an approximation, not a spectral renderer. Only the refractive index varies by band; absorption, emission and thin films stay RGB.

There is more in the guide, and the React component is documented at scenic-prism.pages.dev/scenic-prism-react.