ESSAY

Why your gradient looks muddy

Four reasons a gradient looks cheap. Each one has a control you can drag, and each demo runs the real arithmetic — so if a claim here is wrong, you can break it yourself.

01

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)

At this position the sRGB mix carries 35% less light than the linear one. The loss peaks in the middle and vanishes at both ends, which is why it reads as a dead band through the centre rather than as a wrong colour.

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.

02

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.

Both images have exactly 6 colours in them. The only difference is where each pixel is rounded — and rounding every pixel the same way is what turns a gradient into stripes.

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.

0328402341042481656245018582612444361446638602852206230542233511431339415119592749175725154773913455376331552361295321
Every value from 0 to 63, arranged so that any two neighbours are as far apart in the sequence as possible. That is the entire trick: neighbouring pixels round in opposite directions, so the error cancels across a pair instead of accumulating across a row.
03

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.

The light source is unchanged at every position on that slider. Only the surface it lands on moves — and past about sixty per cent there is nothing left to see, because the ground is already brighter than the thing illuminating it.

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.

04

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.

Watch the overlap. Mixed, it lands somewhere between the two colours — it has to, that is what an average is. Summed, it gets brighter than either well on its own, which is the only thing that reads as light.

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.