Home Ruxel - Building a Ray Tracer with Rust Part 1
Post
Cancel

Ruxel - Building a Ray Tracer with Rust Part 1

This post is Part 1 in a series to share my journey in developing Ruxel, a simple Ray Tracer and 3D Renderer written in Rust, from scratch.

Please see the Series Prelude for more information regarding my Goals for v.0.1.0 of this project.

Ideally by the end of the series, Ruxel will be able to render an image like the one in the header, or better…

Note: Explaining 3D math in depth isn’t the aim of these posts. To learn more about the underlying mathematics and theory please check the books I reference in the Series Prelude.

High-Level Architecture

The long-term objective is that Ruxel will become a big project, and surely it will go through several refactoring phases; however, it’s critical to leverage Rust’s modules’ system from the beginning to keep the project well organized and easier to maintain as it grows larger…

The following diagram presents a high-level view of the architecture of the application, at least for v.0.1.0:

Ruxel - High-level architecture diagram Ruxel high-level architecture diagram - made with https://mermaid.live


In Part 1 and Part 2 of this series, the focus is to create the following:

Part 1:

  • Initial project structure:
    • Cargo.toml
    • Modules tree
    • Unit testing
  • Geometry module:
    • Vectors
      • Struct
      • Traits
      • Implementations
    • Points
      • Struct
      • Traits
      • Implementations

Part 2:

  • Picture module:
    • Canvas
      • Struct
      • Traits
      • Implementations
    • Pixel
      • Struct
      • Traits
      • Implementations
    • Colors
      • Struct
      • Traits
      • Implementations
    • Image
      • File

Implementing these modules first, will allow the testing of the basic geometric types and present a first image in a straightforward format.

Project scaffolding

To begin, open the terminal -in my case it’s the Alacritty + Tmux + Fish + Neovim combo- and start a new Cargo project followed by several mkdir and touch commands to get the proper directory structure…

1
2
3
4
5
cargo new ruxel
mkdir src/geometry
mkdir src/picture

-- other commands

If you use Neovim, you can execute shell commands in Neovim’s command line by prepending a ! before the shell commands, for example: :!mkdir geometry or :!touch vector.rs.

The initial project structure is the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Rust/ruxel on  main [✘] > v0.0.0 | v1.63.0
 λ tree -L 4
.
├── Cargo.lock
├── Cargo.toml
├── images
├── LICENSE-APACHE
├── LICENSE-MIT
├── README.md
└── src
    ├── geometry
    │   ├── vector
    │   │   └── tests.rs
    │   └── vector.rs
    ├── geometry.rs
    ├── main.rs
    ├── picture
    │   ├── canvas
    │   │   └── tests.rs
    │   ├── canvas.rs
    │   ├── colors
    │   │   └── tests.rs
    │   └── colors.rs
    └── picture.rs

8 directories, 16 files

This initial scaffolding allows to continue adding modules, and their respective unit tests, in an structured way.

For example, adding a Matrix module would imply creating a new matrix directory, its rust source file and respective tests:

  • /src/geometry/matrix/
  • /src/geometry/matrix.rs
  • /src/geometry/matrix/tests.rs

This would yield the following tree:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Rust/ruxel on  main [✘] > v0.0.0 | v1.63.0
 λ tree -L 4
.
├── Cargo.lock
├── Cargo.toml
├── images
├── LICENSE-APACHE
├── LICENSE-MIT
├── README.md
└── src
    ├── geometry
**  │   ├── matrix 
**  │   │   └── tests.rs
**  │   ├── matrix.rs
    │   ├── vector
    │   │   └── tests.rs
    │   └── vector.rs
    ├── geometry.rs
    ├── main.rs
    ├── picture
    │   ├── canvas
    │   │   └── tests.rs
    │   ├── canvas.rs
    │   ├── colors
    │   │   └── tests.rs
    │   └── colors.rs
    └── picture.rs

8 directories, 16 files

Rust offers other alternatives to structure projects, like using mod.rs; however, I find the option of using a file and directory name to be the clearer.

Important diagnostic attributes to set up from the beginning:

/src/main.rs

