## Gamma-Correct Rendering

With consumer-level hardware now capable of rendering high dynamic range image data, the days of the 8-bit sRGB framebuffer are numbered. Programmers of next-generation graphics devices are able to model lighting systems to high accuracy, then tone-map these values into a displayable range for conventional 8-bit sRGB equipment, such as PC monitors.

The graphics pipeline from source art to final output is complicated, and requires the programmer to work in several different colour spaces along the way. In this article I’ll give a brief overview of colour spaces, and then detail a commonly overlooked area in the texture pipeline where gamma is important.

## The sRGB Standard

The sRGB colour space is based on the monitor characteristics expected in a dimly lit office, and has been standardised by the IEC (as IEC 61966-1-2). This colour space has been widely adopted by the industry, and is used universally for CRT, LCD and projector displays. Modern 8-bit image file formats (such as JPEG 2000 or PNG) default to the sRGB colour space.

A value in the sRGB colour space is a floating-point triple, with each value between 0.0 and 1.0. Values outside of this range are clipped. An sRGB colour from this [0, 1] interval is commonly encoded as an 8-bit unsigned integer between 0 and 255.

The pivotal fact to remember about sRGB is that it is **non-linear**. It roughly follows the curve *y = x ^{ 2.2}*, although the actual standard curve is slightly more complicated (and will be listed at the end of this article). A graph of sRGB against gamma 2.2 looks as follows:

This mapping has the nice property that more resolution is given to low-luminance RGB values, which fits the human visual model well.

## The Gamma Function As An Approximation

As can be seen by the above graph, the sRGB standard is very close to the gamma 2.2 curve. For this reason, the full sRGB conversion function is often approximated with the much simpler gamma function.

Please note that the value associated with the word *gamma* is the power value used in the function *y = x ^{ p}*. Unfortunately gamma is often associated with brightness, which is not exactly what it is doing. The full [0, 1] interval is always mapped back onto the full [0, 1] interval.

## What Maths Work In This Colour Space?

In general your lighting pipeline should be done in linear space, so that all lighting is accumulated linearly. This is the approach taken in many next-generation engines, and is the only way to ensure that you are being physically correct.

However, assuming that the gamma function approximation is good enough, you can still perform *modulate* operations. In this case we have some constant *A* that we wish to modulate our sRGB source data *x* with, and store the result in sRGB as *y*. In linear space this would be written as:

y^{ 2.2} = A x^{ 2.2} = ( A^{ 1/2.2} x )^{ 2.2}

Since we are working only in the [0, 1] interval, we can remove the power from both sides and work in the sRGB space itself. In which case:

y = A^{ 1/2.2} x

So if we convert our constants into sRGB, then modulate operations can still be performed. However, there are only very few operations that work this way. Additive operations (which are used in additive lighting models, or for alpha-blending) **cannot** be reformulated to work in a gamma 2.2 space, simply because the space is non-linear. If you wish to have a correct additive lighting model, then you have to work in a linear space, which will mean that you need a higher-precision framebuffer to at least match the low-luminance granularity of sRGB.

## An sRGB Example: Mip-Mapping

If you bilinearly filter an image in the sRGB colour space, you always end up with a filtered colour that is darker than the correct result. The amount of error grows as the range of the input colours grows. Here’s a worst-case example of a black-and-white grid filtered with and without colour space conversion:

The center image contains alternating black and white pixels. The left and right images were generated by down-sampling the image and then up-scaling back to the original size. The left image was down-sampled in linear space, the right image was down-sampled in sRGB directly.

On a correctly-calibrated monitor in standard lighting conditions, the left-hand image and the center image should appear the same overall brightness. This is because the linear-space average was 50% grey, which gets mapped to a value of 186 in sRGB. The right-hand image contains the sRGB value of 128, but this is only a 21.4% grey in linear space, so should appear much darker.

Most game textures do not have anywhere near as high luminance variation, and as such the errors introduced by filtering sRGB colours directly are nowhere near as harsh as in this example. However, any high-variation data (such as precomputed lightmaps) should be filtered as linear light values before each mip level gets saved in sRGB.

## Conclusion

The current generation of graphics hardware can move data from sRGB into linear space during a texture read instruction, and assuming that you make use of higher-precision frame buffers, it is cheap to transform a final render back into sRGB for use on the display device. In tools we are concentrating more on correctness, so the extra conversion time necessary to move between colour spaces can be ignored.

Since the next generation of rendering engines will be expected to get all the subtleties of complex lighting equations correctly simulated, it is essential to be colour-space aware throughout your art pipeline and rendering system. Hopefully this article has highlighted areas where colour spaces are important – the next section contains some of the conversion equations that I’ve used in both art tools and rendering code.

## Useful Data

**sRGB to linear RGB**: rgb (sRGB), RGB (linear RGB)

R = | r / 12.92 | for r <= 0.04045 |

( (r + 0.055)/1.055 )^{ 2.4} |
for r > 0.04045 | |

G = | g / 12.92 | for g <= 0.04045 |

( (g + 0.055)/1.055 )^{ 2.4} |
for g > 0.04045 | |

B = | b / 12.92 | for b <= 0.04045 |

( (b + 0.055)/1.055 )^{ 2.4} |
for b > 0.04045 |

This is commonly approximated as X = x^{ 2.2} for all channels.

**linear RGB to sRGB**: RGB (linear RGB), rgb (sRGB)

r = | 12.92 R | for R <= 0.0031308 |

1.055 R^{ 1.0 / 2.4} – 0.055 |
for R > 0.0031308 | |

g = | 12.92 G | for G <= 0.0031308 |

1.055 G^{ 1.0 / 2.4} – 0.055 |
for G > 0.0031308 | |

b = | 12.92 B | for B <= 0.0031308 |

1.055 B^{ 1.0 / 2.4} – 0.055 |
for B > 0.0031308 |

This is commonly approximated as x = X^{ 1/2.2} for all channels.

**XYZ to linear RGB**: [D65 white point]

R = | 3.2406 X | - 1.5372 Y | - 0.4986 Z |

G = | -0.9689 X | + 1.8758 Y | + 0.0416 Z |

B = | 0.0557 X | - 0.2040 Y | + 1.0570 Z |

**linear RGB to XYZ**: [D65 white point]

X = | 0.4124 R | + 0.3576 G | + 0.1805 B |

Y = | 0.2126 R | + 0.7152 G | + 0.0722 B |

Z = | 0.0193 R | + 0.1192 G | + 0.9505 B |

Hi! I was surfing and found your blog post… nice! I love your blog. Cheers! Sandra. R.

sandrar10 Sep 09 at 11:48 pm

This blew my mind. I always assumed that checkerboard pixels would appear to be the same shade as 50% gray, but it’s clear now that 186/255 is more correct. Thanks for the straightforward explanation!

Matt Enright10 Sep 10 at 9:03 pm