4.5 Representing Spectral Distributions

Figure 4.16: Spectral Distribution of Reflection from Lemon Skin.

Spectral distributions in the real world can be complex; we have already seen a variety of complex emission spectra and Figure 4.16 shows a graph of the spectral distribution of the reflectance of lemon skin. In order to render images of scenes that include a variety of complex spectra, a renderer must have efficient and accurate representations of spectral distributions. This section will introduce pbrt’s abstractions for representing and performing computation with them; the corresponding code can be found in the files util/spectrum.h and util/spectrum.cpp.

We will start by defining constants that give the range of visible wavelengths. Both here and for the remainder of the spectral code in pbrt, wavelengths are specified in nanometers, which are of a magnitude that gives easily human-readable values for the visible wavelengths.

<<Spectrum Constants>>= 
constexpr Float Lambda_min = 360, Lambda_max = 830;

4.5.1 Spectrum Interface

We will find a variety of spectral representations useful in pbrt, ranging from spectral sample values tabularized by wavelength to functional descriptions such as the blackbody function. This brings us to our first interface class, Spectrum. A Spectrum corresponds to a pointer to a class that implements one such spectral representation.

Spectrum inherits from TaggedPointer, which handles the details of runtime polymorphism. TaggedPointer requires that all the types of Spectrum implementations be provided as template parameters, which allows it to associate a unique integer identifier with each type. (See Section B.4.4 for details of its implementation.)

<<Spectrum Definition>>= 
class Spectrum : public TaggedPointer<ConstantSpectrum, DenselySampledSpectrum, PiecewiseLinearSpectrum, RGBAlbedoSpectrum, RGBUnboundedSpectrum, RGBIlluminantSpectrum, BlackbodySpectrum> { public: <<Spectrum Interface>> 
using TaggedPointer::TaggedPointer; std::string ToString() const; Float operator()(Float lambda) const; Float MaxValue() const; SampledSpectrum Sample(const SampledWavelengths &lambda) const;
};

As with other classes that are based on TaggedPointer, Spectrum defines an interface that must be implemented by all the spectral representations. Typical practice in C++ would be for such an interface to be specified by pure virtual methods in Spectrum and for Spectrum implementations to inherit from Spectrum and implement those methods. With the TaggedPointer approach, the interface is specified implicitly: for each method in the interface, there is a method in Spectrum that dispatches calls to the appropriate type’s implementation. We will discuss the details of how this works for a single method here but will omit them for other Spectrum methods and for other interface classes since they all follow the same boilerplate.

The most important method that Spectrum defines is operator(), which takes a single wavelength lamda and returns the value of the spectral distribution for that wavelength.

<<Spectrum Interface>>= 
Float operator()(Float lambda) const;

The corresponding method implementation is brief, though dense. A call to TaggedPointer::Dispatch() begins the process of dispatching the method call. The TaggedPointer class stores an integer tag along with the object’s pointer that encodes its type; in turn, Dispatch() is able to determine the specific type of the pointer at runtime. It then calls the callback function provided to it with a pointer to the object, cast to be a pointer to its actual type.

The lambda function that is called here, op, takes a pointer with the auto type specifier for its parameter. In C++17, such a lambda function acts as a templated function; a call to it with a concrete type acts as an instantiation of a lambda that takes that type. Thus, the call (*ptr)(lambda) in the lambda body ends up as a direct call to the appropriate method.

<<Spectrum Inline Method Definitions>>= 
inline Float Spectrum::operator()(Float lambda) const { auto op = [&](auto ptr) { return (*ptr)(lambda); }; return Dispatch(op); }

Spectrum implementations must also provide a MaxValue() method that returns a bound on the maximum value of the spectral distribution over its wavelength range. This method’s main use in pbrt is for computing bounds on the power emitted by light sources so that lights can be sampled according to their expected contribution to illumination in the scene.

<<Spectrum Interface>>+=  
Float MaxValue() const;

4.5.2 General Spectral Distributions

With the Spectrum interface specified, we will start by defining a few Spectrum class implementations that explicitly tabularize values of the spectral distribution function. ConstantSpectrum is the simplest: it represents a constant spectral distribution over all wavelengths. The most common use of the ConstantSpectrum class in pbrt is to define a zero-valued spectral distribution in cases where a particular form of scattering is not present.

The ConstantSpectrum implementation is straightforward and we omit its trivial MaxValue() method here. Note that it does not inherit from Spectrum. This is another difference from using traditional C++ abstract base classes with virtual functions—as far as the C++ type system is concerned, there is no explicit connection between ConstantSpectrum and Spectrum.

<<Spectrum Definitions>>= 
class ConstantSpectrum { public: ConstantSpectrum(Float c) : c(c) {} Float operator()(Float lambda) const { return c; } private: Float c; };

More expressive is DenselySampledSpectrum, which stores a spectral distribution sampled at 1 nm intervals over a given range of integer wavelengths left-bracket lamda Subscript normal m normal i normal n Baseline comma lamda Subscript normal m normal a normal x Baseline right-bracket .

<<Spectrum Definitions>>+=  
class DenselySampledSpectrum { public: <<DenselySampledSpectrum Public Methods>> 
DenselySampledSpectrum(int lambda_min = Lambda_min, int lambda_max = Lambda_max, Allocator alloc = {}) : lambda_min(lambda_min), lambda_max(lambda_max), values(lambda_max - lambda_min + 1, alloc) {} DenselySampledSpectrum(Spectrum s, Allocator alloc) : DenselySampledSpectrum(s, Lambda_min, Lambda_max, alloc) {} DenselySampledSpectrum(const DenselySampledSpectrum &s, Allocator alloc) : lambda_min(s.lambda_min), lambda_max(s.lambda_max), values(s.values.begin(), s.values.end(), alloc) {} PBRT_CPU_GPU SampledSpectrum Sample(const SampledWavelengths &lambda) const { SampledSpectrum s; for (int i = 0; i < NSpectrumSamples; ++i) { int offset = std::lround(lambda[i]) - lambda_min; if (offset < 0 || offset >= values.size()) s[i] = 0; else s[i] = values[offset]; } return s; } PBRT_CPU_GPU void Scale(Float s) { for (Float &v : values) v *= s; } PBRT_CPU_GPU Float MaxValue() const { return *std::max_element(values.begin(), values.end()); } std::string ToString() const; DenselySampledSpectrum(Spectrum spec, int lambda_min = Lambda_min, int lambda_max = Lambda_max, Allocator alloc = {}) : lambda_min(lambda_min), lambda_max(lambda_max), values(lambda_max - lambda_min + 1, alloc) { if (spec) for (int lambda = lambda_min; lambda <= lambda_max; ++lambda) values[lambda - lambda_min] = spec(lambda); } template <typename F> static DenselySampledSpectrum SampleFunction( F func, int lambda_min = Lambda_min, int lambda_max = Lambda_max, Allocator alloc = {}) { DenselySampledSpectrum s(lambda_min, lambda_max, alloc); for (int lambda = lambda_min; lambda <= lambda_max; ++lambda) s.values[lambda - lambda_min] = func(lambda); return s; } Float operator()(Float lambda) const { int offset = std::lround(lambda) - lambda_min; if (offset < 0 || offset >= values.size()) return 0; return values[offset]; } PBRT_CPU_GPU bool operator==(const DenselySampledSpectrum &d) const { if (lambda_min != d.lambda_min || lambda_max != d.lambda_max || values.size() != d.values.size()) return false; for (size_t i = 0; i < values.size(); ++i) if (values[i] != d.values[i]) return false; return true; }
private: <<DenselySampledSpectrum Private Members>> 
int lambda_min, lambda_max; pstd::vector<Float> values;
};

Its constructor takes another Spectrum and evaluates that spectral distribution at each wavelength in the range. DenselySampledSpectrum can be useful if the provided spectral distribution is computationally expensive to evaluate, as it allows subsequent evaluations to be performed by reading a single value from memory.

<<DenselySampledSpectrum Public Methods>>= 
DenselySampledSpectrum(Spectrum spec, int lambda_min = Lambda_min, int lambda_max = Lambda_max, Allocator alloc = {}) : lambda_min(lambda_min), lambda_max(lambda_max), values(lambda_max - lambda_min + 1, alloc) { if (spec) for (int lambda = lambda_min; lambda <= lambda_max; ++lambda) values[lambda - lambda_min] = spec(lambda); }

<<DenselySampledSpectrum Private Members>>= 
int lambda_min, lambda_max; pstd::vector<Float> values;

Finding the spectrum’s value for a given wavelength lambda is a matter of returning zero for wavelengths outside of the valid range and indexing into the stored values otherwise.

<<DenselySampledSpectrum Public Methods>>+= 
Float operator()(Float lambda) const { int offset = std::lround(lambda) - lambda_min; if (offset < 0 || offset >= values.size()) return 0; return values[offset]; }

While sampling a spectral distribution at 1 nm wavelengths gives sufficient accuracy for most uses in rendering, doing so requires nearly 2 kB of memory to store a distribution that covers the visible wavelengths. PiecewiseLinearSpectrum offers another representation that is often more compact; its distribution is specified by a set of pairs of values left-parenthesis lamda Subscript i Baseline comma v Subscript i Baseline right-parenthesis where the spectral distribution is defined by linearly interpolating between them; see Figure 4.17. For spectra that are smooth in some regions and change rapidly in others, this representation makes it possible to specify the distribution at a higher rate in regions where its variation is greatest.

