Gamma-correct rendering, part 3

Welcome to part 3 of the mini-series. It's going to be a bit more involved than the first two parts, so you might want to make sure you're sitting down with a nice cold glass of beer, or whatever your favourite tipple happens to be.

Gamma revisited

The main point to take away from part 1 is that sRGB, the colour space used by just about every computer display device on the planet, is non-linear and must be linearized before performing graphical operations such as alpha blending. It's not an obvious thing at all, and I didn't realise it myself until I started doing serious graphics work a few years ago. After all, pixel value 0 is black, 255 is white, and 128 looks to be about halfway in between, so it must be a linear scale, right? Wrong! In fact, a pixel with value 128 only emits 22% as much light as a pixel with value 255. Your eyes literally deceive you!

We can demonstrate this with some simple tests, without any specialist equipment. In the image below, we have a single pixel on the left, two pixels in the middle, and four on the right:

It's a little hard to gauge, but each dot should appear to be rather less than twice as bright as the one before it. Not convinced? Well, here is the classic gamma test pattern:

The square on the left is RGB level 128, supposedly 50% grey. The square in the middle is composed of alternating black and white lines, so by definition (assuming black is black) it emits 50% as much light as a white square of the same size. So how come the "50% grey" square looks so much darker? Well, this is the effect of the gamma curve. The square on the right has RGB levels of 186, and on a properly set up monitor it should be about the same brightness as the middle square.

I'll leave it to the boffins to say what the exact relationship is between light intensity and the perception of brightness, but it was determined in the 1960s that perceived brightness is roughly proportional to the cube root of the actual intensity. This is similar to the ear's logarithmic perception of loudness - a linear increase in perceived loudness actually corresponds to an exponential increase in sound power.

Now, it just so happens that the response curve of cathode ray tubes follows a power curve with exponent 2.2. This approximately cancels out the non-linearity of human brightness perception - not exactly, but roughly. That's why computer monitors appear to be "perceptually linear". For the sake of compatibility, amongst other reasons, modern LCD monitors mimick the response curve of the old CRT technology. This response curve is standardised as sRGB (OK, it's not quite that simple but let's not get into that here) so we can be pretty sure that any properly set up PC monitor will conform. Also JPEG/PNG files normally contain pixel data in the sRGB colour space. To convert the pixel data to linear RGB, we just need to apply a power curve with exponent 2.2, and exponent 0.4545 to convert it back. To be absolutely clear, linear RGB is the colour space where the individual pixel values are directly proportional to the number of photons emitted. This is what we require in order to perform computer graphics operations simulating the addition of light.

OK, that enough about that. Any questions, see me after class.

Gamma-correct rendering in 8-bit RGBA space

In part 1, I showed how to perform gamma-correct rendering onto an RGB surface. That's all very well, but often we need to blend bitmaps together. This could be because we want to render to a temporary surface for subsequent compositing, or it could be that we want to overlay a transparent bitmap loaded from a PNG file, for example. Unfortunately AGG doesn't currently support gamma-corrected blending for RGBA pixel formats, so we're going to have to make some changes to agg_pixfmt_rgba.h.

Perhaps the first thing to mention is that "on-the-fly" gamma-correction is only going to work with "plain" RGBA formats. Performing gamma correction on premultiplied pixel values first requires dividing out the alpha, which is likely to be computationally expensive and liable to loss of precision. For intensive graphical applications, on-the-fly gamma correction is not recommended. Instead, you should perform all rendering at 16 bits per pixel, then convert to sRGB at the last possible moment before sending the data to the display device.

Having said that, let's see what we can come up with. First we need a gamma-aware blender, similar to blender_rgb_gamma:

//===================================================blender_rgba_gamma

template<class ColorT, class Order, class Gamma> class blender_rgba_gamma

