Gamma vs linear
Why stored colors are not amounts of light, how to decode and encode sRGB, and why lighting and blending belong in linear values.
How it works
Light adds up. Two equal lamps give twice the light of one, and a pixel half covered by a white shape receives half the light of a fully covered one. Shader math assumes that kind of number, one proportional to the amount of light: a linear value. The numbers stored in images and sent to screens are not linear. They are encoded with a curve, and mixing the two up is one of the most common reasons a shader looks muddy, dark or harsh.
Why colors are encoded
An 8-bit channel has 256 steps. Human vision is far more sensitive to differences between dark tones than between
bright ones, so spreading those steps evenly over the amount of light would waste most of them on highlights and leave
visible bands in the shadows. The sRGBThe standard encoding of screen colors. Stored values follow a curve close to a 1/2.2 power of the light, so they must be decoded before lighting or blending. sRGB The standard encoding of screen colors. Stored values follow a curve close to a 1/2.2 power of the light, so they must be decoded before lighting or blending.linear = ((c + 0.055) / 1.055)^2.4 for c > 0.04045
The exact sRGB curve is a short straight segment near black and a power curve above it:
vec3 srgbToLinear(vec3 c) { // decode: stored value to amount of light
vec3 lo = c / 12.92;
vec3 hi = pow((c + 0.055) / 1.055, vec3(2.4));
return mix(hi, lo, lessThanEqual(c, vec3(0.04045)));
}
vec3 linearToSrgb(vec3 l) { // encode: amount of light to stored value
l = max(l, 0.0); // pow is undefined below zero
vec3 lo = l * 12.92;
vec3 hi = 1.055 * pow(l, vec3(1.0 / 2.4)) - 0.055;
return mix(hi, lo, lessThanEqual(l, vec3(0.0031308)));
}
mix with a boolean vector picks per component: the second argument where the condition is true, the first where it
is false. pow(c, vec3(2.2)) and pow(l, vec3(1.0 / 2.2)) are close approximations, slightly off near black.
The rule
Decode what comes in, do the math in linear, encode once at the end.
- Decode colors made by people: image textures, colors picked in an editor, hex codes from a design.
- Compute in linear: lighting, blending, mixing, blurring, averaging, fading.
- Encode the final color once, as it is written to the screen.
Breaking the rule has recognizable symptoms. A gradient mixed between encoded colors sags dark and dull in the middle:
mixing encoded red and green halfway gives vec3(0.5, 0.5, 0.0), a dim olive, while mixing the linear values and
encoding the result gives about vec3(0.74, 0.74, 0.0), a clear yellow. Blurs darken bright details, light falls off
too quickly around a lamp, and antialiased edges look thin and jagged.
Data is already linear
Only colors are encoded. A normal map, a roughness or height map, a noise texture, a mask or a flow map holds numbers,
and decoding them as sRGB bends those numbers. Engines ask for this per texture (Godot’s source_color hint, Unity’s
sRGB checkbox, Unreal’s sRGB flag), and in WebGL2 it is the texture’s format.
Let the GPU do it
The conversions are cheap but you rarely need to write them. A texture stored as SRGB8_ALPHA8 is decoded by the GPU
on every read, before filtering, so texture() returns linear values. A framebuffer in an sRGB format encodes on
write. WebGL2’s default canvas has no sRGB format, so a hand-written WebGL2 shader calls something like linearToSrgb
itself before writing its output. The cards on this site follow the rule: color controls arrive decoded, the effect
works in linear values, and the code around it encodes once.
Precision is the price of working in linear. Linear values stored in an 8-bit texture band visibly in the dark tones, because those are exactly the steps sRGB was made to protect. Keep intermediate results in floating-point or 16-bit targets, and save 8-bit storage for encoded colors.