How the laser works
A spectral ray tracer as a game weapon — Snell, Fresnel, Cauchy and 96 wavelengths, with live examples
In Prisma Duel the weapon is light. Not a line with a hit test on it, but a beam that is traced as light: ninety-six wavelengths leave the muzzle, each one bends by its own amount when it meets a crystal, some of it reflects and some transmits at every face, it fades as it crosses glass, it comes off the mirrored walls at 86%, and whatever lands on a hull is the damage. This page walks through the whole thing, from a single wavelength to the shader that draws it. Every figure is live — drag the sliders, drag on the tracer.
The code shown is the game's own, lightly trimmed. It is all in one file, so you can read the rest of it in the page source.
1. One wavelength, and what colour it is
A ray in this tracer is not red or green or blue. It is a wavelength, in nanometres, somewhere between 398 and 706 — the part of the spectrum a display can say anything useful about. To put it on screen it has to become RGB, and the honest way to do that is the way the eye does: integrate against the CIE 1931 colour-matching functions to get XYZ, then apply the sRGB matrix.
// Wyman/Sloan/Shirley multi-lobe Gaussian fits of the CIE 1931 observer
function cieXYZ(l) {
const x = gauss(l, 1.056, 599.8, 37.9, 31.0) + gauss(l, 0.362, 442.0, 16.0, 26.7) + gauss(l, -0.065, 501.1, 20.4, 26.2);
const y = gauss(l, 0.821, 568.8, 46.9, 40.5) + gauss(l, 0.286, 530.9, 16.3, 31.1);
const z = gauss(l, 1.217, 437.0, 11.8, 36.0) + gauss(l, 0.681, 459.0, 26.0, 13.8);
return [x, y, z];
}
Two details matter. Pure spectral colours lie outside the sRGB gamut, so the matrix hands back negative components; clipping them to zero flattens violet and cyan into mush. Instead each colour is desaturated just enough to bring it inside — add white until the smallest channel is zero — which keeps its hue and its luminance. And the whole band is normalised so that a flat, equal-energy spectrum integrates to neutral white. That normalisation is what makes a dispersed fan keep its true luminance profile: bright in the yellow-green, dim at the violet end, exactly as a real rainbow is.
2. Snell's law: the bend at a face
When a ray reaches the face of a crystal it changes direction. Snell's law says
n₁ sin θᵢ = n₂ sin θₜ: the ratio of the sines is the ratio of the refractive indices. Going
into glass (n about 1.4) the ray bends towards the normal; coming out it bends away. Coming out
there is an angle beyond which sin θₜ would have to exceed 1, and nothing transmits at all
— total internal reflection. That is a big part of why light gets trapped and guided inside a
crystal, and why a shot into one at a shallow angle comes out somewhere you did not expect.
3. Fresnel: how much reflects, and how much goes through
Snell says where; Fresnel says how much. At every face a ray splits into a reflected part and a transmitted part, and the split depends on the angle: head-on, a 1.4 glass reflects about 3%; at a grazing angle it reflects nearly everything, which is why a lake is a mirror at sunset. The game uses the full equations for the two polarisations and averages them, because a laser in this world is unpolarised and because it costs nothing.
const Rs = ((n1 * cosi - nn * cost) / (n1 * cosi + nn * cost)) ** 2;
const Rp = ((n1 * cost - nn * cosi) / (n1 * cost + nn * cosi)) ** 2;
R = clamp((Rs + Rp) * 0.5, 0, 1); // unpolarised
if (sin2t > 1) R = 1; // total internal reflection
4. Dispersion: why the rainbow
Everything above would be true of a grey ray. The rainbow happens because the refractive index is not
one number: it depends on wavelength. Violet bends more than red. The classic empirical form for glass is
Cauchy's equation, n(λ) = A + B/λ², and the game uses it, written so that A is
the index at the sodium D line (589.3 nm) — the number a glass catalogue quotes — and
B is how strongly the crystal disperses:
/** Cauchy dispersion for one prism: n(lambda) = A + B/lambda^2, lambda in um. */
function iorAt(P, lam) {
const um = lam * 1e-3;
return P.ior + P.disp / (um * um) - P.disp / (0.5893 * 0.5893);
}
Every crystal in an arena is generated with its own ior between 1.34 and 1.44 and its own
disp between 0.045 and 0.075, from the arena seed. That is deliberately more dispersive than
real optical glass (a heavy flint is around 0.01 in these units): the arena is small and a beam crosses a
crystal in a couple of hundred pixels, so the fan has to open fast to be visible at all.
How much rainbow you get is decided at the way in. Snell says the angular spread between red and violet grows with the sine of the angle of incidence — a head-on shot barely disperses at all — but past about 75° Fresnel reflects most of the light before it can. So the shot the bots learn to take is off-axis, and not so far off-axis that the glass turns mirror.
5. Absorption inside the glass
Light crossing a medium loses power exponentially with distance: Beer–Lambert. The game applies
p *= exp(-0.55 · d) along every segment inside a crystal. It is a small effect over one crossing
and a large one for light that gets trapped bouncing around inside by total internal reflection —
which is what stops a trapped ray living forever, and what makes the interior glow fade the way a real
guided beam does.
6. Putting it together: the tracer
With those four rules, the tracer is short. Every firing ship emits N rays; each ray is a
wavelength; each ray is pushed on a stack as [x, y, dx, dy, power, depth, insidePrism]; and the
loop pops rays until the stack is empty, finding the nearest thing each one hits:
- a hull — the ray is absorbed and, on the damage pass, recorded;
- a mirrored wall — reflect it, times 0.86, and push it back;
- a crystal face — compute
n(λ), Fresnel, and push two rays: the reflected one withp·Rand, unless it is totally internally reflected, the transmitted one withp·Tin the refracted direction.
while (stack.length) {
let [x, y, ux, uy, p, depth, inPrism, dist] = stack.pop();
if (p < traceRefPower * 0.006 || depth > MAX_BOUNCE) continue; // too dim, or 9 bounces deep
const h = sceneHit(x, y, ux, uy, ...);
if (inPrism >= 0) p *= Math.exp(-h.t * 0.55); // Beer–Lambert
pushSeg(x, y, hx, hy, r, g, b, p, ...); // what the GPU draws
if (h.kind === HULL) { if (collectHits) beamHits.push({...}); continue; }
if (h.kind === WALL) { stack.push([..., p * WALL_REFLECT, depth + 1, ...]); continue; }
// a crystal face: Fresnel split into a reflected and a refracted ray
const nGlass = iorAt(P, lam); ...
if (p * R > cutoff) stack.push([..., p * R, depth + 1, inPrism]);
if (hasT && p * T > cutoff) stack.push([..., p * T, depth + 1, entering ? h.idx : ...]);
}
Two thresholds keep it bounded: a ray below 0.6% of a fresh ray's power is dropped, and nothing goes
deeper than nine bounces. Every segment a ray travels is appended to a flat Float32Array
— up to 90,000 of them — and handed to the GPU as one instanced draw. Here is that loop, in two
dimensions, on one crystal:
7. Sampling: why the fan is a wash, not a comb
A dispersed fan is, in truth, N discrete lines — one per wavelength. Space them evenly and, after a
couple of bounces have spread them apart, they read as a comb of coloured rays instead of a rainbow. The
fix is the usual one in rendering: stratify the wavelengths, then jitter each within its stratum. The
jitter must be deterministic — the physics runs on every peer — so it comes from a van der
Corput sequence rather than Math.random:
const u = (k + (vdc(k + 1) - 0.5) * 0.9) / (N - 1); // stratified + low-discrepancy jitter
const lam = SPEC_LO + clamp(u, 0, 1) * (SPEC_HI - SPEC_LO);
Untick jitter in Figure 6 and drop the ray count to see the comb. In the game the drawn beam uses 96 rays during a match and 512 on the start screen, where nothing is waiting on the CPU; the quality governor steps that down on a machine that cannot hold its frame rate.
8. Damage is dwell, and it is deterministic
The same tracer runs twice. The display pass, above, is allowed to vary. The damage pass is not:
it always uses exactly 16 rays, because every peer in a multiplayer match must compute the same damage from
the same orders with no server to arbitrate. Once per substep — there are 96 in a four-second turn
— every hit ray deposits power × milliseconds × DPS into the hull it landed on. Damage
is therefore literally per millisecond of dwell: a beam that sweeps across a ship for a tenth of a second
does a tenth of a second's worth.
The 16 damage rays are also spread across the beam's waist, on a base-3 van der Corput sequence so it is decorrelated from the wavelength one. Every ray used to leave the muzzle from the same point, so the beam was a mathematically thin line: a hull intercepted all of it or none, and a graze that clipped a wingtip did as much damage as a shot down the throat. Now damage is proportional to how much of the beam the ship actually blocks:
Because the tracer's power already has every real loss taken out of it — Fresnel at each face, absorption inside glass, the walls' 86% — a ray that arrives weakened does proportionally less. A fan through a crystal is many faint rays; a bank shot off two walls is 74% of a direct one. No separate damage table exists.
9. Drawing it so it looks like light
The segments go to a WebGL2 shader as instanced quads, each with its colour, its power and whether it
is inside glass. The core of a laser is far thinner than a pixel, and point-sampling something thinner
than a pixel scintillates as it moves. So the shader draws a Gaussian core convolved with the pixel
footprint — the physical width added in quadrature to the pixel's — and divides the peak
by the widening, so the flux carried by the line is constant however thin it gets. It measures the exact
screen footprint of the local beam frame with the analytic gradient rather than fwidth(), so
a beam at 45° is exactly as thick as one on an axis.
// core: pixel-footprint-convolved gaussian, energy preserving
float s0 = uCore * 0.50 * sqrt(vSpread); // physical sigma
float sp = fwy * 0.42; // the pixel's
float se = sqrt(s0*s0 + sp*sp);
float core = 1.55 * (s0/se) * exp(-0.5*(r*r)/(se*se));
The wings of the beam are deliberately weak. A laser in clean air scatters almost nothing sideways; the soft glow you see around a real beam is lens veiling. So the profile falls to the background within two pixels and carries only the spectral hue outward, and every soft halo comes later from the bloom. The frame is composited in linear HDR (16-bit float), bright-passed, bloomed, given an anamorphic streak, and tonemapped with ACES at the very end — so an over-driven core saturates to white while its wings keep their colour, which is what makes a fan look volumetric instead of like coloured strokes.
10. Why bother
Because it changes how the game plays, not just how it looks. A tracer that obeys Fresnel means the angle you approach a crystal at decides whether it is a window or a mirror. Dispersion means a shot through glass spreads into a fan that can graze three pilots for a little each. Mirrored walls mean your own beam can come back for you. None of that is scripted; it falls out of four short physical rules and a stack. And because the same rules run identically on every peer, a fan that clips somebody on your screen clips them on theirs. How that turns into a game, and how the peers stay in agreement.