LUT colour grading
One frame, three moods. No relighting, no new render.
Every pixel's colour is used as an address into a 32³ colour cube, and the cube says what it becomes.
The problem
A grade is usually a stack of tweaks: curves, saturation, tint in the shadows, warmth in the highlights. Doing each one live costs a pass or a pile of shader maths, and an artist's grade from a photo tool doesn't map to any of them.
How it works
A 3D LUT bakes the whole stack into a cube. The three axes are input red, green and blue. Each cell stores the output colour.
At runtime it's one texture read per pixel:
- Take the pixel's RGB (0 to 1) after tone mapping, in display space.
- Pull it in by half a cell: uvw = 0.5/size + rgb × (1 − 1/size). Without that, 0 and 1 land on the cube's outer edge instead of the centre of the edge cells.
- Sample the 3D texture. Hardware trilinear filtering blends the 8 nearest cells, so 32 steps per axis is enough for smooth gradients.
- mix(original, graded, intensity) for a fade in and out.
The cube itself is a .cube text file: a size line, then size³ rows of RGB with red changing fastest.
What I hit
The LUT has to sit after tone mapping and sRGB output. The grades were made for display values, so feeding linear HDR in would push most of the frame into the bottom cells.
The loader in three.js dev calls FileLoader.setPriority, which my three.js 0.170 build doesn't have. I fetch the file text and call the loader's parse() instead.
.cube files can declare DOMAIN_MIN and DOMAIN_MAX. The loader reads them, but the pass ignores them, so every LUT here uses the default 0 to 1 range.
My build
three.js 0.170: scene → OutputPass (ACES, sRGB) → LUTPass → a light grain and vignette pass. LUTPass and LUTCubeLoader are taken from three.js dev unchanged.
Four 32 × 32 × 32 LUTs, written by a Python script: neutral (output = input), cold night (desaturated, blue shift, slight S-curve), warm dusk (warm highlights, cool shadows), faded film (lifted blacks, rolled-off whites, lower saturation). The loader stores them as 8-bit RGBA, 128 KB each.
The strips in the video are the 32 blue slices of each cube laid side by side, read straight from the 3D texture data.
Trade-offs
- One texture read per pixel, whatever the grade does
- Swapping a mood = swapping a texture; blending two = two reads and a mix
- Artists can export a .cube from their usual photo or video tool
- Works on one pixel at a time: no blur, no local contrast, nothing that depends on neighbours
- 8-bit, 32-step cubes can band on very smooth gradients
- A grade made for display values breaks if the pass moves before tone mapping
When to use it
Day and night moods, flashbacks, area-specific looks, photo modes, and any time an artist hands you a grade made outside the engine.
Source and credits
Repo: github.com/mrdoob/three.js (MIT license), branch dev, commit fa01dd6
Code: examples/jsm/postprocessing/LUTPass.js and examples/jsm/loaders/LUTCubeLoader.js are used unchanged (only the import path of ShaderPass was adjusted). The pass order follows examples/webgl_postprocessing_3dlut.html: render, OutputPass, then LUTPass. The four .cube LUTs were generated by my own script; no commercial or third-party LUT packs. Scene, lighting, grain pass and video overlay are my own.