The Color
Cube

A visual, interactive guide to 3D Look-Up Tables.
From first principles to HLSL shaders — learn by playing.

Act I

Colors Are Places

Every pixel on your screen is described by three numbers: how much red, green, and blue light to emit. Each ranges from 0 to 255. That gives us roughly 16.7 million possible colors.

But here's the leap: if a color is three numbers, it's a coordinate. Red is the X-axis. Green is Y. Blue is Z. Every color is a point in a three-dimensional cube.

drag to orbit · scroll to zoom
Pick a color R:255 G:107 B:53 4096 points

The black corner — (0, 0, 0) — is where all lights are off. The white corner — (255, 255, 255) — is full blast. The glowing marker is the color you picked. Drag it around. You're not choosing a color from a list — you're navigating a space.

A color isn't just a color. It's an address.
This is the RGB color space. Other spaces (HSL, LAB, etc.) just reshape the cube into different geometries — a cylinder, a sphere. The principle is the same: color is spatial.
Rotate the cube so you're looking straight down the diagonal from black to white. That axis is luminance — brightness with no color. You've just discovered the grayscale line.
Act II

The Simplest Transform

What if you want to change the feel of an image? Make it warmer, contrastier, moodier? The most basic tool is a 1D Look-Up Table: three independent curves, one per channel.

Drag the control points on the curves below. The X-axis is the input value (what the pixel had), and the Y-axis is the output (what it becomes). A straight diagonal line means "no change." Bend it, and you're remapping color.

This is exactly what curves in Photoshop, Lightroom, or DaVinci Resolve do. Under the hood, each curve is just a 256-entry array — for each possible input value (0–255), it stores an output value. That's a 1D LUT: one dimension, one lookup per channel.

HLSL // 1D LUT: three independent channel lookups float3 Apply1DLUT(Texture2D lut, float3 color) { color.r = tex2D(lut, float2(color.r, 0.0)).r; red curve color.g = tex2D(lut, float2(color.g, 0.5)).g; green curve color.b = tex2D(lut, float2(color.b, 1.0)).b; blue curve return color; }

The Limitation

Try to do this: make the dark parts of the image warm (orange), and the bright parts cool (blue). You'll find you can't — not precisely. To push warmth into the shadows you'd lift the red curve in the low end, but that affects all dark pixels, including ones that should stay neutral.

The problem? Each channel is remapped in isolation. The red curve can't ask "what's the blue value of this pixel?" Channels can't communicate. To make decisions based on the full color — on where a point sits in the cube — we need all three dimensions at once.

To change colors based on other colors, we need another dimension.
Pull the red curve into an S-shape (lift the top, lower the bottom) to boost contrast in the red channel alone. Then reset and try the same on all three channels — you've just made a contrast curve.
Act III

Enter the Cube

A 3D LUT doesn't remap channels independently. It takes the entire color — the full (R, G, B) coordinate — and maps it to a new color. Every point in the color cube can move to any other point. It's a total deformation of color space.

On the left is the identity LUT: input equals output. It's a perfect, regular grid — no transformation. On the right is a transformed LUT — same points, but shifted to new positions. Toggle the presets and watch the cube warp.

Identity (no transform)
Transformed
Preset Grid

The visual metaphor is everything here: applying a 3D LUT is like grabbing the color cube and squishing, stretching, and twisting it. Warm film tones pull blues toward amber. Cross-processing twists the cube into wild new shapes. An inversion flips it inside out.

But we can't store everything

A full 3D LUT mapping every possible 8-bit color would need 256×256×256×3 bytes = 50 MB. That's impractical. So instead, we sample a sparse grid — typically 17×17×17, 33×33×33, or 65×65×65 points — and interpolate for everything in between. Drag the grid size slider above and watch the lattice change. Even a crude 8³ grid captures the broad shape of the transform.

A 33³ LUT with 16-bit (half-float) precision: 33 × 33 × 33 × 3 × 2 = ~215 KB. A 65³: ~1.6 MB. Tiny by modern standards, but enough to encode an entire film look.
A 3D LUT is just a small grid of colors. Everything in between, we guess.
Switch to "Invert" and set the grid to 2³. With only 8 corner samples, the inversion is wildly wrong in the middle. Slide it up to 12³ — see how much better it gets? That's interpolation at work.
Act IV

The Guess

Most input colors won't land exactly on a grid point. They'll fall between points — inside one of the little cells of the lattice. So we need to interpolate: blend the 8 corner colors based on how close the input is to each one.

This process is called trilinear interpolation — "tri" because it's three linear interpolations in sequence. Let's walk through it step by step.

0.37
0.63
0.50

Step 0 — The 8 Corners

Our point sits inside a cell defined by 8 known colors at the corners. Each corner's color was stored in the LUT.

Step 1 — Lerp along X