{

public:

typedef ColorT color_type;

typedef Order order_type;

typedef Gamma gamma_type;

typedef typename color_type::value_type value_type;

typedef typename color_type::calc_type calc_type;

enum base_scale_e

{

base_shift = color_type::base_shift,

base_mask = color_type::base_mask

};

enum gamma_scale_e

{

hi_res_mask = gamma_type::hi_res_mask

};

//--------------------------------------------------------------------

blender_rgba_gamma() : m_gamma(0) {}

void gamma(const gamma_type& g) { m_gamma = &g; }

//--------------------------------------------------------------------

AGG_INLINE void blend_pix(value_type* p,

unsigned cr, unsigned cg, unsigned cb,

unsigned alpha,

unsigned cover=0)

{

calc_type a1 = p[Order::A];

if (0 == a1)

{

p[Order::R] = cr;

p[Order::G] = cg;

p[Order::B] = cb;

p[Order::A] = alpha;

}

else

{

calc_type const & a2 = alpha;

calc_type a2inv = base_mask - a2;

calc_type a5 = a2inv * a1;

calc_type a3 = a2 + a5 / base_mask;

if (a3 > 0)

{

calc_type r1 = m_gamma->dir(p[Order::R]);

calc_type g1 = m_gamma->dir(p[Order::G]);

calc_type b1 = m_gamma->dir(p[Order::B]);

calc_type r2 = m_gamma->dir(cr);

calc_type g2 = m_gamma->dir(cg);

calc_type b2 = m_gamma->dir(cb);

calc_type a4 = base_mask * a2;

calc_type a6 = base_mask * a3;

calc_type r3 = (r2 * a4 + r1 * a5) / a6;

calc_type g3 = (g2 * a4 + g1 * a5) / a6;

calc_type b3 = (b2 * a4 + b1 * a5) / a6;

p[Order::R] = m_gamma->inv(r3 < hi_res_mask ? r3 : hi_res_mask);

p[Order::G] = m_gamma->inv(g3 < hi_res_mask ? g3 : hi_res_mask);

p[Order::B] = m_gamma->inv(b3 < hi_res_mask ? b3 : hi_res_mask);

p[Order::A] = a3;

}

else

{

p[Order::R] = 0;

p[Order::G] = 0;

p[Order::B] = 0;

p[Order::A] = 0;

}

}

}

private:

const gamma_type* m_gamma;

};

Hmmm, pretty scary looking. The division by base_mask can potentially be optimised, but I don't see any way to avoid the division by a6. As with the existing blender_rgb_gamma class, we must construct the object with a null m_gamma pointer which will be set later via the gamma() function.

Now we need to find a way to drill down through the pixel format class to let the blender know about the gamma table. This is somewhat trickier than with pixfmt_alpha_blend_rgb, because pixfmt_alpha_blend_rgba doesn't define a blender object directly as a member, but instead adds an extra level of indirection via the copy_or_blend_rgba_wrapper class. In order to be able to pass a gamma table pointer down to the blender object, we must add a cob_type member to pixfmt_alpha_blend_rgba and also add the member function blender(), which provides access to a blender object that we add as a member of copy_or_blend_rgba_wrapper. Phew, it's complicated! But there's still more to do. We need to make the copy_or_blend_rgba_wrapper's members non-static, and update all the previously static function calls that now need to be dispatched through member objects. Finally, we need to define the individual pixel format classes such as pixfmt_rgba32_gamma that initialize the underlying pixfmt_alpha_blend_rgba class and pass though the reference to the gamma table. The changes are in the attached file agg_pixfmt_rgba.h at the bottom of this page.

Well, it wasn't pretty, but we got there in the end. Now what? To demonstrate the use of these new gamma-corrected pixel formats, I'll take the blur.cpp demo and modify it so that it renders to an RGBA surface (or "back buffer") and then superimposes it on to a patterned background. Here's a quick reminder of how the original demo looks:

First of all, we add a gamma lookup table to the_application:

#include "agg_gamma_lut.h"

...

class the_application : public agg::platform_support

