I've wanted to write a raytracer ever since I first learned what they were, and now I've finally done it! It would probably be more accurate to say that I copied a raytracer out of a book and translated it from c++ to rust, but that sounds far less fun than it actually was. At first it made me want to write about raytracing, but it turns out that there are like 50 years of literature on the subject and I kept finding things that were essentially what I wanted to write but better. If you're interested in reading about it I'd recommend you try some of the things I read along the way. Also I haven't been thinking too deeply about all the graphics stuff because of how little I know about the subject, it's more like: read book chapter/blog post/academic paper -> implement thing I read about.
Instead I want to record my experience with using rust for the project. I think that writing a few thousand lines of code for this has pushed me over the edge in terms of feeling at home and productive in the language. There were some things that made me fall more in love with it, and some that made me pull my hair out because they shouldn't be so difficult, and I'm just going to share as many of those as I can remember in no particular order.
The joy of grokking
Option and the related combinators was amazing, and I'm
still not over it. I feel warm and fuzzy every time I express my intent clearly
and safely with
Option<T> instead of
*T(which might be null). A lot of
things I like about rust are trade-offs or stylistic differences, but I'm pretty
convinced that this abstraction is objectively better than nullable references
in every way.
This is more of a quality of life thing, but because I was translating a lot of c++ code I kept running into the annoyance of function signatures that use "out-parameters". This is where some arguments are mutable references that are used to store the result of the function. In rust having first class tuples to return arbitrary data from functions is so much nicer. I know "modern idiomatic" c++ probably has tuples and you're supposed to make "input" arguments constant references so it's easier to tell the difference, but the c++ I've had to deal with and the examples in this book were pretty much "c plus classes" so I spent more time than I should have figuring out which params were which.
I didn't use many external dependencies, but when I did they worked painlessly
and were well documented (rustdoc having a great interface definitely helped).
When I wanted to include a new library all it took was 1 line in my
and it was downloaded from crates.io on the next build.
Coming from python and c++ it's refreshing to not have to manually tinker with
the package manager or build system to set up dependencies, not to mention
confusing linker flags or creating isolated language environments for every
Ray tracing happens to be an incredibly good candidate for parallelization.
Since the act of computing a ray bounce doesn't modify the scene each individual
ray can be computed in parallel (in reality it makes more sense parallelize over
regions of the image because there's no way you'd have enough cores to do every
ray in parallel). The example from rayon's README where "you just change your
foo.iter() call into
foo.par_iter(), and Rayon does the rest" seems too good
to be true but that's
literally what I did
and got a huge speedup.
Traits feel like they encourage code reuse more than the multiple inheritance
systems I'm used to. Being able to implement my
Texture trait for the
type from an external crate without modifying its definition feels awesome. I
was a bit disappointed when I learned that you couldn't implement foreign traits
on foreign types but I've since read up on why this doesn't work in rust's
current type system and how to use the newtype pattern to circumvent it. I also
had a considerable amount of friction with the numeric traits and am still not
happy with how I ended up using them to create generic 3D vectors. I'm sure if I
used something like nalgebra or
cgmath I would have been fine, but I was
trying to make both the implementation and interface of my hand-rolled
which was a lot more difficult than I'd assumed.
The language docs and the book are great resources. The only gripe I have is that old versions of them are still the highest search results for a lot of things and it's frustrating to have to click through a link or two and read messages like:
The second edition of the book is no longer distributed with Rust's documentation. If you came here via a link or web search, you may want to check out the current version of the book instead.
I guess they don't want to break links to the old docs, but I'd love it if they just automatically redirected you to the current versions. Also while I was working on the project, the reworked module system landed and I spent quite some time confused due to reading outdated code and docs.
One of the things I'm still figuring out is how to organize my code. There don't seem to be consistent conventions in the rust I've read about what order to put struct definitions, trait impls, util functions, public/private things, macros, etc. or when to split things into multiple files. I'd love a rust style guide or something to give guidelines about ways to make my code more readable.
Since a good deal of my time went to making my raytracer run faster, I got to
try out some of the profiling solutions for rust. It may just be my inexperience
with profiling in general but to me this seemed like one of the weakest parts of
the ecosystem. Benchmarking was fine and worked similarly to writing unit tests,
but once it came to improving those numbers I was pretty stuck. I tried a few
tracing tools and generated
flamegraphs but I got
conflicting results between tools and it was hard to understand the output. I
couldn't manage to get LLVM's profile guided optimization working either.
Besides parallelization and algorithmic improvements I tried incorporating SIMD
to speed up vector math but it surprisingly made some operations much slower.
Similarly I got improvements as well as regressions by switching between passing
small structs like
Vec3 around by value or reference.
Most people agree that tests on the whole are a good thing. The issue is that I am lazy and if I feel like something is just good and not necessary then it probably won't happen a lot of the time. Hopefully I'll have enough experiences where a test helps me find a bug while writing something that I'll realize how necessary they really are. I'm not there yet, but I've gotten to the point where I at least write tests to cover the breaking behavior while debugging, and rust makes the process absolutely painless. Not having to install extra things or write more than a handful of lines of boilerplate makes a world of difference when you're lazy. All this being said I've only ever really done unit testing, so I'm not sure what integration testing and mocking are like in rust. I'm mostly used to the magical libraries in dynamic languages that make mocking super ergonomic, I hope rust can use macros or something to achieve similar ease of use.
Cargo has more up it's sleeve than just being a great build system, package
manager, testing framework, etc. It also happens to have a nice facility for
writing example code. In my raytracer I kind of abused this feature to get
multiple binaries, but I still think it's neat. Essentially I would write each
3D scene as an example program (all that means is putting the file in a
directory called examples) and then I could do
cargo run --release --example name_of_some_scene. Reading over a bunch of
other peoples implementations of this project I kept finding huge commented out
blocks in their
main() to change which scene to render and I'm glad I was able
to avoid that with examples. As a side note: "features" were another thing that
saved me from a bunch of uncommenting and re-commenting code blocks. I was able
to turn on or off multi-threading and HDR output with just a flag, doing
conditional compilation without C style macros is great! Speaking of which...
I tend to go overboard when it comes to trying to make my code terse, especially
definitions, and I went back and forth for a while on how to define the 3D
scenes I'd render. The main choice was between doing it in code and doing it in
some sort of configuration file. The latter seemed preferable because I could
have lots of control over the syntax but after a few failed attempts to abuse
deserialization to come up with an elegant config format I gave up on that and
just wrote the scenes manually in rust. I wasn't satisfied this and eventually
tried to write some macros to shorten the construction of big nested objects. I
especially didn't like that I had to worry about wrapping stuff in
Arc and converting integers to floats when I really just wanted to be thinking
about how to position shapes in the scene. I was pretty scared of the weird
syntax but they really weren't as complicated as they seemed and it solved the
problem pretty well, instead of:
let left_box = Box::new(Translate::new( Rotate::new( Prism::new(Vec3::zero(), Vec3::new(165, 330, 165), white.clone()), Axis::Y, 19.0, ), Vec3::new(265, 0, 295), ));
I can write:
let left_box = translate!( rotate!(Y, prism!((0, 0, 0), (165, 130, 165), white.clone()), 19), (265, 0, 295) );
While it's not a huge difference, it made me feel a bit better about writing scenes in code. I'm still interested in trying procedural macros to do spicier things than just pattern matching and substituting symbols for other ones, but creating a full blown DSL for this project wasn't something I was interested in.
There are quite a few things I didn't get around to implementing because I ended up getting frustrated and put down the project. This was mostly due to the "fighting" with the borrow checker that all rust newcomers seem to face. In particular there's a couple of patterns and datastructures that I've failed over and over again to write in rust. After revisiting it a few times in the past few months I'm still not sure how to solve my problems nicely which is incredibly discouraging, and it's the main thing that's kept me from going back and doing fun stuff like targeting web assembly.
I won't go into the details because I could write another post entirely about
the minutiae of the issues I had but here's the short version: my 3D scene
contains things like shapes and textures. Those shapes are stored in a
special tree datastructure
for performance reasons, which is already a headache to write in rust. Shapes
can share the same texture, so the model of making each object simply own all
its information isn't possible. Moreover, to avoid using globals all of the
scene data is encapsulated in a monolithic object. The issue (well one of them
at least) is that then the references between objects and meshes and textures
Scene struct self-referential which is basically picking a fight
with rustc. I've considered several solutions: using the new
Pin trait to
express that the scene can't be moved around in memory, making the whole scene
static, replacing references with indices into arrays to circumvent the borrow
checker, trying to use raw pointers and/or
unsafe, and wrapping everything in
Arcs. Some of those at least partially solve the problem, but the ones that do
all seem like hacks and I can't shake the feeling that I should be able do
This complex tree with shared internal parts was also a pain in the butt to initialize immutably. I'm not sure if this is a shortcoming of rust or just my inexperience with the language, but I didn't feel great spending hours figuring out how to construct stuff after reading a comparatively simple version in c++.
I wasn't explicitly collaborating with anyone, but when I had questions it was easy to find people willing to help on the community discord. Also it turns out that doing the Raytracer in One Weekend tutorial that I was following is somewhat of a popular project among new rustaceans. I found dozens, and eventually more than a hundred people doing the exact same thing and it was endlessly helpful to compare their approaches to my own. Not to belabor the point, but I've had nothing but positive interactions with the community and it's one of the biggest motivators to do more rust stuff.
I keep meaning to go back and do some more impressive renders now that I've got 3D meshes and colored dielectrics and stuff but I hope you've enjoyed the spheres and boxes!