Add light in linear, not in gamma
Photons add. The numbers in an image file do not, because they carry a gamma curve baked in so that 8 bits are spent where the eye can see them. Add two of those numbers together and you have performed an operation that corresponds to nothing physical.
The place to look is the dim outer falloff, not the bright centers. On the left the whole surround is lifted into a broad, pale, washed-out haze; on the right it stays dark and the colors stay separate. Same numbers, same glow function, same output space. The only difference is where the addition happened.
Why the error is always in the same direction
The sRGB curve is concave: it lifts dark values a long way and bright values very little. So encoded numbers are systematically larger than the linear light they represent, and adding two of them overshoots. Gamma-space additive blending always errs toward too bright, never too dark.
And because the lift is largest at the bottom of the range, the error is worst in the dim regions, which is the opposite of where most people look for it. An encoded 0.1 represents about 0.006 of linear light, roughly sixteen times less than the number suggests. Add a few of those together in the wrong space and a falloff that should be nearly black becomes a visible gray wash. That is why the symptom usually shows up as "my glows are muddy" rather than as blown-out cores.
It also explains the hue shift. The three channels are lifted by different amounts depending on their values, so the ratio between them, which is what hue is, changes as you add.
The two strips are the same numbers. Half-way along, the top strip is mid-gray to the eye, while the bottom is much darker, because linear 0.5 is genuinely half the light and half the light does not look half as bright. That gap is the entire problem in one picture.
The rule
// decode anything authored as sRGB
vec3 lin = pow(srgbColor, vec3(2.2));
// do ALL the work here: add, multiply, blur, blend, tonemap
lin = lightA + lightB + lightC;
// encode exactly once, at the very end
vec3 out = pow(lin, vec3(1.0/2.2));
Once, at the end. Every extra round trip costs precision, and every operation done on the wrong side of it is wrong in a way that looks almost right, which is what makes this bug so durable.
What it breaks besides blending
- Blurs and bloom. A blur is a weighted average, so a gamma-space blur is wrong for exactly the same reason. Bright highlights bleed too far and too pale.
- Antialiasing. A coverage blend between a bright and a dark pixel in gamma space produces edges that look too dark, which is where the old complaint about "muddy" antialiasing came from.
- Alpha compositing. Same operation, same error.
- Anything that then gets tonemapped. A tonemapper expects linear input. Feed it encoded values and its curve is applied to the wrong thing entirely.
Rules of thumb
- Decode on input, encode once on output. Everything between is linear.
- If overlaps look too bright and too pale, suspect this before you touch the values.
- 2.2 is a fine approximation. The exact sRGB piecewise curve matters near black.
- Your framebuffer format may already be doing the conversion. Doing it twice is its own bug.
- Middle gray is not 0.5. Authored 0.5 is roughly 0.21 in linear.
One email when something new goes up. No newsletter, no schedule, nothing else.
Double opt-in, so watch for a confirmation email. Unsubscribe any time.