1.3 pbrt: System Overview

pbrt is structured using standard object-oriented techniques: for each of a number of fundamental types, the system specifies an interface that implementations of that type must fulfill. For example, pbrt requires the implementation of a particular shape that represents geometry in a scene to provide a set of methods including one that returns the shape’s bounding box, and another that tests for intersection with a given ray. In turn, the majority of the system can be implemented purely in terms of those interfaces; for example, the code that checks for occluding objects between a light source and a point being shaded calls the shape intersection methods without needing to consider which particular types of shapes are present in the scene.

There are a total of 14 of these key base types, summarized in Table 1.1. Adding a new implementation of one of these types to the system is straightforward; the implementation must provide the required methods, it must be compiled and linked into the executable, and the scene object creation routines must be modified to create instances of the object as needed as the scene description file is parsed. Section C.4 discusses extending the system in more detail.

Conventional practice in C++ would be to specify the interfaces for each of these types using abstract base classes that define pure virtual functions and to have implementations inherit from those base classes and implement the required virtual functions. In turn, the compiler would take care of generating the code that calls the appropriate method, given a pointer to any object of the base class type. That approach was used in the three previous versions of pbrt, but the addition of support for rendering on graphics processing units (GPUs) in this version motivated a more portable approach based on tag-based dispatch, where each specific type implementation is assigned a unique integer that determines its type at runtime. (See Section 1.5.7 for more information about this topic.) The polymorphic types that are implemented in this way in pbrt are all defined in header files in the base/ directory.

This version of pbrt is capable of running on GPUs that support C++17 and provide APIs for ray intersection tests. We have carefully designed the system so that almost all of pbrt’s implementation runs on both CPUs and GPUs, just as it is presented in Chapters 2 through 12. We will therefore generally say little about the CPU versus the GPU in most of the following.

The main differences between the CPU and GPU rendering paths in pbrt are in their data flow and how they are parallelized—effectively, how the pieces are connected together. Both the basic rendering algorithm described later in this chapter and the light transport algorithms described in Chapters 13 and 14 are only available on the CPU. The GPU rendering pipeline is discussed in Chapter 15, though it, too, is also capable of running on the CPU (not as efficiently as the CPU-targeted light transport algorithms, however).

While pbrt can render many scenes well with its current implementation, it has frequently been extended by students, researchers, and developers. Throughout this section are a number of notable images from those efforts. Figures 1.13, 1.14, and 1.15 were each created by students in a rendering course where the final class project was to extend pbrt with new functionality in order to render an image that it could not have rendered before. These images are among the best from that course.

Figure 1.13: Guillaume Poncin and Pramod Sharma extended pbrt in numerous ways, implementing a number of complex rendering algorithms, to make this prize-winning image for Stanford’s CS348b rendering competition. The trees are modeled procedurally with L-systems, a glow image processing filter increases the apparent realism of the lights on the tree, snow was modeled procedurally with metaballs, and a subsurface scattering algorithm gave the snow its realistic appearance by accounting for the effect of light that travels beneath the snow for some distance before leaving it.

Figure 1.14: Abe Davis, David Jacobs, and Jongmin Baek rendered this amazing image of an ice cave to take the grand prize in the 2009 Stanford CS348b rendering competition. They first implemented a simulation of the physical process of glaciation, the process where snow falls, melts, and refreezes over the course of many years, forming stratified layers of ice. They then simulated erosion of the ice due to melted water runoff before generating a geometric model of the ice. Scattering of light inside the volume was simulated with volumetric photon mapping; the blue color of the ice is entirely due to modeling the wavelength-dependent absorption of light in the ice volume.

Figure 1.15: Chenlin Meng, Hubert Teo, and Jiren Zhu rendered this tasty-looking image of cotton candy in a teacup to win the grand prize in the 2018 Stanford CS348b rendering competition. They modeled the cotton candy with multiple layers of curves and then filled the center with a participating medium to efficiently model scattering in its interior.

Figure 1.16: Martin Lubich modeled this scene of the Austrian Imperial Crown using Blender; it was originally rendered using LuxRender, which started out as a fork of the pbrt-v1 codebase. The crown consists of approximately 3.5 million triangles that are illuminated by six area light sources with emission spectra based on measured data from a real-world light source. It was originally rendered with 1280 samples per pixel in 73 hours of computation on a quad-core CPU. On a modern GPU, pbrt renders this scene at the same sampling rate in 184 seconds.

1.3.1 Phases of Execution

pbrt can be conceptually divided into three phases of execution. First, it parses the scene description file provided by the user. The scene description is a text file that specifies the geometric shapes that make up the scene, their material properties, the lights that illuminate them, where the virtual camera is positioned in the scene, and parameters to all the individual algorithms used throughout the system. The scene file format is documented on the pbrt website, pbrt.org.

The result of the parsing phase is an instance of the BasicScene class, which stores the scene specification, but not in a form yet suitable for rendering. In the second phase of execution, pbrt creates specific objects corresponding to the scene; for example, if a perspective projection has been specified, it is in this phase that a PerspectiveCamera object corresponding to the specified viewing parameters is created. Previous versions of pbrt intermixed these first two phases, but for this version we have separated them because the CPU and GPU rendering paths differ in some of the ways that they represent the scene in memory.

In the third phase, the main rendering loop executes. This phase is where pbrt usually spends the majority of its running time, and most of this book is devoted to code that executes during this phase. To orchestrate the rendering, pbrt implements an integrator, so-named because its main task is to evaluate the integral in Equation (1.1).

1.3.2 pbrt’s main() Function

The main() function for the pbrt executable is defined in the file cmd/pbrt.cpp in the directory that holds the pbrt source code, src/pbrt in the pbrt distribution. It is only a hundred and fifty or so lines of code, much of it devoted to processing command-line arguments and related bookkeeping.

<<main program>>= 
int main(int argc, char *argv[]) { <<Convert command-line arguments to vector of strings>> 
std::vector<std::string> args = GetCommandLineArguments(argv);
<<Declare variables for parsed command line>> 
PBRTOptions options; std::vector<std::string> filenames;
<<Process command-line arguments>> 
for (auto iter = args.begin(); iter != args.end(); ++iter) { if ((*iter)[0] != '-') { filenames.push_back(*iter); continue; } auto onError = [](const std::string &err) { usage(err); exit(1); }; std::string cropWindow, pixelBounds, pixel, pixelMaterial; if (ParseArg(&iter, args.end(), "cropwindow", &cropWindow, onError)) { std::vector<Float> c = SplitStringToFloats(cropWindow, ','); if (c.size() != 4) { usage("Didn't find four values after --cropwindow"); return 1; } options.cropWindow = Bounds2f(Point2f(c[0], c[2]), Point2f(c[1], c[3])); } else if (ParseArg(&iter, args.end(), "pixel", &pixel, onError)) { std::vector<int> p = SplitStringToInts(pixel, ','); if (p.size() != 2) { usage("Didn't find two values after --pixel"); return 1; } options.pixelBounds = Bounds2i(Point2i(p[0], p[1]), Point2i(p[0] + 1, p[1] + 1)); } else if (ParseArg(&iter, args.end(), "pixelbounds", &pixelBounds, onError)) { std::vector<int> p = SplitStringToInts(pixelBounds, ','); if (p.size() != 4) { usage("Didn't find four integer values after --pixelbounds"); return 1; } options.pixelBounds = Bounds2i(Point2i(p[0], p[2]), Point2i(p[1], p[3])); } else if (ParseArg(&iter, args.end(), "pixelmaterial", &pixelMaterial, onError)) { std::vector<int> p = SplitStringToInts(pixelMaterial, ','); if (p.size() != 2) { usage("Didn't find two values after --pixelmaterial"); return 1; } options.pixelMaterial = Point2i(p[0], p[1]); } else if ( #ifdef PBRT_BUILD_GPU_RENDERER ParseArg(&iter, args.end(), "gpu", &options.useGPU, onError) || ParseArg(&iter, args.end(), "gpu-device", &options.gpuDevice, onError) || #endif ParseArg(&iter, args.end(), "debugstart", &options.debugStart, onError) || ParseArg(&iter, args.end(), "disable-pixel-jitter", &options.disablePixelJitter, onError) || ParseArg(&iter, args.end(), "disable-texture-filtering", &options.disableTextureFiltering, onError) || ParseArg(&iter, args.end(), "disable-wavelength-jitter", &options.disableWavelengthJitter, onError) || ParseArg(&iter, args.end(), "displacement-edge-scale", &options.displacementEdgeScale, onError) || ParseArg(&iter, args.end(), "display-server", &options.displayServer, onError) || ParseArg(&iter, args.end(), "force-diffuse", &options.forceDiffuse, onError) || ParseArg(&iter, args.end(), "format", &format, onError) || ParseArg(&iter, args.end(), "log-level", &logLevel, onError) || ParseArg(&iter, args.end(), "log-utilization", &options.logUtilization, onError) || ParseArg(&iter, args.end(), "log-file", &options.logFile, onError) || ParseArg(&iter, args.end(), "mse-reference-image", &options.mseReferenceImage, onError) || ParseArg(&iter, args.end(), "mse-reference-out", &options.mseReferenceOutput, onError) || ParseArg(&iter, args.end(), "nthreads", &options.nThreads, onError) || ParseArg(&iter, args.end(), "outfile", &options.imageFile, onError) || ParseArg(&iter, args.end(), "pixelstats", &options.recordPixelStatistics, onError) || ParseArg(&iter, args.end(), "quick", &options.quickRender, onError) || ParseArg(&iter, args.end(), "quiet", &options.quiet, onError) || ParseArg(&iter, args.end(), "render-coord-sys", &renderCoordSys, onError) || ParseArg(&iter, args.end(), "seed", &options.seed, onError) || ParseArg(&iter, args.end(), "spp", &options.pixelSamples, onError) || ParseArg(&iter, args.end(), "stats", &options.printStatistics, onError) || ParseArg(&iter, args.end(), "toply", &toPly, onError) || ParseArg(&iter, args.end(), "wavefront", &options.wavefront, onError) || ParseArg(&iter, args.end(), "write-partial-images", &options.writePartialImages, onError) || ParseArg(&iter, args.end(), "upgrade", &options.upgrade, onError)) { // success } else if (*iter == "--help" || *iter == "-help" || *iter == "-h") { usage(); return 0; } else { usage(StringPrintf("argument \"%s\" unknown", *iter)); return 1; } }
<<Initialize pbrt>> 
InitPBRT(options);
<<Parse provided scene description files>> 
BasicScene scene; BasicSceneBuilder builder(&scene); ParseFiles(&builder, filenames);
<<Render the scene>> 
if (Options->useGPU || Options->wavefront) RenderWavefront(scene); else RenderCPU(scene);
<<Clean up after rendering the scene>>  }

Rather than operate on the argv values provided to the main() function directly, pbrt converts the provided arguments to a vector of std::strings. It does so not only for the greater convenience of the string class, but also to support non-ASCII character sets. Section B.3.2 has more information about character encodings and how they are handled in pbrt.

<<Convert command-line arguments to vector of strings>>= 
std::vector<std::string> args = GetCommandLineArguments(argv);

We will only include the definitions of some of the main function’s fragments in the book text here. Some, such as the one that handles parsing command-line arguments provided by the user, are both simple enough and long enough that they are not worth the few pages that they would add to the book’s length. However, we will include the fragment that declares the variables in which the option values are stored.

<<Declare variables for parsed command line>>= 
PBRTOptions options; std::vector<std::string> filenames;

The GetCommandLineArguments() function and PBRTOptions type appear in a mini-index in the page margin, along with the number of the page where they are defined. The mini-indices have pointers to the definitions of almost all the functions, classes, methods, and member variables used or referred to on each page. (In the interests of brevity, we will omit very widely used classes such as Ray from the mini-indices, as well as types or methods that were just introduced in the preceding few pages.)

The PBRTOptions class stores various rendering options that are generally more suited to be specified on the command line rather than in scene description files—for example, how chatty pbrt should be about its progress during rendering. It is passed to the InitPBRT() function, which aggregates the various system-wide initialization tasks that must be performed before any other work is done. For example, it initializes the logging system and launches a group of threads that are used for the parallelization of pbrt.

<<Initialize pbrt>>= 
InitPBRT(options);

After the arguments have been parsed and validated, the ParseFiles() function takes over to handle the first of the three phases of execution described earlier. With the assistance of two classes, BasicSceneBuilder and BasicScene, which are respectively described in Sections C.2 and C.3, it loops over the provided filenames, parsing each file in turn. If pbrt is run with no filenames provided, it looks for the scene description from standard input. The mechanics of tokenizing and parsing scene description files will not be described in this book, but the parser implementation can be found in the files parser.h and parser.cpp in the src/pbrt directory.

<<Parse provided scene description files>>= 
BasicScene scene; BasicSceneBuilder builder(&scene); ParseFiles(&builder, filenames);

After the scene description has been parsed, one of two functions is called to render the scene. RenderWavefront() supports both the CPU and GPU rendering paths, processing a million or so image samples in parallel. It is the topic of Chapter 15. RenderCPU() renders the scene using an Integrator implementation and is only available when running on the CPU. It uses much less parallelism than RenderWavefront(), rendering only as many image samples as there are CPU threads in parallel.

Both of these functions start by converting the BasicScene into a form suitable for efficient rendering and then pass control to a processor-specific integrator. (More information about this process is available in Section C.3.) We will for now gloss past the details of this transformation in order to focus on the main rendering loop in RenderCPU(), which is much more interesting. For that, we will take the efficient scene representation as a given.

<<Render the scene>>= 
if (Options->useGPU || Options->wavefront) RenderWavefront(scene); else RenderCPU(scene);

After the image has been rendered, CleanupPBRT() takes care of shutting the system down gracefully, including, for example, terminating the threads launched by InitPBRT().

<<Clean up after rendering the scene>>= 

1.3.3 Integrator Interface

In the RenderCPU() rendering path, an instance of a class that implements the Integrator interface is responsible for rendering. Because Integrator implementations only run on the CPU, we will define Integrator as a standard base class with pure virtual methods. Integrator and the various implementations are each defined in the files cpu/integrator.h and cpu/integrator.cpp.

<<Integrator Definition>>= 
class Integrator { public: <<Integrator Public Methods>> 
virtual ~Integrator(); static std::unique_ptr<Integrator> Create(const std::string &name, const ParameterDictionary &parameters, Camera camera, Sampler sampler, Primitive aggregate, std::vector<Light> lights, const RGBColorSpace *colorSpace, const FileLoc *loc); virtual std::string ToString() const = 0; virtual void Render() = 0; pstd::optional<ShapeIntersection> Intersect(const Ray &ray, Float tMax = Infinity) const; bool IntersectP(const Ray &ray, Float tMax = Infinity) const; bool Unoccluded(const Interaction &p0, const Interaction &p1) const { return !IntersectP(p0.SpawnRayTo(p1), 1 - ShadowEpsilon); } SampledSpectrum Tr(const Interaction &p0, const Interaction &p1, const SampledWavelengths &lambda) const;
<<Integrator Public Members>> 
Primitive aggregate; std::vector<Light> lights; std::vector<Light> infiniteLights;
protected: <<Integrator Protected Methods>> 
Integrator(Primitive aggregate, std::vector<Light> lights) : aggregate(aggregate), lights(lights) { <<Integrator constructor implementation>> 
Bounds3f sceneBounds = aggregate ? aggregate.Bounds() : Bounds3f(); for (auto &light : lights) { light.Preprocess(sceneBounds); if (light.Type() == LightType::Infinite) infiniteLights.push_back(light); }
}
};

The base Integrator constructor takes a single Primitive that represents all the geometric objects in the scene as well as an array that holds all the lights in the scene.

<<Integrator Protected Methods>>= 
Integrator(Primitive aggregate, std::vector<Light> lights) : aggregate(aggregate), lights(lights) { <<Integrator constructor implementation>> 
Bounds3f sceneBounds = aggregate ? aggregate.Bounds() : Bounds3f(); for (auto &light : lights) { light.Preprocess(sceneBounds); if (light.Type() == LightType::Infinite) infiniteLights.push_back(light); }
}

Each geometric object in the scene is represented by a Primitive, which is primarily responsible for combining a Shape that specifies its geometry and a Material that describes its appearance (e.g., the object’s color, or whether it has a dull or glossy finish). In turn, all the geometric primitives in a scene are collected into a single aggregate primitive that is stored in the Integrator::aggregate member variable. This aggregate is a special kind of primitive that itself holds references to many other primitives. The aggregate implementation stores all the scene’s primitives in an acceleration data structure that reduces the number of unnecessary ray intersection tests with primitives that are far away from a given ray. Because it implements the Primitive interface, it appears no different from a single primitive to the rest of the system.

<<Integrator Public Members>>= 
Primitive aggregate; std::vector<Light> lights;

Each light source in the scene is represented by an object that implements the Light interface, which allows the light to specify its shape and the distribution of energy that it emits. Some lights need to know the bounding box of the entire scene, which is unavailable when they are first created. Therefore, the Integrator constructor calls their Preprocess() methods, providing those bounds. At this point any “infinite” lights are also stored in a separate array. This sort of light, which will be introduced in Section 12.5, models infinitely far away sources of light, which is a reasonable model for skylight as received on Earth’s surface, for example. Sometimes it will be necessary to loop over just those lights, and for scenes with thousands of light sources it would be inefficient to loop over all of them just to find those.

<<Integrator constructor implementation>>= 
Bounds3f sceneBounds = aggregate ? aggregate.Bounds() : Bounds3f(); for (auto &light : lights) { light.Preprocess(sceneBounds); if (light.Type() == LightType::Infinite) infiniteLights.push_back(light); }

<<Integrator Public Members>>+= 
std::vector<Light> infiniteLights;

Integrators must provide an implementation of the Render() method, which takes no further arguments. This method is called by the RenderCPU() function once the scene representation has been initialized. The task of integrators is to render the scene as specified by the aggregate and the lights. Beyond that, it is up to the specific integrator to define what it means to render the scene, using whichever other classes that it needs to do so (e.g., a camera model). This interface is intentionally very general to permit a wide range of implementations—for example, one could implement an Integrator that measures light only at a sparse set of points distributed through the scene rather than generating a regular 2D image.

<<Integrator Public Methods>>= 
virtual void Render() = 0;

The Integrator class provides two methods related to ray–primitive intersection for use of its subclasses. Intersect() takes a ray and a maximum parametric distance tMax, traces the given ray into the scene, and returns a ShapeIntersection object corresponding to the closest primitive that the ray hit, if there is an intersection along the ray before tMax. (The ShapeIntersection structure is defined in Section 6.1.3.) One thing to note is that this method uses the type pstd::optional for the return value rather than std::optional from the C++ standard library; we have reimplemented parts of the standard library in the pstd namespace for reasons that are discussed in Section 1.5.5.

<<Integrator Method Definitions>>= 
pstd::optional<ShapeIntersection> Integrator::Intersect(const Ray &ray, Float tMax) const { if (aggregate) return aggregate.Intersect(ray, tMax); else return {}; }

Also note the capitalized floating-point type Float in Intersect()’s signature: almost all floating-point values in pbrt are declared as Floats. (The only exceptions are a few cases where a 32-bit float or a 64-bit double is specifically needed (e.g., when saving binary values to files).) Depending on the compilation flags of pbrt, Float is an alias for either float or double, though single precision float is almost always sufficient in practice. The definition of Float is in the pbrt.h header file, which is included by all other source files in pbrt.

<<Float Type Definitions>>= 
#ifdef PBRT_FLOAT_AS_DOUBLE using Float = double; #else using Float = float; #endif

Integrator::IntersectP() is closely related to the Intersect() method. It checks for the existence of intersections along the ray but only returns a Boolean indicating whether an intersection was found. (The “P” in its name indicates that it is a function that evaluates a predicate, using a common naming convention from the Lisp programming language.) Because it does not need to search for the closest intersection or return additional geometric information about intersections, IntersectP() is generally more efficient than Integrator::Intersect(). This routine is used for shadow rays.

<<Integrator Method Definitions>>+= 
bool Integrator::IntersectP(const Ray &ray, Float tMax) const { if (aggregate) return aggregate.IntersectP(ray, tMax); else return false; }

1.3.4 ImageTileIntegrator and the Main Rendering Loop

Before implementing a basic integrator that simulates light transport to render an image, we will define two Integrator subclasses that provide additional common functionality used by that integrator as well as many of the integrator implementations to come. We start with ImageTileIntegrator, which inherits from Integrator. The next section defines RayIntegrator, which inherits from ImageTileIntegrator.

All of pbrt’s CPU-based integrators render images using a camera model to define the viewing parameters, and all parallelize rendering by splitting the image into tiles and having different processors work on different tiles. Therefore, pbrt includes an ImageTileIntegrator that provides common functionality for those tasks.

<<ImageTileIntegrator Definition>>= 
class ImageTileIntegrator : public Integrator { public: <<ImageTileIntegrator Public Methods>> 
ImageTileIntegrator(Camera camera, Sampler sampler, Primitive aggregate, std::vector<Light> lights) : Integrator(aggregate, lights), camera(camera), samplerPrototype(sampler) {} void Render(); virtual void EvaluatePixelSample(Point2i pPixel, int sampleIndex, Sampler sampler, ScratchBuffer &scratchBuffer) = 0;
protected: <<ImageTileIntegrator Protected Members>> 
Camera camera; Sampler samplerPrototype;
};

In addition to the aggregate and the lights, the ImageTileIntegrator constructor takes a Camera that specifies the viewing and lens parameters such as position, orientation, focus, and field of view. Film stored by the camera handles image storage. The Camera classes are the subject of most of Chapter 5, and Film is described in Section 5.4. The Film is responsible for writing the final image to a file.

The constructor also takes a Sampler; its role is more subtle, but its implementation can substantially affect the quality of the images that the system generates. First, the sampler is responsible for choosing the points on the image plane that determine which rays are initially traced into the scene. Second, it is responsible for supplying random sample values that are used by integrators for estimating the value of the light transport integral, Equation (1.1). For example, some integrators need to choose random points on light sources to compute illumination from area lights. Generating a good distribution of these samples is an important part of the rendering process that can substantially affect overall efficiency; this topic is the main focus of Chapter 8.

<<ImageTileIntegrator Public Methods>>= 
ImageTileIntegrator(Camera camera, Sampler sampler, Primitive aggregate, std::vector<Light> lights) : Integrator(aggregate, lights), camera(camera), samplerPrototype(sampler) {}

<<ImageTileIntegrator Protected Members>>= 
Camera camera; Sampler samplerPrototype;

For all of pbrt’s integrators, the final color computed at each pixel is based on random sampling algorithms. If each pixel’s final value is computed as the average of multiple samples, then the quality of the image improves. At low numbers of samples, sampling error manifests itself as grainy high-frequency noise in images, though error goes down at a predictable rate as the number of samples increases. (This topic is discussed in more depth in Section 2.1.4.) ImageTileIntegrator::Render() therefore renders the image in waves of a few samples per pixel. For the first two waves, only a single sample is taken in each pixel. In the next wave, two samples are taken, with the number of samples doubling after each wave up to a limit. While it makes no difference to the final image if the image was rendered in waves or with all the samples being taken in a pixel before moving on to the next one, this organization of the computation means that it is possible to see previews of the final image during rendering where all pixels have some samples, rather than a few pixels having many samples and the rest having none.

Because pbrt is parallelized to run using multiple threads, there is a balance to be struck with this approach. There is a cost for threads to acquire work for a new image tile, and some threads end up idle at the end of each wave once there is no more work for them to do but other threads are still working on the tiles they have been assigned. These considerations motivated the capped doubling approach.

<<ImageTileIntegrator Method Definitions>>= 
void ImageTileIntegrator::Render() { <<Declare common variables for rendering image in tiles>> 
ThreadLocal<ScratchBuffer> scratchBuffers( []() { return ScratchBuffer(); } ); ThreadLocal<Sampler> samplers( [this]() { return samplerPrototype.Clone(); }); Bounds2i pixelBounds = camera.GetFilm().PixelBounds(); int spp = samplerPrototype.SamplesPerPixel(); ProgressReporter progress(int64_t(spp) * pixelBounds.Area(), "Rendering", Options->quiet); int waveStart = 0, waveEnd = 1, nextWaveSize = 1;
<<Render image in waves>> 
while (waveStart < spp) { <<Render current wave’s image tiles in parallel>> 
ParallelFor2D(pixelBounds, [&](Bounds2i tileBounds) { <<Render image tile given by tileBounds>> 
ScratchBuffer &scratchBuffer = scratchBuffers.Get(); Sampler &sampler = samplers.Get(); for (Point2i pPixel : tileBounds) { <<Render samples in pixel pPixel>> 
for (int sampleIndex = waveStart; sampleIndex < waveEnd; ++sampleIndex) { sampler.StartPixelSample(pPixel, sampleIndex); EvaluatePixelSample(pPixel, sampleIndex, sampler, scratchBuffer); scratchBuffer.Reset(); }
} progress.Update((waveEnd - waveStart) * tileBounds.Area());
});
<<Update start and end wave>> 
waveStart = waveEnd; waveEnd = std::min(spp, waveEnd + nextWaveSize); nextWaveSize = std::min(2 * nextWaveSize, 64);
<<Optionally write current image to disk>> 
if (waveStart == spp || Options->writePartialImages || referenceImage) { ImageMetadata metadata; metadata.renderTimeSeconds = progress.ElapsedSeconds(); metadata.samplesPerPixel = waveStart; if (waveStart == spp || Options->writePartialImages) { camera.InitMetadata(&metadata); camera.GetFilm().WriteImage(metadata, 1.0f / waveStart); } }
}
}

Before rendering begins, a few additional variables are required. First, the integrator implementations will need to allocate small amounts of temporary memory to store surface scattering properties in the course of computing each ray’s contribution. The large number of resulting allocations could easily overwhelm the system’s regular memory allocation routines (e.g., new), which must coordinate multi-threaded maintenance of elaborate data structures to track free memory. A naive implementation could potentially spend a fairly large fraction of its computation time in the memory allocator.

To address this issue, pbrt provides a ScratchBuffer class that manages a small preallocated buffer of memory. ScratchBuffer allocations are very efficient, just requiring the increment of an offset. The ScratchBuffer does not allow independently freeing allocations; instead, all must be freed at once, but doing so only requires resetting that offset.

Because ScratchBuffers are not safe for use by multiple threads at the same time, an individual one is created for each thread using the ThreadLocal template class. Its constructor takes a lambda function that returns a fresh instance of the object of the type it manages; here, calling the default ScratchBuffer constructor is sufficient. ThreadLocal then handles the details of maintaining distinct copies of the object for each thread, allocating them on demand.

<<Declare common variables for rendering image in tiles>>= 
ThreadLocal<ScratchBuffer> scratchBuffers( []() { return ScratchBuffer(); } );

Most Sampler implementations find it useful to maintain some state, such as the coordinates of the current pixel. This means that multiple threads cannot use a single Sampler concurrently and ThreadLocal is also used for Sampler management. Samplers provide a Clone() method that creates a new instance of their sampler type. The Sampler first provided to the ImageTileIntegrator constructor, samplerPrototype, provides those copies here.

<<Declare common variables for rendering image in tiles>>+=  
ThreadLocal<Sampler> samplers( [this]() { return samplerPrototype.Clone(); });

It is helpful to provide the user with an indication of how much of the rendering work is done and an estimate of how much longer it will take. This task is handled by the ProgressReporter class, which takes as its first parameter the total number of items of work. Here, the total amount of work is the number of samples taken in each pixel times the total number of pixels. It is important to use 64-bit precision to compute this value, since a 32-bit int may be insufficient for high-resolution images with many samples per pixel.

<<Declare common variables for rendering image in tiles>>+=  
Bounds2i pixelBounds = camera.GetFilm().PixelBounds(); int spp = samplerPrototype.SamplesPerPixel(); ProgressReporter progress(int64_t(spp) * pixelBounds.Area(), "Rendering", Options->quiet);

In the following, the range of samples to be taken in the current wave is given by waveStart and waveEnd; nextWaveSize gives the number of samples to be taken in the next wave.

<<Declare common variables for rendering image in tiles>>+= 
int waveStart = 0, waveEnd = 1, nextWaveSize = 1;

With these variables in hand, rendering proceeds until the required number of samples have been taken in all pixels.

<<Render image in waves>>= 
while (waveStart < spp) { <<Render current wave’s image tiles in parallel>> 
ParallelFor2D(pixelBounds, [&](Bounds2i tileBounds) { <<Render image tile given by tileBounds>> 
ScratchBuffer &scratchBuffer = scratchBuffers.Get(); Sampler &sampler = samplers.Get(); for (Point2i pPixel : tileBounds) { <<Render samples in pixel pPixel>> 
for (int sampleIndex = waveStart; sampleIndex < waveEnd; ++sampleIndex) { sampler.StartPixelSample(pPixel, sampleIndex); EvaluatePixelSample(pPixel, sampleIndex, sampler, scratchBuffer); scratchBuffer.Reset(); }
} progress.Update((waveEnd - waveStart) * tileBounds.Area());
});
<<Update start and end wave>> 
waveStart = waveEnd; waveEnd = std::min(spp, waveEnd + nextWaveSize); nextWaveSize = std::min(2 * nextWaveSize, 64);
<<Optionally write current image to disk>> 
if (waveStart == spp || Options->writePartialImages || referenceImage) { ImageMetadata metadata; metadata.renderTimeSeconds = progress.ElapsedSeconds(); metadata.samplesPerPixel = waveStart; if (waveStart == spp || Options->writePartialImages) { camera.InitMetadata(&metadata); camera.GetFilm().WriteImage(metadata, 1.0f / waveStart); } }
}

The ParallelFor2D() function loops over image tiles, running multiple loop iterations concurrently; it is part of the parallelism-related utility functions that are introduced in Section B.6. A C++ lambda expression provides the loop body. ParallelFor2D() automatically chooses a tile size to balance two concerns: on one hand, we would like to have significantly more tiles than there are processors in the system. It is likely that some of the tiles will take less processing time than others, so if there was for example a 1:1 mapping between processors and tiles, then some processors will be idle after finishing their work while others continue to work on their region of the image. (Figure 1.17 graphs the distribution of time taken to render tiles of an example image, illustrating this concern.) On the other hand, having too many tiles also hurts efficiency. There is a small fixed overhead for a thread to acquire more work in the parallel for loop and the more tiles there are, the more times this overhead must be paid. ParallelFor2D() therefore chooses a tile size that accounts for both the extent of the region to be processed and the number of processors in the system.

<<Render current wave’s image tiles in parallel>>= 
ParallelFor2D(pixelBounds, [&](Bounds2i tileBounds) { <<Render image tile given by tileBounds>> 
ScratchBuffer &scratchBuffer = scratchBuffers.Get(); Sampler &sampler = samplers.Get(); for (Point2i pPixel : tileBounds) { <<Render samples in pixel pPixel>> 
for (int sampleIndex = waveStart; sampleIndex < waveEnd; ++sampleIndex) { sampler.StartPixelSample(pPixel, sampleIndex); EvaluatePixelSample(pPixel, sampleIndex, sampler, scratchBuffer); scratchBuffer.Reset(); }
} progress.Update((waveEnd - waveStart) * tileBounds.Area());
});

Figure 1.17: Histogram of Time Spent Rendering Each Tile for the Scene in Figure 1.11. The horizontal axis measures time in seconds. Note the wide variation in execution time, illustrating that different parts of the image required substantially different amounts of computation.

Given a tile to render, the implementation starts by acquiring the ScratchBuffer and Sampler for the currently executing thread. As described earlier, the ThreadLocal::Get() method takes care of the details of allocating and returning individual ones of them for each thread.

With those in hand, the implementation loops over all the pixels in the tile using a range-based for loop that uses iterators provided by the Bounds2 class before informing the ProgressReporter about how much work has been completed.

<<Render image tile given by tileBounds>>= 
ScratchBuffer &scratchBuffer = scratchBuffers.Get(); Sampler &sampler = samplers.Get(); for (Point2i pPixel : tileBounds) { <<Render samples in pixel pPixel>> 
for (int sampleIndex = waveStart; sampleIndex < waveEnd; ++sampleIndex) { sampler.StartPixelSample(pPixel, sampleIndex); EvaluatePixelSample(pPixel, sampleIndex, sampler, scratchBuffer); scratchBuffer.Reset(); }
} progress.Update((waveEnd - waveStart) * tileBounds.Area());

Given a pixel to take one or more samples in, the thread’s Sampler is notified that it should start generating samples for the current pixel via StartPixelSample(), which allows it to set up any internal state that depends on which pixel is currently being processed. The integrator’s EvaluatePixelSample() method is then responsible for determining the specified sample’s value, after which any temporary memory it may have allocated in the ScratchBuffer is freed with a call to ScratchBuffer::Reset().

<<Render samples in pixel pPixel>>= 
for (int sampleIndex = waveStart; sampleIndex < waveEnd; ++sampleIndex) { sampler.StartPixelSample(pPixel, sampleIndex); EvaluatePixelSample(pPixel, sampleIndex, sampler, scratchBuffer); scratchBuffer.Reset(); }

Having provided an implementation of the pure virtual Integrator::Render() method, ImageTileIntegrator now imposes the requirement on its subclasses that they implement the following EvaluatePixelSample() method.

<<ImageTileIntegrator Public Methods>>+= 
virtual void EvaluatePixelSample(Point2i pPixel, int sampleIndex, Sampler sampler, ScratchBuffer &scratchBuffer) = 0;

After the parallel for loop for the current wave completes, the range of sample indices to be processed in the next wave is computed.

<<Update start and end wave>>= 
waveStart = waveEnd; waveEnd = std::min(spp, waveEnd + nextWaveSize); nextWaveSize = std::min(2 * nextWaveSize, 64);

If the user has provided the –write-partial-images command-line option, the in-progress image is written to disk before the next wave of samples is processed. We will not include here the fragment that takes care of this, <<Optionally write current image to disk>>.

1.3.5 RayIntegrator Implementation

Just as the ImageTileIntegrator centralizes functionality related to integrators that decompose the image into tiles, RayIntegrator provides commonly used functionality to integrators that trace ray paths starting from the camera. All of the integrators implemented in Chapters 13 and 14 inherit from RayIntegrator.

<<RayIntegrator Definition>>= 
class RayIntegrator : public ImageTileIntegrator { public: <<RayIntegrator Public Methods>> 
RayIntegrator(Camera camera, Sampler sampler, Primitive aggregate, std::vector<Light> lights) : ImageTileIntegrator(camera, sampler, aggregate, lights) {} void EvaluatePixelSample(Point2i pPixel, int sampleIndex, Sampler sampler, ScratchBuffer &scratchBuffer) final; virtual SampledSpectrum Li( RayDifferential ray, SampledWavelengths &lambda, Sampler sampler, ScratchBuffer &scratchBuffer, VisibleSurface *visibleSurface) const = 0;
};

Its constructor does nothing more than pass along the provided objects to the ImageTileIntegrator constructor.

<<RayIntegrator Public Methods>>= 
RayIntegrator(Camera camera, Sampler sampler, Primitive aggregate, std::vector<Light> lights) : ImageTileIntegrator(camera, sampler, aggregate, lights) {}

RayIntegrator implements the pure virtual EvaluatePixelSample() method from ImageTileIntegrator. At the given pixel, it uses its Camera and Sampler to generate a ray into the scene and then calls the Li() method, which is provided by the subclass, to determine the amount of light arriving at the image plane along that ray. As we will see in following chapters, the units of the value returned by this method are related to the incident spectral radiance at the ray origin, which is generally denoted by the symbol upper L Subscript normal i in equations—thus, the method name. This value is passed to the Film, which records the ray’s contribution to the image.

Figure 1.18 summarizes the main classes used in this method and the flow of data among them.

Figure 1.18: Class Relationships for RayIntegrator::EvaluatePixelSample()’s computation. The Sampler provides sample values for each image sample to be taken. The Camera turns a sample into a corresponding ray from the film plane, and the Li() method computes the radiance along that ray arriving at the film. The sample and its radiance are passed to the Film, which stores their contribution in an image.

<<RayIntegrator Method Definitions>>= 
void RayIntegrator::EvaluatePixelSample(Point2i pPixel, int sampleIndex, Sampler sampler, ScratchBuffer &scratchBuffer) { <<Sample wavelengths for the ray>> 
Float lu = sampler.Get1D(); SampledWavelengths lambda = camera.GetFilm().SampleWavelengths(lu);
<<Initialize CameraSample for current sample>> 
Filter filter = camera.GetFilm().GetFilter(); CameraSample cameraSample = GetCameraSample(sampler, pPixel, filter);
<<Generate camera ray for current sample>> 
pstd::optional<CameraRayDifferential> cameraRay = camera.GenerateRayDifferential(cameraSample, lambda);
<<Trace cameraRay if valid>> 
SampledSpectrum L(0.); VisibleSurface visibleSurface; if (cameraRay) { <<Scale camera ray differentials based on image sampling rate>> 
Float rayDiffScale = std::max<Float>(.125f, 1 / std::sqrt((Float)sampler.SamplesPerPixel())); cameraRay->ray.ScaleDifferentials(rayDiffScale);
<<Evaluate radiance along camera ray>> 
bool initializeVisibleSurface = camera.GetFilm().UsesVisibleSurface(); L = cameraRay->weight * Li(cameraRay->ray, lambda, sampler, scratchBuffer, initializeVisibleSurface ? &visibleSurface : nullptr);
<<Issue warning if unexpected radiance value is returned>> 
if (L.HasNaNs()) { LOG_ERROR("Not-a-number radiance value returned for pixel (%d, " "%d), sample %d. Setting to black.", pPixel.x, pPixel.y, sampleIndex); L = SampledSpectrum(0.f); } else if (IsInf(L.y(lambda))) { LOG_ERROR("Infinite radiance value returned for pixel (%d, %d), " "sample %d. Setting to black.", pPixel.x, pPixel.y, sampleIndex); L = SampledSpectrum(0.f); }
}
<<Add camera ray’s contribution to image>> 
camera.GetFilm().AddSample(pPixel, L, lambda, &visibleSurface, cameraSample.filterWeight);
}

Each ray carries radiance at a number of discrete wavelengths lamda (four, by default). When computing the color at each pixel, pbrt chooses different wavelengths at different pixel samples so that the final result better reflects the correct result over all wavelengths. To choose these wavelengths, a sample value lu is first provided by the Sampler. This value will be uniformly distributed and in the range left-bracket 0 comma 1 right-parenthesis . The Film::SampleWavelengths() method then maps this sample to a set of specific wavelengths, taking into account its model of film sensor response as a function of wavelength. Most Sampler implementations ensure that if multiple samples are taken in a pixel, those samples are in the aggregate well distributed over left-bracket 0 comma 1 right-parenthesis . In turn, they ensure that the sampled wavelengths are also well distributed across the range of valid wavelengths, improving image quality.

<<Sample wavelengths for the ray>>= 
Float lu = sampler.Get1D(); SampledWavelengths lambda = camera.GetFilm().SampleWavelengths(lu);

The CameraSample structure records the position on the film for which the camera should generate a ray. This position is affected by both a sample position provided by the sampler and the reconstruction filter that is used to filter multiple sample values into a single value for the pixel. GetCameraSample() handles those calculations. CameraSample also stores a time that is associated with the ray as well as a lens position sample, which are used when rendering scenes with moving objects and for camera models that simulate non-pinhole apertures, respectively.

<<Initialize CameraSample for current sample>>= 
Filter filter = camera.GetFilm().GetFilter(); CameraSample cameraSample = GetCameraSample(sampler, pPixel, filter);

The Camera interface provides two methods to generate rays: GenerateRay(), which returns the ray for a given image sample position, and GenerateRayDifferential(), which returns a ray differential, which incorporates information about the rays that the camera would generate for samples that are one pixel away on the image plane in both the x and y directions. Ray differentials are used to get better results from some of the texture functions defined in Chapter 10, by making it possible to compute how quickly a texture varies with respect to the pixel spacing, which is a key component of texture antialiasing.

Some CameraSample values may not correspond to valid rays for a given camera. Therefore, pstd::optional is used for the CameraRayDifferential returned by the camera.

<<Generate camera ray for current sample>>= 
pstd::optional<CameraRayDifferential> cameraRay = camera.GenerateRayDifferential(cameraSample, lambda);

If the camera ray is valid, it is passed along to the RayIntegrator subclass’s Li() method implementation after some additional preparation. In addition to returning the radiance along the ray L, the subclass is also responsible for initializing an instance of the VisibleSurface class, which records geometric information about the surface the ray intersects (if any) at each pixel for the use of Film implementations like the GBufferFilm that store more information than just color at each pixel.

<<Trace cameraRay if valid>>= 
SampledSpectrum L(0.); VisibleSurface visibleSurface; if (cameraRay) { <<Scale camera ray differentials based on image sampling rate>> 
Float rayDiffScale = std::max<Float>(.125f, 1 / std::sqrt((Float)sampler.SamplesPerPixel())); cameraRay->ray.ScaleDifferentials(rayDiffScale);
<<Evaluate radiance along camera ray>> 
bool initializeVisibleSurface = camera.GetFilm().UsesVisibleSurface(); L = cameraRay->weight * Li(cameraRay->ray, lambda, sampler, scratchBuffer, initializeVisibleSurface ? &visibleSurface : nullptr);
<<Issue warning if unexpected radiance value is returned>> 
if (L.HasNaNs()) { LOG_ERROR("Not-a-number radiance value returned for pixel (%d, " "%d), sample %d. Setting to black.", pPixel.x, pPixel.y, sampleIndex); L = SampledSpectrum(0.f); } else if (IsInf(L.y(lambda))) { LOG_ERROR("Infinite radiance value returned for pixel (%d, %d), " "sample %d. Setting to black.", pPixel.x, pPixel.y, sampleIndex); L = SampledSpectrum(0.f); }
}

Before the ray is passed to the Li() method, the ScaleDifferentials() method scales the differential rays to account for the actual spacing between samples on the film plane when multiple samples are taken per pixel.

<<Scale camera ray differentials based on image sampling rate>>= 
Float rayDiffScale = std::max<Float>(.125f, 1 / std::sqrt((Float)sampler.SamplesPerPixel())); cameraRay->ray.ScaleDifferentials(rayDiffScale);

For Film implementations that do not store geometric information at each pixel, it is worth saving the work of populating the VisibleSurface class. Therefore, a pointer to this class is only passed in the call to the Li() method if it is necessary, and a null pointer is passed otherwise. Integrator implementations then should only initialize the VisibleSurface if it is non-null.

CameraRayDifferential also carries a weight associated with the ray that is used to scale the returned radiance value. For simple camera models, each ray is weighted equally, but camera models that more accurately simulate the process of image formation by lens systems may generate some rays that contribute more than others. Such a camera model might simulate the effect of less light arriving at the edges of the film plane than at the center, an effect called vignetting.

<<Evaluate radiance along camera ray>>= 
bool initializeVisibleSurface = camera.GetFilm().UsesVisibleSurface(); L = cameraRay->weight * Li(cameraRay->ray, lambda, sampler, scratchBuffer, initializeVisibleSurface ? &visibleSurface : nullptr);

Li() is a pure virtual method that RayIntegrator subclasses must implement. It returns the incident radiance at the origin of a given ray, sampled at the specified wavelengths.

<<RayIntegrator Public Methods>>+= 
virtual SampledSpectrum Li( RayDifferential ray, SampledWavelengths &lambda, Sampler sampler, ScratchBuffer &scratchBuffer, VisibleSurface *visibleSurface) const = 0;

A common side effect of bugs in the rendering process is that impossible radiance values are computed. For example, division by zero results in radiance values equal to either the IEEE floating-point infinity or a “not a number” value. The renderer looks for these possibilities and prints an error message when it encounters them. Here we will not include the fragment that does this, <<Issue warning if unexpected radiance value is returned>>. See the implementation in cpu/integrator.cpp if you are interested in its details.

After the radiance arriving at the ray’s origin is known, a call to Film::AddSample() updates the corresponding pixel in the image, given the weighted radiance for the sample. The details of how sample values are recorded in the film are explained in Sections 5.4 and 8.8.

<<Add camera ray’s contribution to image>>= 
camera.GetFilm().AddSample(pPixel, L, lambda, &visibleSurface, cameraSample.filterWeight);

1.3.6 Random Walk Integrator

Although it has taken a few pages to go through the implementation of the integrator infrastructure that culminated in RayIntegrator, we can now turn to implementing light transport integration algorithms in a simpler context than having to start implementing a complete Integrator::Render() method. The RandomWalkIntegrator that we will describe in this section inherits from RayIntegrator and thus all the details of multi-threading, generating the initial ray from the camera and then adding the radiance along that ray to the image, are all taken care of. The integrator operates in a simpler context: a ray has been provided and its task is to compute the radiance arriving at its origin.

Recall that in Section 1.2.7 we mentioned that in the absence of participating media, the light carried by a ray is unchanged as it passes through free space. We will ignore the possibility of participating media in the implementation of this integrator, which allows us to take a first step: given the first intersection of a ray with the geometry in the scene, the radiance arriving at the ray’s origin is equal to the radiance leaving the intersection point toward the ray’s origin. That outgoing radiance is given by the light transport equation (1.1), though it is hopeless to evaluate it in closed form. Numerical approaches are required, and the ones used in pbrt are based on Monte Carlo integration, which makes it possible to estimate the values of integrals based on pointwise evaluation of their integrands. Chapter 2 provides an introduction to Monte Carlo integration, and additional Monte Carlo techniques will be introduced as they are used throughout the book.

In order to compute the outgoing radiance, the RandomWalkIntegrator implements a simple Monte Carlo approach that is based on incrementally constructing a random walk, where a series of points on scene surfaces are randomly chosen in succession to construct light-carrying paths starting from the camera. This approach effectively models image formation in the real world in reverse, starting from the camera rather than from the light sources. Going backward in this respect is still physically valid because the physical models of light that pbrt is based on are time-reversible.

Figure 1.19: A View of the Watercolor Scene, Rendered with the RandomWalkIntegrator. Because the RandomWalkIntegrator does not handle perfectly specular surfaces, the two glasses on the table are black. Furthermore, even with the 8,192 samples per pixel used to render this image, the result is still peppered with high-frequency noise. (Note, for example, the far wall and the base of the chair.) (Scene courtesy of Angelo Ferretti.)

Although the implementation of the random walk sampling algorithm is in total just over twenty lines of code, it is capable of simulating complex lighting and shading effects; Figure 1.19 shows an image rendered using it. (That image required many hours of computation to achieve that level of quality, however.) For the remainder of this section, we will gloss over a few of the mathematical details of the integrator’s implementation and focus on an intuitive understanding of the approach, though subsequent chapters will fill in the gaps and explain this and more sophisticated techniques more rigorously.

<<RandomWalkIntegrator Definition>>= 
class RandomWalkIntegrator : public RayIntegrator { public: <<RandomWalkIntegrator Public Methods>> 
RandomWalkIntegrator(int maxDepth, Camera camera, Sampler sampler, Primitive aggregate, std::vector<Light> lights) : RayIntegrator(camera, sampler, aggregate, lights), maxDepth(maxDepth) {} static std::unique_ptr<RandomWalkIntegrator> Create( const ParameterDictionary &parameters, Camera camera, Sampler sampler, Primitive aggregate, std::vector<Light> lights, const FileLoc *loc); std::string ToString() const; SampledSpectrum Li(RayDifferential ray, SampledWavelengths &lambda, Sampler sampler, ScratchBuffer &scratchBuffer, VisibleSurface *visibleSurface) const { return LiRandomWalk(ray, lambda, sampler, scratchBuffer, 0); }
private: <<RandomWalkIntegrator Private Methods>> 
SampledSpectrum LiRandomWalk(RayDifferential ray, SampledWavelengths &lambda, Sampler sampler, ScratchBuffer &scratchBuffer, int depth) const { <<Intersect ray with scene and return if no intersection>> 
pstd::optional<ShapeIntersection> si = Intersect(ray); if (!si) { <<Return emitted light from infinite light sources>> 
SampledSpectrum Le(0.f); for (Light light : infiniteLights) Le += light.Le(ray, lambda); return Le;
} SurfaceInteraction &isect = si->intr;
<<Get emitted radiance at surface intersection>> 
Vector3f wo = -ray.d; SampledSpectrum Le = isect.Le(wo, lambda);
<<Terminate random walk if maximum depth has been reached>> 
if (depth == maxDepth) return Le;
<<Compute BSDF at random walk intersection point>> 
BSDF bsdf = isect.GetBSDF(ray, lambda, camera, scratchBuffer, sampler);
<<Randomly sample direction leaving surface for random walk>> 
Point2f u = sampler.Get2D(); Vector3f wp = SampleUniformSphere(u);
<<Evaluate BSDF at surface for sampled direction>> 
SampledSpectrum fcos = bsdf.f(wo, wp) * AbsDot(wp, isect.shading.n); if (!fcos) return Le;
<<Recursively trace ray to estimate incident radiance at surface>> 
ray = isect.SpawnRay(wp); return Le + fcos * LiRandomWalk(ray, lambda, sampler, scratchBuffer, depth + 1) / (1 / (4 * Pi));
}
<<RandomWalkIntegrator Private Members>> 
int maxDepth;
};

This integrator recursively evaluates the random walk. Therefore, its Li() method implementation does little more than start the recursion, via a call to the LiRandomWalk() method. Most of the parameters to Li() are just passed along, though the VisibleSurface is ignored for this simple integrator and an additional parameter is added to track the depth of recursion.

<<RandomWalkIntegrator Public Methods>>= 
SampledSpectrum Li(RayDifferential ray, SampledWavelengths &lambda, Sampler sampler, ScratchBuffer &scratchBuffer, VisibleSurface *visibleSurface) const { return LiRandomWalk(ray, lambda, sampler, scratchBuffer, 0); }

<<RandomWalkIntegrator Private Methods>>= 
SampledSpectrum LiRandomWalk(RayDifferential ray, SampledWavelengths &lambda, Sampler sampler, ScratchBuffer &scratchBuffer, int depth) const { <<Intersect ray with scene and return if no intersection>> 
pstd::optional<ShapeIntersection> si = Intersect(ray); if (!si) { <<Return emitted light from infinite light sources>> 
SampledSpectrum Le(0.f); for (Light light : infiniteLights) Le += light.Le(ray, lambda); return Le;
} SurfaceInteraction &isect = si->intr;
<<Get emitted radiance at surface intersection>> 
Vector3f wo = -ray.d; SampledSpectrum Le = isect.Le(wo, lambda);
<<Terminate random walk if maximum depth has been reached>> 
if (depth == maxDepth) return Le;
<<Compute BSDF at random walk intersection point>> 
BSDF bsdf = isect.GetBSDF(ray, lambda, camera, scratchBuffer, sampler);
<<Randomly sample direction leaving surface for random walk>> 
Point2f u = sampler.Get2D(); Vector3f wp = SampleUniformSphere(u);
<<Evaluate BSDF at surface for sampled direction>> 
SampledSpectrum fcos = bsdf.f(wo, wp) * AbsDot(wp, isect.shading.n); if (!fcos) return Le;
<<Recursively trace ray to estimate incident radiance at surface>> 
ray = isect.SpawnRay(wp); return Le + fcos * LiRandomWalk(ray, lambda, sampler, scratchBuffer, depth + 1) / (1 / (4 * Pi));
}

The first step is to find the closest intersection of the ray with the shapes in the scene. If no intersection is found, the ray has left the scene. Otherwise, a SurfaceInteraction that is returned as part of the ShapeIntersection structure provides information about the local geometric properties of the intersection point.

<<Intersect ray with scene and return if no intersection>>= 
pstd::optional<ShapeIntersection> si = Intersect(ray); if (!si) { <<Return emitted light from infinite light sources>> 
SampledSpectrum Le(0.f); for (Light light : infiniteLights) Le += light.Le(ray, lambda); return Le;
} SurfaceInteraction &isect = si->intr;

If no intersection was found, radiance still may be carried along the ray due to light sources such as the ImageInfiniteLight that do not have geometry associated with them. The Light::Le() method allows such lights to return their radiance for a given ray.

<<Return emitted light from infinite light sources>>= 
SampledSpectrum Le(0.f); for (Light light : infiniteLights) Le += light.Le(ray, lambda); return Le;

If a valid intersection has been found, we must evaluate the light transport equation at the intersection point. The first term, upper L Subscript normal e Superscript Baseline left-parenthesis normal p Subscript Baseline comma omega Subscript normal o Baseline right-parenthesis , which is the emitted radiance, is easy: emission is part of the scene specification and the emitted radiance is available by calling the SurfaceInteraction::Le() method, which takes the outgoing direction of interest. Here, we are interested in radiance emitted back along the ray’s direction. If the object is not emissive, that method returns a zero-valued spectral distribution.

<<Get emitted radiance at surface intersection>>= 
Vector3f wo = -ray.d; SampledSpectrum Le = isect.Le(wo, lambda);

Evaluating the second term of the light transport equation requires computing an integral over the sphere of directions around the intersection point normal p Subscript . Application of the principles of Monte Carlo integration can be used to show that if directions omega prime Subscript are chosen with equal probability over all possible directions, then an estimate of the integral can be computed as a weighted product of the BSDF f , which describes the light scattering properties of the material at normal p Subscript , the incident lighting, upper L Subscript normal i , and a cosine factor:

integral Underscript script upper S squared Endscripts f Subscript Baseline left-parenthesis normal p Subscript Baseline comma omega Subscript normal o Baseline comma omega Subscript normal i Baseline right-parenthesis upper L Subscript normal i Baseline left-parenthesis normal p Subscript Baseline comma omega Subscript normal i Baseline right-parenthesis StartAbsoluteValue cosine theta Subscript normal i Baseline EndAbsoluteValue normal d omega Subscript normal i Baseline almost-equals StartFraction f Subscript Baseline left-parenthesis normal p Subscript Baseline comma omega Subscript normal o Baseline comma omega prime Subscript Baseline right-parenthesis upper L Subscript normal i Baseline left-parenthesis normal p Subscript Baseline comma omega prime Subscript Baseline right-parenthesis StartAbsoluteValue cosine theta Superscript prime Baseline EndAbsoluteValue Over 1 slash 4 pi EndFraction period
(1.2)

In other words, given a random direction omega prime Subscript , estimating the value of the integral requires evaluating the terms in the integrand for that direction and then scaling by a factor of 4 pi . (This factor, which is derived in Section A.5.2, relates to the surface area of a unit sphere.) Since only a single direction is considered, there is almost always error in the Monte Carlo estimate compared to the true value of the integral. However, it can be shown that estimates like this one are correct in expectation: informally, that they give the correct result on average. Averaging multiple independent estimates generally reduces this error—hence, the practice of taking multiple samples per pixel.

The BSDF and the cosine factor of the estimate are easily evaluated, leaving us with upper L Subscript normal i , the incident radiance, unknown. However, note that we have found ourselves right back where we started with the initial call to LiRandomWalk(): we have a ray for which we would like to find the incident radiance at the origin—that, a recursive call to LiRandomWalk() will provide.

Before computing the estimate of the integral, we must consider terminating the recursion. The RandomWalkIntegrator stops at a predetermined maximum depth, maxDepth. Without this termination criterion, the algorithm might never terminate (imagine, e.g., a hall-of-mirrors scene). This member variable is initialized in the constructor based on a parameter that can be set in the scene description file.

<<RandomWalkIntegrator Private Members>>= 
int maxDepth;

<<Terminate random walk if maximum depth has been reached>>= 
if (depth == maxDepth) return Le;

If the random walk is not terminated, the SurfaceInteraction::GetBSDF() method is called to find the BSDF at the intersection point. It evaluates texture functions to determine surface properties and then initializes a representation of the BSDF. It generally needs to allocate memory for the objects that constitute the BSDF’s representation; because this memory only needs to be active when processing the current ray, the ScratchBuffer is provided to it to use for its allocations.

<<Compute BSDF at random walk intersection point>>= 
BSDF bsdf = isect.GetBSDF(ray, lambda, camera, scratchBuffer, sampler);

Next, we need to sample a random direction omega prime Subscript to compute the estimate in Equation (1.2). The SampleUniformSphere() function returns a uniformly distributed direction on the unit sphere, given two uniform values in left-bracket 0 comma 1 right-parenthesis that are provided here by the sampler.

<<Randomly sample direction leaving surface for random walk>>= 
Point2f u = sampler.Get2D(); Vector3f wp = SampleUniformSphere(u);

All the factors of the Monte Carlo estimate other than the incident radiance can now be readily evaluated. The BSDF class provides an f() method that evaluates the BSDF for a pair of specified directions, and the cosine of the angle with the surface normal can be computed using the AbsDot() function, which returns the absolute value of the dot product between two vectors. If the vectors are normalized, as both are here, this value is equal to the absolute value of the cosine of the angle between them (Section 3.3.2).

It is possible that the BSDF will be zero-valued for the provided directions and thus that fcos will be as well—for example, the BSDF is zero if the surface is not transmissive but the two directions are on opposite sides of it. In that case, there is no reason to continue the random walk, since subsequent points will make no contribution to the result.

<<Evaluate BSDF at surface for sampled direction>>= 
SampledSpectrum fcos = bsdf.f(wo, wp) * AbsDot(wp, isect.shading.n); if (!fcos) return Le;

The remaining task is to compute the new ray leaving the surface in the sampled direction omega prime Subscript . This task is handled by the SpawnRay() method, which returns a ray leaving an intersection in the provided direction, ensuring that the ray is sufficiently offset from the surface that it does not incorrectly reintersect it due to round-off error. Given the ray, the recursive call to LiRandomWalk() can be made to estimate the incident radiance, which completes the estimate of Equation (1.2).

<<Recursively trace ray to estimate incident radiance at surface>>= 
ray = isect.SpawnRay(wp); return Le + fcos * LiRandomWalk(ray, lambda, sampler, scratchBuffer, depth + 1) / (1 / (4 * Pi));

This simple approach has many shortcomings. For example, if the emissive surfaces are small, most ray paths will not find any light and many rays will need to be traced to form an accurate image. In the limit case of a point light source, the image will be black, since there is zero probability of intersecting such a light source. Similar issues apply with BSDF models that scatter light in a concentrated set of directions. In the limiting case of a perfect mirror that scatters incident light along a single direction, the RandomWalkIntegrator will never be able to randomly sample that direction.

Those issues and more can be addressed through more sophisticated application of Monte Carlo integration techniques. In subsequent chapters, we will introduce a succession of improvements that lead to much more accurate results. The integrators that are defined in Chapters 13 through 15 are the culmination of those developments. All still build on the same basic ideas used in the RandomWalkIntegrator, but are much more efficient and robust than it is. Figure 1.20 compares the RandomWalkIntegrator to one of the improved integrators and gives a sense of how much improvement is possible.

Figure 1.20: Watercolor Scene Rendered Using 32 Samples per Pixel. (a) Rendered using the RandomWalkIntegrator. (b) Rendered using the PathIntegrator, which follows the same general approach but uses more sophisticated Monte Carlo techniques. The PathIntegrator gives a substantially better image for roughly the same amount of work, with 54.5 times reduction in mean squared error.