Simulating Light
As a learning exercise, I recently completed Peter Shirley's Ray Tracing in One Weekend. I am always impressed by the power of computer graphics and how much can be achieved with a small amount of code, especially when performance is not the primary concern. In this post, I share what I learned while creating a ray tracer.
The project struck an ideal balance between complexity and flexibility. It was large enough to require decomposition and design choices, but small enough to remain manageable. Its output was also incredibly satisfying: the rendered image made the results of the collision detection and coordinate transformations visible.
This was the third ray tracer I created. The first was written in Python, but it was too slow. The second used Python and NumPy, which was faster, although I found the array operations confusing. I wrote the third in Rust. I learned a great deal from the progression, and I hope it inspires others to try creating a light simulator of their own.
Python
Python is an excellent language that I use every day for many tasks, and its speed is rarely the bottleneck. In this project, however, Python remained the bottleneck even after I optimized as much as I could without moving work into compiled code. Without a major algorithmic improvement, I could not extract much more performance without changing the underlying data representation.
NumPy
NumPy stores and operates on optimized multidimensional arrays. Vectorized operations move loops into compiled code and can use optimized numerical libraries such as BLAS and LAPACK. That made the second implementation much faster. In the future, I would like to try GPU acceleration.
Writing vectorized array operations was difficult but educational. A few observations from this particular implementation were:
- Index masks were faster than Boolean masks.
- Vectorization was key, but it could make the code harder to follow. This was the first time I encountered Einstein summation notation:
np.einsum('ij,ij->i', m, directions). - 32-bit floating point was fastest in my tests; 64-bit was slower, and 16-bit was slowest.
And the results looked okay:
Rust
I rewrote the ray tracer in Rust for better performance and learned a great deal about structuring Rust programs effectively. At 5,000 rays per pixel, it produced an image in about 12 minutes. The sequence below shows how additional samples reduce noise:
219 ms — 1 ray per pixel — 275,800 rays
1 s — 7 rays per pixel — 1,930,600 rays
10 s — 85 rays per pixel — 23,443,000 rays
116 s — 1,000 rays per pixel — 275,800,000 rays
1,124 s (18.7 minutes) — 10,000 rays per pixel — 2,758,000,000 rays
Writing these ray tracers helped me understand why rasterization has traditionally been preferred for real-time rendering, even when ray tracing can be more intuitive to program and produce more realistic lighting. Rasterization's speed allows more complex scenes. Computers are fast at numerical operations, but the physical world contains enormous numbers: an idealized 100 W source emitting only 600 nm light would release roughly 3 × 1020 photons per second. Even hardware capable of 1013 floating-point operations per second would be about seven orders of magnitude short of performing one operation per emitted photon. Fortunately, a renderer does not need to simulate every photon, and many sampling and acceleration techniques make the problem tractable.
I'm excited to continue learning more about ray tracers and things like:
- GPU acceleration
- Neural network denoising
- Optimized collision detection (e.g., AABBs)
- Improved sampling methods (e.g., probability density functions)