Skip to content
Rendering · OutputIntermediate

sRGB output

Do the math in linear light, then encode to sRGB exactly once, on the way to the screen.

Live demo coming soon

How it works

The number a fragment shader writes is not the amount of light the screen gives off. Screens expect their input encoded with the sRGB curve, which spends more of the 256 available steps on dark tones, where eyes notice small differences, and fewer on bright ones. A stored 0.5 comes out as about 21% of full brightness, not 50%.

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 mix between colors assume values that behave that way, which encoded values don’t. Averaging an encoded black and white gives 0.5, which displays as a dark gray; the correct average of the light is 0.5 in linear, which encodes to about 0.74.

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 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 band.

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