Gamma Encoding
I only occasionally have to think about gamma in my work, and I always have to remind myself exactly what it is and how to get it right. In this article I will explain it in the way that I think about it, mostly as a memory aid for myself, but hopefully others will also find it beneficial.
I’m the sort of person who gets confused by small errors in interpretation when topics such as this are discussed. Articles on gamma often talk about how the eye sees brightness in a power curve, and then show lots of nice gradients to illustrate this. The problem is, it’s not strictly true. To be precise, the way we judge a gradient follows a power curve. We actually see brightness is exactly as it is. Photons enter the eye and we see that level of brightness. It’s how we judge relative brightness that follows the power curve. And there is a very simple reason for this, that we can distinguish more dark shades than light shades.
“The brain can distinguish more dark shades than light”
That one simple statement really sums up gamma for me. My programmer brain can then boil gamma down to a simple problem of how to encode values into 8 bits in the sensible way. It then become an encoding problem that I’m used to dealing with. I’m used to thinking of compression, or encryption, so this is just another encoding. Note that from now on I won’t need to mention anything else about the human eye of the way we interpret colour, that one simple statement is enough. Here brightness means actual photons entering the eye, not perceptual brightness judged in a relative way by our brains. From now on it’s all just about how we go about encoding, decoding and manipulating encoded images and we don’t have to mention human perception again – phew!
If we had infinite precision with which to store brightness then gamma would not be an issue. Gamma is only an issue because we quantise brightness into a limited number of bits. And because we can see more shades of dark than light it makes sense to use more bits for dark shades. Gamma is as simple as that, it’s a way of encoding brightness that uses more bits for dark shades. Encoding brightness values in this way is called gamma encoding.
Figure 1 – Shows how brightness values are encoded into 8 bits (0 – 255)
If we encode brightness values to the 0-256 range, instead of 128 meaning 50% brightness, we push all values to the right and now a higher value (such as 186) would mean 50% brightness. Remember, we are trying to use more bits for darker values. The numbers at the top of Figure 1 are what we store in memory. The numbers are the bottom show the brightness emitted by your monitor.
The most direct way to convince yourself that you are currently working with gamma encoded values is by looking at Figure 2. This shows that an encoded value of 186 really does correspond to 50% brightness. The square on the left is a checkerboard of 0% brightness pixels and 100% brightness pixels. This simulates 50% brightness using only black and white pixels. Your eye will see 50% of the photons that it would from a pure white square, so it appears as mid-grey. The square on the right is a solid square of pixels at 186. These two squares should appear as the same brightness (for this to work your browser must be set to 100% zoom, and DPI scaling disabled). What is happening here is that the driver takes the gamma encoded value of 186 and decodes it to 50%, which is what the monitor displays.
Figure 2 – Illustrating that an encoded value of 186 is shown on screen as 50% brightness.
Gamma encoding is a non-linear mapping
To map to encoded brightness:
encoded = pow(brightness, 1/2.2)
To map from encoded brightness:
brightness = pow(encoded, 2.2)
RGB images are almost always in this gamma encoded colour space (more bits for dark colours). If you store a photo as an RGB image it will usually be stored as a gamma encoded image. If an artist manually creates a texture using a standard monitor they will be making it look right working in a gamma encoded colour space. For example, if they want to create a pixel that is 50% brightness by eye they will end up picking a value of around 186 (see Figure 1). So, unless otherwise stated, assume all RGB images and textures are gamma encoded.
The fact that an image is gamma encoded, and the fact that the driver is expecting a gamma encoded image to send to the monitor means that to display an image correctly we don’t need to be concerned about gamma encoding at all. We simply load up the image in memory and send it to the driver. From a pixel shader we simply sample the texture and return the result. The image was gamma encoded when it was originally stored or created, and is decoded by the driver before it is sent to the monitor, we just passed it straight through.
Usually, we only have to think about gamma when we want to manipulate the image or texture in some way. Some examples are; changing the brightness of an image, blending pixels, lighting textures. All of these operations manipulate brightness in some way. But the problem is that gamma encoded pixels are not storing brightness directly, they are storing encoded brightness. In order to do anything with brightness we first need to decode back to linear colour space.
Say, for example, that we want to reduce the brightness of an image by half. We have an encoded pixel value of 255, so we might think we can just divide by 2, use 128, and get a pixel on the monitor at 50% brightness. Not so! Remember, the encoded value is not specifying the amount of brightness directly. It is encoded. As we have already seen 186 actually corresponds to 50% brightness, not 128. So when we reduce the brightness by half, we really want to end up with an encoded value of 186. To get this right, we need to decode, multiply, and then encode the result.
Here we have an input pixel gamma encoded as 255, which is decoded to 100% brightness:
255 -> 100% brightness pow(255/255, 2.2) = 1.0
We then do our manipulation of the pixel, in this case reducing it by 50%.
We then need to gamma encode the value before we store it
50% -> 186 255 * pow(0.5, 1/2.2) = 186
This applies to any operation we want to do in terms of brightness. First we must decode the pixel value to get the actual brightness. Then we can do whatever operation we want on the brightness. Then we must gamma encode the resulting value before we store it.
To tell OpenGL that you have a gamma encoded texture and you want it automatically decoded when you sample it, use an sRGB format. We can then do whatever manipulations we want in linear colour space, such as blending or lighting. This decoding happens in hardware so it is free. For example, if your sRGB texture contains pixels at 186 brightness, and you sample that texture you will get back a value of 0.5. If the texture is not marked as sRGB you will get back a value of 0.73.
Note that I didn’t say to use sRGB for all gamma encoded textures. It is perfectly valid to have gamma encoded textures in normal formats (not sRGB). In fact I do this for my UI engine. I mostly just want to load up an image and display it on screen, so as I stated above, no gamma encoding or decoding is necessary. The driver expects gamma encoded values, so I just load the texture and output it exactly. No need for sRGB.
If we have used an sRGB texture, once we have finished manipulating the brightness in linear (non-encoded space), at some point we need to convert back to the gamma encoding because that’s what the display driver expects. We could do the conversion ourselves in the pixel shader before we return the result:
return pow(colour, 1 / 2.2)
Alternatively we could do the conversion in a post process after we have combined all of our scene elements.
If we want to go with the first option, we can tell OpenGL to do this automatically with this option:
glEnable(GL_FRAMEBUFFER_SRGB)
Again, this happens in hardware so it is free. Just to be clear, enabling GL_FRAMEBUFFER_SRGB tells OpenGL that we are returning a linear (non-encoded) brightness from all pixel shaders, and that OpenGL should gamma encode the value before sending it to the driver.
Alpha Blending
The alpha value of a pixel is not gamma encoded (in most cases). This is because it is not a brightness value. The alpha value can be interpreted in various different ways, depending on the current blending mode set. For example, if we have GL_SRC_ALPHA and GL_ONE_MINUS_SRC_ALPHA blend modes set, an alpha value of 0.5 means half of the source brightness and half of the destination brightness. It is not a brightness value itself, it’s just telling us how to combine the source and destination brightness values. For this reason, the alpha is not changed by any gamma encoding or decoding.
This does bring up an important point: alpha blending is manipulating brightness values, so for the reasons outlined above it shouldn’t be done with gamma encoded values. Before we do an alpha blend operation we must decode from gamma space, then blend, then gamma encode the result. People often forget this, even if a UI image only uses alpha for anti-aliasing around the edges, that anti-aliasing will be wrong if it is blended in gamma encoded space. The case where this is often overlooked is in rendering fonts, which I will cover in detail in the next post.
However, if you are letting OpenGL do the blending it will do the proper encoding/decoding for you. This is because it knows the format that the pixel shader is returning. Again, this can be done in hardware. If the shader is returning gamma encoded values (the default) then OpenGL knows to decode first, then blend, then encode. If we use GL_FRAMEBUFFER_SRGB, that tells OpenGL that we are returning non-encoded values, so OpenGL knows that it can just do the blend and then gamma encode the result.
We only need to think about gamma correct alpha blending if we are doing a blend in software, or doing it ourselves in the shader. For example, to display an alpha texture onto a plain background we might do:
// fore_colour is gamma encoded foreground colour, for example from a // texture (not sRGB). Before we do the blend we need to decode it into linear colour space vec4 decoded_fore = pow(fore_colour, 2.2); // the background colour is also gamma encoded, maybe passed in as a shader // constant. So that must also be decoded vec4 decoded_back = pow(back_colour, 2.2); // blend in linear space vec4 blended = decoded_fore.a * fore_colour + (1.0 - fore_colour.a) * decoded_back; // and then convert to gamma space return pow(blended, 1.0 / 2.2);
Note that we do not decode the alpha value, we use the original alpha value from the texture.
Summary
The only thing you need to remember about human perception of brightness is that we can distinguish darker shades better than lighter shades. Because we encode brightness into a limited number of bits it makes sense to use more bits for the darker shades. This is called gamma encoding, and it uses a power curve encoded = pow(brightness, 1.0 / 2.2). Before we manipulate encoded brightness values we first need to decode them to get the actual brightness, then do our manipulation, and then encode them before we store the pixel. The driver expects gamma encoded value and decodes them to display the image on screen. It is especially important to consider gamma when manually alpha blending in shaders or in software. Font renders often get this wrong, which will be covered in the next blog.
References:
http://blog.johnnovak.net/2016/09/21/what-every-coder-should-know-about-gamma/