I have started a project that requires the use of a raytracer. Basically, I hope to make a library for rendering optical paths using raytracing and simulate the result that one would get by looking through the optical system. Hopefully, I would like to be able to write somewhere that I will have a couple of lenses here and there, give the parameters such as the refractive index and curvatures and diameters and get the resulting image on a focal plane.
To do this, I need to start small, and I thought that the way to start small was to make a raytracer. I actually already built a raytracer in Python for yet another project, but this is really just a challenge for me. Also, the Python raytracer is incredibly slow, despite using multiprocessing. And really, I wanted an excuse to learn Rust and make something a little more complicated than a command line interface.
I didn’t want to reinvent the wheel, so for this project, I followed the famous course Raytracing in a Weekend, that is originally written in C++. My goal was to translate the C++ code snippets into Rust. I did follow mostly the same structure and file notation, except to some key parts that were impossible to convert.
Rust as a beginner
First of all, here are my take-away. I am not a professional programmer, but I have some experience in C++ and C. One thing that always bothered me with C++ and C is the need to use header files to declare variables and functions, and another separated file for the actual code. Somehow, this irks me a lot. The other thing that bothers me with C++ is the fact that functions can take parameters as output…. It doesn’t make sense to me, and more importantly, it makes the code less readable each time it happens. Last but not least is the library handling. I hate library handling in C++. I loathe it. I don’t understand why no one is putting a stop to this and simplify this, come one. Cmake I hear? Don’t even get me started on it. Modern programming language should have a way to deal with libraries that is unified, and the way to do so is by using packages, like Python, Julia, Rust, Ruby, etc…
Indeed, Rust is really neat with the libraries that come in the form of crates that can be loaded very easily by including the name of the crate in the cargo.toml file. Variables are especially difficult to handle and require clear description regarding their mutability and owning. It makes for a tough learning curve for someone that comes from Python, but it is somewhat understandable for people with the bases of C++. Finally, you can also use variable as output for a function but in a way that is clear on the contrary to C++. Because the declaration of the function is in the same file as the function itself (Ha!) you can quickly check which is the output and which is an input. The output will have a &mut and that is it. As simple as that.
A major advantage of using Rust over C++ is its memory safety, which I haven’t mentioned earlier. Rust doesn’t have a garbage collector, so the programmer needs to be careful but at least, he is helped by cargo, the compiler, that will highlight all the borrowing and ownership errors it sees. Only slight annoyance with Rust: there is a lack of Object Oriented Programming helper functions. It is possible to emulate the various paradigms of OOP, but there is no native abstract methods for instance, one need to do something a little more esoteric to fill the gap. It is still possible though, just not obvious.
Raytracing basics
A little about raytracers. They work by sending virtual rays in the scene and checking for intersection with the objects in the scene. To create the image, I define the camera which is the origin of the rays. These rays pass through a screen which is the final image. This screen is divided in pixels and the combination of the ray origin plus the position of the pixel in space creates a vector that defines the directions of the rays for each pixels.
If there is an intersection between a ray and the objects in the scene, the ray records the color of the object it hit. Because rays can reflect, refract, or scatter from a surface, the direction of the ray after hitting the object changes, depending on the type of surface, and we keep looking for another intersection, until there is no more object that can be intersected, or until the number of intersection reaches a certain number.
All the calculus is done using vectors, which is why the most important part of the raytracer is the Vec3 struct, which defines a 3-D vector with all the necessary arithmetic and helper functions to compute length, or normalized vector. For this, Rust is relatively easy and I found no issue defining the Vec3 struct, along with the methods on Vec3. The problem was with abstract classes that defines the hit function for each of the shapes that I can implement. C++ has a natural way of defining this abstract class, that does not exist in Rust.
Abstract class in Rust
The way to simulate the abstract class is to use the Traits in Rust. I knew that I had to implement a hit function that would be part of a Hittable Trait, so for instance, In the hittable.rs file, I added:
pub trait Hittable {
fn hit(&self, r: &Ray, ray_t: Interval, rec: &mut HitRecord) -> bool;
}
Which defines the Hittable trait. Next, in the sphere.rs file, I make the implementation of the trait this way. After defining the Sphere struct:
pub struct Sphere {
center: Vec3,
radius: f64,
material: Rc<dyn Material>,
}
I write the implementation of the trait:
impl Hittable for Sphere {
fn hit(&self, r: &Ray, ray_t: Interval, rec: &mut HitRecord) -> bool { // Hit function implementation }
So everytime I need to add the hit function to some shape, I need to write the “impl Hittable for Shape { fn hit (…) }” . And when the program runs the hit function, it will select the one defined for the particular shape, always take the same parameters and always return a boolean.
This little trick was the most difficult part for me, when converting the C++ code into rust, as I come from languages without the use of Traits or interfaces like Java. The resulting code is incredibly fast compared to the implementation I did in Python. The release version of my code in Rust was able to compute a large cover image with 100’s of spheres in just half an hour on my macbook pro when It would have taken a day or so with Python. The resulting image is this:
If you feel like it, you can check my code on github and leave some comments. My next step is to implement multiprocessing to go even faster, and also implement more complex shapes for the surfaces to simulate lenses and mirrors.
And just for fun: