Skip to main content
Penumbra

Chapter 5: Ray-Sphere Interactions

Posted on

In chapter 5 of The Ray Tracer Challenge you finally get to implement something that starts to resemble a ray tracer. You implement ray, sphere, and intersection related functions and the exercise at the end ties it all together to create an image.

Finding Intersections

The book does not go into the details of the math for how to determine the intersection points of a ray and sphere. I’m glad for that, but it bugged me that I do not have an intuitive understanding of why this intersect method works.

class Sphere {
  intersect(ray: Ray): IntersectionCollection {
    // Transform the ray into object space
    ray = ray.transform(this.transformInverse)

    // Vector from sphere origin to the ray origin
    const sphereToRayVec = ray.origin.sub(origin)

    // Supporting characters to determine the discriminant and intersection
    const a = ray.direction.dot(ray.direction)
    const b = 2 * ray.direction.dot(sphereToRayVec)
    const c = sphereToRayVec.dot(sphereToRayVec) - 1

    // Discriminant does not intersect sphere if it is negative
    const discriminant = b ** 2 - 4 * a * c
    if (discriminant < 0) return new IntersectionCollection()

    // Calculate the intersection points
    const sqrtDiscriminant = Math.sqrt(discriminant)
    const t1 = (-b - sqrtDiscriminant) / (2 * a)
    const t2 = (-b + sqrtDiscriminant) / (2 * a)
    return new IntersectionCollection(
      new Intersection(t1, this),
      new Intersection(t2, this)
    )
  }
}

The book does suggest some online resources for an explanation of the math at work. I took some time to read through this one. It includes two solutions: a geometric solution and an analytic solution. The geometric solution made sense to me but the analytic solution less so. Still — despite an error[1] in that explanation — it did make some sense. One thing that helped was realizing that the discriminant being negative means there is no intersection because that would require taking the square root of a negative number.

The solution the book provides and I implemented above is the analytic solution. I would have a deeper understanding of it if it were the geometric solution, but at least I now have a better-than-tenuous idea of why this code works.

The Demo: <sphere-shadow>

The exercise at the end of the chapter is to render the shadow of a sphere by casting rays from a light source onto a “wall”. I’ve implemented that here, with the addition that you can change the position of the light source by dragging from the element.

<sphere-shadow>

I don’t normally test drive my demo code since it is more exploratory fun than writing code I intend to reuse. But I figured I would want to make use of the dragging interaction again, so I test-drove the creation of a TouchPad class to track all the mouse and touch events on (and off) of the element and emit only the needed move events. At some point I should add keyboard support to it as well.

Other than that there is not much new from the web technology side compared to the previous demo. Rendering of the canvas still happens in a single web worker.

What’s Next

In the demo I included an output to show the render time of the last frame. I get around 5.5ms in Chrome on my Mac Studio with an M2 Max. Firefox gets around 18.5ms and Safari around 9ms. I find this performance a little disappointing considering I feel like I have optimized things as much as I reasonably can. It makes me wonder if I should skip to targeting Web Assembly earlier than I was planning. I would like to keep the demos interactive in a real-time sort of way. Parallelization will help, but only so much on older devices with fewer CPU cores. Maybe now is the time…


  1. At the time of writing this the issue with that page is that in the “Analytic Solution” section “equation 5” is a repeat of “equation 4”. It should actually be the quadratic formula:

    x = b ± b2 4ac 2a

    Or, with the discriminant represented as Δ:

    x = b ± Δ 2a

    If you decide to dig this deep hopefully the above can save you the intense head scratching that I went through. ↩︎