mmp / pbrt-v3

Source code for pbrt, the renderer described in the third edition of "Physically Based Rendering: From Theory To Implementation", by Matt Pharr, Wenzel Jakob, and Greg Humphreys.
http://pbrt.org
BSD 2-Clause "Simplified" License
4.86k stars 1.18k forks source link

Fix illuminant for spectral reflectance data #294

Open linusmossberg opened 3 years ago

linusmossberg commented 3 years ago

Fixes #293

Reflectance and illuminant spectral distributions are different and must be treated differently when converting to sRGB. The difference essentially stems from that emitted radiance with equal energy for all wavelengths is not considered to be white, while surfaces that reflects radiance equally for all wavelengths are.

More details
The D65 illuminant was chosen as the 'white illuminant' for the sRGB color space. Once integrated with the CIE color matching functions, the XYZ tristimulus value of this illuminant is `XYZ(0.9505, 1.0, 1.0888)`. This value therefore represents white in the sRGB color space, so by definition `XYZ(0.9505, 1.0, 1.0888)` is converted to `sRGB(1.0, 1.0, 1.0)`. A white surface reflects all wavelengths equally, because the reflected radiance is then proportionally equal to the incoming radiance. In other words, the reflectance of a white surface as a function of wavelength is constant, `R(λ)=C`. Once integrated with the CIE color matching functions, the XYZ tristimulus values of this reflectance is `XYZ(C, C, C)`. If we convert this to sRGB, which defines `XYZ(0.9505, 1.0, 1.0888)` as white, this reflectance is `sRGB(1.2048·C, 0.9483·C, 0.9088·C)` ≠ white. To correctly convert our white reflectance to the sRGB color space, we must account for the fact that the color space considers `XYZ(0.9505, 1.0, 1.0888)` to be white, which is done by multiplying the XYZ reflectance values with this white point before performing the conversion to sRGB: `XYZ(0.9505·C, 1.0·C, 1.0888·C)` => `sRGB(C, C, C)`. This applies to all surfaces and not only white ones, but it's easier to illustrate with one.

This PR uses the existing distinction between input illuminant and reflectance spectrums and applies the D65 white point to the integrated reflectance XYZ values before converting to sRGB.

I don't know of a scene where this makes much of a difference, because spectral reflectance distributions are only used for eta and k in metals as far as I'm aware. The difference seems to be close to eliminated in the Fresnel function for conductives when both eta and k are modified in the same way:

crown_ba The color becomes a bit less greenish and more yellowish in places with lots of consecutive gold reflections, but it's barely noticeable. Gold is the only metal I've been able to see a slight difference with.

This is not the case if we specify k=0 however, or if we for example specify the diffuse reflectance for a material as a gray reflectance distribution:

Material "matte"
    "spectrum Kd" [ 300.0 0.5 
                    800.0 0.5 ]

spheres Measured diffuse reflectance distributions exists and are useful for the spectral renderer, and this change should make sure that the result is the same when switching to RGBSpectrum.

Edit:

Another option for a better approximation is to use the Bradford E to D65 chromatic adaption:

static RGBSpectrum FromSampled(const Float *lambda, const Float *v, int n,
                               SpectrumType type = SpectrumType::Illuminant) {
    ...
    if (type == SpectrumType::Reflectance) {
        Float xyz_d65[3];
        xyz_d65[0] =  0.953188f * xyz[0] - 0.026590f * xyz[1] + 0.023873f * xyz[2];
        xyz_d65[1] = -0.038246f * xyz[0] + 1.028841f * xyz[1] + 0.009406f * xyz[2];
        xyz_d65[2] =  0.002607f * xyz[0] - 0.003033f * xyz[1] + 1.089253f * xyz[2];
        return FromXYZ(xyz_d65);
    }
    return FromXYZ(xyz);
}

At that point it's probably best to just include the D65 data and use it to compute the proper reflectance integral though.