Recently I have been wanting to have another go at Rust, having only touched it briefly during university. I figured a great place to start would be with some familiar technology; seeing how I could implement it in Rust. This was a great project for learning many of the core components of Rust as a language.
I stumbled across a version of Ray Tracing In One Weekend, but for Rust: The Ray Tracing Road To Rust. Ray tracing isn't dissimilar to raymarching; both involve shooting rays from a camera to colour a pixel. That gave me the idea to adapt some stuff I had already written to see how concepts I am familiar with can translate to the Rust programming language.
One of the first steps in creating a ray tracer or raymarcher was to create a vector type. As I knew I wanted to make a 4D raymarcher I knew I would need vector types for both 3 and 4 dimensions. As a result I created structs Float2
, Float3
, and Float4
as you might find in game engines or shader languages.
I almost exclusively work with object oriented programming, where typically you would use an interface or inheritance to abstract common functionality. This is useful when you want a function to take a generic parameter. For example the signed distance function for a sphere, which is the same in any dimension:
pub fn sdf_sphere<T: Vector>(p: T, centre: T, radius: f32) -> f32
{
return (p-centre).length() - radius;
}
Rust is not an object oriented programming language; instead it uses Traits to abstract functionality between types.
Implementing Float3 first, I already had to make use of traits to derive operators like Add and Multiply, but vectors also have other operations such as the dot product, cross product, wedge product, and also the ability to normalize a vector. I created my own trait Magnitude
which includes functions for normalizing a vector and getting its length as I knew this would also have to be derived by Rotors later on. I then created a new Vector
trait which included the dot product, and also combined several other traits that the vector types were to implement, including Magnitude
, Add
, Mul
, MulAssign
etc.
Thoughts on Traits:
Traits seem like a pretty tidy way to handle abstracted functionality. Any object can implement any number of traits; and traits can also combine with other traits to specify a wide range of included functionality. This can really tidy up some issues with many object oriented languages where you want to be able to inherit from multiple classes, but are restricted to one. Interfaces in OOPLs do get you most of the way there, but they sometimes are may not be able to wrap up all the functionality you would like in order to describe the object you are working with.
However, not allowing structs to inherit from other structs did become a limitation later on. When I started creating different cameras for 3 and 4 dimensions, I was very frustrated. The 2 cameras shared all the same variables (except vector and rotor types being different for 3D and 4D) and very similar or even identical functions. Traits didn't make a lot of sense here, as they don't declare variables. Being unable to create unique structs that inherit from the same parent led to duplicate code for near identical structs. I did consider working around this with a single generic struct and leave the types to the caller, but that felt quite untidy to me, and I much preferred distinct structs for simpler construction.
Following the vector types I created Bivectors and Rotors for 3D and 4D rotation. I already had some Unit Tests from 4D games, and I figured I would use them as an opportunity to test out Rusts built in Unit Test functionality.
Having Unit Tests built into Rust is a fantastic feature, and they even came in use as I had made a mistake in translating some of the rotor code from C# to Rust, and the tests caught this. It was super simple to set up, and leaves very little excuse not to test your code.
Up to this point, the project was just rendering an image to a file, as was shown in The Ray Tracing Road To Rust tutorial. My next goal was have this open in its own window and continuously render in order to display a rotating shape.
Whilst looking up how to do this I found a guide: Game Development in Rust with SDL2. This could come in handy if I ever wanted to take this project further, but it's main use for me was just an introduction to the SDL2 for Rust crate, and helping me quickly get a window up and running.
I spent a while trying to create a Surface
and set the pixels directly to this type of texture, but I couldn't find many resources on for Rust, and whilst there is plenty of C++ examples, I ultimately decided on just setting the colour of pixels directly on the canvas rather than trying to handle unsafe
code from the C++ examples.
Now I had a continuously rendering window, but the performance was pretty awful. This is totally to be expected as I am not utilising any shaders, but it does provide an opportunity to learn more about Rust. I wanted to explore two types of concurrency: Async and Futures, and Multithreading.
Rusts version of asynchronous functions and futures is pretty similar to C#'s async tasks, which I had some experience with. Overall it was pretty straightforward to set up, and it helped me see how one might structure asynchronous code in Rust. I didn't see much of a performance benefit though, presumably because I made each pixel its own async call which probably just rendered more-or-less synchronously. This code is still visible in the async_rendering branch on Github. Rather than trying to improve on this though I decided to branch again and take a look at multithreading.
At first I had a bit of a struggle spawning threads. Rust was telling me the lifetimes of variables used by the threads may not necessarily live longer than the thread, as the lifetime of the thread is unknown. After some research I found thread::scope
which is a neat piece of tech for telling the rust compiler how long the thread will live among other things.
Unfortunately after successfully spawning a new thread for every pixel, every frame, my performance absolutely tanked. spawning and joining threads of course adds additional overhead and as a result I was essentially finding a more expensive way to render each pixel.
I tried using thread::available_parallelism()
which dropped the number of threads from several thousand to 16, but unsurprisingly this performance wasn't much better than using a single thread given the amount of pixels in the image.
In the end I just made each row of the image its own thread, and this significantly improved performance, hitting nearly 30fps on my AMD Ryzen 7 CPU.
Overall I am happy with where this project ended, and I learned a lot about many core features of Rust!