8.3 Sampling Interface

pbrt’s Sampler interface makes it possible to use a variety of sample generation algorithms for rendering. The sample points that they provide are used by pbrt’s Integrators in a multitude of ways, ranging from determining points on the image plane from which camera rays originate to selecting which light source to trace a shadow ray to and at which point on it the shadow ray should terminate.

As we will see in the following sections, the benefits of carefully crafted sampling patterns are not just theoretical; they can substantially improve the quality of rendered images. The runtime expense for using good sampling algorithms is relatively small; because evaluating the radiance for each image sample is much more expensive than computing the sample’s component values, doing this work pays dividends (Figure 8.20).

Figure 8.20: Scene rendered with (a) a relatively ineffective sampler and (b) a carefully designed sampler, using the same number of samples for each. The improvement in image quality, ranging from the shadow on the floor to the quality of the glossy reflections, is noticeable. Both images are rendered with 8 samples per pixel. (Killeroo model courtesy of headus/Rezard.)

The task of a Sampler is to generate uniform d -dimensional sample points, where each coordinate’s value is in the range left-bracket 0 comma 1 right-parenthesis . The total number of dimensions in each point is not set ahead of time; Samplers must generate additional dimensions on demand, depending on the number of dimensions required for the calculations performed by the light transport algorithms. (See Figure 8.21.) While this design makes implementing a Sampler slightly more complex than if its task was to generate all the dimensions of each sample point up front, it is more convenient for integrators, which end up needing a different number of dimensions depending on the particular path they follow through the scene.

Figure 8.21: Samplers generate a d -dimensional sample point for each of the image samples taken to generate the final image. Here, the pixel left-parenthesis 3 comma 8 right-parenthesis is being sampled, and there are two image samples in the pixel area. The first two dimensions of the sample give the left-parenthesis x comma y right-parenthesis offset of the sample within the pixel, and the next three dimensions determine the time and lens position of the corresponding camera ray. Subsequent dimensions are used by the Monte Carlo light transport algorithms implemented in pbrt’s Integrators.

<<Sampler Definition>>= 
class Sampler : public TaggedPointer<<<Sampler Types>> > { public: <<Sampler Interface>> 
using TaggedPointer::TaggedPointer; static Sampler Create(const std::string &name, const ParameterDictionary &parameters, Point2i fullResolution, const FileLoc *loc, Allocator alloc); int SamplesPerPixel() const; void StartPixelSample(Point2i p, int sampleIndex, int dimension = 0); Float Get1D(); Point2f Get2D(); Point2f GetPixel2D(); Sampler Clone(Allocator alloc = {}); std::string ToString() const;
};

All the samplers save for MLTSampler are defined in this chapter; that one is used solely by the MLTIntegrator, which is described in the online version of the book.

<<Sampler Types>>= 

Sampler implementations specify the number of samples to be taken in each pixel and return this value via SamplesPerPixel(). Most samplers already store this value as a member variable and return it directly in their implementations of this method. We will usually not include the straightforward implementations of this method in the text.

<<Sampler Interface>>= 
int SamplesPerPixel() const;

When an Integrator is ready to start work on a given pixel sample, it starts by calling StartPixelSample(), providing the coordinates of the pixel in the image and the index of the sample within the pixel. (The index should be greater than or equal to zero and less than the value returned by SamplesPerPixel().) The Integrator may also provide a starting dimension at which sample generation should begin.

This method serves two purposes. First, some Sampler implementations use the knowledge of which pixel is being sampled to improve the overall distribution of the samples that they generate—for example, by ensuring that adjacent pixels do not take two samples that are close together. Attending to this detail, while it may seem minor, can substantially improve image quality.

Second, this method allows samplers to put themselves in a deterministic state before generating each sample point. Doing so is an important part of making pbrt’s operation deterministic, which in turn is crucial for debugging. It is expected that all samplers will be implemented so that they generate precisely the same sample coordinate values for a given pixel and sample index across multiple runs of the renderer. This way, for example, if pbrt crashes in the middle of a lengthy run, debugging can proceed starting at the specific pixel and pixel sample index where the renderer crashed. With a deterministic renderer, the crash will reoccur without taking the time to perform all the preceding rendering work.

<<Sampler Interface>>+=  
void StartPixelSample(Point2i p, int sampleIndex, int dimension = 0);

Integrators can request dimensions of the d -dimensional sample point one or two at a time, via the Get1D() and Get2D() methods. While a 2D sample value could be constructed by using values returned by a pair of calls to Get1D(), some samplers can generate better point distributions if they know that two dimensions will be used together. However, the interface does not support requests for 3D or higher-dimensional sample values from samplers because these are generally not needed for the types of rendering algorithms implemented here. In that case, multiple values from lower-dimensional components can be used to construct higher-dimensional sample points.

<<Sampler Interface>>+=  
Float Get1D(); Point2f Get2D();

A separate method, GetPixel2D(), is called to retrieve the 2D sample used to determine the point on the film plane that is sampled. Some of the following Sampler implementations handle those dimensions of the sample differently from the way they handle 2D samples in other dimensions; other Samplers implement this method by calling their Get2D() methods.

<<Sampler Interface>>+=  
Point2f GetPixel2D();

Because each sample coordinate must be strictly less than 1, it is useful to define a constant, OneMinusEpsilon, that represents the largest representable floating-point value that is less than 1. Later, the Sampler implementations will sometimes clamp sample values to be no larger than this.

<<Floating-point Constants>>+= 
static constexpr double DoubleOneMinusEpsilon = 0x1.fffffffffffffp-1; static constexpr float FloatOneMinusEpsilon = 0x1.fffffep-1; #ifdef PBRT_FLOAT_AS_DOUBLE static constexpr double OneMinusEpsilon = DoubleOneMinusEpsilon; #else static constexpr float OneMinusEpsilon = FloatOneMinusEpsilon; #endif

A sharp edge of these interfaces is that code that uses sample values must be carefully written so that it always requests sample dimensions in the same order. Consider the following code:

sampler->StartPixelSample(pPixel, sampleIndex); Float v = a(sampler->Get1D()); if (v > 0) v += b(sampler->Get1D()); v += c(sampler->Get1D());

In this case, the first dimension of the sample will always be passed to the function a(); when the code path that calls b() is executed, b() will receive the second dimension. However, if the if test is not always true or false, then c() will sometimes receive a sample value from the second dimension of the sample and otherwise receive a sample value from the third dimension. This will thus thwart efforts by the sampler to provide well-distributed sample points in each dimension being evaluated. Code that uses Samplers should therefore be carefully written so that it consistently consumes sample dimensions, to avoid this issue.

Clone(), the final method required by the interface, returns a copy of the Sampler. Because Sampler implementations store a variety of state about the current sample—which pixel is being sampled, how many dimensions of the sample have been used, and so forth—it is unsafe for a single Sampler to be used concurrently by multiple threads. Therefore, Integrators call Clone() to make copies of an initial Sampler so that each thread has its own. The implementations of the various Clone() methods are not generally interesting, so they will not be included in the text here.

<<Sampler Interface>>+= 
Sampler Clone(Allocator alloc = {});