Skip to content

niklasmedinger/ray-tracing-weekend

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

90 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Ray Tracing in One Weekend

This is my Rust implementation of the ray tracer developed in the first two books of the Ray Tracing in One Weekend book series by Peter Shirley, Trevor David Black, and Steve Hollasch. The goal of this project is to learn a bit about ray tracing, Rust, and benchmarking in Rust.

Rendered Example Scenes

Here a few selected scenes from the book series rendered with this implementation. If you want to see more scenes, take a look here.

image
The final scene of the first book.

image
The final scene of the second book.

image
The "Cornell Box". A scene that models the interaction of light between diffuse surfaces.

image
A scene with a rectangle light source. The sphere and ground have a perlin texture.

To render the scenes yourself, install Rust and use

cargo run --example scene --release

to render the file scene in the example folder into the file scene.png. Take a look at the ./examples folder for sample scenes. For quicker renders with a lower quality you need to adjust the settings of the Camera struct via the CameraBuilder:

Image
Initialization of the Camera struct via the CameraBuilder struct. Optional values are instantiated with default values when not explicitly provided. See the code here or build the documentation of the proejct (cargo doc --open --no-deps) for an exhaustive list of options.

Benchmarking

I used this project to experiment a bit with benchmarking in Rust. There are four popular options for benchmarking in Rust: libtest bench, Criterion and Iai, and Divan. Since libtest requires the nightly toolchain, it is often not used in favor of crates like Criterion, Iai, and Divan, which work on stable rust.

Both Criterion and Divan are statistics-driven benchmarking libraries which allow their users to measure the latency and throughput of their projects. Iai is an experimental one-shot benchmarking library that uses Cachegrind to measure the cache accesses of your code. For futher information about the respective libraries, I recommend their githubs, crate documentation, and, for Divan, this blogpost.

I ended up choosing Criterion and Iai due to their compatability with Bencher; another benchmarking tool I'm exploring in this project.

To bench the ray tracer, I'm using two macro benchmarks and two micro benchmarks:

  • A complete render of the hollow_glass scene.
  • A complete render of a grid of spheres.
  • A single pixel from the hollow_glass scene.
  • A single pixel from the grid of spheres scene.

See the benches folder for the code of these benchmarks. Each benchmark is executed with both Criterion and Iai.

To benchmark the code changes to the project, I use two approaches: 1) Relative continuous benchmarking, and 2) Continuous statistical benchmarking. For 1), I'm using a Github Action which first checks out the parent of the current commit and benchmarks it, and then checks out the current commit and benchmarks it too. This allows us to inspect the performance difference of these two commits in the logs of the Github Action runs. For instance:

image
The output of Criterion and Iai for one of our benchmarks in CI. These benchmarks are run on both the current commit and the parent of the current commit. The percentages shown are relative from the parent commit to the current commit. I.e., a negative percentage indicates that the current commit is more performant on the benchmark.

For 2), I'm using Bencher: A suite of continuous benchmarking tools. Again, I'm using an action to checkout my code and then use Bencher to execute my benchmarks on their infrastructure. For each of the benchmarks, Bencher tracks its results over time and is even able to issue warnings should a degression occur. Here, you can see the projects perf page, where you can inspect the different measure over time yourself!

It also allows you to easily export automatically up-to-date images of these plots. Here are some example plots:

Latency of Scenes for Raytracing Weekend - Bencher
Latency of the scene renders over time.

In this plot, we can see how the render time for our benchmark scenes evolved over time. The first major drop in rendering time is due to concurrent rendering of each pixel thanks to Rayon. This optimization is not part of the book series. Another optimization which is part of the book series is a bounding volume hiearchy (BVH). In a nutshell, for each pixel, the ray tracer needs to ask the objects in the scene whether the ray originating from this pixel hits the object. Without any optimization, this results in a linear amount of hit queries for each pixel. A simple idea to optimize this is to enclose each object in the scene with a bounding volume which completely encloses it and then create a hierarchy (e.g., a binary tree) of these bounding volumes. Instead of the objects the ray tracer then queries these bounding volumes and, if the bounding volume is not hit by the current ray, does not need query the objects inside the bounding volume. As a result, the ray tracer only needs to make a logarithmic amount of hit queries per pixel instead of a linear amount.

Unfortunately, this optimization did not decrease the latency of our benchmark scenes. In fact, the opposite is true! It increased the rendering time. This is the bump around the 24.08.24 (08/24/24, for you americans) that you can see in the plot. Soooo... did the optimization not work? Did I implement it incorrectly?

