Thursday, October 26, 2017

sRGB versus Linear Colour Space

I keep screwing up my colour spaces, so I forced myself to write down the rationale behind it. A lot of it comes from Tom Forsyth.

CRT monitors respond non-linearly to the signal you drive it with. If you send it value 0.5 you get less than half the brightness (photons) of a pixel with a 1.0 value. This means, you cannot do light calculations in sRGB space. You need to do them in a linear space.

Once you have calculated the light for your rendered image, you need to send it to the monitor. LCD monitors are made to respond the same way as the old CRT monitors. So the values you send to your framebuffer will end up producing too few photons (too dark.)

To account for this, you need to convert your rendered image from linear colour space to sRGB colour space. This means that all dark pixels need to be brightened up. One way to do this, which avoids manual conversion, is to have OpenGL do this for you. You create a framebuffer that is sRGB capable. With SDL2 you do this with the SDL_GL_FRAMEBUFFER_SRGB_CAPABLE flag to SDL_GL_SetAttribute() function. In iOS you can use kEAGLColorFormatSRGBA8 drawable property of the CAEAGLLayer.

Once you have this special framebuffer, you tell OpenGL Core Profile that you want the conversion to happen. To do this, you use glEnable( GL_FRAMEBUFFER_SRGB );

Note that OpenGL ES3 does not have this glEnable flag. If the ES3 framebuffer is sRGB capable, the conversion is always enabled.

When my renderer does the lighting calculations, it will work in a linear colour space. After rendering, it would produce this linear image:

For proper display on a monitor, we need to account for the monitor's response curve, so we change it into the sRGB colour space. After conversion, the dark colours are brighter:

Yes, much brighter! But hey! What's up with those ugly colour bands? Unfortunately, by converting the values into sRGB, we lose a lot of precision, which means that 8-bit colour channels are no longer adequate. In 8-bits, the three darkest linear values are 0x00, 0x01 and 0x02. After converting these values to sRGB, they are mapped to 0x00, 0x0c and 0x15. Let that sink in... there is a gap of "1" between linear colours 0x00 and 0x01, but a gap of "12" between corresponding sRGB neighbours.

So when we convert from linear to sRGB, we should never convert from 8bit linear to 8bit sRGB. Instead, we convert using floating point linear values. If OpenGL is rendering to a sRGB capable framebuffer, it just needs to read from floating point textures. In my game, the ray tracer now renders to a floating point texture. This texture is then put on a full screen quad onto the sRGB framebuffer, resulting in a correct image:

And that's it for this installment, people. In the future I could perhaps gain more precision by rendering to a framebuffer with 10 bit channels. But for now this is good enough.

Please see part two of this blog post series where I explain what you need to do if you sample textures in your shader and want to do light calculations on that.

No comments:

Post a Comment