martedì 20 ottobre 2009

Be linear or be wrong: get rid of gamma correction!

This is a recurring topic.

Everything you need to know about gamma correction and linear color spaces is available for free, in GPU Gems 3, here.

When I received my copy of GPU Gems 3 it took a while to correctly understand what they were talking about in chapter 24. Call me dumb, but I think that chapter is a complex description of a simple problem.

I don't pretend to do better, but here's a simple description of the problem (toghether with a solution). I won't cover sRGB textures or mipmap generation.

It's nothing but a very simple post. After all many people just get textures as standard images, ask the rendering API to generate mipmaps for them and draw stuff on screen.

The problem
The human eye is more sensitive to dark colors and the precision of a display is limited to 256 shades (8-bits) per color channel. In order to increase the amount of shades we can distinguish on a monitor/lcd panel, a function is applied by the display. This operation is called "gamma correction", and is typically c=x^y, where x = original shade, y = something between 2.0 and 2.4 and c is the perceived color.
For simplicity, I will assume a gamma correction factor of 2, thus y = 2.0.

It is important to understand your display is doing something RIGHT NOW to make images look "better". The web page you're looking at is corrected by your display according to what your eyes sees better.

So?
The problem is in theory an intensity should scale linearly. If 1.0 is full intensity, then 0.5 should result in half the intensity. When we apply gamma correction we have:

1.0*1.0 = 1.0 -> full intensity (correct)
0.5*0.5 = 0.25 -> 1/4 intensity (wrong)

Thus, a gamma corrected color space is not linear.

How things can get wrong: an example
Alice the graphic artist has to pick up a solid grey color that can fit well with a white background. She creates a new white image, then draws a solid gray rectangle. She changes the rectangle color until she finds a good one. She picks that color and saves a 1x1 image.

Bob the programmer receives the 1x1 image to be applied as a texture to an interface button.

Alice is happy, she thinks that dark grey, of approximately 1/4th the intensity of full white, is going to fit well.
Bob is happy, he got a 1x1 image and all he needs to do is to load that texture and draw the button.

They are wrong.

The color received by Bob IS NOT THE SAME COLOR Alice saw on her display.

Alice had the perception of a color whose intensity is (0.25,0.25,0.25), since she saw something gamma corrected. The color saved in the image is actually (0.5,0.5,0.5)!

Bob draws the control... and the color is fine.

Bob and Alice think the problem is solved, actually they never thought it was a problem to display a color, but they don't know there's a subtle mistake in their image rendering process.

Why did Bob see the correct color?
Bob loads the texture (0.5,0.5,0.5), then renders the button background color. The display applies gamma correction so:

0.5*0.5 = 0.25

This is the same color Alice saw on her display.

How can things go wrong?
Bob is asked to apply a simple diffuse lighting model to the interface, so he goes for the standard dot(N,L).

Now, let's do the math. We assume dot(N,L) for a given pixel is 0.8.

We have 0.5*0.8 = 0.4

Then the display applies gamma correction:

0.4 * 0.4 = 0.16

We are multiplying the original color by 0.8. That means we want 80% of the original intensity.

Alice saw a color intensity of 1/4th (approx 0.25) on her screen, so we should get a color intensity of 0.20. But we have 0.16 instead of 0.20!

Obviously there's a mistake, as the output color is darker than the one we expected. It's an huge error, 20% darker than we expected!

What's the problem?

Problem number one: the original color isn't the one Alice saw on her display.
Problem number two: the display remaps our color in a non linear color space.

Which is the solution?
The solution is simple and is divided into two steps. Let's see the first.

The original color is not gamma corrected, as its intensity is 0.5. We need to work on the same color intensity Alice saw on her display.

So the first step after the texture sampling is to apply gamma correction:

0.5*0.5 = 0.25

Now we are working on the proper color shade.

0.25*0.8 = 0.2

This is the color we expect to see.

Bob tries to render the interface and he gets something very dark. Too much dark.

Bob forgot the display applies gamma correction AGAIN, so:

0.2*0.2 = 0.04

Thus the second step required is to cancel out the gamma correction, by applying the inverse operation, just before returning the color in our pixel shader.

0.2^0.5 = 0.4472

The display will gamma-correct 0.4472, so:

0.4472*0.4472 = 0.19998

Except for limited precision, Bob is now seeing the correct color shade.

In brief, the solution is the following:

- get the color
- apply gamma correction
- perform operations
- apply inverse gamma correction
- output to screen (this will cancel out the previous step)

Note the same also applies to constants like the color of a light.

It's easy to understand all those mistakes lead to wrong rendering output, expecially when dealing with multiple lights.

Be careful
Just a couple of hints.

Unless you are storing intermediate data on a buffer with 16-bit per channel, NEVER store gamma corrected colors in buffers, or you'll get horrible banding. The problem is by applying gamma correction to a color, you require more precision than the one available on a 8-bit channe. Let's do the math:

1.0/255.0 = 0.003921

This is the step between each intensity for an 8-bit channel. You can't be more precise than that.

"color as an 8-bit value in the image" vs "float you get in your pixel shader"
0 = 0.0
1 = 0.003921
2 = 0.003921+0.003921
.....
254 = 1.0f-0.003921
255 = 1.0

If you apply gamma correction and store the results in an 8-bit per channel buffer you can calculate which is the minimum color you can represent.

0.003921^0.5 = 0.06261

No color below 0.06261 can be represented.

Which color is 0.06261 in your image?

0.06261*255.0 = 15,96.

That means all colors between 0 and 15 will become 0 in your intermediate 8-bit buffer, if the float-to-int conversion is truncation. If it's done by rounding to the nearest integer, then all colors between 0-7 will become 0 while the ones between 8-15 will be 1. Either way, it's not good.

You may ask: what does happen to colors greater than 15?

The same principle applies.

Your image (8-bit numbers) has a color X and a color X+1.
Your shader interprets them as x/255.0 and x/255.0 + 0.0039. When you apply gamma, the difference between the two colors gets so small there's no way to distinguish them.

When you save your color you have lost information, thus the result is an awful rendering with color bands when you retrieve it.

The lesson is: IF THE INTERMEDIATE BUFFER HAS 8-BIT PER CHANNEL, ALWAYS STORE THE GAMMA UNCORRECTED (ORIGINAL) DATA.

Another solution is to use sRGB textures, have a look at the GPU Gems 3 chapter for that.

Which shader instructions should I use?
It's simple, assuming a 2.0 gamma.

Apply gamma correction:
col = col*col;

Cancel display gamma correction:
col = sqrt(col);

Assuming a 2.2 gamma things are a bit different

Apply gamma correction:
col = pow(col,2.2f);

Cancel display gamma correction:
col = pow(col,1.0f/2.2f);

Note: if you're using alpha testing or alpha blending then save the alpha channel value to a temporary variable before applying the color space transformations then restore it. Alpha channel in the original image is ok. Normal maps and height data are also ok.

How does a correct linear rendering looks like?
Sorry for the bad quality, here's a simple sphere with a single spot light.


Left: wrong rendering. Right: correct rendering.

They look different than the ones on GPU Gems 3 because the ambient term is zero.

Nessun commento: