This page runs from a web server, not from a file: in the repository run python3 -m http.server 8000 and open http://localhost:8000/tutorial/.

Part III · What surfaces do · Chapter 11

Rainbows

Glass bends blue light a little more than red, so a prism fans white light into a rainbow. A renderer that carries three numbers per ray, red, green and blue (chapter 6), has no blue and red to bend apart. This chapter is how ShadyTracer does it anyway: one wavelength drawn for a ray at the moment it enters such glass, and a weight for each of its three channels chosen so that, on average, the colours add back to white; and how a coating that reflects some colours and passes others needs no wavelength at all.

1A prism

The index of refraction of glass is not one number: it is larger for shorter wavelengths, so blue bends more than red. That is dispersion. Here a dense flint glass, of index 1.75 at yellow, fans a beam of white light: each line is one wavelength, 400 to 700 nm, in its own colour, refracted at its own index both into the prism and out. Blue (486 nm) leaves 3.24° more steeply than red (656 nm).

BK7, the commonest optical glass, disperses far less: 0.71°. A glass catalogue says how much by its Abbe number, 28 for the flint and 64.17 for BK7: the higher, the less.

With no dispersion every wavelength bends alike, and the white beam stays white: what ShadyTracer does for a glass whose Abbe number is left at 0, the default. No real glass has an Abbe number of 0 (by the formula below that would mean endless spreading), so the renderer uses it as a switch: 0 means "do not disperse". Drag the beam's handle to tilt it; tilt it too far, and some colours are totally reflected inside (chapter 10).