Pair up corners that differ only in X. Blend each pair using the fractional X position. 8 corners → 4 points.

mix(c000, c100, frac.x)

Step 2 — Lerp along Y

Take the 4 results and pair them along Y. Blend each pair. 4 → 2.

mix(c_x0, c_x1, frac.y)

Step 3 — Lerp along Z

Final pair, blended along Z. 2 → 1. This is our output color.

mix(c_xy0, c_xy1, frac.z)

That's the entire algorithm. Three rounds of linear blending. On a GPU, this is even simpler — the hardware's texture unit does trilinear interpolation for free when you sample a 3D texture. The entire 3D LUT lookup becomes a single texture fetch:

HLSL float3 ApplyLUT3D(Texture3D lut, SamplerState samp, float3 color, float size) { float scale = (size - 1.0) / size; map [0,1] → texel centers float offset = 0.5 / size; half-texel inward shift return tex3D(lut, color * scale + offset).rgb; } // That's it. Five lines.

Why scale and offset?

This is a subtlety that trips people up. GPU texels have their color at the center, not the edge. If your LUT has 33 texels along one axis, the first texel center is at 0.5/33, and the last is at 32.5/33. Without the scale and offset, input 0.0 would sample halfway between the first texel and nothing — reading garbage from the edge. The formula maps the 0–1 range to exactly hit the first and last texel centers.

On modern GPUs, a 3D texture sample with trilinear filtering takes a single clock cycle. This is why LUTs are ubiquitous in real-time rendering — the lookup is essentially free.
Move the X/Y/Z sliders to a corner (all zeros or all ones) and watch the intermediate points collapse — there's nothing to interpolate. Now move to the dead center (0.5, 0.5, 0.5) — maximum blending from all 8 corners.
Act V

Build Your Own

Time to see the whole pipeline in action. On the left is an image being processed through a 3D LUT. On the right is the color cube showing the transformation. Choose a preset or tweak the parameters to create your own look.

Look
Intensity Resolution 17³
Set the resolution to 4 and crank the intensity to 100%. See the banding? That's too few sample points — the interpolation can't keep up. Slide the resolution up and watch it smooth out.
Act VI

The .cube File

After all this theory, what does a 3D LUT actually look like on disk? The most common format is Adobe's .cube file — and it's remarkably simple. It's just a text file:

# Created by: The Color Cube tutorial # A tiny 2x2x2 LUT for demonstration   TITLE "Warm Sunset" LUT_3D_SIZE 2 ← 2³ = 8 data lines will follow DOMAIN_MIN 0.0 0.0 0.0 DOMAIN_MAX 1.0 1.0 1.0   0.010 0.005 0.002 ← (R=0, G=0, B=0): near-black 0.950 0.320 0.100 ← (R=1, G=0, B=0): red → warm orange 0.180 0.680 0.050 ← (R=0, G=1, B=0): green → earthy green 0.980 0.850 0.200 ← (R=1, G=1, B=0): yellow → golden 0.050 0.040 0.180 ← (R=0, G=0, B=1): blue → deep indigo 0.750 0.250 0.350 ← (R=1, G=0, B=1): magenta → dusky rose 0.120 0.550 0.400 ← (R=0, G=1, B=1): cyan → teal 1.000 0.960 0.880 ← (R=1, G=1, B=1): white → warm cream

That's it. A header declaring the grid size, then rows of RGB float triplets — one for each lattice point. The points are listed in R-fastest order: R increments first, then G, then B. For a 33³ LUT, that's 35,937 lines of data — typically around 1 MB as text.

The entire pipeline is now clear: a colorist crafts a look by grading an image or adjusting color volumes. Software records the before-and-after mapping at every lattice point and writes out a .cube file. That file can then be loaded by any application — DaVinci Resolve, Premiere, a game engine, a hardware monitor — and applied in real-time as a 3D texture lookup. One file. Universal color transform.

HLSL — Complete pipeline // Full 3D LUT shader with intensity control Texture3D _LUT; the loaded .cube data SamplerState _LUT_Sampler; trilinear filter float _LUTSize; e.g. 33.0 float _Intensity; blend: 0 = original, 1 = full LUT   float3 ApplyLUT(float3 color) { float3 uvw = color * ((_LUTSize - 1.0) / _LUTSize) + 0.5 / _LUTSize; float3 graded = tex3D(_LUT, uvw).rgb; return lerp(color, graded, _Intensity); } // lerp lets you fade between original and graded
One file. A handful of kilobytes. The entire color personality of a film.
Other LUT formats exist: .3dl (Lustre/Flame), .csp (Cinespace), .spi3d (Sony), and binary formats like .mga (Resolve). The .cube format has become the de facto interchange standard because of its simplicity.

Built as an interactive explainer. All demos run live in your browser using Three.js and Canvas 2D.
Inspired by Nicky Case, 3Blue1Brown, and Freya Holmér.