1
#![warn(missing_docs, missing_debug_implementations)]

This will provide linter warnings for missing doc comments used by Rust to generate automatic documentation, and for detecting types missing the Debug trait that provides convenient ways to display type values during development.

And finally, lets make sure all of our files are part of Rust’s module tree:

/src/main.r

1
2
3
4
5
6
7
8
9
/**
The geometry module implements the functionality for Points, Vectors, Matrices, and their transformations
*/
pub mod geometry;

/**
The picture module implements the functionality for Canvas and Colors in order to create an image file.
*/
pub mod picture;

/src/geometry.rs

1
2
3
4
/// Provides data structures, methods and traits for Matrix4 computations.
pub mod matrix;
/// Data structures and methods for Vector3 and Point3 computations.
pub mod vector;

/src/picture.rs

1
2
3
4
5
/// Provides the data structure and implementation of the Color type
pub mod colors;

/// Provides the data structure and implementation of the Canvas type
pub mod canvas;

/src/geometry/vector.rs

1
2
3
// Unit tests for Vector3 and Point3
#[cfg(test)]
mod tests;

/src/picture/canvas.rs

1
2
3
// Canvas Unit Tests
#[cfg(test)]
mod tests;

/src/picture/colors.rs

1
2
3
// Colors Unit Tests
#[cfg(test)]
mod tests;

If everything is properly set up rust-analyzer shouldn’t emit a warning regarding that a file isn’t part of the module tree.

It’s now possible to write some types with their associated functions and bring them into the main.rs scope with full rust-analyzer completion assistance:

/src/main.rs

1
2
3
4
5
6
7
8
9
fn main() {
    let v = Vector3::one();
    let p = Point3::one();

    println!("Vector: {:?} \n Point: {:?}", v, p);

    let c = ColorRgb::red();
    println!("Color: {}", c);
}

Now that the initial scaffolding is done, lets start coding.

Vectors and Points

The module vector contains the types Vector3 and Point3 which are mathematically defined as:

\[\begin{align} \vec{v} = \begin{bmatrix} x & y & z \end{bmatrix} \end{align}\] \[\begin{align} \vec{p} = \begin{bmatrix} x & y & z & w \end{bmatrix} \end{align}\]

Type definitions

The mathematics definition can be translated into Rust code as follows: /src/geometry/vector.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/// Type representing a geometric 3D Vector with x, y, z components.
#[derive(Debug, Clone, Copy)]
pub struct Vector3<T> {
    /// Component on x axis
    pub x: T,
    /// Component on y axis
    pub y: T,
    /// Component on z axis
    pub z: T,
}

/// Type representing a geometric 3D Point with x, y, z components.  
#[derive(Debug, Clone, Copy)]
pub struct Point3<T> {
    /// Component on x axis
    pub x: T,
    /// Component on y axis
    pub y: T,
    /// Component on z axis
    pub z: T,
    /// Component representing the 'weight' 
    pub w: T,
}

Both types:

  • Are named-field structures to have access to their components by name (for example: vector.x, point.z).
  • Have generic type parameters in their components so to have the flexibility to implement them as f64 or i64, etc.
  • Implement the common traits of Debug, Copy and Clone.
  • Are, for the moment, public via pub.

The most consequential decision here is that both types implement the Copy and Clone common traits.

And the main reason is that all their components are expected to be common numeric types like i64, f64, etc., that don’t own heap resources.

Traits and implementation

Out of the countless ways to implement the desired functionality for these types, the preferred way for this project is to do the following for v.0.1.0

  • Focus on implementing Vector and Point for common numeric types f64 and for a 3D coordinate system. So no Vector2<f64> or Point3<i64>.
  • Leverage generics and traits as much as possible, keeping in mind future extensibility with little to no refactoring. For example, including Vector2 should be about adding functionality without having to refactor the existing code.
  • Create a public Enum named Axis that specifies the axis of a 2D,3D or 4D coordinate system.
  • Create a public trait named CoordInit that specifies the methods for initializing the coordinate system for any type that implements it:
    • This provides extensibility for other types of vectors and points, like Vector2 or Point2, or basically any type that requires a coordinate initialization: up, down, back, forward, etc.
  • Implement CoordInit trait for Vector3 and Point3
  • Create a public trait named VecOps that defines common capabilities exclusive for Vectors, like magnitude, cross product, dot product, etc.
  • Implement the associated function new(...) on each type.
  • Implement operator overloading capabilities to conveniently write common binary operations over Vector3 and Point3 (to learn more about this topic you can check this post Basic Operator Overloading with Traits):
    • Add, AddAssign
    • Sub, SubAssign
    • Mul and Div
    • Neg
  • Implement the following common traits:
    • Display, Debug, Default, Eq and PartialEq

