Tetrahedral Interpolation for 3D LUTs in DirectX

Tetrahedral interpolation significantly enhances color accuracy when sampling 3D Lookup Tables (LUTs) in DirectX by eliminating the geometric distortion and hue shifting inherent to hardware-native trilinear filtering. While standard DirectX texture units natively accelerate trilinear interpolation, this cubic approach struggles with steep color transforms and can degrade saturation along the neutral axis. Tetrahedral interpolation divides each cubic cell of a 3D LUT into six non-overlapping tetrahedra sharing the main diagonal, ensuring smoother gradients, superior chromatic fidelity, and precise neutral tones without requiring higher-resolution LUT textures.

The Limitations of Trilinear Interpolation

In DirectX, standard 3D texture sampling utilizes trilinear interpolation executed directly on GPU texture units. When resolving an input RGB coordinate, the hardware samples the eight surrounding lattice points of a cube, performing linear blends along the X, Y, and Z axes sequentially.

This cubic approach assumes linear relationships across all axes simultaneously. However, color spaces—especially non-linear transforms like film emulations, tone mappers, and high-contrast color grades—rarely follow orthogonal boundaries. Trilinear interpolation introduces non-linear cross-terms (\(r \times g \times b\)), which cause the following artifacts:

  • Desaturation in Saturated Transitions: Midpoints inside the sampled cube can become artificially darker or desaturated.
  • Hue Drifts: High-contrast color transformations often shift hue unexpectedly because the interpolation path deviates from the actual transform manifold.
  • Neutral Axis Degradation: Pure grays (\(R=G=B\)) can drift into unwanted color casts if the LUT contains aggressive color grading nearby.

Mechanics of Tetrahedral Interpolation

Tetrahedral interpolation approaches the same discrete cubic lattice differently. Instead of evaluating all eight vertices, it subdivides the cube into six tetrahedra (simplices) that all share the central diagonal running from \((0,0,0)\) to \((1,1,1)\)—the achromatic or neutral axis.

The process functions through three main steps:

  1. Cell and Fractional Coordinate Calculation: The incoming floating-point RGB coordinate is mapped into the discrete grid of the LUT, yielding the base integer coordinate and the fractional offsets \((\Delta r, \Delta g, \Delta b) \in [0, 1)\).
  2. Tetrahedron Identification: The algorithm compares the magnitudes of the fractional values to determine which of the six tetrahedra contains the sample point:
    • \(\Delta r \ge \Delta g \ge \Delta b\)
    • \(\Delta r \ge \Delta b > \Delta g\)
    • \(\Delta g \ge \Delta r \ge \Delta b\)
    • \(\Delta g \ge \Delta b > \Delta r\)
    • \(\Delta b \ge \Delta r \ge \Delta g\)
    • \(\Delta b \ge \Delta g > \Delta r\)
  3. Barycentric Interpolation: Once the specific tetrahedron is identified, the color is computed as a weighted sum of only four vertices (the simplex) rather than eight. The weights correspond directly to the sorted fractional values.

Why Tetrahedral Sampling Improves Color Fidelity

Preservation of the Neutral Axis

Because every one of the six tetrahedra shares the main diagonal of the cube, the path between black, gray, and white remains continuous and piecewise linear. Grayscale inputs are strictly interpolated using vertices that lie on or directly adjacent to the neutral line, preventing ambient color leakage into pure grays.

Elimination of Non-Linear Cross-Talk

Trilinear interpolation generates polynomial terms that bow the interpolated color outward or inward relative to the true color surface. Tetrahedral interpolation uses true barycentric coordinates, creating flat, planar triangular transitions between points. This avoids artificial shifts in hue and preserves the original volume and saturation of the color transform.

Reduced Mach Banding on Small LUTs

