Skip to main content

Welcome to a Grasp lesson

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

Lesson 52

Creating Light Sources with Emissive Materials

Recommended resources

Hello! Welcome to the next stage of our ray tracing journey.

In the last lesson, we confirmed the massive performance benefit of our Bounding Volume Hierarchy. With a fast, optimized renderer, we can now afford to simulate light transport in a much more realistic way. This lesson marks a pivotal shift from the foundational "whitted-style" ray tracing towards the more advanced and physically-based world of path tracing.

Today, you will implement emissive materials to create light sources within the scene. We'll move away from the simple, global background "sky" and learn how to make any object glow and cast light on its surroundings. This is the fundamental technique used in production renderers like Arnold and Karma to create "mesh lights" or "area lights," and it is the first crucial step towards building a full path tracer capable of global illumination.

1. From Fictional Lights to Physical Light Sources

In many graphics applications, especially in real-time rasterization, we use conceptual lights like point lights (infinitely small), directional lights (infinitely far away), and spotlights. While computationally convenient, these are physically inaccurate fictions. In the real world, light is emitted from surfaces that have area—a light bulb filament, a neon sign, a TV screen.

Our goal is to simulate this reality. Instead of adding a "light" object to our scene, we'll give our geometric objects a new property: the ability to emit light. This is what an emissive material does.

The following video provides an excellent conceptual overview of why this approach is more physically grounded and a better fit for a path tracer.

Emission and Emissive Materials // Ray Tracing series

Watch the beginning of this video from The Cherno's 'Ray Tracing series' to understand the core concept of emissive materials and how they differ from the abstract light types you might be familiar with.

Please watch from 00:08 to 06:07. The key idea to grasp is that real-world lights are just emissive surfaces, and our ray tracer can model this directly.

As the video explains, a key principle in physically-based rendering is energy conservation. A non-emissive surface cannot reflect more light than it receives. Emissive materials are the only ones that break this rule by adding energy (light) to the scene.


2. Modifying the Material for Emission

To implement this, our material class needs to know two things: what color of light it emits, and how brightly. A common approach is to add an emission color and an emission strength (power) to the material definition.

Our material abstract base class will need a new virtual function that returns the emitted light. For most materials like our existing Lambertian and Metal, this function will simply return black (0,0,0). We will then create a new diffuse_light material that implements this function to return an actual color.

Following the structure of Peter Shirley's books, let's update our material.h:

// In material.h

class material {
    public:
        // By default, materials emit no light.
        virtual color emitted(double u, double v, const point3& p) const {
            return color(0, 0, 0);
        }

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

Now, let's create a new material class for our lights. This material won't scatter light; it only emits it. When a ray hits it, its path terminates.

Create a new header file, diffuse_light.h:

// In diffuse_light.h

#ifndef DIFFUSE_LIGHT_H
#define DIFFUSE_LIGHT_H

#include "material.h"
#include "texture.h" // Assuming you have a texture class from previous lessons

class diffuse_light : public material {
  public:
    diffuse_light(shared_ptr<texture> a) : emit(a) {}
    diffuse_light(color c) : emit(make_shared<solid_color>(c)) {}

    virtual bool scatter(
        const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered
    ) const override {
        // A light source does not scatter reflected light.
        return false;
    }

    virtual color emitted(double u, double v, const point3& p) const override {
        // Return the light's own color.
        return emit->value(u, v, p);
    }

  private:
    shared_ptr<texture> emit;
};

#endif

This approach, creating a distinct material type for lights, is clean and object-oriented. The article "Ray Tracing in One Weekend: Part 7 Lights" (LINK), which implements these concepts in Rust, demonstrates the same design pattern.


3. Updating the Render Loop

With our materials updated, we must now change our main ray_color function to handle emission. This is where the logic of path tracing begins to truly take shape.

Previously, color was only determined at the end of a ray's journey, when it hit the sky. Now, light can be added at any bounce.

The logic changes as follows:

