Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 30

Scatterable Material Interface

Recommended resources

Hello and welcome back to your C++ ray tracing journey!

In the last module, you did some fantastic work building a sophisticated camera model, culminating in the ability to render scenes with realistic depth of field. You now have full artistic control over focus and blur, a technique fundamental to both photography and high-end VFX rendering.

So far, when a ray has hit an object, we've simply colored the pixel based on the surface normal. This is useful for debugging, but it's not how light works. The appearance of an object—whether it looks like plastic, metal, or glass—is determined by how its surface material scatters light.

Today, we will build the foundation for this crucial aspect of rendering. Our learning outcome is to design an abstract 'material' interface with a 'scatter' method that defines ray-surface interaction. This is a core software design pattern that forms the backbone of modern renderers, including the production systems like Arnold and Karma that you're familiar with.

Material Properties Demonstration with Spheres
Our goal is to create a system that can describe how light interacts with different surfaces, allowing us to render diverse materials like the matte, metallic, and reflective spheres shown here.

1. The "Why": The Need for a Material System

In a VFX pipeline, you don't rewrite the renderer every time you need a new material. Instead, you apply different shaders or materials to your 3D models. Our ray tracer needs a similar system: a clean, extensible way to define surface properties that are separate from the geometry itself.

This leads to a key design decision. We could create one giant "uber-material" class with dozens of parameters (color, shininess, roughness, transparency, etc.), and each object would just use the parameters it needs. A more flexible and powerful approach, however, is to use object-oriented design. We'll define an abstract base class that acts as a contract, or an "interface," for what a material must be able to do. Then, we can create separate, concrete classes for different material types (like Lambertian, Metal, Glass) that each fulfill this contract in their own way.

This approach is powerful because it allows us to add new material types in the future without changing the core ray tracing logic.

To get a better sense of why materials are so central to rendering, let's watch a short introduction from The Cherno's excellent series on ray tracing.

Materials and Physically Based Rendering // Ray Tracing series

This clip discusses why materials, even more than lights, are where the complexity and realism in rendering truly lie. It also introduces the concepts behind Physically Based Rendering (PBR), which will be familiar from your work in VFX.

Watch from the beginning to 03:03, and then from 07:05 to 09:39. Focus on the core idea that a material's job is to define what happens to light when it hits a surface. Note the mention of key PBR parameters like Albedo, Roughness, and Metallic, which we will eventually model.

As you saw, the core task of a material is to answer the question: "A ray of light just hit me. What happens next?" For our ray tracer, this breaks down into two specific actions:

  1. Scatter or Absorb: Does the ray bounce off the surface, or is it absorbed?
  2. Attenuate: If the ray scatters, how should its color be modified? This modification is called attenuation, and it's often determined by the material's base color, or albedo.


2. Designing the Abstract material Interface

To implement this, we'll be following the excellent structure laid out in the Ray Tracing in One Weekend book series. We will create an abstract material class with a single, crucial virtual function: scatter.

An Abstract Class for Materials

Let's read the section from 'Ray Tracing in One Weekend' that introduces the abstract material class. This will provide the exact C++ code for our interface.

In the book, find Chapter 9, 'Metal'. Read the subsection titled 'An Abstract Class for Materials' and study the initial material.h code listing. This defines our interface.

Let's break down the proposed material class. You will create a new header file, material.h, for this.

#ifndef MATERIAL_H
#define MATERIAL_H

#include "rtweekend.h" // Your project's main header with common includes

class hit_record; // Forward declaration

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

    virtual bool scatter(
        const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered
    ) const = 0;
};

#endif

