How does one efficiently implement alpha blending, without botching gamma?

Alpha blending is basically the following expression:

result_color = (dst_color*src_alpha - dst_color*src_alpha*dst_alpha + src_color - src_color*src_alpha)/(1 - src_alpha*dst_alpha)
result_alpha = src_alpha*dst_alpha

The formula is unusual because I use inverted alpha, where 0 means opaque and 1 - completely transparent. Rationale: many CPUs have instruction to compare with zero, but no instruction to compare with 0xff000000. Also, I can pass just 0xRRGGBB to set_pixel instead of 0xffRRGGBB for opaque color.

For gamma-packed 8-bit RGB I've managed to implement that expression somewhat efficiently in early 90ies style code:

void ablend(int dr,int dg,int db,int da, int *sr,int *sg,int *sb,int *sa) {
  int ya = (da * *sa)>>8;
  uint32_t d = idiv_lut[255 - ya];
  uint8_t *st = ab_lut[255 - *sa];
  uint8_t *dt = ab_lut[*sa - ya];
  *sr = ((dt[dr] + st[*sr])*d)>>8;
  *sg = ((dt[dg] + st[*sg])*d)>>8;
  *sb = ((dt[db] + st[*sb])*d)>>8;
  *sa = ya;

Obviously working with RGB, without unpacking it first, produces incorrect result (it is the most common graphics programming pitfall), so they must be unpacked (i.e. pow(x,2.2)), and now one can't use ab_lut anymore, because it would require 2^30 bytes, and replacing division with multiplication would be impossible on a 32-bit system either.

Give that, this question implies some side questions:

  1. Is using float the only solution for unpacked RGB processing?

  2. How can I perform alpha blending with gamma correction?

  3. Is it worth it (L1 cache-wise) to still use gamma packed sRGB, instead of a simple array of 4 32-bit float R,G,B,A components, or would memory access become a bottleneck on a majority of CPUs?

So maybe I just have this 90's mindset, where keeping data size small meant difference between 1 and 60 frames per second, and today we can safely use as many additional bytes as required?

  • \$\begingroup\$ Updating the blending comparison to use inverted alpha as you describe, I still don't get results from the formula above that are consistent with other standard alpha blending approaches. Have you considered using conventional premultiplied alpha to reduce the complexity of the formula (in particular, removing the division)? \$\endgroup\$
    – DMGregory
    Aug 12 '19 at 11:38
  • \$\begingroup\$ Strange. It works fine for me. I've never heard about premultiplied alpha, but as I understand it means destructively modifying RGB values by doing (1-source_alpha)*source_rgb beforehand. I can't use that, because I do a lot of other stuff, like saturation, brightness and hue shifting during blitting, so I can have stylistic effects, like when objects inside fog of war also lose in saturation, in addition to becoming darker. \$\endgroup\$ Aug 12 '19 at 13:45
  • \$\begingroup\$ It's only destructive if we view colour & alpha as two separate things (ie. a thing can somehow be both invisible and pink, at once). We can view it as a more physical way to model light, in terms of absorption (alpha) & emission (rgb), which lets us combine additive & layer effects in one pass. This makes several blending operations like interpolation or mipmapping behave better at the fringes of objects, where non-premult textures typically need colour padding to avoid artifacting. Since you were going to multiply by alpha at the end anyway, it doesn't destroy data you were planning to keep. \$\endgroup\$
    – DMGregory
    Aug 12 '19 at 14:00
  • \$\begingroup\$ Yeah. But it is a big tadeoff. Consider an RTS or RPG, where characters can become invisible. Stereo-typically invisibility is conveyed by making characters half transparent. Now to do that, you need to add 0.5 to the sprite's alpha during blitting. If the sprite already has premultiplied alpha, that can't be done. So I think doing premutliplied alpha is a kind of premature optimization, which can shot one into leg and limit his toolset. It is okay as the late stage optimization, when everything is set in stone. \$\endgroup\$ Aug 12 '19 at 14:05

Most games don't do blending in gamma space.

Instead, on read, we'll unpack a gamma-encoded source into linear RGB. Our calculations, blending, and intermediate storage are all in linear space.

Then, once we've fully composited the frame we want to display, then at that time we'll perform gamma encoding to convert it into the right curve for display.

This can cut down on the total number of gamma calculations you need to do in a blending-heavy scene with lots of overdraw, making the calculations you do frequently cheap and saving the expensive stuff for a batch at the end. I'd expect this to typically be faster than adapting all of your blending operations to occur in gamma space.

If we're using a low bitdepth format for intermediate storage - like 8 bpc / 4 Bpp - then this linear working space does entail some information loss. Two different gamma-encoded colour values could decode and quantize to the same value when stored in 8 bits of linear space, then recompress back to the same value, and some gamma-encoded values might be skipped (have no 8-bit pre-image in linear space), leading to subtle banding artifacts. You'll see this in some older games in particular.

As you say, you can store your colours as floats to maintain high precision throughout all your intermediate calculations and storage, at the cost of quadrupling the data size up to 16 Bpp and needing floating point math (something we tend to take for granted on GPUs made to chew through floats). Whether that's a desirable trade-off for your use case is something you'll need to profile and evaluate for yourself.

You can also go halfway, say decoding to 16 bpc / 8 Bpp. This is a halfway step, giving you a compromise of storage efficiency between 8 bpc and float, while still gaining 256 times the precision - enough to eliminate most banding.

Or, since you're doing this in software and aren't bound by conventionally-supported GPU types, you can use custom solutions. Like for instance decoding your sRGB into linear YCbCr to use as your intermediate. This is a linear transformation of linear RGB space, so it's fast to convert back and forth, and it doesn't introduce non-linearity into any of our familiar colour blending operations. Its main feature is that it concentrates the luminance information to which the eye is most sensitive in the Y channel. So you could store 2 bytes for luminance and just one for each chromaticity channel (and either one or two for alpha, for a 5-6 Bpp total), to get back in the ballpark of 8 bpc / 4 Bpp storage compactness. This is the same trick that JPEG and video compression codecs use to concentrate their limited data payload on the signals with the most perceptual impact.

  • \$\begingroup\$ I didn't knew about YCbCr, but I've already devised my own format, called LUV, where L is the linear light component (usual 16-bit GPU float), while U,V are the coordinates inside CIE XYZ like triangle, it also uses matrix transformation, but has a nice property of also supporting fast HDR, hue shift and saturation. Although it still requires unpacking that 16bit float into full 32bit float, during blitting, but recent x86 CPUs have special opcode for that. I do need hue shift, because I recolor stuff like fonts and character clothes. Unfortunately, alpha channel now doesn't fit inside 32bits. \$\endgroup\$ Aug 12 '19 at 13:58

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.