4.6 Color

“Spectral distribution” and “color” might seem like two names for the same thing, but they are distinct. A spectral distribution is a purely physical concept, while color describes the human perception of a spectrum. Color is thus closely connected to the physiology of the human visual system and the brain’s processing of visual stimulus.

Although the majority of rendering computation in pbrt is based on spectral distributions, color still must be treated carefully. For example, the spectral distribution at each pixel in a rendered image must be converted to RGB color to be displayed on a monitor. Performing this conversion accurately requires using information about the monitor’s color characteristics. The renderer also finds color in scene descriptions that use it to describe reflectance and light emission. Although it is convenient for humans to use colors to describe the appearance of modeled scenes, these colors must be converted to spectra if a renderer uses spectral distributions in its light transport simulation. Unfortunately, doing so is an underspecified problem. A variety of approaches have been developed for it; the one implemented in pbrt is described in Section 4.6.6.

The tristimulus theory of color perception says that all visible spectral distributions can be accurately represented for human observers using three scalar values. Its basis is that there are three types of photoreceptive cone cells in the eye, each sensitive to different wavelengths of light. This theory, which has been tested in numerous experiments since its introduction in the 1800s, has led to the development of spectral matching functions, which are functions of wavelength that can be used to compute a tristimulus representation of a spectral distribution.

Integrating the product of a spectral distribution upper S left-parenthesis lamda right-parenthesis with three tristimulus matching functions m Subscript StartSet 1 comma 2 comma 3 EndSet Baseline left-parenthesis lamda right-parenthesis gives three tristimulus values v Subscript i :

v Subscript i Baseline equals integral upper S left-parenthesis lamda right-parenthesis m Subscript i Baseline left-parenthesis lamda right-parenthesis normal d lamda period

The matching functions thus define a color space, which is a 3D vector space of the tristimulus values: the tristimulus values for the sum of two spectra are given by the sum of their tristimulus values and the tristimulus values associated with a spectrum that has been scaled by a constant can be found by scaling the tristimulus values by the same factor. Note that from these definitions, the tristimulus values for the product of two spectral distributions are not given by the product of their tristimulus values. This nit is why using tristimulus color like RGB for rendering may not give accurate results; we will say more about this topic in Section 4.6.6.

The files util/color.h and util/color.cpp in the pbrt distribution contain the implementation of the functionality related to color that is introduced in this section.

4.6.1 XYZ Color

Figure 4.18: The XYZ Color Matching Curves. A given spectral distribution can be converted to XYZ by multiplying it by each of the three matching curves and integrating the result to compute the values x Subscript lamda , y Subscript lamda , and z Subscript lamda , using Equation (4.22).

An important set of color matching functions were determined by the Commission Internationale de l’Éclairage (CIE) standards body after a series of experiments with human test subjects. They define the XYZ color space and are graphed in Figure 4.18. XYZ is a device-independent color space, which means that it does not describe the characteristics of a particular display or color measurement device.

Given a spectral distribution upper S left-parenthesis lamda right-parenthesis , its XYZ color space coordinates x Subscript lamda , y Subscript lamda , and z Subscript lamda are computed by integrating its product with the upper X left-parenthesis lamda right-parenthesis , upper Y left-parenthesis lamda right-parenthesis , and upper Z left-parenthesis lamda right-parenthesis spectral matching curves:

StartLayout 1st Row 1st Column x Subscript lamda 2nd Column equals StartFraction 1 Over integral Underscript lamda Endscripts upper Y left-parenthesis lamda right-parenthesis normal d lamda Subscript Baseline EndFraction integral Underscript lamda Endscripts upper S left-parenthesis lamda right-parenthesis upper X left-parenthesis lamda right-parenthesis normal d lamda Subscript Baseline 2nd Row 1st Column y Subscript lamda 2nd Column equals StartFraction 1 Over integral Underscript lamda Endscripts upper Y left-parenthesis lamda right-parenthesis normal d lamda Subscript Baseline EndFraction integral Underscript lamda Endscripts upper S left-parenthesis lamda right-parenthesis upper Y left-parenthesis lamda right-parenthesis normal d lamda Subscript Baseline 3rd Row 1st Column z Subscript lamda 2nd Column equals StartFraction 1 Over integral Underscript lamda Endscripts upper Y left-parenthesis lamda right-parenthesis normal d lamda Subscript Baseline EndFraction integral Underscript lamda Endscripts upper S left-parenthesis lamda right-parenthesis upper Z left-parenthesis lamda right-parenthesis normal d lamda Subscript Baseline period EndLayout

The CIE upper Y left-parenthesis lamda right-parenthesis tristimulus curve was chosen to be proportional to the upper V left-parenthesis lamda right-parenthesis spectral response curve used to define photometric quantities such as luminance in Equation (4.6). Their relationship is: upper V left-parenthesis lamda right-parenthesis equals 683 upper Y left-parenthesis lamda right-parenthesis .

Remarkably, spectra with substantially different distributions may have very similar x Subscript lamda , y Subscript lamda , and z Subscript lamda values. To the human observer, such spectra appear the same. Pairs of such spectra are called metamers.

Figure 4.19 shows a 3D plot of the curve in the XYZ space corresponding to the XYZ coefficients for single wavelengths of light over the visible range. The coefficients for more complex spectral distributions therefore correspond to linear combinations of points along this curve. Although all spectral distributions can be represented with XYZ coefficients, not all values of XYZ coefficients correspond to realizable spectra; such sets of coefficients are termed imaginary colors.

Figure 4.19: Plot of XYZ color coefficients for the wavelengths of light in the visible range. The curve is shaded with the RGB color associated with each wavelength.

Three functions in the Spectra namespace provide the CIE XYZ matching curves sampled at 1-nm increments from 360 nm to 830 nm.

<<Spectral Function Declarations>>+= 
namespace Spectra { const DenselySampledSpectrum &X(); const DenselySampledSpectrum &Y(); const DenselySampledSpectrum &Z(); }

The integral of upper Y left-parenthesis lamda right-parenthesis is precomputed and available in a constant.

<<Spectrum Constants>>+= 
static constexpr Float CIE_Y_integral = 106.856895;

There is also an XYZ class that represents XYZ colors.

<<XYZ Definition>>= 
class XYZ { public: <<XYZ Public Methods>> 
XYZ(Float X, Float Y, Float Z) : X(X), Y(Y), Z(Z) {} Float Average() const { return (X + Y + Z) / 3; } Point2f xy() const { return Point2f(X / (X + Y + Z), Y / (X + Y + Z)); } static XYZ FromxyY(Point2f xy, Float Y = 1) { if (xy.y == 0) return XYZ(0, 0, 0); return XYZ(xy.x * Y / xy.y, Y, (1 - xy.x - xy.y) * Y / xy.y); } PBRT_CPU_GPU XYZ &operator+=(const XYZ &s) { X += s.X; Y += s.Y; Z += s.Z; return *this; } PBRT_CPU_GPU XYZ operator+(const XYZ &s) const { XYZ ret = *this; return ret += s; } PBRT_CPU_GPU XYZ &operator-=(const XYZ &s) { X -= s.X; Y -= s.Y; Z -= s.Z; return *this; } PBRT_CPU_GPU XYZ operator-(const XYZ &s) const { XYZ ret = *this; return ret -= s; } PBRT_CPU_GPU friend XYZ operator-(Float a, const XYZ &s) { return {a - s.X, a - s.Y, a - s.Z}; } PBRT_CPU_GPU XYZ &operator*=(const XYZ &s) { X *= s.X; Y *= s.Y; Z *= s.Z; return *this; } PBRT_CPU_GPU XYZ operator*(const XYZ &s) const { XYZ ret = *this; return ret *= s; } PBRT_CPU_GPU XYZ operator*(Float a) const { DCHECK(!IsNaN(a)); return {a * X, a * Y, a * Z}; } PBRT_CPU_GPU XYZ &operator*=(Float a) { DCHECK(!IsNaN(a)); X *= a; Y *= a; Z *= a; return *this; } PBRT_CPU_GPU XYZ &operator/=(const XYZ &s) { X /= s.X; Y /= s.Y; Z /= s.Z; return *this; } PBRT_CPU_GPU XYZ operator/(const XYZ &s) const { XYZ ret = *this; return ret /= s; } PBRT_CPU_GPU XYZ &operator/=(Float a) { DCHECK(!IsNaN(a)); DCHECK_NE(a, 0); X /= a; Y /= a; Z /= a; return *this; } PBRT_CPU_GPU XYZ operator/(Float a) const { XYZ ret = *this; return ret /= a; } PBRT_CPU_GPU XYZ operator-() const { return {-X, -Y, -Z}; } PBRT_CPU_GPU bool operator==(const XYZ &s) const { return X == s.X && Y == s.Y && Z == s.Z; } PBRT_CPU_GPU bool operator!=(const XYZ &s) const { return X != s.X || Y != s.Y || Z != s.Z; } PBRT_CPU_GPU Float operator[](int c) const { DCHECK(c >= 0 && c < 3); if (c == 0) return X; else if (c == 1) return Y; return Z; } PBRT_CPU_GPU Float &operator[](int c) { DCHECK(c >= 0 && c < 3); if (c == 0) return X; else if (c == 1) return Y; return Z; } std::string ToString() const;
<<XYZ Public Members>> 
Float X = 0, Y = 0, Z = 0;
};

Its implementation is the obvious one, using three Float values to represent the three color components. All the regular arithmetic operations are provided for XYZ in methods that are not included in the text here.

<<XYZ Public Methods>>= 
XYZ(Float X, Float Y, Float Z) : X(X), Y(Y), Z(Z) {}

<<XYZ Public Members>>= 
Float X = 0, Y = 0, Z = 0;

The SpectrumToXYZ() function computes the XYZ coefficients of a spectral distribution following Equation (4.22) using the following InnerProduct() utility function to handle each component.

<<Spectrum Function Definitions>>= 
XYZ SpectrumToXYZ(Spectrum s) { return XYZ(InnerProduct(&Spectra::X(), s), InnerProduct(&Spectra::Y(), s), InnerProduct(&Spectra::Z(), s)) / CIE_Y_integral; }

Monte Carlo is not necessary for a simple 1D integral of two spectra, so InnerProduct() computes a Riemann sum over integer wavelengths instead:

integral Subscript lamda Subscript normal m normal i normal n Baseline Superscript lamda Subscript normal m normal a normal x Baseline Baseline f left-parenthesis lamda right-parenthesis g left-parenthesis lamda right-parenthesis normal d lamda Subscript Baseline almost-equals sigma-summation Underscript lamda equals lamda Subscript normal m normal i normal n Baseline Overscript lamda Subscript normal m normal a normal x Baseline Endscripts f left-parenthesis lamda right-parenthesis g left-parenthesis lamda right-parenthesis period

<<Spectrum Inline Functions>>= 
Float InnerProduct(Spectrum f, Spectrum g) { Float integral = 0; for (Float lambda = Lambda_min; lambda <= Lambda_max; ++lambda) integral += f(lambda) * g(lambda); return integral; }

It is also useful to be able to compute XYZ coefficients for a SampledSpectrum. Because SampledSpectrum only has point samples of the spectral distribution at predetermined wavelengths, they are found via a Monte Carlo estimate of Equation (4.22) using the sampled spectral values s Subscript i at wavelengths lamda Subscript i and their associated PDFs:

x Subscript lamda Baseline almost-equals StartFraction 1 Over integral Underscript lamda Endscripts upper Y left-parenthesis lamda right-parenthesis normal d lamda Subscript Baseline EndFraction left-parenthesis StartFraction 1 Over n EndFraction sigma-summation Underscript i equals 1 Overscript n Endscripts StartFraction s Subscript i Baseline upper X left-parenthesis lamda Subscript i Baseline right-parenthesis Over p left-parenthesis lamda Subscript i Baseline right-parenthesis EndFraction right-parenthesis comma

and so forth, where n is the number of wavelength samples.

SampledSpectrum::ToXYZ() computes the value of this estimator.

<<Spectrum Method Definitions>>+=  

The first step is to sample the matching curves at the specified wavelengths.

<<Sample the upper X , upper Y , and upper Z matching curves at lambda>>= 
SampledSpectrum X = Spectra::X().Sample(lambda); SampledSpectrum Y = Spectra::Y().Sample(lambda); SampledSpectrum Z = Spectra::Z().Sample(lambda);

The summand in Equation (4.23) is easily computed with values at hand. Here, we evaluate all terms of each sum with a single expression. Using SampledSpectrum::SafeDiv() to divide by the PDF values handles the case of the PDF being equal to zero for some wavelengths, as can happen if SampledWavelengths::TerminateSecondary() was called. Finally, SampledSpectrum::Average() conveniently takes care of summing the individual terms and dividing by n to compute the estimator’s value for each coefficient.

<<Evaluate estimator to compute left-parenthesis x comma y comma z right-parenthesis coefficients>>= 
SampledSpectrum pdf = lambda.PDF(); return XYZ(SafeDiv(X * *this, pdf).Average(), SafeDiv(Y * *this, pdf).Average(), SafeDiv(Z * *this, pdf).Average()) / CIE_Y_integral;

To avoid the expense of computing the upper X and upper Z coefficients when only luminance is needed, there is a y() method that only returns upper Y . Its implementation is the obvious subset of XYZ() and so is not included here.

Chromaticity and xyY Color

Color can be separated into lightness, which describes how bright it is relative to something white, and chroma, which describes its relative colorfulness with respect to white. One approach to quantifying chroma is the x y z chromaticity coordinates, which are defined in terms of XYZ color space coordinates by

StartLayout 1st Row 1st Column x 2nd Column equals StartFraction x Subscript lamda Baseline Over x Subscript lamda Baseline plus y Subscript lamda Baseline plus z Subscript lamda Baseline EndFraction 2nd Row 1st Column y 2nd Column equals StartFraction y Subscript lamda Baseline Over x Subscript lamda Baseline plus y Subscript lamda Baseline plus z Subscript lamda Baseline EndFraction 3rd Row 1st Column z 2nd Column equals StartFraction z Subscript lamda Baseline Over x Subscript lamda Baseline plus y Subscript lamda Baseline plus z Subscript lamda Baseline EndFraction equals 1 minus x minus y period EndLayout

Note that any two of them are sufficient to specify chromaticity.

Considering just x and y , we can plot a chromaticity diagram to visualize their values; see Figure 4.20. Spectra with light at just a single wavelength—the pure spectral colors—lie along the curved part of the chromaticity diagram. This part corresponds to the x y projection of the 3D XYZ curve that was shown in Figure 4.19. All the valid colors lie inside the upside-down horseshoe shape; points outside that region correspond to imaginary colors.

Figure 4.20: x y Chromaticity Diagram. All valid colors lie inside the shaded region.

The xyY color space separates a color’s chromaticity from its lightness. It uses the x and y chromaticity coordinates and y Subscript lamda from XYZ, since the upper Y left-parenthesis lamda right-parenthesis matching curve was defined to be proportional to luminance. pbrt makes limited use of xyY colors and therefore does not provide a class to represent them, but the XYZ class does provide a method that returns its x y chromaticity coordinates as a Point2f.

<<XYZ Public Methods>>+=  
Point2f xy() const { return Point2f(X / (X + Y + Z), Y / (X + Y + Z)); }

A corresponding method converts from xyY to XYZ, given x y and optionally y Subscript lamda coordinates.

<<XYZ Public Methods>>+= 
static XYZ FromxyY(Point2f xy, Float Y = 1) { if (xy.y == 0) return XYZ(0, 0, 0); return XYZ(xy.x * Y / xy.y, Y, (1 - xy.x - xy.y) * Y / xy.y); }

4.6.2 RGB Color

RGB color is used more commonly than XYZ in rendering applications. In RGB color spaces, colors are represented by a triplet of values corresponding to red, green, and blue colors, often referred to as RGB. However, an RGB triplet on its own is meaningless; it must be defined with respect to a specific RGB color space.

To understand why, consider what happens when an RGB color is shown on a display: the spectrum that is displayed is given by the weighted sum of three spectral emission curves, one for each of red, green, and blue, as emitted by the display elements, be they phosphors, LED or LCD elements, or plasma cells. Figure 4.21 plots the red, green, and blue distributions emitted by an LCD display and an LED display; note that they are remarkably different. Figure 4.22 in turn shows the spectral distributions that result from displaying the RGB color left-parenthesis 0.6 comma 0.3 comma 0.2 right-parenthesis on those displays. Not surprisingly, the resulting spectra are quite different as well.

Figure 4.21: Red, Green, and Blue Emission Curves for an LCD Display and an LED Display. The first plot shows the curves for an LCD display, and the second shows them for an LED. These two displays have quite different emission profiles. (Data courtesy of X-Rite, Inc.)

Figure 4.22: Spectral Distributions from Displaying the RGB Color left-parenthesis 0.6 comma 0.3 comma 0.2 right-parenthesis on LED (red) and LCD (blue) Displays. The resulting emitted distributions are remarkably different, even given the same RGB values, due to the different emission curves illustrated in Figure 4.21.

If a display’s upper R left-parenthesis lamda right-parenthesis , upper G left-parenthesis lamda right-parenthesis , and upper B left-parenthesis lamda right-parenthesis curves are known, the RGB coefficients for displaying a spectral distribution upper S left-parenthesis lamda right-parenthesis on that display can be found by integrating upper S left-parenthesis lamda right-parenthesis with each curve:

r equals integral upper R left-parenthesis lamda right-parenthesis upper S left-parenthesis lamda right-parenthesis normal d lamda Subscript Baseline comma

and so forth. The same approaches that were used to compute XYZ values for spectra in the previous section can be used to compute the values of these integrals.

Alternatively, if we already have the left-parenthesis x Subscript lamda Baseline comma y Subscript lamda Baseline comma z Subscript lamda Baseline right-parenthesis representation of upper S left-parenthesis lamda right-parenthesis , it is possible to convert the XYZ coefficients directly to corresponding RGB coefficients. Consider, for example, computing the value of the red component for a spectral distribution upper S left-parenthesis lamda right-parenthesis :

StartLayout 1st Row 1st Column r 2nd Column equals integral upper R left-parenthesis lamda right-parenthesis upper S left-parenthesis lamda right-parenthesis normal d lamda Subscript Baseline 2nd Row 1st Column Blank 2nd Column almost-equals integral upper R left-parenthesis lamda right-parenthesis left-parenthesis x Subscript lamda Baseline upper X left-parenthesis lamda right-parenthesis plus y Subscript lamda Baseline upper Y left-parenthesis lamda right-parenthesis plus z Subscript lamda Baseline upper Z left-parenthesis lamda right-parenthesis right-parenthesis normal d lamda Subscript Baseline 3rd Row 1st Column Blank 2nd Column equals x Subscript lamda Baseline integral upper R left-parenthesis lamda right-parenthesis upper X left-parenthesis lamda right-parenthesis normal d lamda Subscript Baseline plus y Subscript lamda Baseline integral upper R left-parenthesis lamda right-parenthesis upper Y left-parenthesis lamda right-parenthesis normal d lamda Subscript Baseline plus z Subscript lamda Baseline integral upper R left-parenthesis lamda right-parenthesis upper Z left-parenthesis lamda right-parenthesis normal d lamda Subscript Baseline comma EndLayout

where the second step takes advantage of the tristimulus theory of color perception.

The integrals of the products of an RGB response function and XYZ matching function can be precomputed for given response curves, making it possible to express the full conversion as a matrix:

Start 3 By 1 Matrix 1st Row r 2nd Row g 3rd Row b EndMatrix equals Start 3 By 3 Matrix 1st Row 1st Column integral upper R left-parenthesis lamda right-parenthesis upper X left-parenthesis lamda right-parenthesis normal d lamda Subscript Baseline 2nd Column integral upper R left-parenthesis lamda right-parenthesis upper Y left-parenthesis lamda right-parenthesis normal d lamda Subscript Baseline 3rd Column integral upper R left-parenthesis lamda right-parenthesis upper Z left-parenthesis lamda right-parenthesis normal d lamda Subscript Baseline 2nd Row 1st Column integral upper G left-parenthesis lamda right-parenthesis upper X left-parenthesis lamda right-parenthesis normal d lamda Subscript Baseline 2nd Column integral upper G left-parenthesis lamda right-parenthesis upper Y left-parenthesis lamda right-parenthesis normal d lamda Subscript Baseline 3rd Column integral upper G left-parenthesis lamda right-parenthesis upper Z left-parenthesis lamda right-parenthesis normal d lamda Subscript Baseline 3rd Row 1st Column integral upper B left-parenthesis lamda right-parenthesis upper X left-parenthesis lamda right-parenthesis normal d lamda Subscript Baseline 2nd Column integral upper B left-parenthesis lamda right-parenthesis upper Y left-parenthesis lamda right-parenthesis normal d lamda Subscript Baseline 3rd Column integral upper B left-parenthesis lamda right-parenthesis upper Z left-parenthesis lamda right-parenthesis normal d lamda Subscript Baseline EndMatrix Start 3 By 1 Matrix 1st Row x Subscript lamda Baseline 2nd Row y Subscript lamda Baseline 3rd Row z Subscript lamda Baseline EndMatrix period

pbrt frequently uses this approach in order to efficiently convert colors from one color space to another.

An RGB class that has the obvious representation and provides a variety of useful arithmetic operations (not included in the text) is also provided by pbrt.

<<RGB Definition>>= 
class RGB { public: <<RGB Public Methods>> 
RGB(Float r, Float g, Float b) : r(r), g(g), b(b) {} PBRT_CPU_GPU RGB &operator+=(RGB s) { r += s.r; g += s.g; b += s.b; return *this; } PBRT_CPU_GPU RGB operator+(RGB s) const { RGB ret = *this; return ret += s; } PBRT_CPU_GPU RGB &operator-=(RGB s) { r -= s.r; g -= s.g; b -= s.b; return *this; } PBRT_CPU_GPU RGB operator-(RGB s) const { RGB ret = *this; return ret -= s; } PBRT_CPU_GPU friend RGB operator-(Float a, RGB s) { return {a - s.r, a - s.g, a - s.b}; } PBRT_CPU_GPU RGB &operator*=(RGB s) { r *= s.r; g *= s.g; b *= s.b; return *this; } PBRT_CPU_GPU RGB operator*(RGB s) const { RGB ret = *this; return ret *= s; } PBRT_CPU_GPU RGB operator*(Float a) const { DCHECK(!IsNaN(a)); return {a * r, a * g, a * b}; } PBRT_CPU_GPU RGB &operator*=(Float a) { DCHECK(!IsNaN(a)); r *= a; g *= a; b *= a; return *this; } PBRT_CPU_GPU friend RGB operator*(Float a, RGB s) { return s * a; } PBRT_CPU_GPU RGB &operator/=(RGB s) { r /= s.r; g /= s.g; b /= s.b; return *this; } PBRT_CPU_GPU RGB operator/(RGB s) const { RGB ret = *this; return ret /= s; } PBRT_CPU_GPU RGB &operator/=(Float a) { DCHECK(!IsNaN(a)); DCHECK_NE(a, 0); r /= a; g /= a; b /= a; return *this; } PBRT_CPU_GPU RGB operator/(Float a) const { RGB ret = *this; return ret /= a; } PBRT_CPU_GPU RGB operator-() const { return {-r, -g, -b}; } PBRT_CPU_GPU Float Average() const { return (r + g + b) / 3; } PBRT_CPU_GPU bool operator==(RGB s) const { return r == s.r && g == s.g && b == s.b; } PBRT_CPU_GPU bool operator!=(RGB s) const { return r != s.r || g != s.g || b != s.b; } PBRT_CPU_GPU Float operator[](int c) const { DCHECK(c >= 0 && c < 3); if (c == 0) return r; else if (c == 1) return g; return b; } PBRT_CPU_GPU Float &operator[](int c) { DCHECK(c >= 0 && c < 3); if (c == 0) return r; else if (c == 1) return g; return b; } std::string ToString() const;
<<RGB Public Members>> 
Float r = 0, g = 0, b = 0;
};

<<RGB Public Methods>>= 
RGB(Float r, Float g, Float b) : r(r), g(g), b(b) {}

<<RGB Public Members>>= 
Float r = 0, g = 0, b = 0;

4.6.3 RGB Color Spaces

Full spectral response curves are not necessary to define color spaces. For example, a color space can be defined using x y chromaticity coordinates to specify three color primaries. From them, it is possible to derive matrices that convert XYZ colors to and from that color space. In cases where we do not otherwise need explicit spectral response curves, this is a convenient way to specify a color space.

The RGBColorSpace class, which is defined in the files util/colorspace.h and util/colorspace.cpp, uses this approach to encapsulate a representation of an RGB color space as well as a variety of useful operations like converting XYZ colors to and from its color space.

<<RGBColorSpace Definition>>= 
class RGBColorSpace { public: <<RGBColorSpace Public Methods>> 
RGBColorSpace(Point2f r, Point2f g, Point2f b, Spectrum illuminant, const RGBToSpectrumTable *rgbToSpectrumTable, Allocator alloc); PBRT_CPU_GPU RGBSigmoidPolynomial ToRGBCoeffs(RGB rgb) const; static void Init(Allocator alloc); <<RGBColorSpace Public Members>> 
Point2f r, g, b, w; DenselySampledSpectrum illuminant; SquareMatrix<3> XYZFromRGB, RGBFromXYZ; static const RGBColorSpace *sRGB, *DCI_P3, *Rec2020, *ACES2065_1;
PBRT_CPU_GPU bool operator==(const RGBColorSpace &cs) const { return (r == cs.r && g == cs.g && b == cs.b && w == cs.w && rgbToSpectrumTable == cs.rgbToSpectrumTable); } PBRT_CPU_GPU bool operator!=(const RGBColorSpace &cs) const { return (r != cs.r || g != cs.g || b != cs.b || w != cs.w || rgbToSpectrumTable != cs.rgbToSpectrumTable); } std::string ToString() const; RGB LuminanceVector() const { return RGB(XYZFromRGB[1][0], XYZFromRGB[1][1], XYZFromRGB[1][2]); } RGB ToRGB(XYZ xyz) const { return Mul<RGB>(RGBFromXYZ, xyz); } XYZ ToXYZ(RGB rgb) const { return Mul<XYZ>(XYZFromRGB, rgb); } static const RGBColorSpace *GetNamed(std::string name); static const RGBColorSpace *Lookup(Point2f r, Point2f g, Point2f b, Point2f w);
private: <<RGBColorSpace Private Members>> 
const RGBToSpectrumTable *rgbToSpectrumTable;
};

An RGB color space is defined using the chromaticities of red, green, and blue color primaries. The primaries define the gamut of the color space, which is the set of colors it can represent with RGB values between 0 and 1. For three primaries, the gamut forms a triangle on the chromaticity diagram where each primary’s chromaticity defines one of the vertices.

In addition to the primaries, it is necessary to specify the color space’s whitepoint, which is the color that is displayed when all three primaries are activated to their maximum emission. It may be surprising that this is necessary—after all, should not white correspond to a spectral distribution with the same value at every wavelength? White is, however, a color, and as a color it is what humans perceive as being uniform and label “white.” The spectra for white colors tend to have more power in the lower wavelengths that correspond to blues and greens than they do at higher wavelengths that correspond to oranges and reds. The D65 illuminant, which was described in Section 4.4.2 and plotted in Figure 4.14, is a common choice for specifying color spaces’ whitepoints.

While the chromaticities of the whitepoint are sufficient to define a color space, the RGBColorSpace constructor takes its full spectral distribution, which is useful for forthcoming code that converts from color to spectral distributions. Storing the illuminant spectrum allows users of the renderer to specify emission from light sources using RGB color; the provided illuminant then gives the spectral distribution for RGB white, left-parenthesis 1 comma 1 comma 1 right-parenthesis .

<<RGBColorSpace Method Definitions>>= 
RGBColorSpace::RGBColorSpace(Point2f r, Point2f g, Point2f b, Spectrum illuminant, const RGBToSpectrumTable *rgbToSpec, Allocator alloc) : r(r), g(g), b(b), illuminant(illuminant, alloc), rgbToSpectrumTable(rgbToSpec) { <<Compute whitepoint primaries and XYZ coordinates>> 
XYZ W = SpectrumToXYZ(illuminant); w = W.xy(); XYZ R = XYZ::FromxyY(r), G = XYZ::FromxyY(g), B = XYZ::FromxyY(b);
<<Initialize XYZ color space conversion matrices>> 
SquareMatrix<3> rgb(R.X, G.X, B.X, R.Y, G.Y, B.Y, R.Z, G.Z, B.Z); XYZ C = InvertOrExit(rgb) * W; XYZFromRGB = rgb * SquareMatrix<3>::Diag(C[0], C[1], C[2]); RGBFromXYZ = InvertOrExit(XYZFromRGB);
}

RGBColorSpace represents the illuminant as a DenselySampledSpectrum for efficient lookups by wavelength.

<<RGBColorSpace Public Members>>= 
Point2f r, g, b, w; DenselySampledSpectrum illuminant;

RGBColorSpaces also store a pointer to an RGBToSpectrumTable class that stores information related to converting RGB values in the color space to full spectral distributions; it will be introduced shortly, in Section 4.6.6.

<<RGBColorSpace Private Members>>= 
const RGBToSpectrumTable *rgbToSpectrumTable;

To find RGB values in the color space, it is useful to be able to convert to and from XYZ. This can be done using 3 times 3 matrices. To compute them, we will require the XYZ coordinates of the chromaticities and the whitepoint.

<<Compute whitepoint primaries and XYZ coordinates>>= 
XYZ W = SpectrumToXYZ(illuminant); w = W.xy(); XYZ R = XYZ::FromxyY(r), G = XYZ::FromxyY(g), B = XYZ::FromxyY(b);

We will first derive the matrix upper M that transforms from RGB coefficients in the color space to XYZ:

Start 3 By 1 Matrix 1st Row x Subscript lamda Baseline 2nd Row y Subscript lamda Baseline 3rd Row z Subscript lamda Baseline EndMatrix equals upper M Start 3 By 1 Matrix 1st Row r 2nd Row g 3rd Row b EndMatrix period

This matrix can be found by considering the relationship between the RGB triplet left-parenthesis 1 comma 1 comma 1 right-parenthesis and the whitepoint in XYZ coordinates, which is available in W. In this case, we know that w Subscript x Sub Subscript lamda must be proportional to the sum of the x Subscript lamda coordinates of the red, green, and blue primaries, since we are considering the case of a left-parenthesis 1 comma 1 comma 1 right-parenthesis RGB. The same follows for y Subscript lamda and z Subscript lamda . This relationship can be expressed as

Start 3 By 1 Matrix 1st Row w Subscript x Sub Subscript lamda Subscript Baseline 2nd Row w Subscript y Sub Subscript lamda Subscript Baseline 3rd Row w Subscript z Sub Subscript lamda Subscript Baseline EndMatrix equals Start 3 By 3 Matrix 1st Row 1st Column r Subscript x Sub Subscript lamda Subscript Baseline 2nd Column g Subscript x Sub Subscript lamda Subscript Baseline 3rd Column b Subscript x Sub Subscript lamda Subscript Baseline 2nd Row 1st Column r Subscript y Sub Subscript lamda Subscript Baseline 2nd Column g Subscript y Sub Subscript lamda Subscript Baseline 3rd Column b Subscript y Sub Subscript lamda Subscript Baseline 3rd Row 1st Column r Subscript z Sub Subscript lamda Subscript Baseline 2nd Column g Subscript z Sub Subscript lamda Subscript Baseline 3rd Column b Subscript z Sub Subscript lamda Subscript Baseline EndMatrix Start 3 By 3 Matrix 1st Row 1st Column c Subscript r Baseline 2nd Column 0 3rd Column 0 2nd Row 1st Column 0 2nd Column c Subscript g Baseline 3rd Column 0 3rd Row 1st Column 0 2nd Column 0 3rd Column c Subscript b Baseline EndMatrix Start 3 By 1 Matrix 1st Row 1 2nd Row 1 3rd Row 1 EndMatrix equals Start 3 By 3 Matrix 1st Row 1st Column r Subscript x Sub Subscript lamda Subscript Baseline 2nd Column g Subscript x Sub Subscript lamda Subscript Baseline 3rd Column b Subscript x Sub Subscript lamda Subscript Baseline 2nd Row 1st Column r Subscript y Sub Subscript lamda Subscript Baseline 2nd Column g Subscript y Sub Subscript lamda Subscript Baseline 3rd Column b Subscript y Sub Subscript lamda Subscript Baseline 3rd Row 1st Column r Subscript z Sub Subscript lamda Subscript Baseline 2nd Column g Subscript z Sub Subscript lamda Subscript Baseline 3rd Column b Subscript z Sub Subscript lamda Subscript Baseline EndMatrix Start 3 By 1 Matrix 1st Row c Subscript r Baseline 2nd Row c Subscript g Baseline 3rd Row c Subscript b Baseline EndMatrix comma

which only has unknowns c Subscript r , c Subscript g , and c Subscript b . These can be found by multiplying the whitepoint XYZ coordinates by the inverse of the remaining matrix. Inverting this matrix then gives the matrix that goes to RGB from XYZ.

<<Initialize XYZ color space conversion matrices>>= 
SquareMatrix<3> rgb(R.X, G.X, B.X, R.Y, G.Y, B.Y, R.Z, G.Z, B.Z); XYZ C = InvertOrExit(rgb) * W; XYZFromRGB = rgb * SquareMatrix<3>::Diag(C[0], C[1], C[2]); RGBFromXYZ = InvertOrExit(XYZFromRGB);

<<RGBColorSpace Public Members>>+=  
SquareMatrix<3> XYZFromRGB, RGBFromXYZ;

Given a color space’s XYZ/RGB conversion matrices, a matrix-vector multiplication is sufficient to convert any XYZ triplet into the color space and to convert any RGB in the color space to XYZ.

<<RGBColorSpace Public Methods>>= 
RGB ToRGB(XYZ xyz) const { return Mul<RGB>(RGBFromXYZ, xyz); } XYZ ToXYZ(RGB rgb) const { return Mul<XYZ>(XYZFromRGB, rgb); }

Furthermore, it is easy to compute a matrix that converts from one color space to another by using these matrices and converting by way of XYZ colors.

<<RGBColorSpace Method Definitions>>+=  
SquareMatrix<3> ConvertRGBColorSpace(const RGBColorSpace &from, const RGBColorSpace &to) { if (from == to) return {}; return to.RGBFromXYZ * from.XYZFromRGB; }

SampledSpectrum provides a convenience method that converts to RGB in a given color space, again via XYZ.

<<Spectrum Method Definitions>>+=  
RGB SampledSpectrum::ToRGB(const SampledWavelengths &lambda, const RGBColorSpace &cs) const { XYZ xyz = ToXYZ(lambda); return cs.ToRGB(xyz); }

Standard Color Spaces

There are a number of widely used standard color spaces for which pbrt includes built-in support. A few examples include:

  • sRGB, which was developed in the 1990s and was widely used for monitors for many years. One of the original motivations for its development was to standardize color on the web.
  • DCI-P3, which was developed for digital film projection and covers a wider gamut than sRGB. At the time of writing, it is increasingly being adopted for computer displays and mobile phones.
  • Rec2020, which covers an even wider gamut, and is used in the UHDTV television standard.
  • ACES2065-1, which has primaries that are outside of the representable colors and are placed so that all colors can be represented by it. One reason for this choice was for it to be suitable as a format for long-term archival storage.

The gamuts of each are shown in Figure 4.23.

Figure 4.23: The gamuts of the sRGB, DCI-P3, Rec2020, and ACES2065-1 color spaces, visualized using the chromaticity diagram. sRGB covers the smallest gamut, DCI-P3 the next largest, Rec2020 an even larger one. ACES2065-1, which corresponds to the large triangle, is distinguished by using primaries that correspond to imaginary colors. In doing so, it is able to represent all valid colors, unlike the others.

The RGBColorSpace class provides pre-initialized instances of the RGBColorSpaces for each of these.

<<RGBColorSpace Public Members>>+= 
static const RGBColorSpace *sRGB, *DCI_P3, *Rec2020, *ACES2065_1;

It is also possible to look color spaces up by name or by specifying the chromaticity of primaries and a whitepoint.

<<RGBColorSpace Public Methods>>+= 
static const RGBColorSpace *GetNamed(std::string name); static const RGBColorSpace *Lookup(Point2f r, Point2f g, Point2f b, Point2f w);

4.6.4 Why Spectral Rendering?

Thus far, we have been proceeding with the description of pbrt’s implementation with the understanding that it uses point-sampled spectra to represent spectral quantities. While that may seem natural given pbrt’s physical basis and general adoption of Monte Carlo integration, it does not fit with the current widespread practice of using RGB color for spectral computations in rendering. We hinted at a significant problem with that practice at the start of this section; having introduced RGB color spaces, we can now go farther.

As discussed earlier, because color spaces are vector spaces, addition of two colors in the same color space gives the same color as adding the underlying spectra and then finding the resulting spectrum’s color. That is not so for multiplication. To understand the problem, suppose that we are rendering a uniformly colored object (e.g., green) that is uniformly illuminated by light of the same color. For simplicity, assume that both illumination and the object’s reflectance value are represented by the RGB color left-parenthesis 0 comma 1 comma 0 right-parenthesis . The scattered light is then given by a product of reflectance and incident illumination:

Start 3 By 1 Matrix 1st Row 0 2nd Row 1 3rd Row 0 EndMatrix circled-dot Start 3 By 1 Matrix 1st Row 0 2nd Row 1 3rd Row 0 EndMatrix equals Start 3 By 1 Matrix 1st Row 0 2nd Row 1 3rd Row 0 EndMatrix comma

where componentwise multiplication of RGB colors is indicated by the “ circled-dot ” operator.

In the sRGB color space, the green color left-parenthesis 0 comma 1 comma 0 right-parenthesis maps to the upper vertex of the gamut of representable colors (Figure 4.24), and this RGB color value furthermore remains unchanged by the multiplication.

Now suppose that we change to the wide-gamut color space ACES2065-1. The sRGB color left-parenthesis 0 comma 1 comma 0 right-parenthesis can be found to be left-parenthesis 0.38 comma 0.82 comma 0.12 right-parenthesis in this color space—it thus maps to a location that lies in the interior of the set of representable colors. Performing the same component-wise multiplication gives the result:

Start 3 By 1 Matrix 1st Row 0.38 2nd Row 0.82 3rd Row 0.12 EndMatrix circled-dot Start 3 By 1 Matrix 1st Row 0.38 2nd Row 0.82 3rd Row 0.12 EndMatrix almost-equals Start 3 By 1 Matrix 1st Row 0.14 2nd Row 0.67 3rd Row 0.01 EndMatrix

This time, the resulting color has lower intensity than it started with and has also become more saturated due to an increase in the relative proportion of green light. That leads to the somewhat bizarre situation shown in Figure 4.24: component-wise multiplication in this new color space not only produces a different color—it also increases saturation so severely that the color is pushed outside of the CIE horseshoe shape of physically realizable colors!

Figure 4.24: The same color can have very different RGB values when expressed in RGB color spaces with differently shaped gamuts. The green primary left-parenthesis 0 comma 1 comma 0 right-parenthesis in the sRGB color gamut (inner triangle) has chromaticity coordinates left-parenthesis 0.3 comma 0.6 right-parenthesis (white dot). In the wide-gamut ACES2065-1 color space (outer triangle), the same color has the RGB value left-parenthesis 0.38 comma 0.82 comma 0.12 right-parenthesis .

The ability to multiply spectral values is crucial for evaluating the interaction of materials and light sources in the context of rendering. At the same time, this example demonstrates the problem when RGB values are used for this purpose: the multiplication operation is in some sense arbitrary, because its behavior heavily depends on the chosen color space. Thus, rendering using a spectral model is preferable even in situations where RGB output is ultimately desired, as is the case with pbrt.

Additional benefits come from using spectral representations for rendering: they allow dispersion to easily be modeled and advanced reflectance models often have a natural dependence on wavelength to account for iridescence in thin layers or diffraction from surface microstructure.

4.6.5 Choosing the Number of Wavelength Samples

Even though it uses a spectral model for light transport simulation, pbrt’s output is generally an image in a tristimulus color representation like RGB. Having described how those colors are computed—Monte Carlo estimates of the products of spectra and matching functions of the form of Equation (4.23)—we will briefly return to the question of how many spectral samples are used for the SampledSpectrum class. The associated Monte Carlo estimators are easy to evaluate, but error in them leads to color noise in images. Figure 4.25 shows an example of this phenomenon.

Figure 4.25: (a) Reference image of the example scene. (b) If the scene is rendered using only a single image sample per pixel, each sampling only a single wavelength, there is a substantial amount of variance from error in the Monte Carlo estimates of the pixels’ RGB colors. (c) With four wavelength samples (pbrt’s default), this variance is substantially reduced, though color noise is still evident. In practice, four wavelength samples is usually sufficient since multiple image samples are generally taken at each pixel. (Model courtesy of Yasutoshi Mori.)

Figure 4.25(a) shows a scene illuminated by a point light source where only direct illumination from the light is included. In this simple setting, the Monte Carlo estimator for the scattered light has zero variance at all wavelengths, so the only source of Monte Carlo error is the integrals of the color matching functions. With a single ray path per pixel and each one tracking a single wavelength, the image is quite noisy, as shown in Figure 4.25(b). Intuitively, the challenge in this case can be understood from the fact that the renderer is trying to estimate three values at each pixel—red, green, and blue—all from the spectral value at a single wavelength.

Increasing the number of pixel samples can reduce this error (as long as they sample different wavelengths), though it is more effective to associate multiple wavelength samples with each ray. The path that a ray takes through the scene is usually independent of wavelength and the incremental cost to compute lighting at multiple wavelengths is generally small compared to the cost of finding ray intersections and computing other wavelength-independent quantities. (Considering multiple wavelengths for each ray can be seen as an application of the Monte Carlo splitting technique that is described in Section 2.2.5.) Figure 4.25(c) shows the improvement from associating four wavelengths with each ray; color noise is substantially reduced.

Figure 4.26: (a) Rendering time when rendering the scene in Figure 4.25 graphed as a function of the number of wavelength samples, normalized to rendering time with one wavelength sample. (b) Mean squared error as a function of number of wavelength samples for both independent and stratified samples. (c) Monte Carlo efficiency as a function of number of stratified wavelength samples. These results suggest that at least 32 wavelength samples are optimal.

However, computing scattering from too many wavelengths with each ray can harm efficiency due to the increased computation required to compute spectral quantities. To investigate this trade-off, we rendered the scene from Figure 4.25 with a variety of numbers of wavelength samples, both with wavelengths sampled independently and with stratified sampling of wavelengths. (For both, wavelengths were sampled uniformly over the range 360–830 nm. ) Figure 4.26 shows the results.

Figure 4.26(a) shows that for this scene, rendering with 32 wavelength samples requires nearly 1.6 times more time than rendering with a single wavelength sample. (Rendering performance with both independent and stratified sampling is effectively the same.) However, as shown in Figure 4.26(b), the benefit of more wavelength samples is substantial. On the log–log plot there, we can see that with independent samples, mean squared error decreases at a rate upper O left-parenthesis 1 slash n right-parenthesis , in line with the rate at which variance decreases with more samples. Stratified sampling does remarkably well, not only delivering orders of magnitude lower error, but at a faster asymptotic convergence rate as well.

Figure 4.26(c) plots Monte Carlo efficiency for both approaches (note, with a logarithmic scale for the y axis). The result seems clear; 32 stratified wavelength samples is over a million times more efficient than one sample and there the curve has not yet leveled off. Why stop measuring at 32, and why is pbrt stuck with a default of four wavelength samples for its NSpectrumSamples parameter?

Figure 4.27: (a) A more complex scene, where variance in the Monte Carlo estimator is present from a variety of sources beyond wavelength sampling. (b) Graph of mean squared error versus the number of stratified wavelength samples. The benefits of additional wavelength samples are limited after six of them. (c) Monte Carlo efficiency versus number of stratified wavelength samples, normalized to efficiency with one wavelength sample. For this scene, eight samples is optimal.

There are three main reasons for the current setting. First, although Figure 4.26(a) shows nearly a 500 times reduction in error from 8 to 32 wavelength samples, the two images are nearly indistinguishable—the difference in error is irrelevant due to limitations in display technology and the human visual system. Second, scenes are usually rendered following multiple ray paths in each pixel in order to reduce error from other Monte Carlo estimators. As more pixel samples are taken with fewer wavelengths, the total number of wavelengths that contribute to each pixel’s value increases.

Finally, and most importantly, those other sources of Monte Carlo error often make larger contributions to the overall error than wavelength sampling. Figure 4.27(a) shows a much more complex scene with challenging lighting that is sampled using Monte Carlo. A graph of mean squared error as a function of the number of wavelength samples is shown in Figure 4.27(b) and Monte Carlo efficiency is shown in Figure 4.27(c). It is evident that after eight wavelength samples, the incremental cost of more of them is not beneficial.

4.6.6 From RGB to Spectra

Although converting spectra to RGB for image output is a well-specified operation, the same is not true for converting RGB colors to spectral distributions. That is an important task, since much of the input to a renderer is often in the form of RGB colors. Scenes authored in current 3D modeling tools normally specify objects’ reflection properties and lights’ emission using RGB parameters and textures. In a spectral renderer, these RGB values must somehow be converted into equivalent color spectra, but unfortunately any such conversion is inherently ambiguous due to the existence of metamers. How we can expect to find a reasonable solution if the problem is so poorly defined? On the flip side, this ambiguity can also be seen positively: it leaves a large space of possible answers containing techniques that are simple and efficient.

Figure 4.28: Spectral reflectances of several color checker patches. Each curve is shaded with the associated RGB color.

Further complicating this task, we must account for three fundamentally different types of spectral distributions:

  • Illuminant spectra, which specify the spectral dependence of a light source’s emission profile. These are nonnegative and unbounded; their shapes range from smooth (incandescent light sources, LEDs) to extremely spiky (stimulated emission in lasers or gas discharge in xenon arc and fluorescent lamps).
  • Reflectance spectra, which describe reflection from absorbing surfaces. Reflectance spectra conserve the amount of energy at each wavelength, meaning that values cannot be outside of the left-bracket 0 comma 1 right-bracket range. They are typically smooth functions in the visible wavelength range. (Figure 4.28 shows a few examples of reflectance spectra from a color checker.)
  • Unbounded spectra, which are nonnegative and unbounded but do not describe emission. Common examples include spectrally varying indices of refraction and coefficients used to describe medium scattering properties.

This section first presents an approach for converting an RGB color value with components between 0 and 1 into a corresponding reflectance spectrum, followed by a generalization to unbounded and illuminant spectra. The conversion exploits the ambiguity of the problem to achieve the following goals:

  • Identity: If an RGB value is converted to a spectrum, converting that spectrum back to RGB should give the same RGB coefficients.
  • Smoothness: Motivated by the earlier observation about real-world reflectance spectra, the output spectrum should be as smooth as possible. Another kind of smoothness is also important: slight perturbations of the input RGB color should lead to a corresponding small change of the output spectrum. Discontinuities are undesirable, since they would cause visible seams on textured objects if observed under different illuminants.
  • Energy conservation: Given RGB values in left-bracket 0 comma 1 right-bracket , the associated spectral distribution should also be within left-bracket 0 comma 1 right-bracket .

Although real-world reflectance spectra exist in a wide variety of shapes, they are often well-approximated by constant (white, black), approximately linear, or peaked curves with one (green, yellow) or two modes (bluish-purple).

The approach chosen here attempts to represent such spectra using a function family that is designed to be simple, smooth, and efficient to evaluate at runtime, while exposing a sufficient number of degrees of freedom to precisely reproduce arbitrary RGB color values.

Polynomials are typically a standard building block in such constructions; indeed, a quadratic polynomial could represent constant and linear curves, as well as ones that peak in the middle or toward the endpoints of the wavelength range. However, their lack of energy conservation poses a problem that we address using a sigmoid function:

s left-parenthesis x right-parenthesis equals one-half plus StartFraction x Over 2 StartRoot 1 plus x squared EndRoot EndFraction period

This function, plotted in Figure 4.29, is strictly monotonic and smoothly approaches the endpoints 0 and 1 as x right-arrow minus-or-plus normal infinity .

Figure 4.29: Sigmoid curve. The term sigmoid refers to smooth S-shaped curves that map all inputs to a bounded output interval. The particular type of sigmoid used here is defined in terms of algebraic functions, enabling highly efficient evaluation at runtime.

We apply this sigmoid to a quadratic polynomial defined by three coefficients c Subscript i , squashing its domain to the interval left-bracket 0 comma 1 right-bracket to ensure energy conservation.

upper S left-parenthesis lamda right-parenthesis equals s left-parenthesis c 0 lamda squared plus c 1 lamda plus c 2 right-parenthesis period

Representing ideally absorptive and reflective spectra (i.e., upper S left-parenthesis lamda right-parenthesis equals 0 or 1 ) is somewhat awkward using this representation, since the polynomial must evaluate to positive or negative infinity to reach these two limits. This in turn leads to a fraction of the form plus-or-minus normal infinity slash normal infinity in Equation (4.25), which evaluates to a not-a-number value in IEEE-754 arithmetic. We will need to separately handle this limit case.

We begin with the definition of a class that encapsulates the coefficients c Subscript i and evaluates Equation (4.26).

<<RGBSigmoidPolynomial Definition>>= 
class RGBSigmoidPolynomial { public: <<RGBSigmoidPolynomial Public Methods>> 
RGBSigmoidPolynomial(Float c0, Float c1, Float c2) : c0(c0), c1(c1), c2(c2) {} Float operator()(Float lambda) const { return s(EvaluatePolynomial(lambda, c2, c1, c0)); } Float MaxValue() const { Float result = std::max((*this)(360), (*this)(830)); Float lambda = -c1 / (2 * c0); if (lambda >= 360 && lambda <= 830) result = std::max(result, (*this)(lambda)); return result; }
private: <<RGBSigmoidPolynomial Private Methods>> 
static Float s(Float x) { if (IsInf(x)) return x > 0 ? 1 : 0; return .5f + x / (2 * std::sqrt(1 + Sqr(x))); };
<<RGBSigmoidPolynomial Private Members>> 
Float c0, c1, c2;
};

It has the expected constructor and member variables.

<<RGBSigmoidPolynomial Public Methods>>= 
RGBSigmoidPolynomial(Float c0, Float c1, Float c2) : c0(c0), c1(c1), c2(c2) {}

<<RGBSigmoidPolynomial Private Members>>= 
Float c0, c1, c2;

Given coefficient values, it is easy to evaluate the spectral function at a specified wavelength.

<<RGBSigmoidPolynomial Public Methods>>+=  
Float operator()(Float lambda) const { return s(EvaluatePolynomial(lambda, c2, c1, c0)); }

The sigmoid function follows the earlier definition and adds a special case to handle positive and negative infinity.

<<RGBSigmoidPolynomial Private Methods>>= 
static Float s(Float x) { if (IsInf(x)) return x > 0 ? 1 : 0; return .5f + x / (2 * std::sqrt(1 + Sqr(x))); };

The MaxValue() method returns the maximum value of the spectral distribution over the visible wavelength range 360–830 nm. Because the sigmoid function is monotonically increasing, this problem reduces to locating the maximum of the quadratic polynomial from Equation (4.25) and evaluating the model there.

We conservatively check the endpoints of the interval along with the extremum found by setting the polynomial’s derivative to zero and solving for the wavelength lambda. The value will be ignored if it happens to be a local minimum.

<<RGBSigmoidPolynomial Public Methods>>+= 
Float MaxValue() const { Float result = std::max((*this)(360), (*this)(830)); Float lambda = -c1 / (2 * c0); if (lambda >= 360 && lambda <= 830) result = std::max(result, (*this)(lambda)); return result; }

We now turn to the second half of RGBSigmoidPolynomial, which is the computation that determines suitable coefficients c 0 comma c 1 comma c 2 for a given RGB color. This step depends on the spectral emission curves of the color primaries and generally does not have an explicit solution. We instead formulate it as an optimization problem that minimizes the round-trip error (i.e., the identity goal mentioned above) by computing the difference between input and output RGB values following forward and reverse conversion. The precise optimization goal is

left-parenthesis c 0 Superscript asterisk Baseline comma c 1 Superscript asterisk Baseline comma c 2 Superscript asterisk Baseline right-parenthesis equals normal a normal r normal g normal m normal i normal n Underscript c 0 comma c 1 comma c 2 Endscripts double-vertical-bar Start 3 By 1 Matrix 1st Row r 2nd Row g 3rd Row b EndMatrix minus integral Start 3 By 1 Matrix 1st Row upper R left-parenthesis lamda right-parenthesis 2nd Row upper G left-parenthesis lamda right-parenthesis 3rd Row upper B left-parenthesis lamda right-parenthesis EndMatrix upper S left-parenthesis lamda comma c 0 comma c 1 comma c 2 right-parenthesis upper W left-parenthesis lamda right-parenthesis normal d lamda double-vertical-bar comma

where upper R left-parenthesis lamda right-parenthesis comma upper G left-parenthesis lamda right-parenthesis comma upper B left-parenthesis lamda right-parenthesis describe emission curves of the color primaries and upper W left-parenthesis lamda right-parenthesis represents the whitepoint (e.g., D65 shown in Figure 4.14 in the case of the sRGB color space). Including the whitepoint in this optimization problem ensures that monochromatic RGB values map to uniform reflectance spectra.

In spaces with a relatively compact gamut like sRGB, this optimization can achieve zero error regardless of the method used to quantify color distances. In larger color spaces, particularly those including imaginary colors like ACES2065-1, zero round-trip error is clearly not achievable, and the choice of norm double-vertical-bar dot double-vertical-bar becomes relevant. In principle, we could simply use the 2-norm—however, a problem with such a basic choice is that it is not perceptually uniform: whether a given amount of error is actually visible depends on its position within the RGB cube. We instead use CIE76 normal upper Delta upper E , which first transforms both colors into a color space known as CIELAB before evaluating the upper L 2 -distance.

We then solve this optimization problem using the Gauss–Newton algorithm, an approximate form of Newton’s method. This optimization takes on the order of a few microseconds, which would lead to inefficiencies if performed every time an RGB value must be converted to a spectrum (e.g., once for every pixel of a high-resolution texture).

To avoid this inefficiency, we precompute coefficient tables spanning the left-bracket 0 comma 1 right-bracket cubed RGB color cube when pbrt is first compiled. It is worth noting that the tabulation could in principle also be performed over a lower-dimensional 2D space of chromaticities: for example, a computed spectrum representing the maximally saturated color red left-parenthesis 1 comma 0 comma 0 right-parenthesis could simply be scaled to reproduce less saturated RGB colors left-parenthesis c comma 0 comma 0 right-parenthesis , where c element-of left-parenthesis 0 comma 1 right-parenthesis . However, spectra for highly saturated colors must necessarily peak within a small wavelength range to achieve this saturation, while less saturated colors can be represented by smoother spectra. This is generally preferable whenever possible due to the inherent smoothness of reflectance spectra encountered in physical reality.

We therefore precompute a full 3D tabulation for each RGB color space that pbrt supports (currently, sRGB, DCI-P3, Rec2020, and ACES2065-1). The implementation of this optimization step is contained in the file cmd/rgb2spec_opt.cpp, though we will not discuss it in detail here; see the “Further Reading” section for additional information. Figure 4.30 shows plots of spectra corresponding to a few RGB values.

Figure 4.30: Spectra Computed from RGB Values. Plots of reflectance spectra represented by the RGBSigmoidPolynomial for the RGB colors left-parenthesis 0.7 comma 0.5 comma 0.8 right-parenthesis (purple line), left-parenthesis 0.25 comma 0.44 comma 0.33 right-parenthesis (green line), and left-parenthesis 0.36 comma 0.275 comma 0.21 right-parenthesis (brown line). Each line is colored with its corresponding RGB color.

The resulting tables are stored in the pbrt binary. At system startup time, an RGBToSpectrumTable for each of the RGB color spaces is created.

<<RGBToSpectrumTable Definition>>= 
class RGBToSpectrumTable { public: <<RGBToSpectrumTable Public Constants>> 
static constexpr int res = 64; using CoefficientArray = float[3][res][res][res][3];
<<RGBToSpectrumTable Public Methods>> 
RGBToSpectrumTable(const float *zNodes, const CoefficientArray *coeffs) : zNodes(zNodes), coeffs(coeffs) { } PBRT_CPU_GPU RGBSigmoidPolynomial operator()(RGB rgb) const; static void Init(Allocator alloc); static const RGBToSpectrumTable *sRGB; static const RGBToSpectrumTable *DCI_P3; static const RGBToSpectrumTable *Rec2020; static const RGBToSpectrumTable *ACES2065_1; std::string ToString() const;
private: <<RGBToSpectrumTable Private Members>> 
const float *zNodes; const CoefficientArray *coeffs;
};

The principal method of RGBToSpectrumTable returns the RGBSigmoidPolynomial corresponding to the given RGB color.

<<RGBToSpectrumTable Method Definitions>>= 
RGBSigmoidPolynomial RGBToSpectrumTable::operator()(RGB rgb) const { <<Handle uniform rgb values>> 
if (rgb[0] == rgb[1] && rgb[1] == rgb[2]) return RGBSigmoidPolynomial( 0, 0, (rgb[0] - .5f) / std::sqrt(rgb[0] * (1 - rgb[0])));
<<Find maximum component and compute remapped component values>> 
int maxc = (rgb[0] > rgb[1]) ? ((rgb[0] > rgb[2]) ? 0 : 2) : ((rgb[1] > rgb[2]) ? 1 : 2); float z = rgb[maxc]; float x = rgb[(maxc + 1) % 3] * (res - 1) / z; float y = rgb[(maxc + 2) % 3] * (res - 1) / z;
<<Compute integer indices and offsets for coefficient interpolation>> 
int xi = std::min((int)x, res - 2), yi = std::min((int)y, res - 2), zi = FindInterval(res, [&](int i) { return zNodes[i] < z; }); Float dx = x - xi, dy = y - yi, dz = (z - zNodes[zi]) / (zNodes[zi + 1] - zNodes[zi]);
<<Trilinearly interpolate sigmoid polynomial coefficients c>> 
pstd::array<Float, 3> c; for (int i = 0; i < 3; ++i) { <<Define co lambda for looking up sigmoid polynomial coefficients>> 
auto co = [&](int dx, int dy, int dz) { return (*coeffs)[maxc][zi + dz][yi + dy][xi + dx][i]; };
c[i] = Lerp(dz, Lerp(dy, Lerp(dx, co(0, 0, 0), co(1, 0, 0)), Lerp(dx, co(0, 1, 0), co(1, 1, 0))), Lerp(dy, Lerp(dx, co(0, 0, 1), co(1, 0, 1)), Lerp(dx, co(0, 1, 1), co(1, 1, 1)))); }
return RGBSigmoidPolynomial(c[0], c[1], c[2]); }

If the three RGB values are equal, it is useful to ensure that the returned spectrum is exactly constant. (In some cases, a slight color shift may otherwise be evident if interpolated values from the coefficient tables are used.) A constant spectrum results if c 0 equals c 1 equals 0 in Equation (4.26) and the appropriate value of c 2 can be found by inverting the sigmoid function.

<<Handle uniform rgb values>>= 
if (rgb[0] == rgb[1] && rgb[1] == rgb[2]) return RGBSigmoidPolynomial( 0, 0, (rgb[0] - .5f) / std::sqrt(rgb[0] * (1 - rgb[0])));

Figure 4.31: Plots of Spectrum Polynomial Coefficients c Subscript i . These plots show the polynomial coefficients for the corresponding x y chromaticities in the sRGB color space. Each of (a)  c 0 , (b)  c 1 , and (c)  c 2 mostly vary smoothly, though they exhibit sharp transitions. (d) Partitioning the gamut according to which of red, green, or blue has the largest magnitude closely corresponds to these transitions; coefficients are therefore independently tabularized in those three regions.

The coefficients c Subscript i from the optimization are generally smoothly varying; small changes in RGB generally lead to small changes in their values. (This property also ends up being helpful for the smoothness goal.) However, there are a few regions of the RGB space where they change rapidly, which makes direct 3D tabularization of them prone to error in those regions—see Figure 4.31(a), (b), and (c). A better approach is to tabularize them independently based on which of the red, green, or blue RGB coefficients has the largest magnitude. This partitioning matches the coefficient discontinuities well, as is shown in Figure 4.31(d).

A 3D tabularization problem remains within each of the three partitions. We will use the partition where the red component r has the greatest magnitude to explain how the table is indexed. For a given left-parenthesis r comma g comma b right-parenthesis , the first step is to compute a renormalized coordinate

left-parenthesis x comma y comma z right-parenthesis equals left-parenthesis StartFraction g Over r EndFraction comma StartFraction b Over r EndFraction comma r right-parenthesis period

(By convention, the largest component is always mapped to z .) A similar remapping is applied if g or b is the maximum. With this mapping, all three coordinates span the range left-bracket 0 comma 1 right-bracket , which makes it possible to make better use of samples in a fixed grid.

<<Find maximum component and compute remapped component values>>= 
int maxc = (rgb[0] > rgb[1]) ? ((rgb[0] > rgb[2]) ? 0 : 2) : ((rgb[1] > rgb[2]) ? 1 : 2); float z = rgb[maxc]; float x = rgb[(maxc + 1) % 3] * (res - 1) / z; float y = rgb[(maxc + 2) % 3] * (res - 1) / z;

The resolution of the tabularization, res, is the same in all three dimensions. Because it is set to be a compile time constant here, changing the size of the tables would require recompiling pbrt.

<<RGBToSpectrumTable Public Constants>>= 
static constexpr int res = 64;

An equally spaced discretization is used for the x and y coordinates in the coefficient tables, though z is remapped through a nonlinear function that allocates more samples near both 0 and 1. The c Subscript i coefficients vary most rapidly in that region, so this remapping allocates samples more effectively.

The zNodes array (which is of res elements) stores the result of the remapping where if f is the remapping function then the i th element of zNodes stores f left-parenthesis i slash monospace r monospace e monospace s right-parenthesis .

<<RGBToSpectrumTable Private Members>>= 
const float *zNodes;

Finding integer coordinates in the table is simple for x and y given the equally spaced discretization. For z , a binary search through zNodes is required. Given these coordinates, floating-point offsets from them are then found for use in interpolation.

<<Compute integer indices and offsets for coefficient interpolation>>= 
int xi = std::min((int)x, res - 2), yi = std::min((int)y, res - 2), zi = FindInterval(res, [&](int i) { return zNodes[i] < z; }); Float dx = x - xi, dy = y - yi, dz = (z - zNodes[zi]) / (zNodes[zi + 1] - zNodes[zi]);

We can now implement the fragment that trilinearly interpolates between the eight coefficients around the left-parenthesis x comma y comma z right-parenthesis lookup point. The details of indexing into the coefficient tables are handled by the co lambda function, which we will define shortly, after describing the layout of the tables in memory. Note that although the z coordinate has a nonlinear mapping applied to it, we still linearly interpolate between coefficient samples in z . In practice, the error from doing so is minimal.

<<Trilinearly interpolate sigmoid polynomial coefficients c>>= 
pstd::array<Float, 3> c; for (int i = 0; i < 3; ++i) { <<Define co lambda for looking up sigmoid polynomial coefficients>> 
auto co = [&](int dx, int dy, int dz) { return (*coeffs)[maxc][zi + dz][yi + dy][xi + dx][i]; };
c[i] = Lerp(dz, Lerp(dy, Lerp(dx, co(0, 0, 0), co(1, 0, 0)), Lerp(dx, co(0, 1, 0), co(1, 1, 0))), Lerp(dy, Lerp(dx, co(0, 0, 1), co(1, 0, 1)), Lerp(dx, co(0, 1, 1), co(1, 1, 1)))); }

The coefficients are stored in a five-dimensional array. The first dimension corresponds to whether r , g , or b had the largest magnitude and the next three correspond to z , y , and x , respectively. The last dimension is over the three coefficients c Subscript i .

<<RGBToSpectrumTable Public Constants>>+= 
using CoefficientArray = float[3][res][res][res][3];

<<RGBToSpectrumTable Private Members>>+= 
const CoefficientArray *coeffs;

The coefficient lookup lambda function is now just a matter of using the correct values for each dimension of the array. The provided integer deltas are applied in x , y , and z when doing so.

<<Define co lambda for looking up sigmoid polynomial coefficients>>= 
auto co = [&](int dx, int dy, int dz) { return (*coeffs)[maxc][zi + dz][yi + dy][xi + dx][i]; };

With RGBSigmoidPolynomial’s implementation complete, we can now add a method to RGBColorSpace to transform an RGB in its color space to an RGBSigmoidPolynomial.

<<RGBColorSpace Method Definitions>>+= 
RGBSigmoidPolynomial RGBColorSpace::ToRGBCoeffs(RGB rgb) const { return (*rgbToSpectrumTable)(ClampZero(rgb)); }

With these capabilities, we can now define the RGBAlbedoSpectrum class, which implements the Spectrum interface to return spectral samples according to the sigmoid-polynomial model.

<<Spectrum Definitions>>+=  
class RGBAlbedoSpectrum { public: <<RGBAlbedoSpectrum Public Methods>> 
Float operator()(Float lambda) const { return rsp(lambda); } Float MaxValue() const { return rsp.MaxValue(); } PBRT_CPU_GPU RGBAlbedoSpectrum(const RGBColorSpace &cs, RGB rgb); PBRT_CPU_GPU SampledSpectrum Sample(const SampledWavelengths &lambda) const { SampledSpectrum s; for (int i = 0; i < NSpectrumSamples; ++i) s[i] = rsp(lambda[i]); return s; } std::string ToString() const;
private: <<RGBAlbedoSpectrum Private Members>> 
RGBSigmoidPolynomial rsp;
};

Runtime assertions in the constructor, not shown here, verify that the provided RGB value is between 0 and 1.

<<Spectrum Method Definitions>>+=  
RGBAlbedoSpectrum::RGBAlbedoSpectrum(const RGBColorSpace &cs, RGB rgb) { rsp = cs.ToRGBCoeffs(rgb); }

The only member variable necessary is one to store the polynomial coefficients.

<<RGBAlbedoSpectrum Private Members>>= 
RGBSigmoidPolynomial rsp;

Implementation of the required Spectrum methods is a matter of forwarding the requests on to the appropriate RGBSigmoidPolynomial methods. As with most Spectrum implementations, we will not include the Sample() method here since it just loops over the wavelengths and evaluates Equation (4.26) at each one.

<<RGBAlbedoSpectrum Public Methods>>= 
Float operator()(Float lambda) const { return rsp(lambda); } Float MaxValue() const { return rsp.MaxValue(); }

Unbounded RGB

For unbounded (positive-valued) RGB values, the RGBSigmoidPolynomial foundation can still be used—just with the addition of a scale factor that remaps its range to the necessary range for the given RGB. That approach is implemented in the RGBUnboundedSpectrum class.

<<Spectrum Definitions>>+=  
class RGBUnboundedSpectrum { public: <<RGBUnboundedSpectrum Public Methods>> 
Float operator()(Float lambda) const { return scale * rsp(lambda); } Float MaxValue() const { return scale * rsp.MaxValue(); } PBRT_CPU_GPU RGBUnboundedSpectrum(const RGBColorSpace &cs, RGB rgb); PBRT_CPU_GPU RGBUnboundedSpectrum() : rsp(0, 0, 0), scale(0) {} PBRT_CPU_GPU SampledSpectrum Sample(const SampledWavelengths &lambda) const { SampledSpectrum s; for (int i = 0; i < NSpectrumSamples; ++i) s[i] = scale * rsp(lambda[i]); return s; } std::string ToString() const;
private: <<RGBUnboundedSpectrum Private Members>> 
Float scale = 1; RGBSigmoidPolynomial rsp;
};

A natural choice for a scale factor would be one over the maximum of the red, green, and blue color components. We would then use that to normalize the RGB value before finding polynomial coefficients and then rescale values returned by RGBSigmoidPolynomial accordingly. However, it is possible to get better results by instead normalizing RGB to have a maximum value of 1 slash 2 rather than 1. The reason is illustrated in Figure 4.32: because reflectance spectra must not exceed one, when highly saturated colors are provided, the resulting spectra may have unusual features, including large magnitudes in the unsaturated region of the spectrum. Rescaling to 1 slash 2 gives the fit more room to work with, since the normalization constraint does not immediately affect it.

Figure 4.32: With the sigmoid polynomial representation, highly saturated colors may end up with unexpected features in their spectra. Here we have plotted the spectrum returned by RGBAlbedoSpectrum for the RGB color left-parenthesis 0.95 comma 0.05 comma 0.025 right-parenthesis as well as that color with all components divided by two. With the original color, we see a wide range of the higher wavelengths are near 1 and that the lower wavelengths have more energy than expected. If that color is divided by two, the resulting spectrum is better behaved, though note that its magnitude exceeds the original red value of 0.475 in the higher wavelengths.

<<Spectrum Method Definitions>>+=  
RGBUnboundedSpectrum::RGBUnboundedSpectrum(const RGBColorSpace &cs, RGB rgb) { Float m = std::max({rgb.r, rgb.g, rgb.b}); scale = 2 * m; rsp = cs.ToRGBCoeffs(scale ? rgb / scale : RGB(0, 0, 0)); }

<<RGBUnboundedSpectrum Private Members>>= 
Float scale = 1; RGBSigmoidPolynomial rsp;

In comparison to the RGBAlbedoSpectrum implementation, the wavelength evaluation and MaxValue() methods here are just augmented with a multiplication by the scale factor. The Sample() method has been updated similarly, but is not included here.

<<RGBUnboundedSpectrum Public Methods>>= 
Float operator()(Float lambda) const { return scale * rsp(lambda); } Float MaxValue() const { return scale * rsp.MaxValue(); }

RGB Illuminants

As illustrated in the plots of illuminant spectra in Section 4.4.2, real-world illuminants often have complex spectral distributions. Given a light source specified using RGB color, we do not attempt to infer a complex spectral distribution but will stick with a smooth spectrum, scaled appropriately. The details are handled by the RGBIlluminantSpectrum class.

<<Spectrum Definitions>>+= 
class RGBIlluminantSpectrum { public: <<RGBIlluminantSpectrum Public Methods>> 
RGBIlluminantSpectrum() = default; RGBIlluminantSpectrum(const RGBColorSpace &cs, RGB rgb); Float operator()(Float lambda) const { if (!illuminant) return 0; return scale * rsp(lambda) * (*illuminant)(lambda); } Float MaxValue() const { if (!illuminant) return 0; return scale * rsp.MaxValue() * illuminant->MaxValue(); } const DenselySampledSpectrum *Illuminant() const { return illuminant; } PBRT_CPU_GPU SampledSpectrum Sample(const SampledWavelengths &lambda) const { if (!illuminant) return SampledSpectrum(0); SampledSpectrum s; for (int i = 0; i < NSpectrumSamples; ++i) s[i] = scale * rsp(lambda[i]); return s * illuminant->Sample(lambda); } std::string ToString() const;
private: <<RGBIlluminantSpectrum Private Members>> 
Float scale; RGBSigmoidPolynomial rsp; const DenselySampledSpectrum *illuminant;
};

Beyond a scale factor that is equivalent to the one used in RGBUnboundedSpectrum to allow an arbitrary maximum RGB value, the RGBIlluminantSpectrum also multiplies the value returned at the given wavelength by the value of the color space’s standard illuminant at that wavelength. A non-intuitive aspect of spectral modeling of illuminants is that uniform spectra generally do not map to neutral white colors following conversion to RGB. Color spaces always assume that the viewer is adapted to some type of environmental illumination that influences color perception and the notion of a neutral color. For example, the commonly used D65 whitepoint averages typical daylight illumination conditions. To reproduce illuminants with a desired color, we therefore use a crude but effective solution, which is to multiply the whitepoint with a suitable reflectance spectra. Conceptually, this resembles viewing a white reference light source through a colored film. It also ensures that white objects lit by white lights lead to white pixel values in the rendered image.

<<Spectrum Method Definitions>>+= 
RGBIlluminantSpectrum::RGBIlluminantSpectrum(const RGBColorSpace &cs, RGB rgb) : illuminant(&cs.illuminant) { Float m = std::max({rgb.r, rgb.g, rgb.b}); scale = 2 * m; rsp = cs.ToRGBCoeffs(scale ? rgb / scale : RGB(0, 0, 0)); }

Thus, a pointer to the illuminant is held in a member variable.

<<RGBIlluminantSpectrum Private Members>>= 
Float scale; RGBSigmoidPolynomial rsp; const DenselySampledSpectrum *illuminant;

Implementations of the various Spectrum interface methods follow; here is the one that evaluates the spectral distribution at a single wavelength. One detail is that it must handle the case of a nullptr illuminant, as will happen if an RGBIlluminantSpectrum is default-initialized. In that case, a zero-valued spectrum should be the result.

<<RGBIlluminantSpectrum Public Methods>>= 
Float operator()(Float lambda) const { if (!illuminant) return 0; return scale * rsp(lambda) * (*illuminant)(lambda); }

We will not include the implementations of the Sample() or MaxValue() methods here, as their implementations are as would be expected.