<<Spectrum Definitions>>+=  
class PiecewiseLinearSpectrum { public: <<PiecewiseLinearSpectrum Public Methods>> 
PiecewiseLinearSpectrum() = default; PBRT_CPU_GPU void Scale(Float s) { for (Float &v : values) v *= s; } PBRT_CPU_GPU Float MaxValue() const; PBRT_CPU_GPU SampledSpectrum Sample(const SampledWavelengths &lambda) const { SampledSpectrum s; for (int i = 0; i < NSpectrumSamples; ++i) s[i] = (*this)(lambda[i]); return s; } PBRT_CPU_GPU Float operator()(Float lambda) const; std::string ToString() const; PiecewiseLinearSpectrum(pstd::span<const Float> lambdas, pstd::span<const Float> values, Allocator alloc = {}); static pstd::optional<Spectrum> Read(const std::string &filename, Allocator alloc); static PiecewiseLinearSpectrum *FromInterleaved(pstd::span<const Float> samples, bool normalize, Allocator alloc);
private: <<PiecewiseLinearSpectrum Private Members>> 
pstd::vector<Float> lambdas, values;
};

Figure 4.17: PiecewiseLinearSpectrum defines a spectral distribution using a set of sample values left-parenthesis lamda Subscript i Baseline comma v Subscript i Baseline right-parenthesis . A continuous distribution is then defined by linearly interpolating between them.

The PiecewiseLinearSpectrum constructor, not included here, checks that the provided lambda values are sorted and then stores them and the associated spectrum values in corresponding member variables.

<<PiecewiseLinearSpectrum Public Methods>>= 
PiecewiseLinearSpectrum(pstd::span<const Float> lambdas, pstd::span<const Float> values, Allocator alloc = {});

<<PiecewiseLinearSpectrum Private Members>>= 
pstd::vector<Float> lambdas, values;

Finding the value for a given wavelength requires first finding the pair of values in the lambdas array that bracket it and then linearly interpolating between them.

<<Spectrum Method Definitions>>= 
Float PiecewiseLinearSpectrum::operator()(Float lambda) const { <<Handle PiecewiseLinearSpectrum corner cases>> 
if (lambdas.empty() || lambda < lambdas.front() || lambda > lambdas.back()) return 0;
<<Find offset to largest lambdas below lambda and interpolate>> 
int o = FindInterval(lambdas.size(), [&](int i) { return lambdas[i] <= lambda; }); Float t = (lambda - lambdas[o]) / (lambdas[o + 1] - lambdas[o]); return Lerp(t, values[o], values[o + 1]);
}

As with DenselySampledSpectrum, wavelengths outside of the specified range are given a value of zero.

<<Handle PiecewiseLinearSpectrum corner cases>>= 
if (lambdas.empty() || lambda < lambdas.front() || lambda > lambdas.back()) return 0;

If lambda is in range, then FindInterval() gives the offset to the largest value of lambdas that is less than or equal to lambda. In turn, lambda’s offset between that wavelength and the next gives the linear interpolation parameter to use with the stored values.

<<Find offset to largest lambdas below lambda and interpolate>>= 
int o = FindInterval(lambdas.size(), [&](int i) { return lambdas[i] <= lambda; }); Float t = (lambda - lambdas[o]) / (lambdas[o + 1] - lambdas[o]); return Lerp(t, values[o], values[o + 1]);

The maximum value of the distribution is easily found using std::max_element(), which performs a linear search. This function is not currently called in any performance-sensitive parts of pbrt; if it was, it would likely be worth caching this value to avoid recomputing it.

<<Spectrum Method Definitions>>+=  
Float PiecewiseLinearSpectrum::MaxValue() const { if (values.empty()) return 0; return *std::max_element(values.begin(), values.end()); }

Another useful Spectrum implementation, BlackbodySpectrum, gives the spectral distribution of a blackbody emitter at a specified temperature.

<<Spectrum Definitions>>+=  
class BlackbodySpectrum { public: <<BlackbodySpectrum Public Methods>> 
BlackbodySpectrum(Float T) : T(T) { <<Compute blackbody normalization constant for given temperature>> 
Float lambdaMax = 2.8977721e-3f / T; normalizationFactor = 1 / Blackbody(lambdaMax * 1e9f, T);
} Float operator()(Float lambda) const { return Blackbody(lambda, T) * normalizationFactor; } PBRT_CPU_GPU SampledSpectrum Sample(const SampledWavelengths &lambda) const { SampledSpectrum s; for (int i = 0; i < NSpectrumSamples; ++i) s[i] = Blackbody(lambda[i], T) * normalizationFactor; return s; } PBRT_CPU_GPU Float MaxValue() const { return 1.f; } std::string ToString() const;
private: <<BlackbodySpectrum Private Members>> 
Float T; Float normalizationFactor;
};

The temperature of the blackbody in Kelvin is the constructor’s only parameter.

<<BlackbodySpectrum Public Methods>>= 
BlackbodySpectrum(Float T) : T(T) { <<Compute blackbody normalization constant for given temperature>> 
Float lambdaMax = 2.8977721e-3f / T; normalizationFactor = 1 / Blackbody(lambdaMax * 1e9f, T);
}

<<BlackbodySpectrum Private Members>>= 
Float T;

Because the power emitted by a blackbody grows so quickly with temperature (recall the Stefan–Boltzmann law, Equation (4.19)), the BlackbodySpectrum represents a normalized blackbody spectral distribution where the maximum value at any wavelength is 1. Wien’s displacement law, Equation (4.20), gives the wavelength in meters where emitted radiance is at its maximum; we must convert this value to nm before calling Blackbody() to find the corresponding radiance value.

<<Compute blackbody normalization constant for given temperature>>= 
Float lambdaMax = 2.8977721e-3f / T; normalizationFactor = 1 / Blackbody(lambdaMax * 1e9f, T);

<<BlackbodySpectrum Private Members>>+= 
Float normalizationFactor;

The method that returns the value of the distribution at a wavelength then returns the product of the value returned by Blackbody() and the normalization factor.

<<BlackbodySpectrum Public Methods>>+= 
Float operator()(Float lambda) const { return Blackbody(lambda, T) * normalizationFactor; }

4.5.3 Embedded Spectral Data

pbrt’s scene description format provides multiple ways to specify spectral data, ranging from blackbody temperatures to arrays of lamda -value pairs to specify a piecewise-linear spectrum. For convenience, a variety of useful spectral distributions are also embedded directly in the pbrt binary, including ones that describe the emission profiles of various types of light source, the scattering properties of various conductors, and the wavelength-dependent indices of refraction of various types of glass. See the online pbrt file format documentation for a list of all of them.

The GetNamedSpectrum() function searches through these spectra and returns a Spectrum corresponding to a given named spectrum if it is available.

<<Spectral Function Declarations>>= 
Spectrum GetNamedSpectrum(std::string name);

A number of important spectra are made available directly through corresponding functions, all of which are in a Spectra namespace. Among them are Spectra::X(), Spectra::Y(), and Spectra::Z(), which return the color matching curves that are described in Section 4.6.1, and Spectra::D(), which returns a DenselySampledSpectrum representing the D illuminant at the given temperature.

<<Spectrum Function Declarations>>+= 
DenselySampledSpectrum D(Float T, Allocator alloc);

4.5.4 Sampled Spectral Distributions

The attentive reader may have noticed that although Spectrum makes it possible to evaluate spectral distribution functions, it does not provide the ability to do very much computation with them other than sampling their value at a specified wavelength. Yet, for example, evaluating the integrand of the reflection equation, (4.14), requires taking the product of two spectral distributions, one for the BSDF and one for the incident radiance function.

Providing this functionality with the abstractions that have been introduced so far would quickly become unwieldy. For example, while the product of two DenselySampledSpectrums could be faithfully represented by another DenselySampledSpectrum, consider taking the product of two PiecewiseLinearSpectrums: the resulting function would be piecewise- quadratic and subsequent products would only increase its degree. Further, operations between Spectrum implementations of different types would not only require a custom implementation for each pair, but would require choosing a suitable Spectrum representation for each result.

pbrt avoids this complexity by performing spectral calculations at a set of discrete wavelengths as part of the Monte Carlo integration that is already being performed for image synthesis. To understand how this works, consider computing the (non-spectral) irradiance at some point normal p Subscript with surface normal bold n Subscript over some range of wavelengths of interest, left-bracket lamda 0 comma lamda 1 right-bracket . Using Equation (4.7), which expresses irradiance in terms of incident radiance, and Equation (4.5), which expresses radiance in terms of spectral radiance, we have

upper E equals integral Underscript normal upper Omega Endscripts integral Subscript lamda 0 Superscript lamda 1 Baseline upper L Subscript normal i Baseline left-parenthesis normal p Subscript Baseline comma omega comma lamda right-parenthesis StartAbsoluteValue cosine theta EndAbsoluteValue normal d omega Subscript Baseline normal d lamda comma

where upper L Subscript normal i Baseline left-parenthesis normal p Subscript Baseline comma omega comma lamda right-parenthesis is the incident spectral radiance at wavelength lamda .

Applying the standard Monte Carlo estimator and taking advantage of the fact that omega and lamda are independent, we can see that estimates of upper E can be computed by sampling directions omega Subscript i from some distribution p Subscript omega , wavelengths lamda Subscript i from some distribution p Subscript lamda , and then evaluating:

upper E almost-equals StartFraction 1 Over n EndFraction sigma-summation Underscript i equals 1 Overscript n Endscripts StartFraction upper L Subscript normal i Baseline left-parenthesis normal p Subscript Baseline comma omega Subscript i Baseline comma lamda Subscript i Baseline right-parenthesis StartAbsoluteValue cosine theta Subscript i Baseline EndAbsoluteValue Over p Subscript omega Baseline left-parenthesis omega Subscript i Baseline right-parenthesis p Subscript lamda Baseline left-parenthesis lamda Subscript i Baseline right-parenthesis EndFraction period

Thus, we only need to be able to evaluate the integrand at the specified discrete wavelengths to estimate the irradiance. More generally, we will see that it is possible to express all the spectral quantities that pbrt outputs as integrals over wavelength. For example, Section 4.6 shows that when rendering an image represented using RGB colors, each pixel’s color can be computed by integrating the spectral radiance arriving at a pixel with functions that model red, green, and blue color response. pbrt therefore uses only discrete spectral samples for spectral computation.

So that we can proceed to the implementation of the classes related to sampling spectra and performing computations with spectral samples, we will define the constant that sets the number of spectral samples here. (Section 4.6.5 will discuss in more detail the trade-offs involved in choosing this value.) pbrt uses 4 wavelength samples by default; this value can easily be changed, though doing so requires recompiling the system.

<<Spectrum Constants>>+=  
static constexpr int NSpectrumSamples = 4;

SampledSpectrum

The SampledSpectrum class stores an array of NSpectrumSamples values that represent values of the spectral distribution at discrete wavelengths. It provides methods that allow a variety of mathematical operations to be performed with them.

<<SampledSpectrum Definition>>= 
class SampledSpectrum { public: <<SampledSpectrum Public Methods>> 
PBRT_CPU_GPU SampledSpectrum operator+(const SampledSpectrum &s) const { SampledSpectrum ret = *this; return ret += s; } PBRT_CPU_GPU SampledSpectrum &operator-=(const SampledSpectrum &s) { for (int i = 0; i < NSpectrumSamples; ++i) values[i] -= s.values[i]; return *this; } PBRT_CPU_GPU SampledSpectrum operator-(const SampledSpectrum &s) const { SampledSpectrum ret = *this; return ret -= s; } PBRT_CPU_GPU friend SampledSpectrum operator-(Float a, const SampledSpectrum &s) { DCHECK(!IsNaN(a)); SampledSpectrum ret; for (int i = 0; i < NSpectrumSamples; ++i) ret.values[i] = a - s.values[i]; return ret; } PBRT_CPU_GPU SampledSpectrum &operator*=(const SampledSpectrum &s) { for (int i = 0; i < NSpectrumSamples; ++i) values[i] *= s.values[i]; return *this; } PBRT_CPU_GPU SampledSpectrum operator*(const SampledSpectrum &s) const { SampledSpectrum ret = *this; return ret *= s; } PBRT_CPU_GPU SampledSpectrum operator*(Float a) const { DCHECK(!IsNaN(a)); SampledSpectrum ret = *this; for (int i = 0; i < NSpectrumSamples; ++i) ret.values[i] *= a; return ret; } PBRT_CPU_GPU SampledSpectrum &operator*=(Float a) { DCHECK(!IsNaN(a)); for (int i = 0; i < NSpectrumSamples; ++i) values[i] *= a; return *this; } PBRT_CPU_GPU friend SampledSpectrum operator*(Float a, const SampledSpectrum &s) { return s * a; } PBRT_CPU_GPU SampledSpectrum &operator/=(const SampledSpectrum &s) { for (int i = 0; i < NSpectrumSamples; ++i) { DCHECK_NE(0, s.values[i]); values[i] /= s.values[i]; } return *this; } PBRT_CPU_GPU SampledSpectrum operator/(const SampledSpectrum &s) const { SampledSpectrum ret = *this; return ret /= s; } PBRT_CPU_GPU SampledSpectrum &operator/=(Float a) { DCHECK_NE(a, 0); DCHECK(!IsNaN(a)); for (int i = 0; i < NSpectrumSamples; ++i) values[i] /= a; return *this; } PBRT_CPU_GPU SampledSpectrum operator/(Float a) const { SampledSpectrum ret = *this; return ret /= a; } PBRT_CPU_GPU SampledSpectrum operator-() const { SampledSpectrum ret; for (int i = 0; i < NSpectrumSamples; ++i) ret.values[i] = -values[i]; return ret; } PBRT_CPU_GPU bool operator==(const SampledSpectrum &s) const { return values == s.values; } PBRT_CPU_GPU bool operator!=(const SampledSpectrum &s) const { return values != s.values; } std::string ToString() const; PBRT_CPU_GPU bool HasNaNs() const { for (int i = 0; i < NSpectrumSamples; ++i) if (IsNaN(values[i])) return true; return false; } PBRT_CPU_GPU XYZ ToXYZ(const SampledWavelengths &lambda) const; PBRT_CPU_GPU RGB ToRGB(const SampledWavelengths &lambda, const RGBColorSpace &cs) const; PBRT_CPU_GPU Float y(const SampledWavelengths &lambda) const; explicit SampledSpectrum(Float c) { values.fill(c); } SampledSpectrum(pstd::span<const Float> v) { for (int i = 0; i < NSpectrumSamples; ++i) values[i] = v[i]; } Float operator[](int i) const { return values[i]; } Float &operator[](int i) { return values[i]; } explicit operator bool() const { for (int i = 0; i < NSpectrumSamples; ++i) if (values[i] != 0) return true; return false; } SampledSpectrum &operator+=(const SampledSpectrum &s) { for (int i = 0; i < NSpectrumSamples; ++i) values[i] += s.values[i]; return *this; } PBRT_CPU_GPU Float MinComponentValue() const { Float m = values[0]; for (int i = 1; i < NSpectrumSamples; ++i) m = std::min(m, values[i]); return m; } PBRT_CPU_GPU Float MaxComponentValue() const { Float m = values[0]; for (int i = 1; i < NSpectrumSamples; ++i) m = std::max(m, values[i]); return m; } PBRT_CPU_GPU Float Average() const { Float sum = values[0]; for (int i = 1; i < NSpectrumSamples; ++i) sum += values[i]; return sum / NSpectrumSamples; }
private: pstd::array<Float, NSpectrumSamples> values; };

Its constructors include one that allows providing a single value for all wavelengths and one that takes an appropriately sized pstd::span of per-wavelength values.

<<SampledSpectrum Public Methods>>= 
explicit SampledSpectrum(Float c) { values.fill(c); } SampledSpectrum(pstd::span<const Float> v) { for (int i = 0; i < NSpectrumSamples; ++i) values[i] = v[i]; }

The usual indexing operations are also provided for accessing and setting each wavelength’s value.

<<SampledSpectrum Public Methods>>+=  
Float operator[](int i) const { return values[i]; } Float &operator[](int i) { return values[i]; }

It is often useful to know if all the values in a SampledSpectrum are zero. For example, if a surface has zero reflectance, then the light transport routines can avoid the computational cost of casting reflection rays that have contributions that would eventually be multiplied by zeros. This capability is provided through a type conversion operator to bool.

<<SampledSpectrum Public Methods>>+=  
explicit operator bool() const { for (int i = 0; i < NSpectrumSamples; ++i) if (values[i] != 0) return true; return false; }

All the standard arithmetic operations on SampledSpectrum objects are provided; each operates component-wise on the stored values. The implementation of operator+= is below. The others are analogous and are therefore not included in the text.

<<SampledSpectrum Public Methods>>+= 
SampledSpectrum &operator+=(const SampledSpectrum &s) { for (int i = 0; i < NSpectrumSamples; ++i) values[i] += s.values[i]; return *this; }

SafeDiv() divides two sampled spectra, but generates zero for any sample where the divisor is zero.

<<SampledSpectrum Inline Functions>>= 
SampledSpectrum SafeDiv(SampledSpectrum a, SampledSpectrum b) { SampledSpectrum r; for (int i = 0; i < NSpectrumSamples; ++i) r[i] = (b[i] != 0) ? a[i] / b[i] : 0.; return r; }

In addition to the basic arithmetic operations, SampledSpectrum also provides Lerp(), Sqrt(), Clamp(), ClampZero(), Pow(), Exp(), and FastExp() functions that operate (again, component-wise) on SampledSpectrum objects; some of these operations are necessary for evaluating some of the reflection models in Chapter 9 and for evaluating volume scattering models in Chapter 14. Finally, MinComponentValue() and MaxComponentValue() return the minimum and maximum of all the values, and Average() returns their average. These methods are all straightforward and are therefore not included in the text.

SampledWavelengths

A separate class, SampledWavelengths, stores the wavelengths for which a SampledSpectrum stores samples. Thus, it is important not only to keep careful track of the SampledWavelengths that are represented by an individual SampledSpectrum but also to not perform any operations that combine SampledSpectrums that have samples at different wavelengths.

