' '

1. Quick Explanation

The ray tracer makes extensive use of small wrapper types such as

  • raytracer::primitives::Primitive

  • raytracer::materials::Material

  • raytracer::lights::LightSource

  • raytracer::cameras::Camera

If you look at these classes, you will notice that they look quite alike. Even though they model very different concepts, their members do not differ. The reason for this is that they are just "gateways" to the actual objects.

If you would take a quick look at raytracer::primitives::Primitive, you’ll see that it keeps a pointer to a PrimitiveImplementation object. This object provides the actual Primitive-related functionality.

So, given a Primitive object, you normally would have to first get to the PrimitiveImplementation object it points to, on which you can call methods: primitive.getImplementation()→doSomethingUseful(). However, having to write this all the time would get tiresome very quickly.

Luckily, C++ lets you overload the operator. Normally, is only meant to be used on pointers, but you can define what it means when it’s being used on objects of your own class. In our case, Primitive overloads such that it "redirects" you automatically towards the implementation object. This means that instead of having to write primitive.getImplementation()→doSomethingUseful(), you can use the shorter notation primitive→doSomethingUseful().

In summary, whenever you’re working with a wrapper type such as Primitive, Material, LightSource, Camera, and so on, you should use the operator to get easy access to the "real object"'s functionality.

2. Rationale

Wrapper types are used where there are class hierarchies (i.e. inheritance) making use of dynamic dispatch (i.e. overriding member functions). Let’s say there’s a base class S with subclasses A and B. S defines a virtual method m(), which is overriden in both subclasses.

Remember that in C++, overriding only works correctly when working with pointers:

struct S
{
  virtual void m() { std::cout << "S"; }
};

struct A : public S
{
  void m() override { std::cout << "A"; }
};

struct B : public S
{
  void m() override { std::cout << "B"; }
};


A a;
S s = a;
s.m();     // prints S


A* pa = new A;
S* ps = pa;
ps->m();   // prints A

This means that you should never work with S, but instead with S*. The problem with pointers is that they are quite brittle. A more robust solution would be to rely on std::shared_ptr.

std::shared_ptr<A> pa = std::make_shared<A>();
std::shared_ptr<S> ps = pa;
ps->m();     // prints A

While this approach is technically better, it is also overly verbose. Having to type std::shared_ptr<S> all the time will become annoying very quickly.

Fortunately, C++ allows you to define type aliases. This way, you can introduce a shorter name for std::shared_ptr<S>:

using PS = std::shared_ptr<S>;
using PA = std::shared_ptr<A>;
using PB = std::shared_ptr<B>;

PA pa = std::make_shared<A>();
PS ps = pa;
ps->m();     // Prints A

In the design of our ray tracer, we went a step further: we defined a separate helper class.

class PS
{
public:
  explicit PS(std::shared_ptr<S> impl)
    : m_impl(impl) { }

  S* operator ->()
  {
    return m_impl.get();
  }

private:
  std::shared_ptr<S> m_impl;
};

These helper classes (wrappers) are extensively relied upon in our ray tracer’s design. For example, the primitives module contains a hierarchy of classes each representing different shapes and operations. These classes are private to the module, i.e. code outside the module can’t see the class hierarchy. The only class made public is the wrapper class for the supertype, which in the case of the primitives module is Primitive. To create specific kinds of primitives (e.g. a sphere or a cone), the module also exposes factory functions:

namespace raytracer
{
  namespace primitives
  {
    class Primitive { ... };

    Primitive sphere();
    Primitive cube();
    Primitive cone();
    ...
  }
}

// Creating a sphere
Primitive sphere = raytracer::primitives::sphere();

This design has the following advantages:

  • The module’s internal design can easily be changed without affecting the outside world.

  • You can pass primitives as arguments or return them without syntactic clutter. The parameter type can just be Primitive, no need for pointers or references.

  • We can define operators on Primitives. We didn’t make use of this possibility in the case of Primitives, but we did for Functions.

  • It comes at no performance penalty.