A prism (in the glass colour) and a beam of white light (drawn in the page's ink: white would not show) you can tilt by its handle, aimed at the left face; 31 wavelengths from 400 to 700 nm, each drawn in its colour once it leaves, refracted through the prism at its own index (inside, the colours have hardly parted yet, so the beam there is one line in ink). Set the glass's index at yellow and its Abbe number.

2Two numbers for a glass

A catalogue gives a glass's index at three wavelengths named after dark lines in the sun's spectrum (Fraunhofer's): F at 486 nm, d at 588 and C at 656. It lists nd, and the Abbe number Vd = (nd − 1) / (nF − nC). ShadyTracer takes those two and draws the index at every wavelength with Cauchy's formula, n = A + B/λ²: exactly nd at d, the gap nF − nC exactly (nd − 1)/Vd, as the Abbe number defines it, and Cauchy's shape between. The two indices themselves are each a little off: for BK7 it gives nF 1.52243 and nC 1.51438, against the catalogue's 1.52238 and 1.51432 (the rings), 5 in the fifth decimal; their gap, 0.00805, is the catalogue's 0.5168/64.17, which its own rounded values give as 0.00806.

The index of BK7 and of a glass you set, at every wavelength, by Cauchy's formula through nd and Vd; the F, d and C lines in their colours, and BK7's catalogue values (rings). Point at the curves to read them.

3A wavelength for a ray

Why not simply bend red, green and blue at three indices? Because each channel stands for a spread of wavelengths (chapter 6), and the spread within red would be lost: a prism would make three beams, not a rainbow. And why not give every ray one wavelength from the start? Then white light everywhere, not just in a prism, would be colour noise (section 6). So ShadyTracer does neither until a material sorts colours.

A ray carries red, green and blue; white light is all three alike, (1, 1, 1). When one is refracted into a dispersive glass, the renderer picks it a single wavelength at random, which it then keeps, out of the prism and into any glass after. The wavelengths are split into 40 bins of 10 nm. Each channel stands for a range of wavelengths (chapter 6's curves), and its responses over the bins add up to 1. A bin is picked as often as the three channels together respond to it (the bars, in each bin's colour). That gives two humps: blue near 445 nm, where blue's whole response is crowded into a few bins, and orange near 590 nm, where red and green overlap; deep violet and deep red rarely.

Then each channel is weighted by how much it responds to that wavelength, divided by the bin's chance: chapter 8's rule, pick with chance P and divide by P, so that rarely picked wavelengths count for more each time and the average comes out right. Point at a bin to read its three weights. One pick, worked: the bin from 440 to 450 nm is picked 6.19% of the time; there red responds 0.0132, green 0 and blue 0.1725 (each channel's share of its whole response), so the weights are 0.0132 / 0.0619 = 0.21, 0 and 0.1725 / 0.0619 = 2.79. A white ray (1, 1, 1) that draws it goes on as (0.21, 0, 2.79): violet-blue, and bright, because it stands in for the blue that other paths will not carry.

So each channel's weight averages exactly 1 over the picks (the fold-out): a white ray becomes one colour on each path, and averaged over many paths is white again. 20,000 picks average 0.993, 1.014 and 0.993, heading for 1.

The reflections stay white. At the prism's face a path first decides, at random, whether it reflects or refracts (chapter 10: it reflects with a chance equal to the share the glass reflects), and that decision uses the index at yellow, the same for every colour. Reflection hardly sorts colours anyway: from 380 to 780 nm a prism's reflectance changes by about 0.01 at most (section 6). A reflected ray never gets a wavelength: it keeps all three channels alike, white, and free of colour noise, the speckle of different colours from path to path that only many paths average away. Only the refracted light carries the rainbow, and its noise.

The fit's red end. The colour-matching curves are a fit (chapter 6), and its tails are its weakest part: past about 700 nm its ȳ outlasts its x̄, and its red response is 0 from 733 nm, so the last bins weigh green only (worked out from spectral.js in node). The CIE's own deep reds all look red (the red corner of chapter 6's diagram), so this is the fit's, not the light's. The draw lands there about once in 80,000 (1.25 × 10⁻⁵), so no picture should show it; a test of a deep-red laser through a prism would.

The mathsWhy the weights average to 1

Let Wc(k) be channel c's response in bin k, each channel's responses adding up to 1 over the bins (so that a white light, 1 in every channel, is made of them exactly). The bin is picked with chance P(k) = (WR + WG + WB)/3 (the three responses' average, so that the chances add up to 1) and gives channel c the weight Wc(k)/P(k). The average of anything over the picks is each value times its chance, added up (Σk, "add up over every bin k"):

Σk P(k) × Wc(k)P(k) = Σk Wc(k) = 1

A weight is at most 3, where one channel alone responds. Small weights mean little noise: a pick that counts enormously, as a rare one divided by a tiny chance would, makes a speck. Picking in proportion to the summed responses keeps the weights that small, and wastes no pick on a wavelength no channel sees.

Left: the chance of each 10 nm bin being picked, each bar in its wavelength's colour; point at a bin for the weights a ray then carries. Right: the three weights' average over the picks so far, against 1 (dashed), each bar in its channel's colour. Pick 100 or 10,000 more, or start again.

4A coating that sorts colours

A dichroic coating, thin layers on glass, reflects some wavelengths and passes the rest: this one reflects above 600 nm, so it reflects red and passes cyan. It needs no wavelength picked. Each of the ray's three channels already stands for a spread of wavelengths (chapter 6's curves, drawn under the strip), so the renderer averages the coating's reflectance over each channel's own wavelengths and reflects that much of the channel: red 0.584, green 0.014, blue 0.000. Red is not all reflected because its range starts well below the edge, near 550 nm: about 41% of its response lies below 600 nm.

Exact for a coating that reflects the same at every wavelength; at an edge, an approximation: a laser's pure 650 nm light is, to the renderer, mostly red channel, and reflects 0.584 of it, where the real coating would reflect nearly all of it. A ray that already carries a wavelength of its own is different: one a prism has split (section 3), or a ray from a lamp maker's ray file that lists each ray's wavelength. Since version 0.83.0 such a ray is reflected as the coating reflects that one wavelength, the same share in all three channels, because the average would be about light spread over many wavelengths, and this ray has one (materials_pbr.js, "A ray with a wavelength of its own").

Tilt the light, and the edge moves towards blue, as real coatings do: at 45° it is at 551.8 nm, and now most of the red and a third of the green reflect. The renderer moves it by the usual first-order rule, edge × √(1 − (sin θ / neff)²), with neff the layers' average index, 1.8 by default.

A dichroic coating's reflectance over the wavelengths (the strip below in their colours), its edge moved by the light's tilt, and under it what each channel responds to; right, what each of the ray's three channels reflects (spectral.js's own edgeRGB). Set the edge, its width and the tilt; point at the curve to read it.

5How ShadyTracer does it

The index at any wavelength, from a catalogue's two numbers:


    

The draw of section 3:


    

And where a ray meets a dispersive glass, in the shading kernel: an index for a ray that already has a wavelength, a pick for one that does not, used only if it refracts; then, if it does, the line that multiplies the ray's three channels by the weights and gives it its wavelength ():


    

The wavelength rides in the ray's tag, a 32-bit word of flags, in its top 15 bits: 0 for none, otherwise 380 nm plus (the code − 1) × 400/32,766 nm (codes 1 to 32,767: 32,766 steps between them), steps of 0.012 nm (), so a ray grows no larger.

One switch turns it all off, and it is the scene's: its optical features, dispersion off, compile every material without its Abbe number (), so no ray picks a wavelength and every glass bends all colours alike, at its nd, with none of the colour noise. Camera paths, a light's photons and the Optics page all trace the scene's own code, so all of them lose it together, and the Abbe number is not shown on any material while it is off.

Numbers in the comments, checked. spectral.js's header says the wavelength rides in the tag's "upper 16 bits … in bits 17-31": that is 15 bits, as its own later comment and LAMBDA_SHIFT = 17 say. It says a coating's reflectance is "41 softplus differences": the loop takes 41 values and 40 differences, one a bin. It says red's response is negative "from about 440 to 545 nm": with this fit, red's weight is 0 from 460 to 552 nm. It says BK7's index comes out "within 3e-4" of the catalogue: 5.0 × 10⁻⁵ and 5.6 × 10⁻⁵ (section 2), as its test says.

6Tricks and trade-offs

Each is stamped with how it is known; point at a stamp to see what it means.

"Looks right" first. ShadyTracer's developer chose (2026-10-08) to keep three channels and give a ray a wavelength only where a material sorts them, rather than trace every ray at one wavelength, which turns white light into colour noise everywhere (spectral.js's header; chapter 6).

What "looks right" costs an LED. Averaging a filter over each channel's wavelengths assumes the channel's light is spread the way its weights are, and an LED's blue is a narrow peak. On a white LED through a dichroic edge at 500 nm, through an amber filter and through two filters met at a gather, the three channels read Y 6 to 10% low and the colour 0.016 to 0.028 u′v′ off. One wavelength a ray has no such error, but each path's Y is then off by about its own size. Four wavelengths a ray, drawn stratified, need about 1,000 paths to settle the colour to 0.001 u′v′, and three, what fits in the ray's three colour numbers, 1,200 to 2,700. Sixteen fixed bands per ray are about 1% off through these filters and 9% through a phosphor layer (core/test/spectralnoise.test.mjs, exact sums in Node, 2026-10-29 and 2026-11-04: arithmetic, no graphics card). The optics view offers the three as a scene's choice (chapter 31); on a simulated card a dichroic plate then passes 0.9672 of a white LED's light against its integral 0.9677, where three channels read 0.8345 (optics/test/spectral.gpu.mjs, Dawn on a software driver: correctness, never speed).

Checked on a simulated graphics card. The shading kernel's own bounce, 65,536 times a case, a ray at 45° onto glass of index 1.75: reflected at the yellow line's Fresnel and white; refracted along Snell at the index of its own wavelength to within 10⁻⁵; averaging the share not reflected in every channel (core/test/dispersion.gpu.mjs, Dawn on a software driver: correctness, never speed). On the user's card the test with a wedge (optics-dispersion) has not yet been run; it expects −23.892°, −24.177° and −24.860° for red, green and blue.

What it leaves out. The reflectance's own change with wavelength at the first surface (straight on, from 380 to 780 nm: 0.0029 for BK7 and 0.0107 for the dense flint, as CHANGELOG.md 0.40.0 says and the formula gives); a light that starts inside dispersive material, which leaves at nd; and in rough dispersive glass, a facet that totally reflects at the ray's index but not at nd loses that ray (CHANGELOG.md, 0.40.0).

7What went wrong before

  • Green reflections. The optics view drew each ray's colour scaled to its brightest channel, right for a white ray and nothing else: a dispersed bundle summed to (0.35, 0.71, 0.14), and the reflections inside the prism of light already split, where the colours overlap again, came out green in the drawing (the user's picture, 2026-10-09). A line is now the ray's own red, green and blue, and the same bundle sums to white (CHANGELOG.md, 0.44.1).
  • A wavelength squeezed. The tag first gave the wavelength 16 bits; another flag took one in 0.44.0, leaving 15, steps of 0.012 nm instead of 0.006 (spectral.js).
  • A test that crashed the driver. A loop around the shading function made the software graphics driver crash; the test calls it once a thread instead (dispersion.gpu.mjs).

8Try it

  • In the prism, slide the Abbe number from 90 to 15. How does the fan's width change?
  • In the index chart, set your glass to BK7's numbers. Do the curves meet?
  • Draw 100 wavelengths several times over (start again in between). How far from 1 do the averages wander, and how does that shrink with 10,000?
  • In ShadyTracer, make a prism of glass with index 1.75 and Abbe number 28 in the sun, with caustics on: a rainbow on the floor beside it.

Words from this chapter