The Hardest Part of a Raytracer Is Fitting It in Your Head

I've been interested in 3D rendering since I was a kid. I started programming at nine or ten years old, and I had no idea how people made such amazing graphics on a screen. It was a long time before I felt I could make one myself.

The motivation, when it finally came, was unexpected. Around 2022 I purchased a used Nikon Ti Eclipse research microscope from my employer at the time, AbCellera Biologics. I had been working with machine vision cameras for microscopy for a couple of years and had learned a great deal about how image sensors work. I got interested in computational imaging, particularly the work of Laura Waller's lab at UC Berkeley and the papers she published with Lei Tian on light field measurements using LED array microscopes. I wanted to replicate some of that work with my own microscope.

As I read their papers, I realized this was going to be a long learning journey. I needed a solid mental model for how light behaves, how images form, and how the physics of rendering works. So I decided to start by building a 3D renderer from scratch.

That was five years ago. The renderer is still growing.

Three Hundred Lines

I started with the "Ray Tracing in One Weekend" book series, adapting the C++ instructions to Go. My first renderer was a few hundred lines of code. It could render spheres. That's it.

What struck me immediately was how little code it takes to produce an image. Before I started, I expected I would need to write massive systems and implement dozens of subsystems before anything worked. Instead, a few hundred lines gave me colored spheres on a screen. I was generating images on my first day.

An early low-resolution render of an Einstein bust STL model with simple flat lighting and visible aliasing
One of the first renders after adding STL support. Low resolution, simple lighting, crude. But it worked.

The other thing that surprised me was how iterative the process is. I never expected a renderer could be continuously evolved the way this one has been. I started with the crudest possible program and iterated on it thousands of times. Each iteration added something small. Over five years, the accumulation is dramatic. I kept a record of every image the renderer produced so I could see the project evolving in capabilities over time.

The Milestones

Finishing the Ray Tracing in One Weekend series was the first milestone. That's when I got hooked. From there I started reading other renderers' source code, particularly fogleman/pt, a path tracer written in pure Go. I was astonished at how fast and high quality his renders were compared to mine, and that drove intense curiosity to understand the tricks he used.

The second milestone was loading real 3D models. I implemented STL and OBJ importers from scratch and learned a lot about how those file formats work. The moment I realized I could download any model from the internet and render it in my own renderer was a genuine thrill. But at this point everything was still single-material objects. No textures.

Materials came next. Metallic, dielectric, diffuse. They worked, but they looked wrong. The glass was too perfect. The metal was too clean. Real glass has flaws, reflections, and imperfections that everyone recognizes subconsciously. Real metal has complex microstructures that scatter light in subtle ways. Without those details, the renders had this unnatural, almost alien quality. This wasn't a code bug. The code was doing exactly what I told it to. The problem was that my mental model of how real materials interact with light was too simple.

The same Einstein bust model rendered with improved lighting, adaptive sampling, and higher resolution, showing dramatically better quality
The same Einstein model today. Adaptive sampling, improved lighting, higher resolution. Same renderer, years of understanding later.

That's what led me to "Physically Based Rendering: From Theory to Implementation." It was very challenging at first, but the improvements were dramatic. Physically based glass and metal transformed the quality of every render. The materials finally looked like things you could touch.

Lights

For a long time, my renderer used enormous area lights. The lighting model from Ray Tracing in One Weekend is simple, and I couldn't wrap my head around the math of point light sources. Area lights work, but they need a huge number of samples to render cleanly, and they limit the kinds of scenes you can build.

When I finally worked through the PBR book and understood point lighting, it was a revelation. The quality of the lighting went up immediately. I could place precise light sources in a scene and get the results I wanted. Even though the amount of code wasn't that much in the end, it was getting the math to fit in my head that was the real challenge. That's been the pattern with this whole project. The code is compact. The mental model is the hard part.

Four spheres lit by multiple point light sources, with the bottom metallic sphere realistically reflecting the other spheres and light sources
Point lights and metallic reflection. This seemingly simple image represented a huge advance in the renderer's lighting capabilities.

Scanning the Real World

A later milestone was buying a Creality CR-Scan Otter, my first 3D scanner. It captures models and textures from real objects. This forced me to finally solve texture rendering, a longstanding limitation in my renderer that I had been putting off. The challenge was less about the code and more about understanding how texture coordinates map onto geometry, another concept I had to fit in my head before the implementation could follow. The payoff was worth it. The 3D scanner captures real-world lighting and surface appearance, and the resulting renders are some of the best I've done. There's an authenticity to scanned models that's hard to achieve any other way.

Animated orbit around a 3D scanned isopropyl alcohol bottle surrounded by scanning markers, rendered with realistic textures captured by the 3D scanner
A 3D scanned isopropyl bottle rendered in my raytracer. The textures come directly from the scanner. Some glitches are artifacts of the scan, not the renderer.

Another milestone was adding animated cameras. The renderer can now move a camera along a path while maintaining a constant gaze toward a target, which turned out to be essential for creating the demo animations I use to test new features.

Animated camera moving in a straight line while maintaining constant gaze toward a red diffuse sphere on a checkerboard pattern, with red, green, and blue axis lines visible
Animated camera tracking. The camera moves along a path while keeping its gaze fixed on the sphere. The RGB lines show the coordinate axes.