The answer is: No. The optimization works and it is implemented correctly (to the best of my knowledge), but our benchmark scenes do not show any performance increase because they do not contain enough objects! The additional book keeping that the BVH introduces does more harm than good. With the problem identified, let's solve it! I added another scene, the many scene, which contains around ~900 spheres and first benchmarked it without the BVH and then with the BVH. And, as expected, we have a sharp decrease in rendering time from 109,21ms to 14,199ms! Awesome!

Raytracing Weekend - Bencher
Latency of the single pixel renders over time.

This plot shows the latency of the single pixel renders over time and, as we can see, it mostly increased throughout the project. The first small increases are due to code changes necessary to parallelize the pixel rendering. In a nutshell, switching from 'normal` reference counting (i.e., Rc) to atomic reference counting (Arcs) introduces some overhead. However, looking at the first plot, this overhead is well worth it for the rendering time of the scenes!

The second increase is due to the aforementioned BVH optimization. As explained earlier, our benchmark scenes actually perform worse with the optimization because it does not contain enough objects. I did not add a single pixel render of the many scene I described earlier.

Instructions for Single Pixel Renders for Raytracing Weekend - Bencher
Instructions executed for the scene renders over time.

Iai allows us to measure the instructions executed for our benchmarks. I only added this library/benchmarks late into the project, so we cannot see the impact of our optimizations.

Instructions for Single Pixel Renders for Raytracing Weekend - Bencher
Instructions executed for the single pixel renders over time.

Rust vs C++

The original implementation of the ray tracer developed throughout the book series is written in C++. However, I chose to implement my ray tracer in Rust. While a straightforward port of the C++ code to Rust is possible, this would result in very un-idiomatic Rust code. Therefore, I chose make minor changes to the code to make it more idiomatic. In this Section, I'll give a quick overview of these changes.

bool as a Return Value to Communicate Optional Results

The C++ interface for a hittable object looks like this:

class hittable {
  public:
    virtual ~hittable() = default;

    virtual bool hit(const ray& r, interval ray_t, hit_record& rec) const = 0;
};

The idea is that any implementation of the hit method writes to the hit_record that rec points to if and only if the hit method returned true. Put simply: hit makes optional changes to rec and uses a boolean to communicate whether a change occured.

For cases like this, Rust features the Option<T> type. It looks like this:

pub enum Option<T> {
    None,
    Some(T),
}

The None variant encodes that no value of type T is present and the Some(T) variant encodes that a value is present. Exactly what the C++ code wanted to implement but needed to use booleans for! Here, my Rust version of the hittable interface:

pub trait Hittable {
    /// Compute whether `ray` hits the `self` in [Interval] `ray_t`.
    fn hit(&self, ray: &Ray, ray_t: Interval) -> Option<HitRecord>;
}

This type signature communicates more clearly what is happening. If a hit occurs, the function returns a Some(HitRecord) and, should not hit occur, it returns a None.

Public Values in Structs to Avoid Constructors

Throughout the book series structs like the Camera evolve quite a bit. Naturally, this means that they store more and more fields. It is standard practice to initialize these fields in a constructor which, often, does nothing more than passing on a few values it received when called. For instance, here is the constructor for the translate class:

class translate : public hittable {
  public:
    translate(shared_ptr<hittable> object, const vec3& offset)
      : object(object), offset(offset)
    {
        bbox = object->bounding_box() + offset;
    }
}

The constructor simply forwards object and offset to internal fields and computes bbox from these arguments.

If we now extend the translate class, we have a problem: Every time we add a field, we have to make a change to the constructor. Very annoying! Especially, if you are writing a book series which incrementally extends the code!

To sidestep this issue, the C++ code makes quite a few fields public and assumes that the calling code will set the values it needs. Here is an example of how the camera is set up for a rendering scene:

{
    // ...
    camera cam;

    cam.aspect_ratio      = 16.0 / 9.0;
    cam.image_width       = 400;
    cam.samples_per_pixel = 100;
    cam.max_depth         = 50;
    cam.background        = color(0.70, 0.80, 1.00);

    cam.vfov     = 20;
    cam.lookfrom = point3(13,2,3);  
    cam.lookat   = point3(0,0,0);
    cam.vup      = vec3(0,1,0);

    cam.defocus_angle = 0.6;
    cam.focus_dist    = 10.0;

    cam.render(world);
}

About

My implementation of `Ray Tracing in One Weekend` in Rust.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages