Source convention: foundation chapters use the earliest project revision that contains the implementation being discussed. A later revision is used only when the relevant feature did not yet exist, and is identified immediately before the corresponding excerpt.

This chapter defines the spectral quantities used by HdRestir and relates the mathematical estimator to the CPU and GPU implementations. HdRestir stores four radiance samples per path. Each component corresponds to a wavelength between 360 and 830 nanometres. The four values remain associated with those wavelengths through emission, BSDF evaluation, absorption, and path throughput. Conversion to linear RGB occurs only when the renderer writes an image value or computes an RGB-derived diagnostic.

This chapter covers:

  1. the distinction between a sampled spectrum and an RGB color value;
  2. numerical integration of a spectrum against the CIE color-matching functions;
  3. the four-wavelength sampling rule used by HdRestir;
  4. the CPU and Slang data representations;
  5. RGB-to-spectrum uplift and its limitations.

Spectral distributions and RGB values

A spectral radiance distribution \(L(\lambda)\) gives radiance per unit wavelength. Its units include an inverse wavelength term; if wavelength is measured in nanometres, one can write the spectral part as \(\mathrm{W\,sr^{-1}\,m^{-2}\,nm^{-1}}\). Integrating over a wavelength interval removes that inverse-nanometre term and gives radiance for that band.

An RGB value has a different meaning. It contains three coordinates in a specified color space, usually after integrating a spectrum against three response functions and applying a matrix transform. The mapping from spectra to three coordinates is many-to-one. Two different spectra that produce the same tristimulus coordinates are called metamers. Consequently, an RGB triplet cannot be inverted to recover one physically unique spectrum.

Wavelength-dependent refraction, absorption, fluorescence, and thin-film interference depend on the spectral variable directly. An RGB renderer can approximate some of these effects with fitted three-channel models, but it cannot generally represent a narrow feature that falls between its basis functions. Spectral transport retains wavelength as an integration variable, although its accuracy still depends on the spectra supplied for lights and materials.

From a spectrum to a pixel

The CIE 1931 standard observer dataset defines the three color-matching functions \(\bar x(\lambda),\bar y(\lambda),\bar z(\lambda)\). For a spectrum \(L(\lambda)\), the tristimulus values are

\[\begin{bmatrix}X\\Y\\Z\end{bmatrix} = \frac{1}{k} \int_{\lambda_{\min}}^{\lambda_{\max}} L(\lambda) \begin{bmatrix} \bar x(\lambda)\\ \bar y(\lambda)\\ \bar z(\lambda) \end{bmatrix} \,d\lambda.\]

\(k\) is a normalization convention. HdRestir uses \(106.856\), an approximation to the integral of \(\bar y\) over the visible interval. It then applies the standard XYZ-to-linear-sRGB matrix. These operations produce scene-linear RGB; tone mapping and transfer-function encoding are separate post-processing operations.

The implementation does not tabulate the official CIE dataset. It evaluates the analytic fits published by Wyman, Sloan, and Shirley. The fits are compact and convenient for CPU/GPU parity, but they approximate the tabulated functions. The estimator below is unbiased for the functions actually evaluated by the program; approximation error in those functions is a separate source of error from Monte Carlo variance.

The integral is continuous, but a renderer cannot evaluate every wavelength. Choose wavelengths \(\lambda_i\) from a density \(p_\lambda\). The Monte Carlo estimate is

\[\widehat{\mathbf c} = \frac{1}{N} \sum_{i=1}^{N} \frac{L(\lambda_i)\,\bar{\mathbf c}(\lambda_i)} {k\,p_\lambda(\lambda_i)}, \qquad \bar{\mathbf c} = \begin{bmatrix}\bar x&\bar y&\bar z\end{bmatrix}^{T}.\]
Proof The wavelength estimator is unbiased

Assumptions: The wavelength density is positive wherever the evaluated spectral integrand is non-zero, and the integrand is finite.

For one sample \(\Lambda\sim p_\lambda\),

\[\mathbb E\!\left[ \frac{L(\Lambda)\bar{\mathbf c}(\Lambda)} {k\,p_\lambda(\Lambda)} \right] = \int p_\lambda(\lambda) \frac{L(\lambda)\bar{\mathbf c}(\lambda)} {k\,p_\lambda(\lambda)}\,d\lambda = \frac1k\int L(\lambda)\bar{\mathbf c}(\lambda)\,d\lambda.\]

Linearity of expectation makes the average of \(N\) such terms estimate the same integral. This proof requires \(p_\lambda(\lambda)>0\) wherever the integrand is non-zero.

HdRestir’s four-wavelength packet

The implementation draws one \(u\sim U[0,1)\). Let \(\Delta=830-360=470\) nm and \(S=4\). It builds

\[\lambda_j = 360 + \operatorname{mod} \left(u\Delta+j\frac{\Delta}{S},\,\Delta\right), \qquad j\in\{0,1,2,3\}.\]

The four samples are evenly separated, and the whole pattern receives a random cyclic shift. This is the central construction in hero-wavelength spectral sampling. HdRestir carries all four wavelengths through the path.

Proof Every component of the shifted packet has a uniform marginal distribution

Assumptions: The initial shift is uniform on the half-open wavelength interval and wrapping is interpreted modulo its width.

Fix any \(j\). Adding a constant and wrapping modulo \(\Delta\) preserves a uniform distribution on a periodic interval. Therefore \(\lambda_j\sim U[360,830)\) and its marginal density is \(p_\lambda=1/\Delta\).

The samples within one packet are correlated, so we must not claim they are independent. Independence is unnecessary for unbiasedness: each term has the correct marginal expectation, and linearity of expectation still applies. The estimator becomes

\[\widehat{\mathbf c} = \frac{\Delta}{S\,k} \sum_{j=0}^{S-1}L(\lambda_j)\bar{\mathbf c}(\lambda_j).\]

Even spacing reduces the chance that one path samples four wavelengths from a single narrow region. The random shift prevents a permanent fixed-grid bias.

The construction is easier to inspect by moving the initial random shift. Every marker moves by the same amount; crossing 830 nm wraps that marker to the start of the half-open interval.

Interactive model: HdRestir's four-wavelength packet Move the random cyclic shift; the 117.5 nm spacing remains fixed and samples wrap at 830 nm.
Packet Spacing 117.5 nm Marginal PDF 1/470 nm⁻¹

The displayed colors only identify positions along the visible-wavelength interval. They are not the RGB values reconstructed by the renderer.

flowchart LR
  U["one random u"] --> W["4 wrapped wavelengths"]
  W --> P["same geometric path"]
  P --> T["4 spectral throughputs"]
  T --> XYZ["CIE XYZ reconstruction"]
  XYZ --> RGB["linear RGB → display"]

Wavelength packets in the C++ implementation

Spectral sampling is already present in the first project commit, e6fe6bb. The following excerpts are literal selections from its source/core/spectrum.h; omitted members are not needed for this derivation.

#define SPECTRUM_SAMPLES 4
#define LAMBDA_MIN 360.0f
#define LAMBDA_MAX 830.0f

class SampledWavelengths {
public:
    float lambda[SPECTRUM_SAMPLES];

    // ...

    // u is a uniform random number in [0, 1)
    static SampledWavelengths SampleUniform(float u) {
        SampledWavelengths swl;
        swl.lambda[0] = LAMBDA_MIN + u * (LAMBDA_MAX - LAMBDA_MIN);
        for (int i = 1; i < SPECTRUM_SAMPLES; ++i) {
            swl.lambda[i] = swl.lambda[0] + (i * (LAMBDA_MAX - LAMBDA_MIN) / SPECTRUM_SAMPLES);
            if (swl.lambda[i] > LAMBDA_MAX) {
                swl.lambda[i] -= (LAMBDA_MAX - LAMBDA_MIN);
            }
        }
        return swl;
    }
};

The loop is the wrapped construction written mathematically above. With \(\Delta=470\) nm, the expression added for each successive component is \(i\Delta/4\). One random number controls the cyclic shift, so the code consumes one RNG value rather than four. The packet components are stratified but correlated.

The use of > rather than >= deserves a precise qualification. In the continuous derivation the probability of sampling exactly an endpoint is zero. Floating-point random numbers form a discrete set, so an exact 830.0F can in principle be retained by the implementation. This is a boundary convention, not evidence that the four components are independent. A future change that requires a strict half-open interval should wrap on >= and be covered by a boundary test.

The values transported at those wavelengths are stored separately:

class SampledSpectrum {
public:
    float values[SPECTRUM_SAMPLES];

    // ...

    SampledSpectrum operator*(const SampledSpectrum& s) const {
        SampledSpectrum ret;
        for (int i = 0; i < SPECTRUM_SAMPLES; ++i) ret.values[i] = values[i] * s.values[i];
        return ret;
    }

    // ...
};

Component-wise multiplication is appropriate because every factor refers to the same four wavelengths. A SampledSpectrum is not meaningful without the corresponding SampledWavelengths; the implementation passes the wavelength packet alongside spectral values rather than storing it inside every value.

Spectrum reconstruction in SpectrumToRGB

The CPU reconstruction first accumulates approximate CIE XYZ values:

float weight = (LAMBDA_MAX - LAMBDA_MIN) / SPECTRUM_SAMPLES;
// ...
weight /= 106.856f;

for (int i = 0; i < SPECTRUM_SAMPLES; ++i) {
    float l = lambda.lambda[i];
    float val = s.values[i];
    X += val * xFit_1931(l);
    Y += val * yFit_1931(l);
    Z += val * zFit_1931(l);
}

X *= weight;
Y *= weight;
Z *= weight;

(kLambdaMax-kLambdaMin)/kSpectrumSamples is \(\Delta/S=117.5\), which combines the reciprocal uniform PDF with the packet average. Dividing by 106.856F applies the chosen luminance normalization. The result is then transformed to linear sRGB:

float r =  3.2404542f * X - 1.5371385f * Y - 0.4985314f * Z;
float g = -0.9692660f * X + 1.8760108f * Y + 0.0415560f * Z;
float b =  0.0556434f * X - 0.2040259f * Y + 1.0572252f * Z;

// ...

r /= 1.198185f;
g /= 0.950366f;
b /= 0.907827f;

return GfVec3f(std::max(0.0f, r), std::max(0.0f, g), std::max(0.0f, b));

The three divisors white-balance the analytic reconstruction so a flat equal-energy spectrum maps approximately to (1,1,1). The final clamp removes negative linear-sRGB components. Negative components can be valid intermediate coordinates for spectra outside the sRGB gamut, so this clamp is a gamut handling choice rather than part of the CIE integral.

It also changes the statistical statement: XYZ before the clamp follows the unbiased Monte Carlo derivation; applying max(0,x) is nonlinear, therefore the expected clamped RGB value need not equal the clamp of the exact expected RGB value. The clamp prevents negative framebuffer values but can introduce small bias near the gamut boundary.

Where spectra enter the renderer

Scene materials and lights commonly arrive as RGB values. HdRestir “uplifts” those values to a smooth spectrum evaluated at the four active wavelengths. Multiplication is then component-wise: emission, BSDF value, and path throughput remain spectral until reconstruction.

The uplift implemented by RGBToSpectrum separates the achromatic component and adds three Gaussian lobes:

float minRGB = std::min(rgb[0], std::min(rgb[1], rgb[2]));
float white = minRGB;

GfVec3f rem(rgb[0] - white, rgb[1] - white, rgb[2] - white);

// ...

const float R_peak = 615.0f;
const float G_peak = 540.0f;
const float B_peak = 460.0f;
const float sigma = 20.0f;

for (int i = 0; i < SPECTRUM_SAMPLES; ++i) {
    float l = lambda.lambda[i];

    float val = white;

    // ...

    val += rem[0] * Gaussian(l, R_peak, sigma) * 1.295f;
    val += rem[1] * Gaussian(l, G_peak, sigma) * 1.556f;
    val += rem[2] * Gaussian(l, B_peak, sigma) * 1.382f;

    s.values[i] = std::max(0.0f, val);
}

This is a renderer-specific basis, not a physical inversion of RGB. The peak positions, widths, and scale factors are chosen to provide a useful round-trip. They do not guarantee that the reconstructed spectrum is the reflectance or emission spectrum of the original object, nor do they enforce all constraints required of measured reflectance data.

The GPU implementation does not exist at this point in the history. It is introduced by commit 4648adc and is discussed in the GPU chapters, where its constants can be compared with this original CPU implementation.

See the first implementation in spectrum.h and spectrum.cpp. For a broader reference, read PBRT’s radiometry, spectra, and color chapter.

Record this: In usdview, render the same glass or strongly colored material twice: first with a neutral/no-dispersion setup, then with a wavelength-dependent IOR if available. Keep the camera and exposure fixed. A 10–15 second split-screen clip is ideal.
Caption to keep: RGB stores a display-oriented three-number approximation; spectral transport evaluates the light–material interaction at sampled physical wavelengths.

Terminology used in the following chapters

Term Meaning
Spectrum Energy or reflectance as a function of wavelength
RGB Three coordinates in a selected color space
Spectral sampling Monte Carlo integration over wavelength
CIE XYZ A standardized three-number model of human color matching
Tone mapping Mapping scene-referred radiance into a displayable range

The next chapter adds the other integral in the renderer: light arriving from directions and paths through the scene.