Axis enumerator

A critical, yet opinionated decision, is how to best handle the initialization of new coordinate-related types, like vectors and points, using a trait with type-associated functions with different signatures. In essence, how to handle this case:

/src/geometry/vector.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
trait Init{
        fn new(...);
    }

impl Init for Vector2<T>{
        fn new(x: T, y: T) -> Self{...}
    }
impl Init for Vector3<T>{
        fn new(x: T, y: T, z: T) -> Self{...}
    }
impl Init for Point4<T>{
        fn new(x: T, y: T, z: T, w: T) -> Self{...}
    }

There are various alternatives like using associated functions with the turbofish syntax, however for this project I favor utilizing an enumerator to encapsulate the method parameters:

/src/geometry/vector.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
Enumerator that encapsulates the different coordinate systems used to initialize a Vector or
Point
*/
#[derive(Debug)]
pub enum Axis<U> {
    /// Coordinate system with X and Y axis.
    XY(U, U),
    /// Coordinate system with X, Y and Z axis.
    XYZ(U, U, U),
    /// Coordinate system with X, Y, Z and W axis.
    XYZW(U, U, U, U),
}

And then bring it into scope using a simplified alias:

/src/geometry/vector.rs

1
2
3
4
5
use geometry::vector::{
    Axis,
    Axis::{XY as xy, XYZ as xyz, XYZW as xyzw},
};

With the end result in being able to initialize a new coordinate-related type in a consistent, clear and condensed manner using the new(...) method name:

/src/main.rs

1
2
    let vec3 = Vector3::new(xyz(1.0, 2.0, 3.0));
    let point3 = Point3::new(xyzw(1.0, 2.0, 3.0, 4.0));

It provides the added benefit of extensibility to new types while maintaining a consistent initialization syntax.

So for example, if in the future the Vector2, Vector4, Point2 or any other coordinate-related type requires a new method we would use the same syntax:

/src/main.rs

1
2
3
4
5
6
    let vec2 = Vector2::new(xy(1.0, 2.0));
    let vec3 = Vector3::new(xyz(1.0, 2.0, 3.0));
    let vec4 = Vector4::new(xyzw(1.0, 2.0, 3.0, 4.0));
    let point2 = Point2::new(xy(1.0, 2.0));
    let point3 = Point3::new(xyz(1.0, 2.0, 3.0));
    let point4 = Point4::new(xyzw(1.0, 2.0, 3.0, 4.0));

In the following section, where the CoordInit trait is defined, it’s shown how to utilize the Axis enumerator in the trait function.

CoordInit trait

The main goal of the CoordInit trait is to define the functionality to initialize any coordinate-related type, in its most common ways, but also by in a user-defined way.

In the case of coordinate-related types the most common initializations are: up, down, left, right, forward, back, one and zero.

Also, having a self.equal() method to compare one type against another can be handy.

In Rust, this trait can be defined as follows:

/src/geometry/vector.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/// Trait allows Types with coordinates (x, y, etc.) to be efficiently initialized with common shorthand.
pub trait CoordInit<T, U> {
    /// Return a type with shorthand, for example [0, 0, -1].
    fn back() -> T;
    /// Return a type with shorthand, for example  [0, -1, 0].
    fn down() -> T;
    /// Return true if a type is identical to another, else return false.
    fn equal(self, rhs: Self) -> bool;
    /// Return a type with shorthand, for example  [0, 0, 1].
    fn forward() -> T;
    /// Return a type with shorthand, for example  [-1, 0, 0].
    fn left() -> T;
    /// Return a type with user-defined Axis components.
    fn new(axis: Axis<U>) -> T;
    /// Return a type with shorthand, for example  [1, 1, 1].
    fn one() -> T;
    /// Return a type with shorthand [1, 0, 0].
    fn right() -> T;
    /// Return a type with shorthand [0, 1, 0].
    fn up() -> T;
    /// Return a type with shorthand [0, 0, 0].
    fn zero() -> T;
}

