Lucis is a simple ray tracer written in Rust. The purpose of this project was to learn the Rust programming language. Since Rust is a systems language, I thought ray tracing would be a good choice for learning the language so I can start thinking about performance right off the bat.

Rust

Writing my ray tracer in rust ended up being the most time consuming objective by far. Rust is a good language for the task because it has a large focus on safety and immutability. In Rust everything is immutable by default, and although rust is a low level systems programming lanauge, you cannot segfault in rust code unless you are using incorrectly written unsafe blocks. Rust has a complicated layer on top of the compiler that adds a lot of new programming concepts: borrowing, ownership, lifetimes.

To summarize quickly, a lot of the advantages of rust comes from its borrow system. When you have data (no matter if it's on the stack or the heap) and you want to reference it (i.e. a pointer), you can only have one mutable reference to that data or unlimited immutable references to that data at the same time. This makes concurrency safe because the compiler makes sure at runtime that you cannot have mutable references to the same data from different threads. On top of the borrow system also comes lifetimes. References have lifetimes associated with them that are calculated at runtime. If you try to use a reference outside of its lifetime, your code will not compile. This is what stops you from evil segfault errors at compile time.

Although this system is powerful, it can be tough when getting started with your first big project in rust (as this was for me). There were many times I would write code and KNOW that it will work, but because of Rusts complicated borrow and lifetime system, the compiler just wont accept it and tells me to write it in a more "rust-like" way. This was especially difficult because I used the A4 C++ code as a guide when starting out. I quickly had to ditch the A4 code design and take an approach that works better with rust.

The Good

Rust was an incredibly fun programming language to work with. Although a lot of time is spent fighting with the borrow checker, I felt like I improved at understanding the borrow checker and started writing better code throughout the process.

Some of the things that I liked in Rust:

  1. Instead of classes, we just deal with structs. Methods are created by creating functions with receivers similar to Golang.
  2. Rust has fantastic pattern matching to make dynamic dispatch very nice. Instead of using pointers to a base class, we can pattern match on an enum and call the function we want:
let closest_root = match find_roots_quadratic(a, b, c) {
    Roots::One([r1]) => r1,
    Roots::Two([r1, _]) => r1,
    _ => return false,
};
  1. Cargo. Rust has a package manager built in called cargo. No need to deal with hundreds of different build and library management systems.

The Bad

There were a few things that made using rust a big challenge.

  1. Rust hates cyclic data (see here)

Usually, writing linked lists in a programming language is one of the easiest things you can do to get started. In rust, writing a linked list will make you tear your hair out. The reason is because Rust hates the possibility of cycles in your data. For the compiler, any possibility of cycles in references and data structures are unsafe and must be destroyed.

As you can probably see where I'm going, Rust hates the hierarchical data structure we use for scenes because it introduces the possibility for cyclic data. After spending multiple days stuck on this problem, the best solution at the time is to accept that Rust knows best and avoid using a cyclic structure all together. Instead, each SceneNode owns its children. You can see what I mean by this in the SceneNode struct:

#[derive(Debug, Clone)]
pub struct SceneNode {
    pub id: u32,
    pub children: Vec<SceneNode>,
    pub transform: Affine3<f32>,
    pub inv_transform: Affine3<f32>,
    pub name: String,

    // Material and Primitive
    pub material: Material,
    pub primitive: Primitive,
}

When looking at the C++ version, we store a vector of SceneNode*, each one pointing to the next SceneNode. Instead, in Rust we are storing the actual children inside of the SceneNode. This means that each node owns its children. Further, this makes modelling a little bit trickier. The scene hierarchy must be created from the bottom of the tree up to the top because you cannot manipulate a SceneNode once you add it as a child to another SceneNode.

  1. Interfacing with lua is dangerous, Rust doesn't like that

In A4 we used lua to create scenes in our raytracer. I found this to be incredibly useful because it was easy to write basic loops to generate things in your scene rather than writing them all out manually. Unfortunately, this ended up being another big struggle in rust.

I used a library called rlua, giving me lua bindings for Rust. The difficult part is that lua is an unsafe, especially for rust standards, so most of the work with lua is handled through copying of large data structures and passing around copies.

Multithreading

I implemented multithreading twice over for this project. The first was in the lucis-cpp folder, my initial implementation of lucis in c++. I have included benchmarks in the benchmark folder as well as an image of the benchmarks graph in the render folder.

I implemented multithreading again in the Rust version of lucis using fork-join iterators. I iterate over all the pixels in the image using a parallel iterator and then join them once everything is finished. The iterator uses a work queue in the background to manage work.

Spacial Partitioning

I did not end up doing spacial partitioning, primarily because my ray tracer has been fast enough for my needs. Rendering my final scene in 4K took less than 30 minutes even with the thousands of SceneNodes from the L-System trees.

Human Model and Flashlight

For the human model and flashlight I found obj models online that were implemented using faces with four vertices instead of triangles. I opened them both in blender to rescale the models, compose them together, and then triangulate them.

Cylinder and Cone Primitives

Both cylinder and cone primitives are implemented and can be seen in the render folder. I ended up using cylinders to implement my trees and cones to implement my flashlight lighting.

Soft Shadows

I implemented soft shadows by adding a new command for lights light:set_soft(radius, samples). These soft shadows work by generating uniform points in a sphere with the radius specified and then those points are sampled every time a shadow ray check is done. The effect of a light ends up being equal to the effect of the light multiplied by the ratio of shadow rays that hit the sampled point in the sphere over total number of shadow rays.

Textures

Textures are implemented with my new command rt.textured_material(file_name, u_max, v_max, s, p). The image file is loaded and it is mapped over the mesh or cylinder using the u_max and v_max values. These values allow you to either stretch the image to the primitive or tile it (u_max, v_max) = 1 implies stretching, lower values of u_max and v_max will tile the texture.

Spotlight and Volumetric Effects

I implemented a volumetric effect system by creating an additional type of object in the scene called volumes. Volumes are processed after the SceneNode tree is traversed and can apply effects to the pixel. I implemented two effects: Fog and Light.

Fog effect works by taking the distance travelled through the volume and applying a fog color to it using alpha blending. The more time the light spends in the fog, the higher the alpha will be.

Light effect is implemented similarly except instead of using alpha blending to apply an effect, we increase the intensity of the pixel based on how long the pixel travels through the light. This was used to create the flashlight effect in the final image.

L-Systems

L-Systems ended up being very fun to implement. I created a lindenmayer program in Golang for modelling lsystems using text. I started with the axiom S and introduce the following rules:

system.AddWeightedProduction('S', "QX", 10)
system.AddWeightedProduction('B', "BB", 10)
system.AddWeightedProduction('B', "B", 10)
system.AddProduction('X', "B[LX[RX]B][RX[LX]B]X")
  • "B" represents branching upwards
  • "R" represents a random right rotation
  • "L" represents a random left rotation
  • "[" and "]" act as pushing and popping a stack to track past nodes
  • "X" is ignored by the processor

Note that the weighted productions indicate random chance with the weights supplied. I then wrote Go code to generate lua code that represents the LSystem. The final result is Go code that generates lua code that is interpreted by rust code to finally render my scene!

The Go code can be seen in the folder lindenmayer, and generated lua files are in the generated folder.