<<SampledWavelengths Definitions>>= 
class SampledWavelengths { public: <<SampledWavelengths Public Methods>> 
PBRT_CPU_GPU bool operator==(const SampledWavelengths &swl) const { return lambda == swl.lambda && pdf == swl.pdf; } PBRT_CPU_GPU bool operator!=(const SampledWavelengths &swl) const { return lambda != swl.lambda || pdf != swl.pdf; } std::string ToString() const; static SampledWavelengths SampleUniform(Float u, Float lambda_min = Lambda_min, Float lambda_max = Lambda_max) { SampledWavelengths swl; <<Sample first wavelength using u>> 
swl.lambda[0] = Lerp(u, lambda_min, lambda_max);
<<Initialize lambda for remaining wavelengths>> 
Float delta = (lambda_max - lambda_min) / NSpectrumSamples; for (int i = 1; i < NSpectrumSamples; ++i) { swl.lambda[i] = swl.lambda[i - 1] + delta; if (swl.lambda[i] > lambda_max) swl.lambda[i] = lambda_min + (swl.lambda[i] - lambda_max); }
<<Compute PDF for sampled wavelengths>> 
for (int i = 0; i < NSpectrumSamples; ++i) swl.pdf[i] = 1 / (lambda_max - lambda_min);
return swl; } Float operator[](int i) const { return lambda[i]; } Float &operator[](int i) { return lambda[i]; } SampledSpectrum PDF() const { return SampledSpectrum(pdf); } void TerminateSecondary() { if (SecondaryTerminated()) return; <<Update wavelength probabilities for termination>> 
for (int i = 1; i < NSpectrumSamples; ++i) pdf[i] = 0; pdf[0] /= NSpectrumSamples;
} bool SecondaryTerminated() const { for (int i = 1; i < NSpectrumSamples; ++i) if (pdf[i] != 0) return false; return true; } static SampledWavelengths SampleVisible(Float u) { SampledWavelengths swl; for (int i = 0; i < NSpectrumSamples; ++i) { <<Compute up for i th wavelength sample>> 
Float up = u + Float(i) / NSpectrumSamples; if (up > 1) up -= 1;
swl.lambda[i] = SampleVisibleWavelengths(up); swl.pdf[i] = VisibleWavelengthsPDF(swl.lambda[i]); } return swl; }
private: <<SampledWavelengths Private Members>> 
friend struct SOA<SampledWavelengths>; pstd::array<Float, NSpectrumSamples> lambda, pdf;
};

To be used in the context of Monte Carlo integration, the wavelengths stored in SampledWavelengths must be sampled from some probability distribution. Therefore, the class stores the wavelengths themselves as well as each one’s probability density.

<<SampledWavelengths Private Members>>= 
pstd::array<Float, NSpectrumSamples> lambda, pdf;

The easiest way to sample wavelengths is uniformly over a given range. This approach is implemented in the SampleUniform() method, which takes a single uniform sample u and a range of wavelengths.

<<SampledWavelengths Public Methods>>= 
static SampledWavelengths SampleUniform(Float u, Float lambda_min = Lambda_min, Float lambda_max = Lambda_max) { SampledWavelengths swl; <<Sample first wavelength using u>> 
swl.lambda[0] = Lerp(u, lambda_min, lambda_max);
<<Initialize lambda for remaining wavelengths>> 
Float delta = (lambda_max - lambda_min) / NSpectrumSamples; for (int i = 1; i < NSpectrumSamples; ++i) { swl.lambda[i] = swl.lambda[i - 1] + delta; if (swl.lambda[i] > lambda_max) swl.lambda[i] = lambda_min + (swl.lambda[i] - lambda_max); }
<<Compute PDF for sampled wavelengths>> 
for (int i = 0; i < NSpectrumSamples; ++i) swl.pdf[i] = 1 / (lambda_max - lambda_min);
return swl; }

It chooses the first wavelength uniformly within the range.

<<Sample first wavelength using u>>= 
swl.lambda[0] = Lerp(u, lambda_min, lambda_max);

The remaining wavelengths are chosen by taking uniform steps delta starting from the first wavelength and wrapping around if lambda_max is passed. The result is a set of stratified wavelength samples that are generated using a single random number. One advantage of sampling wavelengths in this way rather than using a separate uniform sample for each one is that the value of NSpectrumSamples can be changed without requiring the modification of code that calls SampleUniform() to adjust the number of sample values that are passed to this method.

<<Initialize lambda for remaining wavelengths>>= 
Float delta = (lambda_max - lambda_min) / NSpectrumSamples; for (int i = 1; i < NSpectrumSamples; ++i) { swl.lambda[i] = swl.lambda[i - 1] + delta; if (swl.lambda[i] > lambda_max) swl.lambda[i] = lambda_min + (swl.lambda[i] - lambda_max); }