The trait is defined with two generic components <T, U>.

T represents the coordinate-related types (Vector, Points, etc.) that will be initialized, and that will be returned from almost all the functions.

U represents the common type (f64) that will use the Axis enum to initialize the coordinate-related type.

The next step is to implement each of these functions for the specific types that are needed.

As defined above, for v.0.1.0 the focus is only to support types in 3 dimensions and with floating points (f64). However, the framework to make it extensible to other dimensions and primitive data types is already established.

For the full implementation details you can visit Ruxel’s GitHub repository.

Here, I will explain just a couple in its implementation for Vector3<f64>:

  • fn new(axis: Axis<U>) -> T
  • fn zero()

The Rust code is the following:

/src/geometry/vector.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
impl CoordInit<Vector3<f64>, f64> for Vector3<f64> {
    
    // other type-associated functions

    // new()
    fn new(axis: Axis<f64>) -> Vector3<f64> {
        match axis {
            Axis::XY(x, y) => Vector3 { x, y, z: 0.0 },
            Axis::XYZ(x, y, z) => Vector3 { x, y, z },
            Axis::XYZW(x, y, z, _w) => Vector3 { x, y, z },
        }
    }

    fn zero() -> Self {
        Vector3 {
            x: 0.0,
            y: 0.0,
            z: 0.0,
        }
    }
}

The first line specifies the implementation of the CoordInit trait for the Vector3 type utilizing f64 in its components.

The function fn new(...) receives the Axis<f64> enumerator, performs a match against the supported coordinate systems (XYZ, etc.) and returns the Vector initialized with user-defined or sane values.

In this case, the sane value is to return a Vector3 even if the user inputs a 2D coordinate system.

The function fn zero() returns a Vector3 with all its components with value 0.0.

Operator overloading

To implement operator overloading capabilities it’s necessary to bring the std::ops module into scope and implement the desired traits in each of the types.

/src/geometry/vector.rs

1
2
// Bring overflow operator's traits into scope
use std::ops::{Add, AddAssign, Div, Mul, Neg, Sub, SubAssign}

Binary operations between Vectors and Points need to follow some mathematical logic, summarized in this table:

OperationLHSRHSResult
AddVPP
AddPVP
AddVVV
AddPPN/A
SubVPN/A
SubPVP
SubVVV
SubPPV
MulPScalarN/A
MulVScalarV
MulVPN/A
DivPScalarN/A
DivVScalarV
DivVPN/A
NegVN/A-V
NegPN/AN/A

And hence, it’s only necessary to implement the combinations that yield a logic result. Not implementing the others carries the additional benefit of being stopped by the Rust compiler.

For example, to support the Add operation between Vector3 and Point3 three implementation functions are needed:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
impl Add<Point3<f64>> for Vector3<f64> {
    type Output = Point3<f64>;

    fn add(self, rhs: Point3<f64>) -> Point3<f64> {
        Point3 {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
            z: self.z + rhs.z,
            w: rhs.w,
        }
    }
}

impl Add<Vector3<f64>> for Point3<f64> {
    type Output = Point3<f64>;

    fn add(self, rhs: Vector3<f64>) -> Point3<f64> {
        Point3 {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
            z: self.z + rhs.z,
            w: self.w,
        }
    }
}

impl Add for Vector3<f64> {
    type Output = Vector3<f64>;

    fn add(self, rhs: Self) -> Vector3<f64> {
        Vector3 {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
            z: self.z + rhs.z,
        }
    }
}

Check the GitHub repository for the full implementation of all the operators.

Once all the binary operators are overloaded, it’s possible to ‘chain’ Vector and Point operations like with any common primitive, which is extremely useful and convenient:

