Skip to main content
GameDev.net gamedev.net

Linear and sRGB Gamma Correction

by GameDev.net · GLSL ES 3.00 (WebGL2) · 30 Aug 2026

Use in your engine

Run the shader to adjust these controls.

What it demonstrates

Gamma correction is about performing lighting arithmetic in linear space and using an sRGB transfer only at storage or display boundaries. This demo presents an explicit before-and-after split over a generated scene of gradients, coloured patches, and overlapping lights, where incorrect encoded-space exposure is easy to recognize.

Source Revision 1

Author notes are linked to specific lines.

Shader inputs

void mainImage(out vec4 fragColor, in vec2 fragCoord)

Called once per pixel. Write the colour to fragColor.

iResolution vec3
Viewport size in pixels (z is the pixel aspect ratio).
iTime float
Seconds since the shader started.
iTimeDelta float
Seconds since the previous frame.
iFrameRate float
Frames per second, smoothed.
iFrame int
Frames rendered since the start.
iMouse vec4
Mouse position: xy while held, zw of the last click.
iDate vec4
Year, month, day, and seconds within the day.
iChannel0 sampler2D
Texture bound to channel 0.
iChannel1 sampler2D
Texture bound to channel 1.
iChannel2 sampler2D
Texture bound to channel 2.
iChannel3 sampler2D
Texture bound to channel 3.
iChannelResolution vec3[4]
Pixel size of each bound channel texture.
iChannelTime float[4]
Playback time of each channel, in seconds.
iSampleRate float
Audio sample rate, always 44100.
Common1
1 vec3 srgbToLinear(vec3 color) {
2 vec3 low = color / 12.92;
3 vec3 high = pow((color + 0.055) / 1.055, vec3(2.4));
4 return mix(low, high, step(vec3(0.04045), color));
5 }
6
7 vec3 linearToSrgb(vec3 color) {
8 color = max(color, 0.0);
9 vec3 low = color * 12.92;
10 vec3 high = 1.055 * pow(color, vec3(1.0 / 2.4)) - 0.055;
11 return mix(low, high, step(vec3(0.0031308), color));
12 }
13
14 vec3 renderLinearScene(vec2 p, float time) {
15 vec3 color = vec3(smoothstep(-0.48, 0.48, p.x)) * 0.45;
16 color += vec3(0.02, 0.04, 0.08);
17 vec3 patches[3] = vec3[3](vec3(0.72, 0.06, 0.03), vec3(0.04, 0.62, 0.12), vec3(0.03, 0.16, 0.82));
18 for (int i = 0; i < 3; i++) {
19 vec2 center = vec2(-0.28 + float(i) * 0.28, 0.16);
20 vec2 q = abs(p - center) - vec2(0.11, 0.085);
21 float mask = 1.0 - smoothstep(0.0, 0.012, max(q.x, q.y));
22 color = mix(color, patches[i], mask);
23 }
24 vec2 moving = vec2(0.20 * sin(time * 0.45), -0.16);
25 float glow = exp(-18.0 * dot(p - moving, p - moving));
26 color += vec3(0.95, 0.48, 0.10) * glow;
27 float rampMask = 1.0 - smoothstep(0.0, 0.012, abs(p.y + 0.35) - 0.055);
28 color = mix(color, vec3(smoothstep(-0.42, 0.42, p.x)), rampMask);
29 return color;
30 }
31
32 vec2 linearScenePoint(vec2 fragCoord) {
33 return (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
34 }
35
Buffer A1
1 void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2 vec3 linearColor = renderLinearScene(linearScenePoint(fragCoord), iTime);
3 vec3 encoded = linearToSrgb(linearColor);
4 fragColor = vec4(encoded, 1.0);
5 }
6
Main Image3
1 uniform float uExposure; // @param 0.25..2.0 = 0.55 "Exposure"
2 uniform float uSplit; // @param 0.1..0.9 = 0.5 "Comparison split"
3
4 void mainImage(out vec4 fragColor, in vec2 fragCoord) {
5 vec2 uv = fragCoord / iResolution.xy;
6 vec3 encoded = texture(iChannel0, uv).rgb;
7 vec3 incorrect = clamp(encoded * uExposure, 0.0, 1.0);
8 vec3 linearColor = srgbToLinear(encoded);
9 linearColor *= uExposure;
10 vec3 correct = clamp(linearToSrgb(linearColor), 0.0, 1.0);
11 vec3 color = mix(incorrect, correct, step(uSplit, uv.x));
12 float divider = 1.0 - smoothstep(0.0, 2.0 / iResolution.x, abs(uv.x - uSplit));
13 color = mix(color, vec3(0.95), divider);
14 fragColor = vec4(color, 1.0);
15 }
16

Inputs for this pass

  • iChannel0 Buffer A
  • iChannel1 Empty
  • iChannel2 Empty
  • iChannel3 Empty

Learn from this shader

How it works

Buffer A builds its scene in linear light and explicitly converts the result with the piecewise sRGB encoding function. The image pass receives those encoded values. On the left, the before version multiplies them directly by Exposure, which incorrectly treats perceptual code values as physical light. On the right, the correct version decodes sRGB to linear, applies exposure, then encodes back to sRGB. Both transfer functions include the standard near-black linear segment rather than relying on one approximate power. A narrow divider and adjustable Split place the two calculations on identical source content.

Try changing

Set Exposure below one and compare how quickly the incorrect side darkens. Raise it and inspect the smooth grayscale ramp and saturated patches for clipping. Move Split across individual features to compare exactly matching pixels. Exposure one should make both sides agree, a useful identity test for the encode and decode pair.

Using it in a game

Know whether every render target is linear, sRGB encoded, or automatically converted by the graphics API. Decode colour textures when sampled as data only if hardware is not already doing so, perform lighting, blending, bloom, and exposure in linear space, then encode once for an sRGB display target. Avoid double conversion, especially when a framebuffer format already applies encoding. Keep UI compositing consistent with the chosen pipeline and use split-screen diagnostics like this shader when tracking washed-out or overly dark assets.

Explore the techniques

Continue with curated explanations and progressively related examples.

LicenseMIT
Views1
Forks0

Discussion

Loading comments...