Ray Tracing in Elixir
What Happens When You Run Heavy Math in a 'Slow' Language?
July 11, 2026
Computer graphics algorithms are fascinating. For a lot of us, Peter Shirley’s Ray Tracing in One Weekend was the rite of passage that taught us how light, math, and code intersect. Because ray tracing is brutally compute-heavy, most implementations gravitate toward low-level languages like C, C++, or even Go. Scripting languages? Historically a hard no. Their floating-point performance is notoriously sluggish, and they weren’t exactly built for vector crunching. But in academic experiments have proved Ruby can do it. So why not Elixir?
Sure, it might be “slow” in the traditional sense, but what if we lean into its actual superpower: fearless parallelism?
Setting the Baseline
A while back, I started reading and implementing Shirley’s book. Then, as happens to every developer with half a dozen side projects, life happened. The book “stayed open”, the code stayed unwritten, and I moved on to greener pastures.
Recently, I decided to revisit it—but this time, I wasn’t going to start from scratch. I ported Tyler Porter’s Ruby implementation raytrace-ruby. Why? Because my goal wasn’t just to prove it could be done in Elixir. I wanted to answer a different questions:
- How slow is elixir/erlang crunching numbers?
- How much parallelizaion helps compute-heavy algorithms?
To establish a baseline, I ran the original Ruby version on my Mac Mini Pro (8+4 cores). It finished in 1h 47m. (A massive jump when the Tyler reported 6 years ago it took 6-hour runtime on Ruby 2.6 and hardware has come a long way, thank goodness.)
Elixir is Faster Than Ruby?
Now that I have a baseline, let’s run my Elixir port. To my surprise, it finished in 42 minutes. That’s roughly 2.5× faster on a single core. But Why?
It’s not magic, it’s architecture.
| Factor | Ruby | Elixir/Erlang | Impact |
|---|---|---|---|
| Object layout | RClass with ivar table, dynamic dispatch |
Compact tuple/struct, pattern matching | ~2-3× less memory per allocation |
| GC | Stop-the-world mark-sweep | Per-process generational, concurrent | No pause spikes during long renders |
| Math calls | Math.sqrt (C), but through Ruby’s method dispatch |
:math.sqrt (Erlang NIF, direct) |
Slightly faster FP ops |
That 2.5× single-core advantage comes primarily from BEAM’s per-process memory management and struct memory density. Each Vector3D in Ruby carries a full Ruby object header and hash-table lookups, whereas an Elixir %Vector{} struct is a compact tagged tuple under the hood. With millions allocations over a single render, that memory efficiency compounds into a real-world speed advantage.
Note: At this moment this is the most interesting fact about this project I need to investigate deeper what caused elixir to be faster than ruby, both run with native code underneath
But single-core speed is just the warm-up. Elixir (and the BEAM VM) were built for concurrency. So I made the simplest possible change: swap the nested for loop for Task.async_stream.
for j <- (height - 1)..0//-1, i <- 0..(width - 1), into: [] do
IO.write(:stderr, "\rScanlines remaining: #{j + 1} ")
render_pixel(camera, world, i, j, width, height, spp, max_depth)
end
(height - 1)..0//-1
|> Task.async_stream(
&render_line(camera, world, width, height, spp, max_depth, &1),
ordered: true,
timeout: :infinity
)
|> Enum.with_index()
|> Enum.flat_map(fn {{:ok, line}, idx} ->
IO.write(:stderr, "\rScanlines remaining: #{height - idx - 1} ")
line
end)
That’s it. By handing each render line to the scheduler and processing them in parallel across a 12-core Apple Silicon machine, runtime dropped from 42 min to 3 min. 14× faster, just by embracing functional concurrency.
Optimization 2: Bounding Volume Hierarchy (BVH)
On the second book, we hit a classic computer graphics concept: Bounding Volume Hierarchy (BVH).
In a naive ray tracer, every ray checks every primitive in the scene. That’s O(N) per ray. BVH organizes geometry into a spatial tree. For each ray, the algorithm only traverses a node if the ray actually intersects its bounding box. This drops complexity from O(N) to O(log N) per ray.
Implementing BVH was a game-changer. Render times plummeted to 9 seconds, and CPU usage soared past 1000% (basically, every core was screaming). If I’d added BVH to the Ruby version, it would’ve slashed the runtime too but here, Elixir’s scheduler turned algorithmic gains into wall clock reality.
Optimization 3: Tiling the Scheduler
Rendering line-by-line sounds efficient, but on large images it starves the BEAM scheduler and creates load-balancing bottlenecks. The fix? Tiling.
I split the image into fixed 64×64 tiles and distributed them across workers. This reduced scheduler contention, brought peak usage down to a more stable 892%, and pushed the runtime to ~10 seconds. (Yes, technically slower than 9 seconds, but far more predictable and closer to real-world production behavior.)
What’s Next & The Big Picture
So, what’s next? I’m currently experimenting with Nx (Elixir’s numerical library). My initial attempts actually ran slower than line-by-line parallelization, likely due to overhead and compilation quirks, but I’m still digging deeper.
The real frontier, though, is distribution. One of Elixir’s killer features is transparent node-to-node scaling. Distributing this ray tracer across a cluster shouldn’t require distributed systems jitters—just a few config changes. But to make that worthwhile, I’ll need to continue into Shirley’s next books (whew, there are three of them).
In an era where clock speeds have plateaued but core counts keep climbing, parallelism isn’t just an optimization it should be the standard. Languages like Erlang/Elixir were born for this. We aren’t waiting for faster CPUs or magically faster programming languages; we’re leveraging the parallel capacity already sitting on our desks. And algorithmic improvements? They’re the real heavyweight champions. BVH alone proved that better math often beats raw hardware.
A Side Note
This project finally crossed the finish line thanks to open-weight AI models like GLM-5.2, MiniMax-M3, and Kimi K2.7. When the best open models start acting like pair programmers, even abandoned repos can find their second wind. Thanks, AI. I owe you a beer.