  1. When a ray intersects an object, we first ask the material for any light it emits. This is the light generated at the hit point itself.
  2. We then ask the material if the ray scatters. If it does, we recursively call ray_color for the new scattered ray. The color returned from this recursive call represents the light coming from elsewhere in the scene.
  3. The final color for the pixel is the sum of the emitted light plus the scattered light.

Here is the updated ray_color function:

// In main.cpp or wherever your ray_color function is

color ray_color(const ray& r, const hittable& world, int depth) {
    hit_record rec;

    // If we've exceeded the ray bounce limit, no more light is gathered.
    if (depth <= 0)
        return color(0,0,0);

    // If the ray hits nothing, return a background color. For now, let's make it black.
    // The scene will be lit only by our emissive objects.
    if (!world.hit(r, 0.001, infinity, rec))
        return color(0,0,0);

    ray scattered;
    color attenuation;
    color emitted = rec.mat_ptr->emitted(rec.u, rec.v, rec.p);

    if (!rec.mat_ptr->scatter(r, rec, attenuation, scattered))
        return emitted; // If it doesn't scatter, we only get the emitted light.

    // If it does scatter, we get emitted light PLUS light from subsequent bounces.
    return emitted + attenuation * ray_color(scattered, world, depth-1);
}

This is a profound change. The attenuation * ray_color(...) part handles reflected light, while the emitted part handles light generated directly at the surface.

Test your understanding!

Imagine a ray hits a simple, non-emissive, diffuse white ball (attenuation = (0.8, 0.8, 0.8)). The ray then scatters and hits a bright yellow light source (emitted = (2.0, 2.0, 0.0)). What is the final color returned from the initial ray_color call for the hit on the white ball, assuming a recursion depth of 2?

Show answer
  1. Hit 1 (White Ball):

    • emitted = (0,0,0) because the ball is not emissive.
    • scatter() returns true. attenuation is (0.8, 0.8, 0.8). A new ray is scattered.
    • The function will return (0,0,0) + (0.8, 0.8, 0.8) * ray_color(new_ray, world, 1).
  2. Hit 2 (Yellow Light):

    • The ray_color(new_ray, world, 1) call executes. The ray hits the light.
    • emitted = (2.0, 2.0, 0.0).
    • The light material's scatter() returns false.
    • Therefore, this call returns just emitted, which is (2.0, 2.0, 0.0).
  3. Final Calculation:

    • Substituting back into the first call:
      Final Color = (0,0,0) + (0.8, 0.8, 0.8) * (2.0, 2.0, 0.0)
      Final Color = (1.6, 1.6, 0.0)
    • The light from the yellow source is attenuated (dimmed) by the reflectivity of the white ball.

4. Lighting Up the Scene

Now for the rewarding part. Let's create a scene and add a light source. You can modify your scene generation code to add a sphere or a new rectangle primitive and assign it a diffuse_light material.

// In your scene setup function
// ...
auto light_mat = make_shared<diffuse_light>(color(4, 4, 4));
world.add(make_shared<sphere>(point3(0, 2, 0), 0.5, light_mat));
// ... add other objects

When you render this, you'll see something remarkable. The sphere will not only appear bright white, but it will also cast light onto the other objects in the scene, producing beautiful, soft shadows automatically.

Ray Tracer with Emissive Materials in a Cornell Box Scene
An example of what you can achieve. Here, the walls themselves are given emissive materials, serving as large area lights that illuminate the entire scene and produce soft shadows. Image from The Cherno.

The following video segments demonstrate the code integration and the stunning visual results. While the code structure might differ slightly from our implementation (e.g., adding emission properties to all materials instead of a new class), the changes to the render loop and the final effect are identical.

Emission and Emissive Materials // Ray Tracing series

This video demonstrates the final implementation steps: modifying the main render loop to accumulate light and showing the results of adding an emissive sphere to the scene.

Watch from 10:43 to 21:50. Pay close attention to how the old sky light is removed and how the emitted light is simply added in the main loop. The demonstration of the resulting soft shadows highlights one of the most powerful and beautiful aspects of path tracing.

The soft shadows you see are not the result of a special "soft shadow" algorithm. They are a natural, emergent property of tracing light paths from area sources. This is a core reason why path tracing is the standard for photorealistic rendering in VFX and animation.


Conclusion

Congratulations on implementing your first physically-based light source! This is a major milestone.

Key Takeaways:

  • Emissive materials allow any object to become a light source, which is a more physically accurate model than abstract point or directional lights.
  • The implementation involves adding an emitted() method to your material interface and creating a specific diffuse_light material.
  • The ray_color function is updated to accumulate light at each bounce by adding the material's emission to the color contributed by scattered rays.
  • This approach naturally produces realistic soft shadows without any specialized shadow code.

Next Steps

We have now built the core components of a simple path tracer. Our renderer simulates light bouncing around a scene, gathering light from emissive sources. But what we've done is an intuitive implementation. To build upon this and add more advanced features, it's crucial to understand the formal theory that underpins it all.

In the next lesson, we will introduce the Rendering Equation, the formal mathematical basis for all physically-based rendering. We will see how our current ray_color function is actually a recursive Monte Carlo solver for this equation, a technique known as path tracing.