Here's what each part does:

  • class hit_record;: This is a forward declaration. Since material needs to know about hit_record (for the scatter function) and hit_record will soon need to know about material, this breaks a potential circular dependency between header files.
  • virtual ~material() = default;: A virtual destructor is essential for any class intended to be a polymorphic base class. It ensures that when you delete a derived class object through a base class pointer, the correct destructor is called.
  • virtual bool scatter(...) const = 0;: This is the heart of our design.
    • virtual: Declares it as a function that can be overridden by derived classes.
    • = 0: Makes this a pure virtual function. This has two effects: it prevents you from creating an instance of the material class itself (it's now an abstract class), and it forces any concrete derived class (like Metal or Glass) to provide its own implementation of scatter.
    • Parameters:
      • const ray& r_in: The incoming ray that struck the surface.
      • const hit_record& rec: The struct containing all information about the hit (point, normal, etc.).
      • color& attenuation: An output parameter. The function will fill this with the material's color (albedo), which tells us how much the scattered ray's light is reduced.
      • ray& scattered: Another output parameter. The function will fill this with the new ray that bounced off the surface.
    • Return bool: The function returns true if the ray was scattered and false if it was absorbed by the material.


3. Integrating Materials into the Scene

Now that we have a material interface, we need a way to attach materials to our objects.

  1. Update hit_record: First, the hit_record needs to be able to hold a reference to the material of the object that was hit.
  2. Update hittable Objects: Second, each hittable object (like our sphere class) needs to own a material.

Since multiple objects might share the same material (e.g., five spheres all made of chrome), using a std::shared_ptr is a very efficient approach. This avoids duplicating material data and simplifies memory management, which is a great way to put your C++ skills into practice.

Integrating Materials

The next section of the book explains exactly how to add the material pointer to hit_record and sphere.

Continue reading in Chapter 9. Read the subsections 'A Data Structure to Describe Ray-Object Intersections' and the text immediately following, focusing on the code listings that show modifications to hittable.h and sphere.h. Implement these changes in your own code.

To summarize the changes you need to make:

  1. In hittable.h, include <memory> and add std::shared_ptr<material> mat; to your hit_record struct. Remember to also add the forward declaration class material; at the top of this file.
  2. In sphere.h, include material.h and <memory>. Add a std::shared_ptr<material> mat; member variable to the sphere class.
  3. Update the sphere constructor to accept a material pointer and store it: sphere(point3 center, double radius, shared_ptr<material> m) : center(center), radius(radius), mat(m) {}.
  4. Crucially, in your sphere::hit method, when you find a valid intersection, set the material on the hit record: rec.mat = mat;.
Test your understanding!

Imagine you want to create a procedural checkerboard texture for a sphere. The color of the sphere at any given point would depend on the 3D coordinates of the hit point.

Based on our new design, where would you implement this logic?

  1. Inside the sphere::hit() method.
  2. Inside the main ray_color() function.
  3. Inside the scatter() method of a new CheckerMaterial class.
Show answer
  1. Inside the scatter() method of a new CheckerMaterial class.

The key principle here is separation of concerns. The sphere class should only care about its geometry (center and radius). The material's appearance, even if it depends on the hit point's world-space position, is the responsibility of the material class. The hit_record provides all the necessary information (rec.p) for the scatter method to compute the checkerboard pattern and set the attenuation color accordingly.



Conclusion

Congratulations! You have successfully designed and integrated a flexible, abstract material system into your ray tracer. While we haven't rendered any new images today, this refactoring is a massive step forward. You've created a clean, extensible architecture that precisely mirrors the design of professional rendering engines.

Key Takeaways:

  • An abstract base class (material) with pure virtual functions (scatter) creates a powerful, polymorphic interface for defining surface interactions.
  • The scatter method is the core of this interface, responsible for determining if a ray is absorbed or scattered, and what the resulting ray and color attenuation should be.
  • Using std::shared_ptr to manage materials is an efficient way to allow multiple objects to share material properties while simplifying memory management.
  • This design separates the concerns of geometry (hittable objects) from surface appearance (material objects), leading to a cleaner and more scalable codebase.

Next Steps

We now have the interface for materials, but no concrete implementations. In our next lesson, we will finally bring this system to life. We will:

  1. Implement our first material: a diffuse or Lambertian material.
  2. Explain how these surfaces scatter light uniformly.
  3. Modify our main rendering loop to use the scatter method and implement recursive ray tracing, allowing us to simulate light bouncing multiple times around the scene. This is our first major step towards true global illumination