src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    let v1 = Vector3::new(xyz(2.0, 3.5, 4.0));
    let v2 = Vector3::new(xyz(3.0, 7.5, 8.0));
    let v3 = Vector3::new(xyz(2.55555, 7.88888, 9.34343));
    let v4 = Vector3::new(xyz(2.55553, 7.88887, 9.34342));
    let p1 = Point3::new(xyz(2.5, 3.5, 4.5));
    let p2 = Point3::new(xyz(3.0, 7.0, 8.0));
    let p3 = Point3::new(xyz(2.55555, 7.88888, 9.34343));
    let p4 = Point3::new(xyz(2.55553, 7.88887, 9.34342));


    println!("{:?}", v1 + v4 - v1 - v3 + (v2 - v4) / 1.522445523);
    println!("{:?}", v3 + p4 + v1);
    println!("{:?}", p1 - p2 / 3.7626374);
    println!("{:?}", p2 - v1);
    println!("{:?}", v2 + v1);

Common traits

The Rust API Guidelines offer an extensive explanation of the recommendations on how to develop APIs for the language in order to produce idiomatic code.

One of the key objectives of this project is to abide to the standards as much as possible.

As the project evolves, more common traits will likely be implemented as needed, and only if it makes sense to do so.

For starters, these are the common traits that are implemented at this stage for the Vector3 and Point3 types:

TypeCommon Trait
Vector3Eq, PartialEq, Display, Debug, Clone, Copy, Default
Point3Eq, PartialEq, Display, Debug, Clone, Copy, Default

The default implementations can be derived by decorating the struct definitions with the #[derive(Debug, Copy,...)] attributes.

However, only in the particular cases of Display and Default a manual implementation will be written to modify the default behavior:

Default implementations via derive:

src/geometry/vector.rs

1
2
3
4
5
6
7
8
#[derive(Debug)]
pub enum Axis<U>

#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub struct Vector3<T> 

#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub struct Point3<T> 

And now the manual implementations:

src/geometry/vector.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
impl Display for Vector3<f64> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let s = format!("v: [{:^5.2},{:^5.2},{:^5.2}]", self.x, self.y, self.z);
        f.write_str(&s)
    }
}

impl Display for Point3<f64> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let s = format!(
            "p: [{:^5.2},{:^5.2},{:^5.2},{:^5.2}]",
            self.x, self.y, self.z, self.w
        );
        f.write_str(&s)
    }
}

impl Default for Point3<f64> {
    fn default() -> Self {
        Self {
            x: 0.0,
            y: 0.0,
            z: 0.0,
            w: 1.0,
        }
    }
}

impl Default for Vector3<f64> {
    fn default() -> Self {
        Self {
            x: 0.0,
            y: 0.0,
            z: 0.0,
        }
    }
}

Even when Default for Vector3 implements Rust’s defaults of filling the value with 0.0’s for f64, it’s convenient to have the manual implementation at hand for testing purposes.

Common vector operations

The last important implementations that are needed for the Vector type are those regarding their common mathematical operations:

  • Calculate magnitude
  • Normalize the vector
  • Calculate dot product
  • Calculate cross product
  • Obtain the minimum component in the vector
  • Obtain the maximum component in the vector
  • Return components of the vector by name and index

These capabilities can be defined using a public trait named VecOps<T>, with a generic parameter in order to extend its implementation for types other than Vector3<f64>:

src/geometry/vector.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// A trait that encapsulates common Vector Operations.
pub trait VecOps<T> {
    /// Computes the magnitude of a Vector.
    fn magnitude(&self) -> f64;
    /// Returns the vector normalized (with magnitude of 1.0)
    fn normalized(&mut self) -> Self;
    /// Returns the Dot Product of two Vectors.
    fn dot(lhs: T, rhs: T) -> f64;
    /// Returns the Cross Product of two Vectors.
    fn cross(lhs: T, rhs: T) -> T;
    /// Returns the Smallest component in the Vector.
    fn min_component(&self) -> (i8, char, f64);
    /// Returns the Largest component in the Vector.
    fn max_component(&self) -> (i8, char, f64);
    /// Returns the component of the Vector by index. this(1)
    fn this(&self, index: i8) -> Option<(i8, char, f64)>;
    /// Returns the component of the Vector by name. this_n('x')
    fn this_name(&self, index: char) -> Option<(i8, char, f64)>;
}

