I spend most of my programming time writing C++. And like many other C++ programmers, I’ve shot myself in the foot countless times with a feature I didn’t fully grok. And I’ve spent enormous amounts of time trying to understand the language. Like many other C++ developers in this position, I find myself frequently daydreaming about switching to another more modern and easily understood language.
The two languages that I spent most of my time daydreaming about writing code in are Rust and Zig.
Rust, because of its focus on safety and performance. The language effectively tries to prevent you from shooting yourself in the foot. The language has been designed with more care than C++ – features are tested for feasibility in a nightly branch before being released. A few promising graphics libraries, like gfx-rs
, make the language very appealing. But on the other hand, the language has a steep learning curve. Additionally, some of the community discussion gives me little hope that the language is going to stop growing anytime soon. At this time, there are already two slightly different ways of using modules.
Zig, because of its focus on robustness and clarity. The language aims to be easily readable and understandable. The language also features a very elegant take on metaprogramming and generics, far nicer to use than what you currently get in C++. But on the other hand, the language lacks lots of features, like operator overloading, interfaces, and traits.
Would the lack of features in Zig make me more or less productive than with Rust’s feature overload? Which language is more enjoyable to use for writing a small, self-contained computer graphics project?
To find out, I decided to implement the same simple project in both languages: a small ray tracer, following the book Ray Tracing in One Weekend. Briefly put, ray tracing is a computer graphics algorithm in which light is simulated by shooting a bunch of rays from the camera into the scene and tracking the path the rays take as they interact with the scene. The book’s code samples are C++, but the code is not complex and lends itself well to being reimplemented in other languages. Here’s how it went.
Rendering the first pixels
To kick the project off, a way to display images is required. I wanted to render the image directly to an open window, instead of outputting the image to an image file, like the book does. So, to start this project off, I had to open a window and display some colored pixels on the screen.
Rust
In the C/C++ ecosystem, I’m most familiar with using the SDL media library for providing a window to use for rendering. Of course, you can use SDL in Rust as well. But Rusts’s main package registry, crates.io, actually had something better: the minifb
crate (crates are Rust parlance for packages). It does one thing well – it lets you open a window and set its pixel buffer. No complex setup required, perfect!
Adding the crate was as easy as adding one line to Cargo.toml
.
|
|
The crate was automatically downloaded and compiled in the background the next time the build was executed.
I have to say the discoverability of packages is really nice on websites like crates.io and especially lib.rs. While many packages are not mature, it is nice to see what people have worked on in one place. If you want to find a C++ library, you most likely have to scrape through sourceforge, only to find something written during the last millenia using completely different style and standards than you are.
Zig
Zig is a very new language in comparison to Rust. That means no convenient packages to get you started. But Zig has something else up its sleeve: C headers can be imported into the current Zig module using @cInclude("SDL.h")
allowing you to use the C functions, structs, and even macro values directly in the Zig code. This made introducing SDL into the project fairly painless.
Zig actually goes a step further, because the Zig compiler is also a C compiler. I wrote a small C function to set pixel values in the window surface in C, and the C source file could be compiled alongside the Zig source, with the following small addition to the build.zig
file, which tells the Zig compiler how your project should be built.
|
|
This allows Zig to easily coexist alongside C source code.
Implementing the ray tracer
Rendering images in the ray tracing book involves writing definitions for ray-sphere collision testing as well as writing definitions for how the ray interacts with the surface of a sphere once it collides.
Controlling how the ray interacts with the sphere’s surface is implemented using a number of different material objects, which define how the ray scatters from the sphere’s surface. The materials the book implements are lambertian (paper, marble), metal, and dielectric (glass).
Rust
Program structure
Ray Tracing in One Weekend adheres to a more old-fashioned style of C++, where relations between types are often modeled using inheritance. I opted to leave polymoprhism on the table and go for a very simple approach.
In Rust, there are no fancy inheritance mechanism so my scene objects, World
and Sphere
, are just simple structs.
|
|
As we can see from the definition of the Sphere
, it has one field for a material. The sphere’s material can be any one of the three materials, lambertian, metal, or a dielectric. Once again, this could be a good place to use polymorphism, but I decided to use Rust’s really handy tagged unions instead.
|
|
One could argue that the degree of code reuse is less in this scheme than in the book, since now we have to pattern match on the material union type to call their associated member scatter functions.
|
|
But I thought it was an easy tradeoff to make.
Finally, one last missing bit from the ray tracer. Here’s the snippet which calculates whether a given ray intersects a sphere. You can see I created a Hitable
trait for my sphere and world structs to implement. In the end, I did not need the trait, because I just called the hit
functions directly on the objects. A little bit of premature “architecture” on my part.
|
|
Control flow
After spending around a weekend’s worth of time with the project, the thing I actually liked most about Rust was the control flow.
The ability to return values from if
expressions and blocks is awesome and I don’t know how I’ve managed to live up until now without it. Instead of conditionally assigning to a bunch of variables it is better to return them from an if
expression instead.
|
|
|
|
Returning all the values is more watertight and leaves less room for accidentally leaving something unassigned.
Rust also allows breaking out of loops with a value.
|
|
|
|
Even though the code looks very similar this time around, the Rust version is just more watertight and wholesome.
Using the Option
type was a great way to return the ray-sphere collision information as well as indicate whether a hit occurred, all at once.
|
|
It’s as if the language was custom-designed to solve many of slightly icky control flow situations you encounter in the book, and in C++ in general. Going back to writing C++ code after this felt like a downgrade.
Zig
Program structure
Even though Zig is trying to be familiar to C programmers, I found that I could structure the program in a practically identical fashion to the Rust version, due to the language having a surprising degree of little similarities with Rust.
The scene works identically to Rust.
|
|
I defined the materials in an identical fashion in Zig, since Zig also has very handy tagged unions.
|
|
A small aside: notice how in Zig, structs aren’t given names in their definition, but are instead assigned to named constants. The language allows you to return and assign types, like any other variables, at compile time. In fact, generics in Zig are built on this idea: you pass a function a type, and it returns a new type using the parameter type you specified.
|
|
This feature has a Lua-like elegance to it. In the Lua language, all objects are essentially tables. The global state is a table, and classes and inheritance mechanisms can be implemented using tables. When you import another module, the module is namespaced inside a table. In Zig, your type declarations, generics, and type name aliasing are based on returning and assigning types. When you import another module, the import returns an anonymous struct with the module content within. Sweet, I love it!
Control flow
Zig also allows you to return values out of if
-statements and blocks. Here’s an example where the conditional body contains only one statement.
|
|
If the body or block contains more than one statement, though, you have to break
out of the body with the value.
|
|
Not quite as effortless and elegant as Rust, but it is sort of consistent with C at least.
There’s no tuples in the language and that means no multiple return values. The dielectric material scattering snippet looks much like the C++ version:
|
|
The language forces you to declare your variables as undefined
explicitly for added safety, however. This should also make undefined variables much easier to find in a codebase.
Living without operator overloading
I’m not sure what I think about the lack of operator overloading. Here’s a small snippet of code I had to write which contained some vector math. While you avoid the problem of operator precedence entirely as well as the possibility of hiding something complicated behind a simple-seeming operator, I’m pretty sure I spent much more time writing this little expression out compared to when I had access to operators.
|
|
|
|
How do C programmers manage?
Loops
There was one surprising facet of the language which made me scratch my head on multiple occasions: loops.
I wanted to loop through a range of numbers using a for-loop, but it looks like you can’t do that. Instead, you can use a while-loop with a continuation expression:
|
|
I don’t think I’ve seen such a construct in a language before, and I’m not sure if this is the final way iterating through ranges is going to work. What is the benefit of doing it this way? I would think that a loop which doesn’t leak the idx
variable outside the loop body would be better.
To be clear, for
loops are in the language, but based on the documentation, I think that they can only be used for iterating over slices.
|
|
There’s one more use case where Zig’s loops caused me to scratch my head. Let’s return briefly to our earlier example of the random_in_unit_sphere
function. Just like in Rust, it is possible to break out of loops with values. However, Zig doesn’t have the loop
construct, and so you have to use a while
loop instead. Thus Zig has to assume that the while loop terminates at some point without breaking. This leads to a weird construct: you have to include an else
branch after your while loop to ensure that a value is returned even in the case that the loop terminates without hitting the break. I didn’t think of including the else
branch in this snippet at first, and it took me an embarassingly long time to figure out why it didn’t compile.
|
|
Somehow, of all the things in the language, I feel like loops shouldn’t be this surprising to work with.
Multithreaded rendering
The scope of Ray Tracing in One Weekend is not large and so far I had only really touched upon the absolute basic features of Rust. I wanted to add simple multithreading to accelerate the rendering and get a small taste of what it is like to get smacked around by the Rust compiler!
The plan was to split the image into multiple blocks and render them all independently, at once. This is not a hard task on paper, since the only shared resource that we are changing is the pixel buffer we are rendering to, and there is no overlap between the image blocks.
A similar image to the final image in the book. Rendered in about 20 seconds using 128 rays per pixel.
Rust
My Rust journey so far had actually been very smooth, with no major stumbling blocks. But rendering in separate threads turned out to be (unsurprisingly) harder than the way I would do it in C++.
Even though there is no overlap between the image blocks, the compiler doesn’t know that and the code didn’t compile.
It was a bit frustrating to figure out how to accomplish this. Googling yielded a few stack overflow posts with similar questions, and were answered by people basically saying use my crate! I suppose package popularity contests are bound to happen whenever you have a central package registry displaying stats like downloads and dependents.
In the end, the best way to learn was to browse similarly-scoped Rust source code on Github.
I wrapped my objects in atomic reference counters, and wrapped my pixel buffer in a mutex. It might not be the most efficient scheme, but it was a cool to get this non-trivial piece of Rust code to compile and run!
|
|
Zig
Zig doesn’t have any checks for multiple mutable data access, so I was free to do whatever I wanted. The challenge mainly arose from the fact that this aspect of the language is somewhat incomplete. In order to see how to use threads, you have to read the standard library source code directly.
This is the gist of my first attempt.
|
|
We need to pass a thread context object and a function pointer to os.spawnThread
. There are no closures in the language, so you have to manage the variable capture yourself, manually.
The problem with this code was that, in Zig, function arguments are implicitly const
and therefore I couldn’t use the random number generator contained in the context. I worked around this limitation by casting the random number generator into a non-const pointer using this unholy hack.
|
|
This really seemed to be working against the language, though, so I popped by the Zig community on Reddit, and was told that a thread context can be passed via a pointer to the thread function. Duh!
And so, the problem was fixed by changing the renderFn
signature to contain context: *ThreadContext
.
Explicit memory management
There is one more thing worth mentioning. Memory management in Zig is explicit and manual.
|
|
If any allocations are made, by e.g. a container, you need to free the memory manually. Zig makes that much easier than C with the defer
keyword. You should always pair an init
call with a deferred deinit
call.
The philosophy of Zig is to always pass an allocator to a function if it needs to do allocation. Likewise, if there is an allocation failure, then that function returns an error. Here’s what creating an array of spheres looks like in Rust and Zig, respectively.
|
|
|
|
Rust is rather implicit in this regard since containers use the crate’s allocator internally. I’m not exactly sure what the custom allocator story in Rust is, but it seems like at this time it’s not possible to customize allocations on a per-container level. I like the fine-grained control of memory management you get with Zig, even if there’s no RAII like in C++ and Rust!
My impressions
So, which language is better? 🙂
I actually liked both languages, but for different reasons.
I was surprised by how frictionless Rust felt, despite what some say – even at this very small scope, I found its functional features to be super useful. The availability of the minifb
crate made it actually easier to get started with writing the ray tracer than in Zig. For the most part, there were no stumbling blocks while writing code. I think that if I were to tackle a larger project, especially one with complex resource management, the added safety guarantees of Rust would make programming more smooth and thus more fun as well.
But I also liked Zig’s simplicity. In total, I spent way more time on the Rust part of this post, if you count the time spent reading documentation as well. I read a good chunk of the Programming in Rust book and followed along with a code editor before starting this project. As for Zig, I downloaded the available documentation for offline viewing, and read it through during a 1.5 hour bus ride (and wrote a small vector math library in the process). I feel like mucking around with Zig in smaller scale projects might be more fun in the long run. While I had some issues with the language, it isn’t done yet and it is a project I will definitely be keeping in my radar.
The full Rust and Zig code can be found on Github in case you want to take a look: weekend-raytracer-rust, weekend-raytracer-zig.