Production pipelines frequently use \(32\times32\times32\) or \(64\times64\times64\) 3D LUTs to minimize VRAM usage. At these sizes, steep color shifts processed via trilinear filtering produce visible contouring (Mach banding). Tetrahedral filtering produces significantly smoother visual gradients across color boundaries, effectively delivering the visual fidelity of a much denser LUT without the associated memory overhead.

Implementation in DirectX and HLSL

DirectX hardware samplers do not possess native fixed-function support for tetrahedral interpolation. As a result, it must be implemented directly in High-Level Shader Language (HLSL) via pixel or compute shaders using manual point fetches (Texture3D.Load).

float3 SampleLUT_Tetrahedral(Texture3D<float4> lut, float3 uvw, int lutSize)
{
    float3 coord = uvw * (lutSize - 1.0f);
    int3 baseIndex = int3(floor(coord));
    float3 f = frac(coord);

    // Fetch the common bounds
    float3 c000 = lut.Load(int4(baseIndex, 0)).rgb;
    float3 c111 = lut.Load(int4(baseIndex + int3(1, 1, 1), 0)).rgb;

    float3 color;

    if (f.r >= f.g && f.g >= f.b)
    {
        float3 c100 = lut.Load(int4(baseIndex + int3(1, 0, 0), 0)).rgb;
        float3 c110 = lut.Load(int4(baseIndex + int3(1, 1, 0), 0)).rgb;
        color = (1.0f - f.r) * c000 + (f.r - f.g) * c100 + (f.g - f.b) * c110 + f.b * c111;
    }
    else if (f.r >= f.b && f.b > f.g)
    {
        float3 c100 = lut.Load(int4(baseIndex + int3(1, 0, 0), 0)).rgb;
        float3 c101 = lut.Load(int4(baseIndex + int3(1, 0, 1), 0)).rgb;
        color = (1.0f - f.r) * c000 + (f.r - f.b) * c100 + (f.b - f.g) * c101 + f.g * c111;
    }
    else if (f.g >= f.r && f.r >= f.b)
    {
        float3 c010 = lut.Load(int4(baseIndex + int3(0, 1, 0), 0)).rgb;
        float3 c110 = lut.Load(int4(baseIndex + int3(1, 1, 0), 0)).rgb;
        color = (1.0f - f.g) * c000 + (f.g - f.r) * c010 + (f.r - f.b) * c110 + f.b * c111;
    }
    else if (f.g >= f.b && f.b > f.r)
    {
        float3 c010 = lut.Load(int4(baseIndex + int3(0, 1, 0), 0)).rgb;
        float3 c011 = lut.Load(int4(baseIndex + int3(0, 1, 1), 0)).rgb;
        color = (1.0f - f.g) * c000 + (f.g - f.b) * c010 + (f.b - f.r) * c011 + f.r * c111;
    }
    else if (f.b >= f.r && f.r >= f.g)
    {
        float3 c001 = lut.Load(int4(baseIndex + int3(0, 0, 1), 0)).rgb;
        float3 c101 = lut.Load(int4(baseIndex + int3(1, 0, 1), 0)).rgb;
        color = (1.0f - f.b) * c000 + (f.b - f.r) * c001 + (f.r - f.g) * c101 + f.g * c111;
    }
    else // f.b >= f.g && f.g > f.r
    {
        float3 c001 = lut.Load(int4(baseIndex + int3(0, 0, 1), 0)).rgb;
        float3 c011 = lut.Load(int4(baseIndex + int3(0, 1, 1), 0)).rgb;
        color = (1.0f - f.b) * c000 + (f.b - f.g) * c001 + (f.g - f.r) * c011 + f.r * c111;
    }

    return color;
}

Although this shader code incurs higher ALU cost and branch divergence compared to a single hardware Sample() call, it fetches only 4 texture nodes instead of the 8 required for a complete manual trilinear blend. In color-critical post-processing pipelines—such as HDR tonemapping, broadcast mastering, and visual effects grading—the trade-off reliably ensures reference-grade color precision.