Shader Lab
34.LUT color grading

LUT color grading

Arc 7 · Post-Pipeline · Lesson 34 of 35
coordinate signal color
The strip above IS the LUT — every input brightness (left = black, right = white) maps to the color shown.
identity

Purpose

A LUT is a texture that encodes the answer to "every possible input color → what output color should it become?", which means the creative work of grading an entire scene's look happens in an image, not in code.

Key insight

Tone mapping (L33) gave you a displayable image that looks technically correct. Color grading gives it a mood — cool teal shadows, warm orange skin, lifted blacks, crushed greens, the "Netflix look," the "horror movie look," the "sunset biome." A LUT (Look-Up Table) handles this with one idea: take a reference chart that contains every possible color, grade it in Photoshop exactly the way you want the scene to look, export it as a small texture, hand it to the shader. At runtime, each pixel's color becomes coordinates into that texture, and the texture's value at that coordinate is the new color.

The entire creative work lives in the texture. The shader just does a sample. This is the lesson where art direction is the work. You are not briefing a developer to write math — you are briefing a colorist (which might be you, in Photoshop) on the look.

This lab uses a simplified 1D "strip" LUT — 256 pixels wide, 1 tall, indexed by pixel brightness. Real production uses a 3D cube (every RGB → every RGB), but the lesson is the same: a texture swap changes the whole scene.

Break it

Sit on the identity LUT (mood = 0). The scene looks tone-mapped-but-ungraded — correct, neutral, slightly lifeless. This is the "before" state every AAA scene starts in. Now slide to warm (1), cool (2), and bleached (3) and watch the scene gain personality — or lose it, in the bleached case. Nothing in the lighting, geometry, or materials changed. The strip below the canvas is the only thing that did. Teaches: tone-mapping alone never has a "look." Look is the LUT. If a scene feels neutral or lifeless, the brief is for grading, not for lighting or tone-map changes.

Direct Claude

"stronger grade" mood pinned at the target LUT "subtler grade" mood partway toward target (blend with identity) "warmer LUT" grade the chart toward oranges in highlights, browns in shadows "cooler LUT" grade toward teal in shadows, cool blues in highlights "crushed blacks / more contrast" lower the bottom of the chart's tonal range "bleached / desaturated" pull saturation down across the chart "day-for-night" shift the whole chart toward blue, darken, crush highlights
Meta-phrase you gain here: "grade the reference chart." This is the correct brief for any LUT work. Never ask for a grade "in shader" — ask for a reference chart graded the way you want the scene to look. The engine applies it automatically.
Combines with: L33 (ACES) — LUT must come after tone mapping; grading HDR or clipped values defeats the LUT. L32 (bloom) — bloom is done before tone mapping, so by the time the LUT sees the image the glow halos are already part of it; the grade colors them alongside everything else. L35 (SSAO) — SSAO also runs pre-grade, so darkened crevices also get graded. Canonical order: SSAO → bloom → ACES → LUT.
the LUT shader pass running above
// --- The LUT itself, built in JS as a 256x1 RGBA texture.
// Four "strips" concatenated into one 256x4 texture:
//   row 0: identity  — grayscale (no grade)
//   row 1: warm      — brown-orange shadows, golden highlights
//   row 2: cool      — teal shadows, cold blue highlights
//   row 3: bleached  — desaturated with lifted blacks
// The shader picks rows by mood and crossfades between them.

const lutTex = new THREE.DataTexture(lutData, 256, 4, THREE.RGBAFormat);

// --- Shader pass, applied AFTER tone-mapping:
//     RenderPass → BloomPass → ToneMap → THIS → display

uniform sampler2D tDiffuse;  // the tone-mapped image
uniform sampler2D tLUT;      // the 256x4 LUT
uniform float u_mood;        // 0..3, crossfades between LUT rows

vec3 applyLUT(vec3 color, float moodIndex) {
  // Use perceptual luminance as the index into the LUT
  float brightness = dot(color, vec3(0.2126, 0.7152, 0.0722));

  // Two nearest LUT rows + blend fraction
  float idx = clamp(moodIndex, 0.0, 3.0);
  float row0 = floor(idx);
  float row1 = min(row0 + 1.0, 3.0);
  float t = idx - row0;

  // Sample both rows at the brightness index
  vec2 uv0 = vec2(brightness, (row0 + 0.5) / 4.0);
  vec2 uv1 = vec2(brightness, (row1 + 0.5) / 4.0);
  vec3 lut0 = texture2D(tLUT, uv0).rgb;
  vec3 lut1 = texture2D(tLUT, uv1).rgb;

  // The LUT defines a per-brightness tint; blend the original toward it
  vec3 graded0 = color * (lut0 / max(brightness, 1e-4));
  vec3 graded1 = color * (lut1 / max(brightness, 1e-4));
  return mix(graded0, graded1, t);
}

void main() {
  vec4 src = texture2D(tDiffuse, vUv);
  gl_FragColor = vec4(applyLUT(src.rgb, u_mood), src.a);
}