Eventhough gamma-correct rendering is absolutely crucial in order to get good and realistic lighting results, many programmers (sadly) still don’t care about it. Today, I want to show how different the visual results of lighting in gamma-space vs. linear-space actually are, and what you can do about it.
Much has been said and written about gamma-correct rendering/lighting already, and there’s a few good resources on the internet, especially this blog post and a chapter from GPU Gems 3.
If you haven’t read anything about gamma, gamma-correction, gamma-space, etc. yet, here’s a very quick run-down of basic facts:
- The intensity of light generated by a physical device (your monitor) usually is not a linear function in terms of the applied input signal, e.g. the response of a CRT monitor follows approximately a pow(x, 2.2) curve.
- Most of the images you are viewing, e.g. pictures on the internet, JPEGs stored on your PC, etc. are stored in gamma-corrected space, so they end up looking “nice” on your monitor.
- Rendering, lighting, etc. cares about intensities, not some gamma-corrected values. Hence, all lighting should be done in a linear space, where the mathematics behind the equations actually make sense (1+1 = 2).
It is absolutely crucial that everybody understands the first two points – we’ll come to linear-space lighting in a minute.
- Because the response curve of a CRT monitor (and LCD/LED, for that matter) follows a pow(x, 2.2)-curve, doubling the input values does not lead to double the intensity seen on the monitor. This means that if you e.g. output 0.2 from your renderer, outputting 0.4 instead will not lead to double the intensity seen on your monitor.
- If you could see the actual values stored in e.g. JPEG pictures, all the pictures would look wrong, because they are stored in gamma-corrected space. Gamma-corrected space means that a pow(x, 1.0/2.2) operation has been applied to all values before storing them, so that the CRT gamma-curve and the gamma-correction applied to the pictures cancel out each other, leading to a linear response.
So, what does that mean, visually? Because a picture says more than a thousand words, let us consider two point lights slightly overlapping each other, and see what the difference between gamma-space and linear-space lighting is:
The above shows two point lights without any attenuation, each at 10% intensity (the output from the shader was 0.1). As should be clear, the overlapping portion should be twice as bright, but it actually isn’t, because the pow(x, 2.2)-curve caused by the monitor messes with the results.
If we apply a gamma-correction step (raising all values to 1.0/2.2) before outputting the values, the result looks like this:
The above is what this simple lighting example should look like in your renderer! Because lighting usually is done in linear space (e.g. adding different light source intensities in the shader, accumulating lighting using blending), the results needs to be gamma-corrected before they are sent to the monitor.
If you’re still not convinced yet, let us add simple N.L lighting and attenuation to our light sources. First the incorrect version, just adding two points lights at 25% intensity without applying any gamma-correction:
This is probably what you are used to seeing, and boy is it wrong! What the lighting actually should look like is the following, gamma-corrected version of lighting done in linear space:
We’ve only talked about diffuse lighting in this example, the difference with specular lighting is even bigger, because the monitor’s gamma-curve messes with the lights’ falloff, the color of the specular highlight, and more. Make sure to check out this excellent post if you’re looking for more examples.
In essence, if you’re working on PC/Xbox360/PS3 and you don’t care about gamma-correct rendering, all the lighting just looks wrong, and artists will have a hard time getting realistic and good-looking results, because incorrectly rendered images cannot be compensated by e.g. applying color correction in a post-process step. Certain parts of the image will always be too dark or too bright, depending on the “correction” done.
Furthermore, more advanced features like HDR, physically-based rendering, and image-based lighting are almost impossible to get right because non-linear lighting will not look correct under varying lighting conditions.
If you care about being gamma-correct, how can it be done using modern graphics hardware? It’s not that hard, there’s two major things you have to look out for:
- Before storing final values in the back buffer, apply a gamma-correction step to them, e.g. do pow(x, 1.0/2.2) as the very final step in your final shader. Of course, a better alternative is to use a sRGB-backbuffer format and let the hardware do the conversion for you, for free. In Direct3D 9, this used to be a separate render state, in Direct3D 11 just use one of the sRGB formats.
- Make sure to undo gamma-correction when using images such as diffuse/albedo textures in your shader. Again, this can be done by applying pow(x, 2.2) when reading from textures, but hardware offers dedicated sRGB texture formats for this purpose as well – use them, they are free.
As a final note, make sure to undo gamma-correction only for images which actually store brightness/intensity data, such as albedo textures. You certainly shouldn’t use sRGB textures for normal maps, alpha-channels, high-dynamic range data (e.g. 16-bit formats), etc., but this is something that can be dealt with off-line, in the asset conditioning pipeline.
Further recommended reading: this, that, and the other.
So, correct me if I’m wrong:
Gamma-correct rendering is all about doing your math (e.g. combining light-values) in LINEAR space, and not in GAMMA-space. Most legacy hardware will do everything in GAMMA-space, yielding incorrect results.
What you are doing is applying a GAMMA-ramp to the result. I am guessing you are doing that without setting the SRGB-flag on the rendertarget, yielding the incorrect results you are seeing.
Yes and no.
It’s not so much about what legacy hardware will do, but rather about what each and every monitor will do. Forget about lighting for a second, and consider a very basic example instead:
If you output a value of 0.2f into e.g. your backbuffer, what ends up being displayed is some quantity approx. equal to 0.2f^2.2f = 0.029f – this is what you see.
If you want the same value to appear twice as bright, you might want to output 0.4f into your backbuffer, but again, what’s being displayed is a quantity approx. equal to 0.4f^2.2f = 0.133f.
As can be seen from this simple example, values which you wanted to be twice as bright will be more than 4 times as bright because 0.133f / 0.029f (the actual quantities you see) equals 4.58f.
This simple 0.2f vs. 0.4f example is exactly the thing you’re seeing when you just add two lighting values in the shader, and blindly output the result into the backbuffer. In order to alleviate this problem, you could output pow(0.2f, 1.0/2.2) and pow(0.4f, 1.0/2.2) instead, so that the implicit pow-operation applied by the monitor nicely cancels out, and you actually end up seeing something which is twice as bright.
I didn’t really get the second part of your question – I *am* using both sRGB textures and sRGB backbuffers and do all lighting in linear space, and the screenshots above show both incorrect and correct results. The incorrect results are simply there to show how much of a difference incorrect vs. correct rendering actually makes.
Make sure to check out the post I linked to – it explains the whole process of getting data from your camera, using that as e.g. a JPEG image, and how gamma-incorrect rendering messes up your whole lighting pipeline.
– ach, I see I wrote “without setting the srgb-flag”… should have been “while setting the srgb-flag”… sorry about that. Easy to get this stuff wrong, I’ll probably speak more crap below 🙂
My point is this: The hardware is perfectly capable of generating gamma-corrected output images. Setting the srgb-flag on your rendertarget makes it do this automagically.
In your examples, you appear to be explicitly doing the conversion to gamma-space as well as telling the hw to do the conversion – giving you a double-conversion. This is the final result you show. Look at the following link, and notice how non-overlapping areas stay roughly the same (the difference being due to the incorrect blending, in gamma-space).
The main reason why gamma-correction is needed, is to give more resolution in the dark range, as eyes are very sensitive to darks. If we are doing 8bit-per-channel rendering, we can do gamma-compression in order to give more precision in the darks. Not enough precision means banding. If we do 16bit-per-channel rendering, we can keep everything linear – except for the very final step (usually tonemapping) that does the gamma-compression required for our 8bit-per-channel monitors to display a correct image.
If we have intermediate render-steps, it is important to do the conversions back-and-forth correctly. If you do blending, it is also important that this is done correctly, i.e. in linear-space. The reason this has been a hot topic over the past couple of years, is because legacy-hardware (i.e. current consoles) is broken in various interesting and subtle ways.
Yes, the hardware is capable of generating gamma-corrected output images. Please check my original post where I state how it can be done in the shader using pow() instructions, and how a better alternative would be to use the sRGB textures and backbuffer formats (in both Direct3D 9 and Direct3D 11) because they are essentially free. I really fail to see where I said anything different.
However, judging from your statemenet “how non-overlapping areas stay roughly the same” it seems as if you fail to realize that not even constant colors stay the same if you don’t render them “gamma correct”.
I’ll try to explain it with an even simpler example this time: Take a DXGI_FORMAT_R8G8B8A8_UNORM backbuffer, and clear it with 0.5f. If you take a look at the result in e.g. Photoshop, the values will be 128 – so far, so good.
Now change the backbuffer to DXGI_FORMAT_R8G8B8A8_UNORM_SRGB, and clear it with 0.5f again. Taking a look at the result in photoshop, you will realize that the values are actually 186, because a pow(0.5f, 1.0f/2.2f) = 0.7297f = 186 has been applied in order to cancel the monitor’s gamma-curve of pow(0.5f, 2.2f). Whether the pow-operation is applied because of the sRGB format or because it’s done manually in the shader doesn’t matter for now.
Now put both a black and white rectangle on either side of the picture, and tell me which grey-value actually looks like lying halfway between black and white? 128 or 186? The answer is 186, and that’s the reason why an inverse pow-operation has to be applied, because otherwise 1.0f will not appear twice as bright as 0.5f.
So, if you just take one single light source (no blending, no adding of light, etc.), and have it output a single value of 0.5f (no attenuation, no L*N, etc)., the colors will end up looking differently depending on whether a pow-operation has been applied or not (or whether a UNORM or UNORM_SRGB format has been used, for that matter). Can we agree on that?
Again, I can wholeheartedly recommend the post I linked to, it explains the problem in far more detail, and too has a simple example of how to “fix” the output in the shader, done as a very last operation:
1. float specular = …;
2. float3 color=pow(tex2D(samp,uv.xy),2.2);
3. float diffuse = saturate(dot(N,L));
4. float3 finalColor = pow(color * diffuse + specular,1/2.2);
5. return finalColor;
If you leave out everything but the lightcolor, you’ll end up with:
4. float3 finalColor = pow(color, 1/2.2);
5. return finalColor;
So it should be clear that not even the non-overlapping areas of light sources will stay the same color! The difference between non-gamma-correct and gamma-correct rendering is far more pronounced for darker colors than it is for brighter ones, so don’t let yourself be fooled by screenshots showing very bright lightsources.
I agree with pretty much all of your text, I have read the articles, and after vigorous discussions with a colleague of mine, I think your examples may be what you intended, but we do not quite understand what they are showing 🙂
Thanks for your explanations.
Also it is worth to mention the importance of gamma-correction for such things as mipmapping, alpha blending, antialiasing, and any kind of image operations (upscale, downscale, blur, etc.).
Very good points, thank you for mentioning them!
There was an article about mip-mapping by Jonathan Blow in GDMag a few years back… here it is: http://number-none.com/product/Mipmapping,%20Part%202/index.html
Pingback: Gamma Correction and Tone Mapping – Castle Game Engine