Convert Linear RGB to sRGB in GLSL

This article explains how to accurately transform color values from a linear color space to the non-linear sRGB color space in OpenGL Shading Language (GLSL). Rendering engines calculate lighting and shading in linear space to maintain physical accuracy, but display monitors expect an sRGB transfer function. Below is the precise mathematical formula defined by the IEC 61966-2-1 standard, followed by clean and optimized GLSL code implementations.

The Mathematical Formula

The official transformation from linear RGB (\(C_{linear}\)) to sRGB (\(C_{srgb}\)) is a piecewise function applied independently to each color channel (red, green, and blue) normalized between 0.0 and 1.0:

\[ C_{srgb} = \begin{cases} 12.92 \times C_{linear} & \text{if } C_{linear} \le 0.0031308 \\ 1.055 \times C_{linear}^{1/2.4} - 0.055 & \text{if } C_{linear} > 0.0031308 \end{cases} \]

  • Linear Segment (\(C_{linear} \le 0.0031308\)): A linear ramp near pure black prevents infinite slope issues and numerical instability.
  • Exponential Segment (\(C_{linear} > 0.0031308\)): Applies a power function using an exponent of approximately \(1 / 2.4 \approx 0.41666\) and an offset to simulate a dynamic gamma curve averaging roughly 2.2.

Exact GLSL Implementation

GLSL allows for branchless, vectorized calculation across vec3 variables using lessThanEqual and mix:

vec3 linearToSRGB(vec3 linearRGB) {
    bvec3 cutoff = lessThanEqual(linearRGB, vec3(0.0031308));
    vec3 higher = vec3(1.055) * pow(linearRGB, vec3(1.0 / 2.4)) - vec3(0.055);
    vec3 lower = linearRGB * vec3(12.92);

    return mix(higher, lower, cutoff);
}

If clamped input is required to avoid undefined results from negative linear values, wrap the input in clamp(linearRGB, 0.0, 1.0).

Fast Approximation

When maximum GPU performance is preferred over exact color-standard compliance, developers often approximate the transformation using a fixed gamma power of 2.2:

\[C_{srgb} \approx C_{linear}^{1/2.2}\]

In GLSL:

vec3 linearToSRGBFast(vec3 linearRGB) {
    return pow(linearRGB, vec3(1.0 / 2.2));
}

While computationally cheaper, this approximation skips the linear segment, leading to crushed dark tones near black compared to standard sRGB hardware encoding.