sRGB output
Do the math in linear light, then encode to sRGB exactly once, on the way to the screen.
How it works
The number a fragment shaderThe GPU program that runs once for every pixel a triangle covers and decides its color. Fragment shader The GPU program that runs once for every pixel a triangle covers and decides its color.
That is good for storage and bad for math. Light adds up: two lamps give twice the light of one. Lighting, blending,
blur and every mixBlends from a to b by a factor t, along a straight line. GLSL calls it mix; HLSL and most node editors call it lerp. Linear interpolation (mix) Blends from a to b by a factor t, along a straight line. GLSL calls it mix; HLSL and most node editors call it lerp.mix(a, b, t) = a (1 − t) + b t
So a correct renderer keeps two kinds of numbers apart:
- Linear values measure light. All shading happens in them.
- sRGB-encoded values are for storage and display: image files, color pickers, and the final screen.
The rule is decode on the way in, encode once on the way out.
The curve
sRGB is close to a power of 2.2, with a short straight piece near black so the slope never becomes infinite. Both directions, per channel (alpha is never encoded):
vec3 srgbToLinear(vec3 c) {
vec3 low = c / 12.92;
vec3 high = pow((c + 0.055) / 1.055, vec3(2.4));
return mix(low, high, step(vec3(0.04045), c));
}
vec3 linearToSrgb(vec3 c) {
vec3 low = c * 12.92;
vec3 high = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
return mix(low, high, step(vec3(0.0031308), c));
}
pow(c, vec3(1.0 / 2.2)) is a common shortcut. It is close but not equal; it lifts the darkest tones a little.
Decoding the inputs
Colors a person picked or painted are sRGB. A color picker’s #808080 is encoded, and so is almost every photo and
hand-painted texture. Decode them before using them in math.
For textures the GPU can do it for you. A texture created as SRGB8_ALPHA8 is decoded to linear each time it is
sampled, before filtering, so a blurred or mipmapped sRGB texture still averages correctly. Data textures (normal maps,
noise, masks) are not colors and must stay plain RGBA8: decoding them bends the data.
Encoding the output
OpenGL can encode on write, too. When the color attachment of a framebuffer is an SRGB8_ALPHA8 texture, WebGL2 and
OpenGL ES 3.0 encode every value written to it, and blending happens in linear before the encode. (Desktop OpenGL asks
for glEnable(GL_FRAMEBUFFER_SRGB) as well.)
The WebGL2 canvas is the exception. Its default framebuffer has no sRGB mode: whatever the last shader writes is
shown as is, and the browser treats it as already encoded. So the last pass in WebGL2 has to call linearToSrgb
itself, after any tone mapping:
fragColor = vec4(linearToSrgb(clamp(color, 0.0, 1.0)), alpha);
Exactly once
Two mistakes are common, and each has a look:
- Never encoded. Linear values go to the screen raw. The image is too dark and too contrasty, and lit spheres show a harsh edge where light falls off instead of a soft one.
- Encoded twice. An engine already encodes and the shader encodes again. The image looks washed out and milky, with no real blacks.
Intermediate render targets should hold linear values in Banding & dithering Banding is the stripes a smooth gradient breaks into when stored with too few levels. Dithering adds tiny per-pixel noise before rounding, so the stripes become fine grain.RGBA16F, or in SRGB8_ALPHA8 if they must stay small.
Linear values in plain RGBA8 lose the darks: they get 3 of the 256 steps below 1% brightness, where the sRGB curve
spends about 25, and the shadows bandBanding is the stripes a smooth gradient breaks into when stored with too few levels. Dithering adds tiny per-pixel noise before rounding, so the stripes become fine grain.
In Godot
The 3D renderer works in linear and encodes at the end, so a spatial shader writes linear ALBEDO. Color uniforms and
color textures marked source_color arrive decoded. The 2D renderer is different: unless the viewport enables HDR 2D,
it works directly in sRGB, and source_color does nothing in a canvas_item shader. That is why this site’s Godot
template for 2D cards decodes its colors and encodes its result by hand.
Further reading
- Gamma Correction (opens in a new tab)Article
- sRGB (opens in a new tab)Reference