Computing CIE xyY from Tristimulus Values in GLSL

This article explains how to compute the CIE xyY color representation from CIE XYZ tristimulus values using the OpenGL Shading Language (GLSL). Converting XYZ to xyY separates the chromaticity coordinates (\(x, y\)) from the luminance component (\(Y\)), which is essential for gamut mapping, color analysis, and tone mapping in rendering pipelines. Below is the direct mathematical formulation and an optimized, robust GLSL implementation that handles potential edge cases such as division by zero.

Mathematical Formulation

The CIE XYZ color space represents colors via three tristimulus values: \(X\), \(Y\), and \(Z\). The CIE xyY representation normalizes these values so that chromaticity is independent of luminance:

  • \(x = \frac{X}{X + Y + Z}\)
  • \(y = \frac{Y}{X + Y + Z}\)
  • \(Y = Y\)

The third coordinate, \(z = \frac{Z}{X + Y + Z}\), is redundant because \(x + y + z = 1\) (meaning \(z = 1 - x - y\)). Therefore, only \(x\), \(y\), and the original luminance value \(Y\) are stored.

GLSL Implementation

When implementing the conversion in GLSL, total darkness (\(X = Y = Z = 0\)) causes a division by zero. To prevent NaN artifacts, a small epsilon must guard the denominator, or a conditional check can substitute a fallback chromaticity (typically the reference white point, such as D65 where \(x \approx 0.3127, y \approx 0.3290\)).

// Converts CIE XYZ tristimulus values to CIE xyY
vec3 XYZ_to_xyY(vec3 XYZ) {
    float sum = XYZ.x + XYZ.y + XYZ.z;
    
    // Avoid division by zero for black pixels
    if (sum < 1e-6) {
        // Fallback to D65 white point chromaticity with 0 luminance
        return vec3(0.3127, 0.3290, 0.0);
    }
    
    float invSum = 1.0 / sum;
    float x = XYZ.x * invSum;
    float y = XYZ.y * invSum;
    float Y = XYZ.y;
    
    return vec3(x, y, Y);
}

Branchless Alternative

In high-throughput fragment shaders where branching may impact performance, an epsilon clamp provides a branchless alternative:

vec3 XYZ_to_xyY_branchless(vec3 XYZ) {
    float sum = max(XYZ.x + XYZ.y + XYZ.z, 1e-6);
    return vec3(XYZ.x / sum, XYZ.y / sum, XYZ.y);
}

Inverting the Transformation

To return to tristimulus values for final screen rendering, the inverse transformation calculates \(X\) and \(Z\) from \(x\), \(y\), and \(Y\):

vec3 xyY_to_XYZ(vec3 xyY) {
    if (xyY.y < 1e-6) {
        return vec3(0.0);
    }
    
    float X = (xyY.x * xyY.z) / xyY.y;
    float Y = xyY.z;
    float Z = ((1.0 - xyY.x - xyY.y) * xyY.z) / xyY.y;
    
    return vec3(X, Y, Z);
}

Ensure that input color values are linearized and converted to the CIE XYZ color space via the appropriate \(3 \times 3\) color matrix (such as sRGB-to-XYZ) before executing these functions.