The midpoint is where the light goes missing
Take a blue and a yellow and blend between them. Every tool you own does this the same way: it walks the red, green and blue numbers from one end to the other in a straight line. It looks like it should work. It produces grey.
The reason is that those numbers are not measurements of light. sRGB is gamma-encoded — the value 128 is not half as bright as 255, it is closer to a fifth. So averaging two encoded numbers does not average two brightnesses, and the further apart the two colours are, the more light quietly disappears in the middle.
sRGB — the top band
rgb(138, 137, 130)
Linear — the bottom band
rgb(186, 160, 162)
The fix is to decode, mix, and re-encode. Two lines. It is the single highest-leverage change in this entire essay, and it costs nothing.
vec3 toLin(vec3 c){ return pow(c, vec3(2.2)); }
vec3 toSrgb(vec3 c){ return pow(c, vec3(1.0 / 2.2)); }
vec3 lmix(vec3 a, vec3 b, float t){
return toSrgb(mix(toLin(a), toLin(b), t));
}One caveat worth stating plainly, because it cuts the other way: when you are matching something that was tuned in encoded space — a reference dither, an ASCII ramp — linearising makes your output more correct in isolation and wrong next to everything else. Correctness is contextual more often than people admit.
Banding is a rounding error you can see
A screen has 256 steps per channel. A shallow gradient across a wide area needs more than that, so pixels round to the nearest available colour — and because every pixel in a vertical run rounds the same way, the rounding error lines up into stripes.
The instinct is to add more colours. You usually cannot: the steps are the hardware. What you can change is where each pixel rounds. Offset the rounding threshold per pixel and the boundary stops being a straight line and starts being a texture your eye averages out.
The offsets come from an ordered matrix — the Bayer 8×8. Its arrangement is the whole idea: adjacent cells sit as far apart in the sequence as possible, so neighbouring pixels round in opposite directions and the error cancels over a pair instead of piling up along a row.
Light needs somewhere dark to land
This one took me longest to accept, because it sounds like a styling preference and it is not. Emitted light can only ever add to what is behind it. It has no way to subtract. So the brightness of the surface you put it on is a ceiling on how much of your effect survives.
Drag the ground under this light source. The source itself never changes.
This is why a shader that looks extraordinary on a black page looks like a faint tint on a white one, and why the answer is not to turn the effect up. There is nothing to turn up. The room is already brighter than the lamp.
An average cannot glow
Here is a mistake that survives review because the code reads correctly. You have two colour wells and you want them to blend, so you take a weighted average by distance. Reasonable. It is also structurally incapable of producing light.
An average is bounded by its inputs. Wherever two wells overlap, the result lands between them — so the one place that should be hottest is the place that goes flat. Summing instead lets the overlap exceed either source, which is what real light does when two beams cross.
Every effect in this system is built additively out of a dark ground for exactly this reason. It is also why bloom is a separate pass: light spilling past its own edges cannot be expressed by any formula that only looks at one pixel at a time.
What it adds up to
None of these are clever. They are four pieces of arithmetic, and each one is the difference between an effect that reads as light and one that reads as a tinted rectangle. The gap between amateur and professional work here is not taste or tooling — it is knowing which space you are doing the maths in.