GLSL 3D LUT Trilinear Interpolation Algorithm
This article provides a technical overview and implementation guide for trilinear interpolation across 3D color lookup tables (LUTs) in GLSL. It explains the underlying mathematical formulation for sampling within a discretized 3D lattice, covers manual 8-point interpolation using direct texel fetches, and demonstrates how to apply this algorithm to both native 3D textures and 2D unwrapped LUT strips.
Mathematical Concept of Trilinear Interpolation
A 3D LUT discretizes an RGB color space into an \(N \times N \times N\) cube of output colors. When an input color does not fall directly on an integer grid point, trilinear interpolation approximates the value by calculating a weighted average of the eight nearest surrounding lattice vertices (corners of the surrounding voxel).
Given an input color \(C = (R, G, B)\) normalized to \([0.0, 1.0]\) and a LUT size \(N\):
- Scale Coordinates: Convert normalized coordinates to grid space coordinates: \[\mathbf{P} = C \times (N - 1)\]
- Determine Bounds: Compute lower indices \(\mathbf{P}_0 = \lfloor \mathbf{P} \rfloor\) and upper indices \(\mathbf{P}_1 = \min(\mathbf{P}_0 + 1, N - 1)\).
- Calculate Fractional Weights: Compute the fractional distance within the cell: \[\mathbf{f} = \mathbf{P} - \mathbf{P}_0\]
- Interpolate Across Dimensions:
- Linearly interpolate along the X-axis (Red) to produce 4 intermediate values.
- Linearly interpolate those 4 values along the Y-axis (Green) to produce 2 values.
- Linearly interpolate the remaining 2 values along the Z-axis (Blue) to produce the final color.
GLSL
Implementation: Manual 8-Point Interpolation
(sampler3D)
While setting texture filtering to GL_LINEAR on a native
3D texture delegates trilinear filtering directly to the GPU hardware,
writing the algorithm manually is required when using
texelFetch or custom precision routines.
#version 330 core
uniform sampler3D u_LutTexture;
uniform float u_LutSize; // e.g., 32.0, 64.0
vec3 sampleLutTrilinear(vec3 color) {
// Clamp input to [0.0, 1.0] to prevent sampling outside the lattice
color = clamp(color, 0.0, 1.0);
// Scale to the lattice domain [0, LUT_SIZE - 1]
vec3 coord = color * (u_LutSize - 1.0);
// Get the base voxel corner and the fractional offset
ivec3 i0 = ivec3(floor(coord));
ivec3 i1 = min(i0 + ivec3(1), ivec3(int(u_LutSize) - 1));
vec3 f = fract(coord);
// Fetch the 8 surrounding lattice vertices
vec3 c000 = texelFetch(u_LutTexture, ivec3(i0.x, i0.y, i0.z), 0).rgb;
vec3 c100 = texelFetch(u_LutTexture, ivec3(i1.x, i0.y, i0.z), 0).rgb;
vec3 c010 = texelFetch(u_LutTexture, ivec3(i0.x, i1.y, i0.z), 0).rgb;
vec3 c110 = texelFetch(u_LutTexture, ivec3(i1.x, i1.y, i0.z), 0).rgb;
vec3 c001 = texelFetch(u_LutTexture, ivec3(i0.x, i0.y, i1.z), 0).rgb;
vec3 c101 = texelFetch(u_LutTexture, ivec3(i1.x, i0.y, i1.z), 0).rgb;
vec3 c011 = texelFetch(u_LutTexture, ivec3(i0.x, i1.y, i1.z), 0).rgb;
vec3 c111 = texelFetch(u_LutTexture, ivec3(i1.x, i1.y, i1.z), 0).rgb;
// Interpolate along the X-axis (Red)
vec3 c00 = mix(c000, c100, f.x);
vec3 c10 = mix(c010, c110, f.x);
vec3 c01 = mix(c001, c101, f.x);
vec3 c11 = mix(c011, c111, f.x);
// Interpolate along the Y-axis (Green)
vec3 c0 = mix(c00, c10, f.y);
vec3 c1 = mix(c01, c11, f.y);
// Interpolate along the Z-axis (Blue)
return mix(c0, c1, f.z);
}GLSL Implementation: 2D Texture Atlas (Horizontal Strip)
In environments without native 3D texture support, 3D LUTs are
typically stored as a 2D strip of size \((N
\times N) \times N\). Trilinear interpolation in this
configuration combines hardware bilinear interpolation across the 2D
tiles with a manual mix() between the two nearest
Z-slices.
#version 330 core
uniform sampler2D u_LutAtlas;
uniform float u_LutSize; // e.g., 32.0 or 64.0
vec3 sample2DStripLutTrilinear(vec3 color) {
color = clamp(color, 0.0, 1.0);
float maxCoord = u_LutSize - 1.0;
float blue = color.b * maxCoord;
// Determine neighboring blue slices
float slice0 = floor(blue);
float slice1 = min(slice0 + 1.0, maxCoord);
float fz = fract(blue);
// Standard half-texel offset for accurate UV sampling
vec2 halfTexel = vec2(0.5 / (u_LutSize * u_LutSize), 0.5 / u_LutSize);
// Compute base UV for slice 0 and slice 1
float uOffset0 = slice0 * u_LutSize;
float uOffset1 = slice1 * u_LutSize;
vec2 uv0 = vec2(
(uOffset0 + 0.5 + color.r * maxCoord) / (u_LutSize * u_LutSize),
(0.5 + color.g * maxCoord) / u_LutSize
);
vec2 uv1 = vec2(
(uOffset1 + 0.5 + color.r * maxCoord) / (u_LutSize * u_LutSize),
(0.5 + color.g * maxCoord) / u_LutSize
);
// Hardware bilinear filtering performs X and Y interpolation
vec3 sample0 = texture(u_LutAtlas, uv0).rgb;
vec3 sample1 = texture(u_LutAtlas, uv1).rgb;
// Linear interpolation along the Z axis completes trilinear filtering
return mix(sample0, sample1, fz);
}Hardware Half-Texel Compensation
When converting continuous \([0.0, 1.0]\) coordinates to normalized texture coordinates, sampling exact cell centers requires adding an offset of half a texel (\(\frac{0.5}{N}\)):
\[\text{UV} = \frac{C \times (N - 1) + 0.5}{N}\]
Failing to apply this offset compresses the dynamic range, causing boundary clamping artifacts where the outer edges of the color gamut sample toward adjacent texture boundaries.