Writing a path
tracer in Rust

Part III

Operators

written by Ruud van Asseldonk
published

As a learning exercise, I am porting the Luculentus spectral path tracer to Rust. You can follow the port on GitHub. This post will outline the vector and quaternion implementations, and I will highlight some of the differences between C++ and Rust.

Vector3

The vector shows up in virtually every program that manipulates geometry. Luculentus has a fairly straightforward Vector3:

struct Vector3
{
    float x, y, z;

    inline float MagnitudeSquared() const
    {
      return Dot(*this, *this);
    }
};

There are also Magnitude and Normalise methods that I omitted here. C++ allows initialization like this:

Vector3 v = { 1.0f, 0.0f, 0.0f };

The vector in Rust is similar:

pub struct Vector3 {
    pub x: f32,
    pub y: f32,
    pub z: f32
}

Struct members are not public by default, and visibility can be specified for the struct as well. In Rust, there is a clear separation between code and data. Methods are defined in a separate impl block, outside of the struct:

impl Vector3 {
    pub fn magnitude_squared(self) -> f32 {
        dot(self, self)
    }
}

Again, there are more methods here that I omitted. Note that there is no explicit return: by omitting a semicolon, the last expression in a function determines the return value. Initialization in Rust can be done as follows:

let v = Vector3 { x: 1.0, y: 1.0, z: 1.0 };

Note that the numbers do not need an f suffix, even though they are single-precision floats. The type of a literal can depend on the context.

Operator overloading

There are a few obvious operators to overload for Vector3: binary + and -, unary -, and binary * for scalar multiplication. One way to overload + in C++ is this:

inline Vector3 operator+(const Vector3 a, const Vector3 b)
{
    Vector3 sum = { a.x + b.x, a.y + b.y, a.z + b.z };
    return sum;
}

In Rust, overloading + involves implementing the Add trait. Traits are like interfaces in C#. Add takes two generic parameters: the type of the right-hand side, and the type of the result. Both are Vector3 in this case.

impl Add<Vector3, Vector3> for Vector3 {
    fn add(&self, other: &Vector3) -> Vector3 {
        Vector3 {
            x: self.x + other.x,
            y: self.y + other.y,
            z: self.z + other.z
        }
    }
}

Overloading * for scalar multiplication in C++ is similar to addition:

inline Vector3 operator*(const Vector3 a, const float f)
{
    Vector3 prod = { a.x * f, a.y * f, a.z * f };
    return prod;
}

In Rust, the Mul trait takes two type parameters as well: the type of the right-hand side, and the type of the result.

impl Mul<f32, Vector3> for Vector3 {
    fn mul(&self, f: &f32) -> Vector3 {
        Vector3 {
            x: self.x * *f,
            y: self.y * *f,
            z: self.z * *f
        }
    }
}

This looks a bit awkward, because f must be dereferenced: the Mul trait dictates that that mul takes its arguments by reference. Note that self is automatically dereferenced in self.x, there is no -> like in C++.

Now we can wite things like v * f where v is a vector and f a scalar. Can we also implement f * v? In C++ it is straightforward, just switch the arguments. In Rust, I think it cannot be done at this point. Multiplication is a mul call on the left hand side, so we would have to implement Mul<Vector3, Vector3> for f32. Unfortuntely, the compiler allows only one implementation of Mul for a type, regardless of the type parameters for Mul. Because regular multiplication of two f32s implements Mul already, we cannot implement it for Vector3 any more.

Quaternions

Luculentus uses quaternions to represent rotations. Most of the Quaternion implementation is similar to that of Vector3, but with four components instead of three. (In fact, quaternions form a vector space, so they are vectors.) The interesting thing is that quaternions support quaternion multiplication in addition to scalar multiplication.

In C++, we can implement them both:

inline Quaternion operator*(const Quaternion q, const float f)
{
    Quaternion prod = { q.x * f, q.y * f, q.z * f, q.w * f };
    return prod;
}

inline Quaternion operator*(const Quaternion a, const Quaternion b)
{
    Quaternion prod =
    {
        a.w * b.x  +  a.x * b.w  +  a.y * b.z  -  a.z * b.y,
        a.w * b.y  -  a.x * b.z  +  a.y * b.w  +  a.z * b.x,
        a.w * b.z  +  a.x * b.y  -  a.y * b.x  +  a.z * b.w,
        a.w * b.w  -  a.x * b.x  -  a.y * b.y  -  a.z * b.z
    };
    return prod;
}

As we saw before, we cannot implement Mul twice in Rust. Luckily, the IRC channel was very helpful, and there is a solution. The trick is to define a trait for “things that can be the right-hand side of multiplication with a quaternion”:

trait MulQuaternion {
    fn mul(&self, lhs: &Quaternion) -> Quaternion;
}

Then we can implement Mul where the right-hand side must implement MulQuaternion:

impl<T: MulQuaternion> Mul<T, Quaternion> for Quaternion {
    fn mul(&self, other: &T) -> Quaternion {
        other.mul(self)
    }
}

Finally, we can implement MulQuaternion for f32 as well as Quaternion itself:

impl MulQuaternion for f32 {
    fn mul(&self, lhs: &Quaternion) -> Quaternion {
        Quaternion {
            x: lhs.x * *self,
            y: lhs.y * *self,
            z: lhs.z * *self,
            w: lhs.w * *self
        }
    }
}

impl MulQuaternion for Quaternion {
    fn mul(&self, lhs: &Quaternion) -> Quaternion {
        Quaternion {
            x: lhs.w * self.x + lhs.x * self.w + lhs.y * self.z - lhs.z * self.y,
            y: lhs.w * self.y - lhs.x * self.z + lhs.y * self.w + lhs.z * self.x,
            z: lhs.w * self.z + lhs.x * self.y - lhs.y * self.x + lhs.z * self.w,
            w: lhs.w * self.w - lhs.x * self.x - lhs.y * self.y - lhs.z * self.z
        }
    }
}

It feels a bit weird, because the second argument is the left-hand side of the multiplication. Like with Vector3, I think it is impossible to implement scalar multiplication with the scalar on the left. Please let me know if I am wrong! There is an RFC for multidispatch in traits. If it gets accepted, it might allow multiple implementations of Add and Mul for the same type. The correct one would be selected based on the types of the operands (or method arguments in general). That would certainly simplify the quaternion code, and it would allow scalar multiplication with the scalar on the left.

Edit: Rust 0.13 does have multidispatch now, and the code has been simplified accordingly.

Next time I will discuss more of the type system, and there will finally be rays! I will also discuss more of the internals of the path tracer.

Discuss this post on Reddit. Rust 0.12.0-pre-nightly was used in this post.

More words

Writing a path tracer in Rust, part 4: tracing rays

Tracing rays is at the core of a path tracer. Implementing the algorithm in Rust is a great way to get a feel for the language. Read full post