2
$\begingroup$

I'm trying to write my own spectral path tracer and a bit stuck with converting computed SPDs to LDR RGB values. What I'm doing right now is convert sampled SPDs (in W/m^-1) to XYZ using CIE 1931 color matching functions:

Vec3 xyz(0, 0, 0);
float norm = 0;

for (int i = 0; i < SPD_SAMPLE_COUNT; ++i) {
    const float wavelength =
        lerp(MIN_WAVELENGTH, MAX_WAVELENGTH, i / SPD_SAMPLE_COUNT);
    const Vec3 cieMatchingFunctionValue = cie1931(wavelength);
    xyz += cieMatchingFunctionValue * spd[i];
    norm += cieMatchingFunctionValue.y;
}

xyz /= norm;

Here implementation of cie1931 is an anylitic approximation from Wyman et al.

Then XYZ to xyY (i.e. chromaticity and luminance), normalise luminance to be in [0, 1] range across the whole image (for now by dividing my max luminance just to keep it simple):

float maxLuminance = 0;

for (auto &xyz : image) {
    maxLuminance = std::max(maxLuminance, xyz.y);
}

for (auto &xyz : image) {
    Vec2 chromaticity = Vec2(xyz.x, xyz.y) / (xyz.x + xyz.y + xyz.z);
    float luminance = xyz.y / maxLuminance;

    // then to "scaled" XYZ and RGB, see below
}

Also I've tried Reinhard tone mapping operator:

float logSum = 0;

for (auto &xyz : image) {
    logSum += std::log(xyz.y);
}

float logAvgLuminance = std::exp(logSum / image.size());

for (auto &xyz : image) {
    Vec2 chromaticity = Vec2(xyz.x, xyz.y) / (xyz.x + xyz.y + xyz.z);
    const float a = 0.18; // constant from Reinhard's paper.
    float luminance = a * xyz.y / logAvgLuminance;
    luminance /= luminance + 1;

    // then to "scaled" XYZ and RGB, see below
}

Then back to XYZ, than to linear RGB (wia this transform):

for (auto &xyz : image) {
    // XYZ to chromaticity and "scaled luminance", see above

    Vec3 scaledXyz(
        chromaticity.x * luminance / chromaticity.y,
        luminance,
        (1 - chromaticity.x - chromaticity.y) * luminance / chromaticity.y
    );

    float r = dot(Vec3( 3.2406, -1.5372, -0.4986), scaledXyz);
    float g = dot(Vec3(-0.9689,  1.8758,  0.0415), scaledXyz);
    float b = dot(Vec3( 0.0557, -0.2040,  1.0570), scaledXyz);

    // Output RGB, i.e. std::cout << Vec3(r, g, b);
}

After that I still get RGB values outside of [0..1]. What am I doing wrong? It seems that "max" luminance should depend on chromaticities of the color. Is there an algorithm to account for that?

$\endgroup$
  • $\begingroup$ Hello and welcome to the site. You are using quite a few equations there which means not many people know all of them well enough to answer. I suggest you flesh out the question more by adding the relevant equations and/or code. $\endgroup$ – bernie Jan 23 at 19:54
  • $\begingroup$ @bernie Thank for that comment! Added code to illustrate what I've tried so far. $\endgroup$ – Kirill Dmitrenko Jan 24 at 10:53
2
$\begingroup$

Getting sRGB values outside [0, 1] is expected and normal when using spectral rendering.

The sRGB gamut only covers a triangle in the middle of the CIE chromaticity space:

CIE chromaticity diagram with sRGB gamut highlighted (diagram from Wikipedia)

The big "horseshoe" shape is the set of all physically possible chromaticities (CIE xy coordinates) from any possible spectrum. So your xy coordinates should be expected to land somewhere in the horseshoe...but only the ones that land inside the triangle are representable as sRGB! The others are "too saturated" for sRGB, and will have to be clamped or remapped somehow. (Tone mapping per se doesn't fix this, as it's concerned with luminance rather than chromaticity.)

There are a variety of approaches to deal with out-of-gamut colors depending on how sophisticated you want to be:

  • You can of course simply clamp the sRGB values, but this may change the hue and/or the luminance of the color in the process.
  • To preserve both hue and luminance, you can edit the xy coordinates before calculating scaledXyz. If the initial xy coordinates are outside the sRGB triangle, find the line segment from the xy coordinates to the white point (e.g. D65); then find the intersection of that line segement with the sRGB triangle. That's the closest sRGB point that has the same luminance and hue as the original point, but lower saturation. (The vertices of the sRGB triangle can be obtained by converting RGB(1, 0, 0), RGB(0, 1, 0), and RGB(0, 0, 1) to CIE xy space.)
  • Alternatively, rather than clamping (which throws away any detail in the saturation outside the sRGB gamut), you could try doing a linear or non-linear remapping of saturation values in the image, similar to how you do for luminance. By "saturation" here I mean distance from the white point. The idea would be to squish the whole image toward the white point so as to bring everything within the gamut. But that may affect the apparent saturation of in-gamut colors in visually undesirable ways.
$\endgroup$

Your Answer

By clicking "Post Your Answer", you acknowledge that you have read our updated terms of service, privacy policy and cookie policy, and that your continued use of the website is subject to these policies.

Not the answer you're looking for? Browse other questions tagged or ask your own question.