Computer Graphics · Color and Display

Why Blending in Gamma-Encoded Space Produces Wrong Colours

What the display transfer function does to a weighted average of two colours, why source-over compositing belongs in linear light, and where the correction sits in a renderer.

A frame buffer holds numbers, not light. The mapping from those numbers to emitted radiance is fixed by the display and by the encoding convention, and for essentially every consumer format that mapping is nonlinear. Alpha blending, anti-aliasing coverage, additive accumulation of light, and texture filtering are all operations defined on radiance. Performing any of them on the stored numbers instead of on the light they represent gives a result that is arithmetically clean and physically wrong. The error is largest exactly where it is most visible: the midpoint of a gradient, the edge of a dark sprite, the falloff of a specular highlight.

The sRGB display curve is close enough to a pure power of 2.2 that the argument can be made with a power law and then refined. If a stored code $c$ in $[0,1]$ produces roughly $c^{2.2}$ of the display’s maximum luminance, then the average of two codes is not the average of two luminances. Averaging 0 and 1 gives 0.5 in code, which emits about $0.5^{2.2} \approx 0.218$ of peak. The luminance halfway between black and white is 0.5, and reaching it requires a code near 0.73. In 8-bit terms, blending black and white at 50% should land on roughly code 188, not code 128.

The weighted average that is not an average

Source-over compositing is a weighted sum. With premultiplied colours $C_s$ and $C_d$ and source alpha $\alpha_s$,

$$C = C_s + (1 - \alpha_s),C_d$$

That equation is linear in radiance, which is the whole point: two partially transmitting surfaces in front of a background attenuate the background by the product of their transmittances, and transmittance is a ratio of radiances. When the frame buffer stores encoded values, the arithmetic still runs, but on $\mathrm{enc}(L)$ rather than $L$. Since $\mathrm{enc}$ is concave and steep near zero, the composed value is systematically darker than the physical result for any blend involving a dark operand, and the size of the error depends on the pair of colours rather than on the alpha alone.

The same argument covers three other operations that are easy to mistake for pixel bookkeeping. Multisample resolve averages subsamples; each subsample is a radiance estimate, so the resolve is a radiance average and must be linear. Bilinear texture filtering interpolates neighbouring texels; if the texels are sRGB-encoded, the interpolated value is too dark on the dark side of every edge, which shows up as a widened dark fringe on sprite borders and on magnified text atlases. Mipmap generation is a sequence of box filters over the same encoded values, so a chain built without decoding drifts progressively darker as the levels shrink, and the error is worst for high-contrast content where the coarse levels average large separations.

Additive blending is not exempt. Adding two encoded values corresponds to adding two light sources only if the encoding is linear; at 0.5 + 0.5 the encoded sum saturates at 1.0 while the physical sum of two half-intensity emitters is twice the half-intensity radiance, which clips to white in both cases but for different reasons and with different amounts of headroom below saturation.

Where the correction belongs

There are three places to put the decode and encode, and they differ in cost, precision, and how much of the pipeline has to know about colour.

The first is to keep render targets in a linear format — 16-bit floating point per channel, or 32-bit float where precision demands it — and apply the encoding once, at present time or at the point where the image leaves the renderer. Every blend, every accumulate, every resolve is then linear by construction, which removes a whole class of bugs rather than fixing one instance. Bloom, motion blur, depth of field, and any other effect that convolves the image also require linear data to be correct, so a linear target is usually mandatory for reasons unrelated to blending. The cost is bandwidth: 16 bits per channel is twice the storage of 8-bit RGBA, and a half-float target with an alpha channel occupies 8 bytes per pixel. Half float also has a precision limitation worth knowing: it holds about 10 bits of mantissa, so values near 1.0 are spaced roughly $2^{-11}$, and a linear half-float target can therefore develop banding in smooth dark gradients unless the encode adds dither or the target carries more bits.

The second is to use a frame buffer format whose values are declared to be sRGB-encoded, such as an 8-bit sRGB render target. The API contract for such a format is that stored values are decoded when read and encoded when written, including around the blend operation, so the hardware performs the conversion on both operands and on the result. This keeps blending correct while retaining 8 bits per channel, which matters for memory and for bandwidth on tile-based renderers. What it does not fix is quantization: the value written back is encoded and then rounded to 8 bits, so the error of the round trip is a step in encoded space, and near black that step is a small absolute luminance change but a large relative one. The format also only helps if every consumer of the target respects it; reading it as a plain 8-bit texture returns encoded values, and a shader that then applies its own decode has decoded twice.

