Smoke and Mirrors: Raytracing Light Beams

Spectral Lab Part 1

Building an interactive raytracing lab from scratch: intersection math, the law of reflection, and why my mirrors kept spawning ghost beams.
Author

Art E. Pants

Published

January 3, 2026

I spent two hours yesterday staring at a laser pointer in a dark room with a mirror. My neighbours probably think I’m planning a heist, but really, I was reflecting on why my digital mirror code was acting up.

Welcome to the first deep-dive into my Spectral Lab implementation. This is my first blog post as Art E. Pants, where I’m kicking off the new year with a series of posts to share some coding experiments I’ve been working on.

Before we can get to the trippy, cascading rainbows, we have to master the basics: light moving in straight lines and hitting things.

The Spectral Lab

Open in full window

An Optical Toolkit

Before we dive into the math, let’s talk about what each of these optical elements actually does to light. Think of this as the high-school physics refresher for the casual reader:

Mirrors reflect light like a billiard ball bouncing off a wall. The angle going in equals the angle coming out. That’s it. Simple, predictable, and the foundation of everything else in this lab.

Prisms are the rainbow makers. When light enters glass at an angle, it bends (refracts). Different colours bend by different amounts—violet bends more than red. This separation of white light into its component colours is called dispersion. It’s why Pink Floyd’s Dark Side of the Moon album cover works.

Convex lenses are the “magnifying glass” shape—thick in the middle, thin at the edges. They focus parallel rays toward a single point. In our lab, you’ll see beams converge and cross.

Concave lenses do the opposite—thin in the middle, thick at the edges. They spread light rays apart, making beams diverge. Think of them as the “anti-magnifying glass.”

Each of these elements obeys the same underlying physics: Snell’s Law (which we’ll get to in Part 2) and the Law of Reflection (coming up next!). The art is in how they combine.

Light Moves in a Line

You’ve likely heard the term “Ray Tracing” if you’ve looked at an NVIDIA box or a Pixar credits roll lately. But what is it, actually?

In traditional computer graphics (rasterization), we usually take an object and “squash” it onto your 2D screen, painting pixels like a paint-by-numbers set. It’s fast, but it doesn’t really understand where light comes from.

Ray Tracing flips the script. Instead of painting shapes, we simulate the physics of individual “rays” of light. We cast a mathematical line from a source and ask the computer:

  • “Where is this ray going?”
  • “Does it hit anything along the way?”
  • “If it hits something, how does that surface change the ray’s path?”

In the Spectral Lab, we need this because I wanted a lab that reacts. When you drag a mirror, the light needs to “feel” that movement. By tracing the path, we allow the light to have a history. A single ray can bounce off a mirror, pass through a prism, and end up somewhere completely unexpected.

It’s essentially a high-speed game of “connect the dots” where the dots are moving at the speed of light.

Intersection Mathematics

To make a mirror work, the computer first has to find exactly where the light hits it. We represent our light ray as a parametric equation—a way of describing a line using a parameter that “travels” along it. Hopefully you recall what a vector is (if not, Wikipedia is great!) – this is where it gets fun:

\[P(t) = P_{origin} + t \cdot \vec{D}\]

Here, \(t\) is a geometric parameter, not time. Think of it as “how far along the ray” we are—a distance multiplier. When \(t=0\), we’re at the ray’s origin. When \(t=1\), we’ve traveled one full direction-vector length. When \(t=2.5\), we’ve gone two and a half times that distance.

This geometric ray tracing finds where rays intersect surfaces using pure geometry, then instantly spawns new rays from those points. If we solve for \(t\) where our ray line crosses the mirror’s line, we find our point of impact. Finding that point \(P\) is the “handshake”—the moment the light and the mirror acknowledge each other.

NoteWhy Parametric?

In classical geometry (Euclid, Descartes), we describe lines using slope-intercept form: \(y = mx + b\). But this breaks down for vertical lines (infinite slope) and becomes cumbersome in higher dimensions.

Parametric equations, developed in the 17th century and refined by mathematicians like Gottfried Leibniz, let us describe curves and lines using a “traveler” parameter. Modern computer graphics uses parametric forms almost exclusively because they’re dimension-agnostic, handle edge cases elegantly, and vectorize beautifully.

When you’re casting thousands of rays per frame, parametric math isn’t just elegant—it’s fast.

But the most important piece of information isn’t just the point; it’s the Normal (\(\vec{N}\)). The Normal is a vector that sticks straight out of the mirror at a perfect 90-degree angle. Without it, the light wouldn’t know which way is “back.”

NoteAn Aside

What did the surface said to the slanted line?