Making It Fast

Once the mental model for rendering was in place, the remaining challenge was performance. But even here, the bottleneck wasn't writing fast code. It was understanding where the renderer was slow and why. Three optimizations made the biggest difference.

Smarter sampling. The simple sampler from the PBR book evenly distributes ray samples across the image. Dark, empty regions get the same number of samples as complex surfaces with high variance. Switching to an adaptive sampler that allocates more effort to complex regions and less to simple ones was a huge win. There's a whole rabbit hole of sampler techniques, each with different tradeoffs and optical artifacts. Once I implemented it, I never looked back. Every competent renderer today uses something fancier than simple unbiased random sampling.

Profiling. Understanding where the renderer spends its time is what unlocks every other optimization. I designed data structures threaded through the key sections of the codebase to collect detailed rendering statistics. You can't optimize what you can't measure.

Parallel workers. Rendering is an embarrassingly parallel problem, but my first version was single-threaded. I added multiple workers that render patches of the image in parallel, with a scheduler dispatching work. One Go-specific pitfall nearly killed performance: using the global random number generator. Go's default RNG has a mutex, and with multiple workers hammering it, lock contention was enormous. I had to refactor so each worker had its own RNG. Performance jumped immediately.

Why It Matters

The renderer was never the goal. The goal was understanding optics well enough to do computational imaging. And it worked.

I studied engineering physics at UBC and took an optics class, but that's not the same as building a renderer. One of the great things about a raytracer is that if your conceptual model is wrong, reality smacks you right in the face. The render looks wrong, and you can see exactly how. When you finally get it right and a beautiful image appears, the satisfaction is intense.

Having a small, lean renderer that I knew deeply meant I could experiment with specialized scenarios that would be difficult in a large off-the-shelf renderer. I tested fluorescence, where one wavelength of light excites a material that then emits at other wavelengths. I modeled my 257-LED illuminator board, which I designed as a PCB for Fourier Ptychography based on the PhD work of Zachary F. Phillips. I later met Phillips and showed him my version of his illuminator. The renderer helped me test light field rendering and differential phase contrast methods.

Top-down view of a 257-LED Fourier Ptychography illuminator lighting up one LED at a time, with a dielectric sphere positioned above showing individual light sources refracting through it
My 257-LED Fourier Ptychography illuminator modeled in the renderer. Each LED lights up individually and refracts through the dielectric sphere above.
Animated render of the circular 257-LED illuminator with multiple dielectric and metallic spheres, showing realistic refraction and reflection from each light source
The same illuminator with multiple dielectric and metallic spheres. Each light source is realistically traced, refracted, and reflected.
Animated render showing concentric LEDs gradually illuminating with three synthetic ruby spheres above, demonstrating simultaneous fluorescence and refraction
Fluorescence and refraction in the same material. The ruby spheres both refract light and fluoresce as surface emitters. This is the kind of specialized scenario that's hard to test in an off-the-shelf renderer.

The most valuable outcome is that I got over the initial barrier of having no mental model for image formation. Now that I understand it deeply, I feel like I can take on computational imaging problems that require rendering knowledge as a prerequisite.

Why Go, Why No Dependencies

I used Go because I was interested in learning the conceptual models, not in shipping a fast renderer. Correctness mattered more than performance. I also genuinely love writing Go. Coming from embedded and systems programming, I find it a joy to work with.

I used no external dependencies because I wanted to learn the entire stack. Vector math, material shaders, image format encoding, 3D model loaders, everything from scratch. If my goal was to bring something to market, I would use existing libraries. For learning, writing it all myself was the right choice. I know that because of how deeply I now understand every layer of the system.

The project is freely available at github.com/scottlawsonbc/raytrace.

You Should Build One

I encourage anyone to try building a renderer. It is surprisingly easy to get started. A few hundred lines of code gives you a program that generates images, and those images are personally rewarding in a way that few other programming projects can match. Every time you run it, you get a picture. That feedback loop is addictive.

The reason it takes so little code is that the physics of light transport is compact to express. A few equations capture most of how light behaves. The complex part isn't the code. It's understanding what the equations mean and building a mental model for how they interact.

A grid of spheres showing a palette of different materials including diffuse colors, frosty glass with increasing roughness, debug normal-mapped materials, and metallic surfaces with realistic light scattering
A material palette. Diffuse, glass with increasing roughness, debug normals, and metals that scatter light instead of mirroring it perfectly.

It's a project that is easy to start and difficult to master. I started with spheres and I'm still adding features five years later. Each improvement sends me down a rabbit hole of curiosity that teaches me something I didn't expect to learn.

What surprised me most is how evolutionary the process turned out to be. The renderer didn't get built. It grew, piece by piece, at the same pace as my understanding. I didn't improve the renderer and then understand the math. I understood the math and the renderer improved as a consequence. The two evolved together, and I don't think you can separate them. The value of this project was never just the executable. It was the process of constructing it, and the person I became by doing so.

The hardest part of a raytracer is not the code. It's fitting it in your head. The moment it does, you see the light, and everything snaps into focus. I started noticing caustics in pools, imperfections in glass, the way metal scatters sunlight instead of reflecting it cleanly. The world sharpened my renderer and the renderer sharpened how I see the world. I built a renderer, but what I really gained was a new perspective.