The third is to convert in the shader. This is what a pipeline does when it has no sRGB-aware targets and no float targets, and it costs two conversions per blended surface: decode both operands, blend, encode the result. Doing it correctly means the encode happens exactly once per write, which is hard to guarantee when several passes each think they own the final value. Doing it incorrectly is common enough that the failure has a name of its own — the double gamma application that leaves mid-tones too dark and crushes shadow detail.

/* Correct source-over for one channel, straight alpha, encoded storage. */
static float srgb_decode(float v) {
    return (v <= 0.04045f) ? v / 12.92f
                           : powf((v + 0.055f) / 1.055f, 2.4f);
}

static float srgb_encode(float v) {
    return (v <= 0.0031308f) ? v * 12.92f
                             : 1.055f * powf(v, 1.0f / 2.4f) - 0.055f;
}

/* dst already holds an encoded value; src is an encoded colour with coverage a. */
float blend_channel(float dst_enc, float src_enc, float a) {
    float d = srgb_decode(dst_enc);
    float s = srgb_decode(src_enc);
    return srgb_encode(s * a + d * (1.0f - a));
}

The thresholds are the sRGB ones rather than a bare power law because the piecewise definition matters near black, where the slope of a pure 2.2 curve would go to zero and lose the last few code values entirely. The exact curve and its linear segment are treated in the sRGB transfer function.

Alpha, coverage, and the premultiplied convention

Straight alpha stores colour independent of coverage, so the colour of a fully transparent texel is undefined and filtering across an alpha edge mixes in whatever colour was written there. Premultiplied alpha stores coverage-scaled radiance, which makes filtering and blending both linear operations with no special case for zero alpha, and it makes the source-over equation a single add. Premultiplication is therefore only meaningful when the multiplication happens in linear light: an authoring tool that multiplies an sRGB-encoded colour by 0.5 has produced a colour whose decoded luminance is not half of the original. The common symptom is a halo or a dark rim around a soft-edged sprite, visible when the same asset is composited over both light and dark backgrounds.

Coverage from rasterization is linear by definition — a pixel half covered by a triangle receives half the radiance — so anti-aliasing blends correctly only if the destination is linear. The pathological case is thin dark geometry on a light background: a one-pixel-wide black line at 50% coverage in encoded space resolves to code 128 rather than code 188, making the line look heavier and darker than its geometric coverage warrants.

When gamma-space blending is the right answer

Two situations call for reproducing encoded-space blending deliberately.

The first is fidelity to an existing compositor. Interface toolkits, page renderers, and image editors have historically composited in the encoded space of their working format, and assets are authored against that behaviour. A renderer that switches to linear compositing for such content will produce different edge appearance than the tool the designer used, and the difference reads as a regression even though it is more correct physically. Matching the reference means matching its arithmetic, not its intent.

The second is that some operations are defined on encoded values by specification rather than by physics. Image resampling for display, certain sharpening and unsharp-mask filters, and most legacy video processing operate on the encoded signal because that is what the standard describes. Applying them to linear data changes their effect, and a filter tuned against encoded input will over-sharpen or halo on linear input.

Failure modes that survive the fix

Linear compositing removes one class of error but leaves several adjacent ones. Textures flagged as sRGB are decoded by the sampler; a shader that also decodes gets a value near zero for mid-grey, and the surface renders almost black. Compressed texture formats store blocks quantized in whatever space they were compressed in, and while sRGB-encoded blocks distribute error more evenly across the tonal range, the format flag must match the encoding or every sample is off by a gamma curve. Float targets carry no encoding metadata, so an importer that assumes sRGB on read will silently misinterpret them.

The subtlest case is a pipeline that is linear in the middle but encodes too early. Encoding before the last convolution — before bloom, before a final grade, before a resolution scale — reintroduces exactly the nonlinearity the linear target was built to avoid, and the result is a highlight that clips to a flat white instead of rolling off. Encoding belongs at the boundary where the image becomes display-referred, and the boundary should be crossed once.

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. Digital Video and HD: Algorithms and Interfaces — Charles Poynton, Morgan Kaufmann