{

typedef agg::gamma_lut<agg::int8u, agg::int16u, 8, 12> gamma_type;

gamma_type m_gamma_lut;

...

The gamma table is initialized with the value 2.2 in the constructor. Now let's change the app's pixel format to BGRA and add a window-sized back buffer, using agg::platform_support's create_img() function:

int agg_main(int argc, char* argv[])

{

the_application app(agg::pix_format_bgra32, flip_y);

app.caption("Modified AGG Example. Gamma-correct compositing");

if(app.init(440, 330, 0))

{

app.create_img(0, 440, 330);

return app.run();

}

return 1;

}

The point of this article is to show the difference that gamma correction makes, so let's add a "Gamma correction" checkbox:

As with all controls, this involves initializing the member variable in the constructor, informing the_application of the control by calling add_ctrl(), and remembering to add the appropriate render_ctrl() call in on_draw().

Now we move the on_draw() code into a template function called render(), so that we can switch at runtime between gamma-corrected and non-gamma-corrected rendering.

agg::cbox_ctrl<agg::rgba8> m_enable_gamma;

template<class PixFmt> void render(PixFmt& dest, PixFmt& pixf)

{

...

}

We rewrite on_draw() so that it instantiates the render() template either with pixfmt_bgra32_gamma or pixfmt_bgra32_plain, according to whether the m_enable_gamma checkbox is selected:

virtual void on_draw()

{

if (m_enable_gamma.status())

{

typedef agg::pixfmt_bgra32_gamma<gamma_type> pixfmt;

pixfmt screen(rbuf_window(), m_gamma_lut);

pixfmt backbuffer(rbuf_img(0), m_gamma_lut);

render(screen, backbuffer);

}

else

{

typedef agg::pixfmt_bgra32_plain pixfmt;

pixfmt screen(rbuf_window());

pixfmt backbuffer(rbuf_img(0));

render(screen, backbuffer);

}

}

Now we reimplement the drawing code so that it renders into the back buffer, then draws a Photoshop-style chequered background before compositing the back buffer on top.

template<class PixFmt> void render(PixFmt& dest, PixFmt& pixf)

{

typedef agg::renderer_base<PixFmt> ren_base;

// Define a renderer for the destination

ren_base ren_dest(dest);

// Draw a Photoshop-style background

const unsigned gridsize = 16;

const unsigned xmax = dest.width() / gridsize;

const unsigned ymax = dest.height() / gridsize;

const agg::rgba8 white(255, 255, 255);

const agg::rgba8 grey(224, 224, 224);

for (unsigned y = 0; y <= ymax; ++y)

{

for (unsigned x = 0; x <= xmax; ++x)

{

unsigned x1 = x * gridsize;

unsigned y1 = y * gridsize;

unsigned x2 = x1 + gridsize - 1;

unsigned y2 = y1 + gridsize - 1;

ren_dest.copy_bar(x1, y1, x2, y2, (x + y) % 2 ? white : grey);

}

}

// Define a renderer for the back buffer

ren_base renb(pixf);

renb.clear(agg::rgba8::no_color());

m_ras.clip_box(0,0, width(), height());

...

Before doing any drawing in the back buffer, we must first initialize the pixel data to all zeros. Note the use of rgba8::no_color() - we could just as well say agg::rgb8(0, 0, 0, 0), but we must take care to avoid the default constructor as it leaves the member data uninitialized! The drawing code then continues much as before, with with a few minor modifications.

We change stack_blur_rgb24 to stack_blur_rgba32, and change the Step template parameter of pixfmt_alpha_blend_gray from 3 to 4, to reflect the move from rgb24 to rgba32. At the end of on_draw(), we alpha-blend the back buffer over the chequered background we drew earlier.

The demo now looks like this:

Looking good! The background grid demonstrates that the alpha blending is working properly. The effect of the gamma-correction makes quite a difference to the weight of the text and shadows. It might seem at first sight that the gamma correction is making things look too "light" but that is not the case at all. In fact, the gamma-corrected version on the left is how things are supposed to look, whereas the version on the right is incorrectly darkened by the effect of gamma.

As we are working in RGBA space, let's add an extra "Alpha" checkbox for when the "Channels" mode is selected. Now we can compare against the screen shot from the Antigrain web site:

Well, this looks very different indeed. Because we are blurring the alpha channel in the back buffer before compositing, there is no "glow" effect as there was in the original demo.

The attentive reader may have noticed that the actual blurring effect is still not being done with gamma correction. But in the context of this article it doesn't really matter - the aim was to provide a means of performing gamma-correct rasterization and compositing. Adding gamma correction to the blurring routines is left as an exercise for the reader. However, the gamma correction techniques I present here are not intended for intensive image processing applications. There is no real possibility of using premultiplied alpha (where each pixel's RGB values are scaled by its alpha value, increasing the efficiency of blending operations). As I mentioned before, for heavyweight graphical applications, I suggest performing all graphical operations in a linear RGB pixel format of at least 16 bits per pixel, converting the final result to 8-bit sRGB as required. On the other hand, applications having only moderate requirements for polygon rasterization and compositing may benefit from the convenience of on-the-fly gamma correction, with only a relatively minor decrease in performance compared with with gamma-incorrect rendering.

Postscript

Just for fun, we can jack up the saturation levels to illustrate rather vividly the damage done by gamma:

In the uncorrected case, see how the anti-aliasing around the green letter "a" is completely ruined, and the blurry edge of the blue shadow has turned black.

Posted by Jim Barry, 2010-06-26.