Skip to main content
GameDev.net gamedev.net

Generated LUT Color Grading

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

A sampled three-dimensional colour transform can be arranged as a two-dimensional atlas and reconstructed with interpolation. This package generates both a colourful source scene and a viewport-sized atlas with sixteen blue slices, so the complete grading pipeline is visible without depending on an external image or authored lookup texture.

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.
Common
uniform float uLutContrast; // @param 0.7..1.4 = 1.12 "LUT contrast"
uniform float uLutSaturation; // @param 0.0..1.5 = 0.88 "LUT saturation"
uniform float uLutWarmth; // @param -0.2..0.2 = 0.08 "LUT warmth"

vec3 applyGrade(vec3 color) {
    color = (color - 0.5) * uLutContrast + 0.5;
    float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));
    color = mix(vec3(luma), color, uLutSaturation);
    color += vec3(uLutWarmth, uLutWarmth * 0.25, -uLutWarmth * 0.7);
    color.r += color.b * color.b * 0.06;
    return clamp(color, 0.0, 1.0);
}

void lutTilePixelBounds(vec2 tile, out vec2 firstPixel, out vec2 lastPixel) {
    firstPixel = floor(tile * iResolution.xy / 4.0);
    lastPixel = floor((tile + 1.0) * iResolution.xy / 4.0) - 1.0;
}

vec3 renderChart(vec2 p, float time) {
    vec3 color = vec3(p.x + 0.5, p.y + 0.5, 0.5 + 0.25 * sin(time * 0.35));
    color = clamp(color, 0.0, 1.0) * 0.55;
    vec3 patches[6] = vec3[6](
        vec3(0.90, 0.12, 0.08), vec3(0.95, 0.62, 0.08), vec3(0.12, 0.78, 0.25),
        vec3(0.08, 0.62, 0.92), vec3(0.34, 0.18, 0.88), vec3(0.88, 0.16, 0.62)
    );
    for (int i = 0; i < 6; i++) {
        float fi = float(i);
        vec2 center = vec2(-0.39 + mod(fi, 3.0) * 0.39, 0.24 - floor(fi / 3.0) * 0.28);
        vec2 q = abs(p - center) - vec2(0.14, 0.09);
        float mask = 1.0 - smoothstep(0.0, 0.012, max(q.x, q.y));
        color = mix(color, patches[i], mask);
    }
    float ramp = smoothstep(-0.42, 0.42, p.x);
    float rampMask = 1.0 - smoothstep(0.0, 0.012, abs(p.y + 0.34) - 0.055);
    color = mix(color, vec3(ramp), rampMask);
    return color;
}

vec2 chartPoint(vec2 fragCoord) {
    return (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
}
Buffer A
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    fragColor = vec4(renderChart(chartPoint(fragCoord), iTime), 1.0);
}
Buffer B2
1 void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2 vec2 pixel = floor(fragCoord);
3 vec2 tile = clamp(ceil((pixel + 1.0) * 4.0 / iResolution.xy) - 1.0, 0.0, 3.0);
4 vec2 firstPixel;
5 vec2 lastPixel;
6 lutTilePixelBounds(tile, firstPixel, lastPixel);
7 vec2 redGreen = (pixel - firstPixel) / max(lastPixel - firstPixel, vec2(1.0));
8 float blue = (tile.x + tile.y * 4.0) / 15.0;
9 vec3 sourceColor = vec3(redGreen, blue);
10 fragColor = vec4(applyGrade(sourceColor), 1.0);
11 }
12
Main Image3
1 uniform float uStrength; // @param 0.0..1.0 = 1.0 "Grade strength"
2
3 vec2 lutCoordinate(vec2 redGreen, float slice) {
4 vec2 tile = vec2(mod(slice, 4.0), floor(slice / 4.0));
5 vec2 firstPixel;
6 vec2 lastPixel;
7 lutTilePixelBounds(tile, firstPixel, lastPixel);
8 vec2 tileMin = (firstPixel + 0.5) / iResolution.xy;
9 vec2 tileMax = (lastPixel + 0.5) / iResolution.xy;
10 return mix(tileMin, tileMax, clamp(redGreen, 0.0, 1.0));
11 }
12
13 void mainImage(out vec4 fragColor, in vec2 fragCoord) {
14 vec2 uv = fragCoord / iResolution.xy;
15 vec3 sourceColor = clamp(texture(iChannel0, uv).rgb, 0.0, 1.0);
16 float blueIndex = clamp(sourceColor.b, 0.0, 1.0) * 15.0;
17 float lowSlice = floor(blueIndex);
18 float highSlice = min(lowSlice + 1.0, 15.0);
19 vec3 lowSample = texture(iChannel1, lutCoordinate(sourceColor.rg, lowSlice)).rgb;
20 vec3 highSample = texture(iChannel1, lutCoordinate(sourceColor.rg, highSlice)).rgb;
21 vec3 graded = mix(lowSample, highSample, fract(blueIndex));
22 vec3 color = mix(sourceColor, graded, uStrength);
23 fragColor = vec4(color, 1.0);
24 }
25

Inputs for this pass

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

Learn from this shader

How it works

Buffer A renders neutral gradients, colour patches, and lit geometry. Buffer B lays sixteen blue slices in a four-by-four grid. Within each viewport-sized tile, the first and last texels map red and green exactly to zero and one; integer pixel bounds keep that mapping correct when dimensions are not divisible by four. Every coordinate is transformed by a warm cinematic grading function. The image pass uses those same bounds to sample neighbouring blue slices, then interpolates between them. Two bilinear reads reconstruct the sampled transform without implying that the atlas is a literal 16 by 16 by 16 cube. Strength blends the graded and original frames.

Try changing

Move Strength between zero and one to compare the source directly. Adjust LUT Contrast, Saturation, and Warmth to regenerate the buffer and observe how one lookup represents a combined transform. Push extreme settings and inspect saturated patches for clipping or hue shifts. The smooth gradient reveals interpolation seams if tile addressing is changed incorrectly.

Using it in a game

Replace Buffer A with scene colour and Buffer B with a flattened LUT exported by a colour-grading tool. Match its tile dimensions, texel-centre mapping, slice count, and filtering assumptions. Grade in a documented colour space; production HDR pipelines often apply a suitable transfer or log encoding before lookup. LUTs are efficient because multiple artistic operations collapse into two samples and interpolation, while blending Strength supports volumes, transitions, underwater zones, and temporary gameplay states.

Explore the techniques

Continue with curated explanations and progressively related examples.

LicenseMIT
Views0
Forks0

Discussion

Loading comments...