As with the CoordInit trait, the implementation needs to be defined for each function within an impl block for Vector3<f64>:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
impl VecOps<Vector3<f64>> for Vector3<f64> {
    fn magnitude(&self) -> f64 {
        (self.x.powf(2.0) + self.y.powf(2.0) + self.z.powf(2.0)).sqrt()
    }

    fn normalized(&mut self) -> Self {
        let magnitude = self.magnitude();
        Self {
            x: self.x / magnitude,
            y: self.y / magnitude,
            z: self.z / magnitude,
        }
    }

    // other functions in the VecOps<T> trait

Unit tests

Now that the basic implementations and capabilities for Vector3 and Point3 are created it’s time to perform unit tests to ensure that there are no issues or bugs in the code.

As described at the beginning, the tests for each of the modules are implemented in the test.rs under the module’s directory.

In the case of Vector3 and Point3, the file is located in src/geometry/vector/tests.rs.

There are 5 tests that will be executed to validate our types:

  1. Vector and Point construction integrity
  2. Vector and Point operator overloading integrity
  3. Vector common operations integrity
  4. Rocket launch simulator, based on The Ray Tracer Challenge book1

First step is to bring into scope the vector module and use the alias for the Axis enumerator:

src/geometry/vector/tests.rs

1
2
3
4
5
/// Unit testing for Vector3 and Point3 types
use super::*;

use super::Axis::XYZ as xyz;

Second we define the tests functions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#[test]
// This test validates the construction of the Vector3 and Point3 types
fn vector_and_point_construction_integrity() {}

#[test]
// This test validates the operation overloading Add, Sub, Div, Equality, Mul, Neg, AddAssign, SubAssign  for the Vector3 and Point3
fn vector_and_point_operator_overloading_integrity() {}

#[test]
// This test validates the implementation of the fuctions in the VecOps trait
fn vector_common_operations_integrity() {} 

#[test]
// This test validates integrity by simulating a rocket launch
fn simulate_rocket_lauch() {}

Each test is basically a set of assert!() and assert_eq!() macros, as well as println!() statements.

The testing file is pretty extensive, so in this post I will only show the code for vector_common_operations_integrity() and the ‘simulate_rocket_lauch()’ tests:

src/geometry/vector/tests.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#[test]
// This test validates the implementation of the fuctions in the VecOps trait
fn vector_common_operations_integrity() {
    // Magnitude
    let v1 = Vector3::new(xyz(1.0, 2.0, 3.0));
    assert_eq!(v1.magnitude(), 14f64.sqrt());
    // Normalization
    let mut v2 = v1;
    assert_eq!(v2.normalized().magnitude(), 1f64);
    // Dot product
    let a = Vector3::new(xyz(1.0, 2.0, 3.0));
    let b = Vector3::new(xyz(2.0, 3.0, 4.0));
    assert_eq!(Vector3::dot(a, b), 20f64);
    // Cross product
    assert_eq!(Vector3::cross(a, b), Vector3::new(xyz(-1.0, 2.0, -1.0)));
    assert_eq!(Vector3::cross(b, a), Vector3::new(xyz(1.0, -2.0, 1.0)));
    // Min, Max and Get Components
    assert_eq!(a.min_component(), (0, 'x', 1.0));
    assert_eq!(a.max_component(), (2, 'z', 3.0));
    assert_eq!(a.this(0).unwrap(), (0, 'x', 1.0));
    assert_eq!(a.this(9), None);
    assert_eq!(b.this(b.min_component().0).unwrap(), (0, 'x', 2.0));
    assert_eq!(a.this_name('z').unwrap(), (2, 'z', 3.0));
}

Running the test using cargo test vector_common_operations_integrity yields a positive result:

1
2
3
4
5
 λ cargo test vector_common_operations_integrity
running 1 test
test geometry::vector::tests::vector_common_operations_integrity ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 8 filtered out; finished in 0.00s

And now, the rocket launch simulation that brings everything together in a single test. To validate, the test will print the coordinates of the rocket based on initial launching conditions and the environment (gravity and wind).

The expectation is to see the data of a parabolic launch:

  • x axis getting larger.
  • y axis getting larger and then going down to 0.0.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#[test]
// This test validates integrity by simulating a rocket launch
fn simulate_rocket_lauch() {
    #[derive(Debug)]
    struct Projectile {
        position: Point3<f64>,
        velocity: Vector3<f64>,
    }

    struct Environment {
        gravity: Vector3<f64>,
        wind: Vector3<f64>,
    }

    let mut proj = Projectile {
        position: Point3::up(),
        velocity: Vector3::new(xyz(1.0, 1.0, 0.0)).normalized(),
    };

    let env = Environment {
        gravity: Vector3::down() / 10f64,
        wind: Vector3::left() / 100f64,
    };

    fn tick<'a, 'b>(env: &'a Environment, proj: &'b mut Projectile) -> &'b mut Projectile {
        proj.position = proj.position + proj.velocity;
        proj.velocity = proj.velocity + env.gravity + env.wind;
        proj
    }

    println!(
        "Launch position: - x: {:^5.2}, y: {:^5.2}, z: {:^5.2}",
        proj.position.x, proj.position.y, proj.position.z
    );
    while proj.position.y > 0.0 {
        tick(&env, &mut proj);
        if proj.position.y <= 0.0 {
            break;
        }
        println!(
            "Projectile position - x: {:^5.2}, y: {:^5.2}, z: {:^5.2}",
            proj.position.x, proj.position.y, proj.position.z
        );
    }
    println!("========================== End");
}

To view the println!() results it’s necessary to run cargo test with the -- --nocapture argument:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Rust/ruxel on  main [!] > v0.0.0 | v1.63.0
 λ cargo test simulate_rocket_lauch  -- --nocapture

     Finished test [unoptimized + debuginfo] target(s) in 0.00s
     Running unittests src/main.rs (target/debug/deps/ruxel-6b30efdff903fb79)

running 1 test
Launch position: - x: 0.00 , y: 1.00 , z: 0.00
Projectile position - x: 0.71 , y: 1.71 , z: 0.00
Projectile position - x: 1.40 , y: 2.31 , z: 0.00
Projectile position - x: 2.09 , y: 2.82 , z: 0.00
Projectile position - x: 2.77 , y: 3.23 , z: 0.00
Projectile position - x: 3.44 , y: 3.54 , z: 0.00
Projectile position - x: 4.09 , y: 3.74 , z: 0.00
Projectile position - x: 4.74 , y: 3.85 , z: 0.00
Projectile position - x: 5.38 , y: 3.86 , z: 0.00
Projectile position - x: 6.00 , y: 3.76 , z: 0.00
Projectile position - x: 6.62 , y: 3.57 , z: 0.00
Projectile position - x: 7.23 , y: 3.28 , z: 0.00
Projectile position - x: 7.83 , y: 2.89 , z: 0.00
Projectile position - x: 8.41 , y: 2.39 , z: 0.00
Projectile position - x: 8.99 , y: 1.80 , z: 0.00
Projectile position - x: 9.56 , y: 1.11 , z: 0.00
Projectile position - x: 10.11, y: 0.31 , z: 0.00
========================== End
test geometry::vector::tests::simulate_rocket_lauch ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 8 filtered out; finished in 0.00s

As expected, the test passed. This means that our Vector3 and Point3 implementations have been successful.

And with that we conclude Part 1 of this series!

Next steps

In Part 2 we will focus on:

  • Creating the Color, Pixel and Canvas types.
  • Defining and implementing their traits.
  • Writing and executing their unit tests.
  • And finally, producing the first image.

Links, references and disclaimers:

Header Photo by Rohit Choudhari on Unsplash

Books:

  1. Buck, Jamis. (2019). The ray tracer challenge: A test-driven guide to your first 3D renderer. The Pragmatic Programmers. 

This post is licensed under CC BY 4.0 by the author.
Source code samples licensed under MIT or Apache-2.0