You’re not normal.

What did the line say to the surface?

At least I’m not plane and boring.

Code From the Lab

We need to detect when our ray of light (traveling from point 1 to point 2) crosses the mirror’s edge (a line segment from point 3 to point 4). Both are just lines, so this becomes a classic “line-line intersection” problem.

Remember our parametric equation from above: \(P(t) = P_{origin} + t \cdot \vec{D}\). In our code, we define rays by two points (start and end), not a point and a direction vector. So we need to convert:

  • The direction vector \(\vec{D}\) is just the difference between the two points: \(\vec{D} = P_2 - P_1\)
  • Substituting this back: \(P(t) = P_1 + t(P_2 - P_1)\)

Now we can write two parametric equations—one for the ray, one for the mirror segment:

\[\text{Ray: } P_{\text{ray}}(t) = P_1 + t(P_2 - P_1)\] \[\text{Mirror: } P_{\text{mirror}}(u) = P_3 + u(P_4 - P_3)\]

The parameter t (by convention, often the first parameter) tells us how far along the ray the intersection occurs. The parameter u tells us how far along the mirror segment it occurs. These are just traditional variable names from analytic geometry—you’ll see them in textbooks and papers whenever two parametric lines need to be solved simultaneously.

When the ray and mirror intersect, both equations describe the same point in space, so we set them equal and solve for both \(t\) and \(u\).

Here’s the real intersection code from the spectral_lab.html:

intersect(rayStart, rayEnd, segmentStart, segmentEnd) {
    // The ray: line from (x1,y1) to (x2,y2)
    const x1 = rayStart.x, y1 = rayStart.y;
    const x2 = rayEnd.x, y2 = rayEnd.y;

    // The mirror segment: line from (x3,y3) to (x4,y4)
    const x3 = segmentStart.x, y3 = segmentStart.y;
    const x4 = segmentEnd.x, y4 = segmentEnd.y;

    // Check if lines are parallel (denominator = 0 means no intersection)
    const denominator = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
    if (denominator === 0) return null;  // Parallel lines never meet

    // Solve for t and u using parametric line equations
    const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denominator;
    const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denominator;

    if (t > INTERSECTION_EPSILON && u >= 0 && u <= 1) {
        // Use the ray's parametric equation to find the exact intersection point
        return { x: x1 + t * (x2 - x1), y: y1 + t * (y2 - y1), t };
    }
    return null;
}

This code is solving those two parametric equations simultaneously. The formulas for \(t\) and \(u\) come from setting the two equations equal (since they both describe the same intersection point), then expanding into their \(x\) and \(y\) components and solving the resulting system of linear equations.

Once we have \(t\) and \(u\), we check: - t > INTERSECTION_EPSILON: The ray hits forward from its origin (not behind it), and far enough away to avoid floating-point errors (more on that later) - u ≥ 0 and u ≤ 1: The hit occurs on the physical mirror segment, not beyond its endpoints

Think of it like this: t tells us “the ray hits something at this distance,” and u tells us “that something is actually within the bounds of the mirror.”

If both conditions pass, we calculate the exact \((x, y)\) coordinate of impact. If not, we return null and the ray keeps sailing through empty space.

The Law of Reflection

Once we have the incoming ray (\(\vec{V}\)) and the Normal (\(\vec{N}\)), we calculate the reflection. The ancient Greeks knew this empirically: the angle of incidence equals the angle of reflection. But in code, we need a formula that works with vectors.

The reflection equation is a beautiful piece of vector algebra:

\[\vec{R} = \vec{V} - 2(\vec{V} \cdot \vec{N})\vec{N}\]

NoteA Brief History: From Hero to Huygens

The Law of Reflection dates back to Hero of Alexandria (c. 10–70 CE), who proved that light takes the shortest path when reflecting off a mirror. In the 17th century, Christiaan Huygens reformulated this using his wave principle, showing that every point on a wavefront acts as a source of new wavelets.

But the vector form we use today? That’s pure 20th-century linear algebra—taking ancient geometry and making it GPU-friendly.

Breaking Down the Equation

Let’s decode this piece by piece:

  1. \(\vec{V} \cdot \vec{N}\) (the dot product): This measures how much of the incoming ray is pointing “into” the mirror. If the ray is perpendicular to the surface, this value is large. If it’s grazing the surface at a shallow angle, it’s small.

  2. \(2(\vec{V} \cdot \vec{N})\vec{N}\): We take that “inward component” and double it. This creates a vector pointing perpendicular to the surface with exactly twice the inward momentum.

  3. \(\vec{V} - 2(\vec{V} \cdot \vec{N})\vec{N}\): We subtract this from the original ray. Geometrically, this “flips” the perpendicular component while keeping the parallel component unchanged—which is exactly what a mirror does.

Think of it like this: imagine the ray has two parts—one parallel to the mirror (which slides along unchanged) and one perpendicular (which bounces back). The equation isolates the perpendicular part, reverses it, and recombines.

Try rotating the mirror below. Watch how the ray (blue) and the reflection (dashed white) react to the mirror’s angle.

Code
import {DOM} from "@observablehq/stdlib"

viewof mirrorAngle = Inputs.range([0, 360], {value: 45, label: "Mirror Angle"});

canvas1 = {
  const width = 600;
  const height = 350;
  const canvas = DOM.canvas(width, height);
  const ctx = canvas.getContext("2d");

  const rayP1 = {x: 50, y: 175};
  const rayP2 = {x: 550, y: 175};
  const mirrorPos = 300; // Fixed center position

  const draw = () => {
    ctx.fillStyle = "#020205";
    ctx.fillRect(0, 0, width, height);

    // Calculate Mirror Geometry
    const rad = (mirrorAngle * Math.PI) / 180;
    const mSize = 150;
    const mP1 = { x: mirrorPos - Math.cos(rad) * mSize/2, y: 175 - Math.sin(rad) * mSize/2 };
    const mP2 = { x: mirrorPos + Math.cos(rad) * mSize/2, y: 175 + Math.sin(rad) * mSize/2 };

    // Draw Mirror Surface
    ctx.strokeStyle = "#ffffff";
    ctx.lineWidth = 4;
    ctx.beginPath(); ctx.moveTo(mP1.x, mP1.y); ctx.lineTo(mP2.x, mP2.y); ctx.stroke();

    // Intersection Logic
    const x1 = rayP1.x, y1 = rayP1.y, x2 = rayP2.x, y2 = rayP2.y;
    const x3 = mP1.x, y3 = mP1.y, x4 = mP2.x, y4 = mP2.y;
    const den = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);

    if (den !== 0) {
      const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / den;
      const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / den;

      // If the ray hits the mirror segment
      if (t > 0 && u >= 0 && u <= 1) {
        const ix = x1 + t * (x2 - x1);
        const iy = y1 + t * (y2 - y1);

        // 1. Draw Incoming Ray (Stops at intersection)
        ctx.strokeStyle = "#00d2ff";
        ctx.setLineDash([]);
        ctx.lineWidth = 2;
        ctx.beginPath(); ctx.moveTo(rayP1.x, rayP1.y); ctx.lineTo(ix, iy); ctx.stroke();

        // 2. Calculate Reflection
        // The angle of the mirror normal
        const normalAngle = rad + Math.PI/2;
        const incomingAngle = 0; // Horizontal
        const reflectionAngle = 2 * rad - incomingAngle;

        // 3. Draw Reflected Ray (Solid Yellow/Gold)
        ctx.strokeStyle = "#ffcc00";
        ctx.beginPath();
        ctx.moveTo(ix, iy);
        ctx.lineTo(ix + Math.cos(reflectionAngle) * 400, iy + Math.sin(reflectionAngle) * 400);
        ctx.stroke();
      } else {
        // Ray missed mirror - draw full length
        ctx.strokeStyle = "#00d2ff";
        ctx.setLineDash([]);
        ctx.beginPath(); ctx.moveTo(rayP1.x, rayP1.y); ctx.lineTo(rayP2.x, rayP2.y); ctx.stroke();
      }
    }

    requestAnimationFrame(draw);
  }

  draw();
  return canvas;
}

The Ghost Mirror Bug

In my first attempt, the mirrors were doing their job too well—or rather, doing two jobs at once.

I successfully found the intersection point. I calculated the perfect reflection angle. The reflected ray shot off beautifully in the right direction. But I forgot one critical detail: I never told the original ray to stop.

The result? The light would hit the mirror, spawn a reflected ray, and also keep going straight through like the mirror was made of glass. Or air. Or pure optimism.

My mirrors were acting like one-way glass in a police interrogation room, except they were reflecting and transmitting simultaneously. The rays were having it both ways.

The lesson: In a ray-tracer, a collision must be an event, not just a calculation. When a ray hits a mirror, the original path needs to die at that exact point, and a new reflected ray is born from the ashes. This is state management—the hit changes the world. Without it, you get ghost mirrors that can’t decide if they’re solid or spectral.

Adding Smoke to the Mirrors

Here’s the thing about building a physics simulator: you can have perfect math and still have an ugly, unusable interface.

One of the initial visual hurdles was that the lab looked like a collection of floating math segments. The mirrors looked the same as the light beams. If no light hit a prism, it was literally invisible. Black glass on a black background is great for ninjas, but terrible for users trying to actually place and rotate optical elements.

I needed the mirrors and lenses to feel tangible—like physical objects you could reach out and touch, not abstract geometric constructs. This is where aesthetics saves usability.

I added “smoke” to the mirrors and lenses. I used layered radial gradients and white outlines to create the illusion of volume and materiality. The prisms get a subtle cyan glow from the center outward. The mirrors get a metallic gradient that catches the eye. Even when no light is hitting them, you can see where they are and what they’re doing.

Here’s the actual shader logic for the prism from spectral_lab.html:

// From the drawPrism() method - note the colour stops with alpha channels,
// making these parts of the gradient mostly transparent
const glassGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, this.size / 2);
glassGrad.addColorStop(0, 'rgba(180, 230, 255, 0.15)');
glassGrad.addColorStop(1, 'rgba(100, 150, 255, 0.05)');
ctx.fillStyle = glassGrad;
ctx.fill();
ctx.stroke();

Zeno’s Paradox: The Infinite Reflection Loop

The most interesting bug—the one that nearly melted my laptop—happened when a ray hit a mirror and refused to leave.

In a perfect mathematical world, \(t=0\) is the start. But computers use “floating-point” numbers, which have tiny rounding errors. Sometimes, a ray would hit a mirror, bounce, and then immediately detect that it was still touching that same mirror. It would bounce again. And again. Forever.

The photon was stuck in a “Zeno’s Paradox”—it could never move far enough away to stop hitting the surface it just left.

NoteThe Original Zeno’s Paradox

The ancient Greek philosopher Zeno of Elea (c. 490–430 BCE) proposed a series of paradoxes to challenge our understanding of motion and infinity. The most famous: “Achilles and the Tortoise.”

If Achilles gives a tortoise a head start in a race, he must first reach where the tortoise was. But by then, the tortoise has moved forward. Achilles must then reach that new position, but the tortoise has moved again. This repeats infinitely—so logically, Achilles should never catch the tortoise.

Of course, in reality, Achilles wins easily. The paradox arises from dividing motion into infinite steps. Zeno showed that our intuition about continuous motion and discrete steps don’t always align.

In our ray tracer, we face the computational version: a reflected ray trying to “leave” the surface it just bounced from, but floating-point arithmetic keeps detecting phantom intersections at infinitesimally small distances.

The Nudge Strategy

The fix is a classic bit of “dirty” engineering: The Nudge.

When we spawn a reflected ray, we don’t start it at exactly \((x, y)\). We start it at \((x + \Delta x, y + \Delta y)\), where \(\Delta\) is a tiny REFLECTION_NUDGE (0.1 pixel) push in the direction of the reflection.

Here’s where that t > INTERSECTION_EPSILON check comes in from our intersection code above (spectral_lab.html:363). And here’s how we apply the nudge when spawning the new ray (spectral_lab.html:461-467):

// From handleReflection() method
const REFLECTION_NUDGE = 0.1;  // Defined as constant at top of file

handleReflection(hitPoint, incomingAngle, normalAngle, band, depth, nOverride, canSplit, lastObjId) {
    const reflectionAngle = 2 * normalAngle - incomingAngle + Math.PI;
    const nudgedX = hitPoint.x + Math.cos(reflectionAngle) * REFLECTION_NUDGE;
    const nudgedY = hitPoint.y + Math.sin(reflectionAngle) * REFLECTION_NUDGE;

    this.trace(nudgedX, nudgedY, reflectionAngle, band, depth + 1, nOverride, canSplit, lastObjId);
}

That tiny nudge is just enough to clear the mirror’s “skin” and let the photon fly free.

What Happened Before the Nudge

Without this fix, you’d get something like this:

Ray hits mirror at t=0.000000
  └─ Spawns reflection at exact same point
     └─ New ray immediately detects hit at t=0.000000001
        └─ Spawns reflection at essentially same point
           └─ New ray detects hit at t=0.000000002
              └─ Spawns reflection...
                 └─ (browser freezes, fans spin up)

The t > INTERSECTION_EPSILON threshold (where INTERSECTION_EPSILON = 0.01) effectively says: “ignore any intersection closer than 0.01 pixels away—that’s probably just floating-point noise from the surface we just left.”


Next Time: We’re breaking out the fancy lab coats. We’re moving from mirrors to glass and tackling Snell’s Law to finally get those rainbows fanning.

Don’t worry, the math can’t hurt you if you’re holding a paintbrush.