3D LUT Color Grading in DirectX Shaders

This article explores how 3D Look-Up Tables (3D LUTs) operate as a cornerstone of real-time post-processing and how DirectX pixel shaders implement them for high-performance color grading. You will learn the core concept behind three-dimensional color remapping, how volumetric textures represent complex color transformations, and the exact HLSL (High-Level Shader Language) operations required to sample and apply these transformations efficiently in modern graphics pipelines.


What is a 3D LUT?

A Look-Up Table (LUT) is a precomputed array of data used to map an input value directly to an output value without calculating complex formulas at runtime. While a 1D LUT maps individual color channels (Red to Red, Green to Green, Blue to Blue) independently, a 3D LUT maps all three channels simultaneously within a three-dimensional coordinate space.

In a 3D LUT, the red, green, and blue components of an input color serve as \((X, Y, Z)\) coordinates within a color cube:

  • Input Color: Defines a point inside the lattice \((R, G, B)\).
  • Output Color: The transformed color stored at that specific coordinate in the cube.

Because all three color channels interact within the same volume, 3D LUTs can handle complex color grading tasks that 1D LUTs cannot, including selective color shifts, saturation changes, hue adjustments, and non-linear contrast curves simultaneously.

A full \(256 \times 256 \times 256\) table is too large for runtime memory, so production pipelines use downsampled grids—typically \(16^3\), \(32^3\), or \(64^3\) nodes. The GPU then interpolates between these discrete points to produce smooth, continuous gradients.


How 3D LUTs Are Stored in DirectX

In DirectX (Direct3D 11 and 12), a 3D LUT is typically represented in one of two formats:

  1. Native 3D Texture (Texture3D): The engine uploads the color cube directly into a volumetric texture resource. This is the most efficient and straightforward method because the hardware natively supports trilinear filtering across three dimensions.
  2. Flattened 2D Texture (Texture2D): For compatibility across older platforms or standard image formats (such as .png), the 3D volume is sliced into 2D tiles laid out side by side (e.g., a \(1024 \times 32\) texture representing a \(32 \times 32 \times 32\) LUT). In this case, the shader must manually compute slice coordinates and blend between neighboring slices.

Using a native Texture3D is standard practice in modern DirectX pipelines.


Implementing a 3D LUT in HLSL

Using a 3D LUT in an HLSL pixel shader involves three main steps: reading the original scene color, compensating for half-texel boundaries, and sampling the volumetric texture with trilinear filtering.

1. Coordinate Correction (The Half-Texel Offset)

Texture coordinates in DirectX range from \(0.0\) to \(1.0\). Because a 3D LUT contains a finite number of samples (e.g., \(N = 32\)), the centers of the boundary texels do not lie precisely at \(0.0\) and \(1.0\); they lie at \(\frac{0.5}{N}\) and \(1.0 - \frac{0.5}{N}\).

Sampling without an offset maps values outside the bounds, resulting in edge clamping artifacts. The coordinate must be scaled and biased:

\[\text{Coord} = \text{Color} \times \left(\frac{N - 1}{N}\right) + \frac{0.5}{N}\]

2. Trilinear Filtering

When defining the SamplerState in DirectX, set the filter mode to MIN_MAG_MIP_LINEAR (or standard linear filtering). The GPU's texture sampling unit automatically performs trilinear interpolation—linearly blending across the eight nearest grid points surrounding the coordinate.

3. HLSL Shader Code

Texture2D SceneTexture : register(t0);
Texture3D LUTTexture   : register(t1);

SamplerState PointClampSampler  : register(s0);
SamplerState LinearClampSampler : register(s1);

cbuffer PostProcessBuffer : register(b0)
{
    float LUTSize; // e.g., 32.0
};

struct VSOutput
{
    float4 Position : SV_POSITION;
    float2 TexCoord : TEXCOORD0;
};

float4 PSMain(VSOutput input) : SV_Target
{
    // Step 1: Sample the rendered scene color
    float3 sceneColor = SceneTexture.Sample(PointClampSampler, input.TexCoord).rgb;

    // Step 2: Clamp input color to [0.0, 1.0] range
    sceneColor = saturate(sceneColor);

    // Step 3: Scale and bias to align with texel centers
    float scale = (LUTSize - 1.0) / LUTSize;
    float offset = 1.0 / (2.0 * LUTSize);
    float3 lutCoord = sceneColor * scale + offset;

    // Step 4: Sample the 3D LUT using hardware trilinear filtering
    float3 gradedColor = LUTTexture.Sample(LinearClampSampler, lutCoord).rgb;

    return float4(gradedColor, 1.0);
}

Why 3D LUTs Are Preferred for Real-Time Rendering

  • Constant Computational Cost (\(O(1)\)): Whether an artist applies a simple tint or an intricate sequence of color balances, split-toning, and contrast curves, the GPU cost remains a single texture read.
  • Artist-to-Engine Parity: Artists can capture a screenshot, grade it inside professional software such as DaVinci Resolve or Adobe Photoshop, apply the exact same adjustments to a neutral LUT identity strip, and export it directly into the game engine with identical visual output.
  • Hardware Acceleration: DirectX hardware samplers perform trilinear interpolation at the silicon level, making the color grading pass virtually free in terms of frame time.