Computer Graphics · Color and Display

The sRGB Transfer Function: Piecewise Curve, Constants, and Decode Cost

How the sRGB encoding is defined, why it bends into a straight line near black, and what the exact thresholds cost in a shader or a texture lookup table.

The sRGB encoding is a single scalar function applied independently to each of three channels. Its shape is the interesting part: a power law with exponent 2.4 for most of the range, spliced to a straight line for the bottom 0.3% of it. Versions of the curve circulate with the exponent rounded to 2.2, with the linear segment dropped, or with the segment extended to a different threshold. Each of those substitutions is visible in a gradient, and the ones that drop the segment lose code values at the bottom of the range entirely.

Two branches and where they meet

The encoding maps linear light $L$ in $[0,1]$ to a stored value $V$:

$$V = \begin{cases} 12.92,L & L \le 0.0031308 \ 1.055,L^{1/2.4} - 0.055 & L > 0.0031308 \end{cases}$$

and the decoding is the inverse:

$$L = \begin{cases} V / 12.92 & V \le 0.04045 \ \left(\dfrac{V + 0.055}{1.055}\right)^{2.4} & V > 0.04045 \end{cases}$$

The two branches are constructed to agree in value and in slope at the join. The slope condition is what fixes the constants: a straight line of slope $k$ tangent to the curve $a L^{1/2.4} - b$ at $L = L_0$ requires $k = a,(1/2.4),L_0^{1/2.4 - 1}$ and $k L_0 = a L_0^{1/2.4} - b$. Solving those two equations with the published $a$ and $b$ yields a join near $L_0 = 0.0031308$ and a slope near 12.92. Because the slope is continuous, the derivative of the encoding never jumps, and a gradient crossing the join has no visible crease — which is the property that a spliced curve with mismatched slope fails to have.

The join point also corresponds to the 8-bit threshold: $0.0031308$ encodes to about $0.04045$, and $0.04045 \times 255 \approx 10.3$. So the linear segment covers encoded values below roughly 10/255, and everything above it is the power branch. Nine or ten code values out of 256 sit in the linear region.

Why a straight line at the bottom

The reason is not aesthetic. A pure power law with exponent $n > 1$ has zero derivative at the origin, so a fixed quantization step in encoded space maps to a luminance step that shrinks without bound as the value approaches zero. With 8 bits and a pure $L^{1/2.2}$ encoding, the first code above zero corresponds to a linear luminance around $2^{-22}$, and the second to $2^{-19.5}$: the ratio between adjacent steps in the deep shadows is enormous, and any dark gradient produced by rounding into 8 bits lands on a handful of values with big gaps between them. Worse, the perceptual spacing is wrong at the bottom: the eye can distinguish luminance ratios of a few percent in dark adaptation, and a power law oversamples that region in encoded terms while undersampling it in relative terms as it approaches the origin.

Splicing in a straight line caps the derivative at a finite value (about 12.9) rather than letting it fall to zero, so each encoded step near black corresponds to a bounded luminance step. The result is that the darkest few codes are perceptually closer to evenly spaced in relative luminance than a bare power law would give, at the price of coarser relative resolution over the bottom 0.3% than an ideal perceptual curve would provide. The published constants were chosen so that the straight segment approximates the power branch to within a small fraction of a code value at the join, which is why the splice is invisible even under a slope-discontinuity test.

QuantityValue
Encoding slope in linear segment12.92
Linear-segment upper bound, linear0.0031308
Linear-segment upper bound, encoded0.04045
Power-branch exponent, encode1/2.4
Power-branch scale and offset1.055, −0.055
8-bit codes in linear segmentabout 10

What the decoding costs

A shader decode is a comparison, a multiply, and a pow. The pow is the expensive part: a general power function is a logarithm, a multiply, and an exponential, each of which is a polynomial or table approximation on the hardware. Compared with a texture fetch, the arithmetic is not the bottleneck; compared with a cheap blend in a fill-rate-bound pass, it can be, because it runs per channel per fragment.

Three alternatives exist, in increasing order of fidelity per unit cost.

The first is to approximate with $V^{2.2}$. The difference from the true curve is largest in the mid-tones and reaches roughly 1–2% of full scale in linear luminance around encoded 0.5; it is a smooth error, not a discontinuity, and no gradient shows a step because of it. The reason 2.2 is used at all is that it is close to the effective exponent of many display systems considered end to end, and close to the value that makes the sRGB curve nearly match a luminance-domain estimate of perceptual uniformity. For content authored against 2.2 and displayed with a 2.2 decode, using 2.2 on both ends cancels; mixing a 2.2 encode with an sRGB decode does not cancel, and leaves the mid-tones about a code value or two off in 8 bits.

