Skip to content
Fundamental · Color mathIntermediate

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 sRGB standard spends them unevenly instead: the stored value is roughly the amount of light raised to the power 1/2.2. Mid-gray as stored, 0.5, is only about 21% of white’s light.

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.

Further reading