Computing sRGB color from SPDs 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 &lt; 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 &amp;xyz : image) {
    maxLuminance = std::max(maxLuminance, xyz.y);
}

for (auto &amp;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 &amp;xyz : image) {
    logSum += std::log(xyz.y);
}

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

for (auto &amp;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 &amp;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 &lt;&lt; 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?
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:


(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.