sRGB vs linear color in shaders

· updated

Shaders and game engines often represent the same color in two different ways: display and linear. Display (sRGB) values are what you use in everyday work: websites, Photoshop, UI, and most colors you pick by eye. Linear values show up mainly in lighting math, and when colors are blended or mixed. Use the wrong set and midtones often look too bright or washed out.

Jump to engines: Unity · Unreal · Godot · WebGL and WebGPU

What is a display color?

A display color (also called sRGB or gamma-space) is the number system screens and ordinary images use.

Human vision notices small differences in dark tones more than in bright ones. If brightness were stored in evenly spaced steps from black to white, you would waste detail on highlights you barely see and get banding in the shadows. So sRGB stores color on a curve: more of the available codes go to the dark end, where the eye is picky.

That is why the familiar mid gray from a design tool sits near 128, 128, 128, or about 0.5 when written as floats from 0 to 1. Those numbers describe how bright the color looks on a monitor, not how much light is in a lighting calculation.

// Display (sRGB): mid gray as it appears on screen
vec3 displayGray = vec3(0.5, 0.5, 0.5);

What is a linear color?

A linear color is the number system lighting math wants.

In the real world, light adds in a straight line. Two identical lamps produce twice the light of one. Display values do not work that way (they sit on the sRGB curve), so you cannot multiply, blend, or light with them and expect a physically sensible result.

For the same mid gray on screen, the linear value is about 0.214, not 0.5:

// Linear: same mid gray, for lighting and blending
vec3 linearGray = vec3(0.214, 0.214, 0.214);

Put the display 0.5 into lighting code and the engine treats it as brighter light. Grays lift, contrast drops, and the result looks washed out.

The same idea shows up when mixing colors. A 50/50 blend of red and green done with display values comes out too dark. Done with linear values, it matches mixed light more closely. Lighting, alpha blending, mipmaps, bloom and anti-aliasing all want linear numbers for that reason.

Which should I use?

Situation Use
Lights, material multiply, blending, bloom, other lighting math Linear
Flat color with no lighting math Display (sRGB)

Non-color data (normal maps, roughness maps, masks) is already in the form math expects. Do not convert those as if they were sRGB colors.

Every color on the picker shows both forms. Copy the one that matches the row above.

What each engine expects

Unity

Color holds display values as floats from 0–1. Color32 is the same kind of color as whole numbers from 0 to 255. In a Linear color-space project, use color.linear when you pass a value into lighting or a shader yourself. Assigning a color to a material or UI Image is usually handled for you.

Full walkthrough: Unity Color vs Color32.

Unreal

FLinearColor expects linear values, not display (sRGB) floats from 0–1. FColor is whole numbers from 0 to 255 when you are not doing lighting math. Put display values into FLinearColor and grays often look too bright and faded.

Full walkthrough: Unreal FLinearColor vs FColor.

Godot

Color holds display values in most contexts. Godot 4 exposes srgb_to_linear() when you need the linear form.

WebGL and WebGPU

Depends on your texture and surface formats. An sRGB texture is usually converted when sampled; one without that flag is not. Prefer an sRGB output surface when you can, so the GPU handles the final conversion to display values.

Advanced: the exact conversion

Most code approximates the curve as pow(2.2). The real sRGB function is slightly different: a short straight segment near black, then a 2.4 power. This site uses the real curve:

// Display (sRGB) -> linear
float srgbToLinear(float c) {
    return c <= 0.04045
        ? c / 12.92
        : pow((c + 0.055) / 1.055, 2.4);
}

// Linear -> display (sRGB)
float linearToSrgb(float c) {
    return c <= 0.0031308
        ? c * 12.92
        : 1.055 * pow(c, 1.0 / 2.4) - 0.055;
}

For mid gray, display 0.5 becomes linear 0.2140. The pow(0.5, 2.2) shortcut gives 0.2176, close, but off where dark tones and banding matter.

Related guides:

Questions

Why does mid gray look wrong as 0.5, 0.5, 0.5 in a shader or FLinearColor?

0.5 is a display (sRGB) value: the usual 0–1 form of mid gray on screen. Lighting math expects a linear value for the same gray, about 0.214. Put 0.5 into lighting code and the gray looks too bright and washed out.

Should I use sRGB or linear values in my shader?

Use linear for lighting, blending, and anything that mixes colors. Use display (sRGB) values when you only need the color as it appears on screen and you are not doing lighting math. When the color goes into a light or a multiply, use linear.

Is sRGB the same as gamma 2.2?

Almost. The real sRGB curve is a bit more precise than a simple pow(2.2). For most game work the shortcut is fine; for dark tones and banding, use the real conversion (this site does).

Why does my color look washed out in Unreal?

You almost certainly put display (sRGB) values into an FLinearColor. Unreal treats those numbers as linear light, so mid gray at 0.5 becomes far brighter than intended. Use about 0.214 instead, or copy FLinearColor from a tool that outputs linear values. See the Unreal FLinearColor vs FColor guide.

More guides

  • OKLCh explained, and why HSL keeps letting you downHow OKLCh works, why its lightness matches what you see when HSL lightness does not, and how to use it to build tonal scales that actually look even.
  • Color format cheat sheetEvery common color format in one place: hex variants, CSS functions, shader vectors and engine constructors, with the channel ranges and ordering each one expects.
  • Choosing accessible text colorsHow WCAG 2 contrast ratios work, where they go wrong, what APCA does differently, and a practical method for picking text colors that pass and look right.