The second is a small lookup table. A 256-entry table indexed by an 8-bit texture value gives the decoded float exactly for every representable input, with one dependent texture read and no arithmetic. This is the standard approach when sampling 8-bit sRGB textures on hardware without a native decode, and it is exact rather than approximate because the input alphabet is finite. The table costs 1 KiB at 4 bytes per entry, fits in constant memory or a small 1D texture, and eliminates the branch entirely. A 1024-entry table serves 10-bit inputs, and 16-bit inputs are not sensibly tabulated — 64K entries at 4 bytes is 256 KiB, which starts to compete with texture cache for other work.

The third is the hardware path: a texture format declared as sRGB, where the sampler applies the decode as part of the fetch. The decode is then free in shader instructions and costs nothing in bandwidth, since the stored data remains 8 bits per channel. The constraint is that the conversion is tied to the format, not to the semantic value, so the same bytes read through a linear format return encoded values with no warning. Mixing the two views of one texture in a single pass is the most common way to get a wrong result that looks plausible.

/* Exact 8-bit decode table; index is the stored code, value is linear light. */
static void build_srgb_table(float table[256]) {
    for (int i = 0; i < 256; i++) {
        float v = (float)i / 255.0f;
        table[i] = (v <= 0.04045f) ? v / 12.92f
                                   : powf((v + 0.055f) / 1.055f, 2.4f);
    }
}

The division by 255 matters. The curve is defined on the normalized range, and a table built by dividing by 256 puts every entry slightly low, with the error growing toward white; the top entry then decodes to 0.996 rather than 1.0, and a full-white surface never reaches the display’s maximum.

Encoding is not the same as the camera curve

A separate confusion attaches to the OETF in Rec. BT.709, which is also a power law with a linear segment near black. The two curves are numerically close but not identical: BT.709 uses a slope of 4.5 and a power of 0.45, with a join at 0.018 in linear terms, reflecting the different constraints of a camera encoding designed to distribute quantization error over a scene-referred signal. Applying the BT.709 curve where sRGB is meant shifts mid-tones by around a code value and shifts the shadow join by a factor of about six in linear luminance. The practical consequence is that a pipeline mixing the two conventions has a slightly wrong black point that no amount of white balancing fixes.

The distinction matters more for the inverse. The BT.709 inverse (the EOTF used in the standard) is a power of 2.4 applied to the whole range in some formulations, and a piecewise inverse in others; the difference between the variants is confined to the darkest few percent. For display of ordinary imagery the difference is below the threshold of visibility on most content, but a test pattern that ramps through the join will show a shift in the shadow detail if the wrong inverse is used.

Edge cases in implementation

Rounding at the ends. An encoder that clamps before applying the curve produces a flat top: values above 1.0 encode to exactly 1.0 and lose all highlight separation. An encoder that applies the curve and then clamps produces the same flat top but with the additional problem that the curve’s output for 1.0 is exactly 1.0 only if the constants are used exactly; a truncated constant leaves the maximum at 0.999 and a visible off-white.

Sign handling. The curve is defined for non-negative inputs. Applying it to a negative value — a common occurrence in a shader that has subtracted two colours — produces NaN from the pow, which propagates through the frame buffer and typically appears as a black or magenta pixel whose origin is impossible to locate from the image alone. Guarding the negative case with a reflection or a clamp before the pow is necessary if negative intermediates are possible.

Precision in half float. Storing decoded values in 16-bit float and then re-encoding is lossy near black, because the linear segment’s outputs are tiny and half float’s exponent range covers them but its relative precision is fixed at about 10 bits. A decode–encode round trip through half float shifts the darkest codes by a code value or two in 8-bit output, which is why intermediate targets that must round-trip exactly use more bits or stay encoded.

The broader pipeline question — whether to work in linear light at all — is covered in linear-light blending; the encoded form is a storage and display convention, and every arithmetic operation on light has to happen on the other side of it.

References

  1. A Standard Default Color Space for the Internet: sRGB — W3C
  2. IEC 61966-2-1: Multimedia systems and equipment — Colour measurement and management — Part 2-1: Default RGB colour space — sRGB — IEC
  3. Recommendation ITU-R BT.709: Parameter values for the HDTV standards for production and international programme exchange — ITU-R