The probability density for each sample is easily computed, since the sampling distribution is uniform.

<<Compute PDF for sampled wavelengths>>= 
for (int i = 0; i < NSpectrumSamples; ++i) swl.pdf[i] = 1 / (lambda_max - lambda_min);

Additional methods provide access to the individual wavelengths and to all of their PDFs. PDF values are returned in the form of a SampledSpectrum, which makes it easy to compute the value of associated Monte Carlo estimators.

<<SampledWavelengths Public Methods>>+=  
Float operator[](int i) const { return lambda[i]; } Float &operator[](int i) { return lambda[i]; } SampledSpectrum PDF() const { return SampledSpectrum(pdf); }

In some cases, different wavelengths of light may follow different paths after a scattering event. The most common example is when light undergoes dispersion and different wavelengths of light refract to different directions. When this happens, it is no longer possible to track multiple wavelengths of light with a single ray. For this case, SampledWavelengths provides the capability of terminating all but one of the wavelengths; subsequent computations can then consider the single surviving wavelength exclusively.

<<SampledWavelengths Public Methods>>+=  
void TerminateSecondary() { if (SecondaryTerminated()) return; <<Update wavelength probabilities for termination>> 
for (int i = 1; i < NSpectrumSamples; ++i) pdf[i] = 0; pdf[0] /= NSpectrumSamples;
}

The wavelength stored in lambda[0] is always the survivor: there is no need to randomly select the surviving wavelength so long as each lambda value was randomly sampled from the same distribution as is the case with SampleUniform(), for example. Note that this means that it would be incorrect for SampledWavelengths::SampleUniform() to always place lambda[0] in a first wavelength stratum between lambda_min and lambda_min+delta, lambda[1] in the second, and so forth.

Terminated wavelengths have their PDF values set to zero; code that computes Monte Carlo estimates using SampledWavelengths must therefore detect this case and ignore terminated wavelengths accordingly. The surviving wavelength’s PDF is updated to account for the termination event by multiplying it by the probability of a wavelength surviving termination, 1 / NSpectrumSamples. (This is similar to how applying Russian roulette affects the Monte Carlo estimator—see Section 2.2.4.)

<<Update wavelength probabilities for termination>>= 
for (int i = 1; i < NSpectrumSamples; ++i) pdf[i] = 0; pdf[0] /= NSpectrumSamples;

SecondaryTerminated() indicates whether TerminateSecondary() has already been called. Because path termination is the only thing that causes zero-valued PDFs after the first wavelength, checking the PDF values suffices for this test.

<<SampledWavelengths Public Methods>>+=  
bool SecondaryTerminated() const { for (int i = 1; i < NSpectrumSamples; ++i) if (pdf[i] != 0) return false; return true; }

We will often have a Spectrum and a set of wavelengths for which we would like to evaluate it. Therefore, we will add a method to the Spectrum interface that provides a Sample() method that takes a set of wavelengths, evaluates its spectral distribution function at each one, and returns a SampledSpectrum. This convenience method eliminates the need for an explicit loop over wavelengths with individual calls to Spectrum::operator() in this common case. The implementations of this method are straightforward and not included here.

<<Spectrum Interface>>+= 
SampledSpectrum Sample(const SampledWavelengths &lambda) const;

Discussion

Now that SampledWavelengths and SampledSpectrum have been introduced, it is reasonable to ask the question: why are they separate classes, rather than a single class that stores both wavelengths and their sample values? Indeed, an advantage of such a design would be that it would be possible to detect at runtime if an operation was performed with two SampledSpectrum instances that stored values for different wavelengths—such an operation is nonsensical and would signify a bug in the system.

However, in practice many SampledSpectrum objects are created during rendering, many as temporary values in the course of evaluating expressions involving spectral computation. It is therefore worthwhile to minimize the object’s size, if only to avoid initialization and copying of additional data. While the pbrt’s CPU-based integrators do not store many SampledSpectrum values in memory at the same time, the GPU rendering path stores a few million of them, giving further motivation to minimize their size.

Our experience has been that bugs from mixing computations at different wavelengths have been rare. With the way that computation is structured in pbrt, wavelengths are generally sampled at the start of following a ray’s path through the scene, and then the same wavelengths are used throughout for all spectral calculations along the path. There ends up being little opportunity for inadvertent mingling of sampled wavelengths in SampledSpectrum instances. Indeed, in an earlier version of the system, SampledSpectrum did carry along a SampledWavelengths member variable in debug builds in order to be able to check for that case. It was eliminated in the interests of simplicity after a few months’ existence without finding a bug.