Skip to content

LUT System

Attribution: Portions of this guide are adapted from OpenRV documentation, Copyright Contributors to the OpenRV Project, Apache License 2.0. Content has been rewritten for OpenRV Web's browser-based WebGL2 architecture. See ATTRIBUTION.md for full details.


Overview

A Look-Up Table (LUT) is a precomputed mapping from input color values to output color values. LUTs encode complex color transforms -- ranging from simple gamma curves to elaborate film stock emulations -- in a format that can be applied to every pixel in constant time. OpenRV Web uses LUTs throughout the rendering pipeline for input device transforms, creative grading, display calibration, and film emulation.


1D vs 3D LUT Concepts

1D LUTs

A 1D LUT applies an independent transfer function to each color channel. It is essentially three separate curves (one for red, one for green, one for blue). Each entry maps a single input value to a single output value per channel.

Capabilities:

  • Gamma correction
  • Contrast curves (S-curves, log-to-linear)
  • Per-channel color balance
  • Transfer function conversion (sRGB, Rec.709, PQ)

Limitations:

  • Cannot model channel crosstalk (where the red output depends on the green or blue input).
  • Cannot perform hue rotations, saturation changes, or cross-channel color grading.

In OpenRV Web, 1D LUTs are stored as 2D textures (size x 3, one row per channel) with R32F precision and hardware linear interpolation.

3D LUTs

A 3D LUT maps an (R, G, B) input triplet to a new (R, G, B) output triplet. The LUT data is arranged as a cube where each axis represents one input channel. The cube is sampled at the input color's coordinates, and the output is interpolated from the surrounding lattice points.

Capabilities:

  • All 1D LUT operations, plus:
  • Channel crosstalk (hue shifts, color matrix transforms)
  • Saturation and vibrance adjustments
  • Complex film stock emulations
  • Complete color space conversions

Memory: A 3D LUT of size N requires N x N x N x 3 floating-point values. Common sizes:

SizeEntriesMemory (Float32)
174,913~57 KB
3335,937~420 KB
65274,625~3.1 MB

Larger LUTs capture finer color distinctions but consume more GPU memory and texture cache.

WebGL2 3D Textures

OpenRV Web stores 3D LUTs as WebGL2 TEXTURE_3D objects. This provides several advantages:

  • Hardware trilinear interpolation: The GPU's texture unit performs trilinear interpolation across the 8 surrounding lattice points automatically when sampling with texture(). This is significantly faster than computing interpolation in the shader.
  • Float32 precision: LUT textures use RGB32F internal format, avoiding the 8-bit quantization bottleneck found in some LUT implementations.
  • Single-pass evaluation: LUT application requires only a single texture() call per pixel, regardless of LUT size.
mermaid
graph LR
    subgraph "1D LUT"
        A["Input R"] --> B["R Curve"]
        C["Input G"] --> D["G Curve"]
        E["Input B"] --> F["B Curve"]
        B --> G["Output R"]
        D --> H["Output G"]
        F --> I["Output B"]
    end
mermaid
graph LR
    subgraph "3D LUT"
        J["Input (R,G,B)"] --> K["3D Cube\nLookup"]
        K --> L["Trilinear\nInterpolation"]
        L --> M["Output (R,G,B)"]
    end

CPU Fallback: Tetrahedral Interpolation

For offline or high-quality processing, OpenRV Web includes a CPU-based tetrahedral interpolation path (TetrahedralInterp.ts). Tetrahedral interpolation divides each lattice cube into six tetrahedra and interpolates within the containing tetrahedron, producing slightly more accurate results than trilinear interpolation at the cost of higher computational complexity. The GPU path uses trilinear interpolation exclusively.

LUT Texture Upload

The createLUTTexture() function in LUTLoader.ts handles the WebGL2 texture creation:

  1. A TEXTURE_3D object is created and bound.
  2. Wrapping is set to CLAMP_TO_EDGE on all three axes (S, T, R) to prevent color bleeding at domain boundaries.
  3. Filtering is set to LINEAR for both minification and magnification, enabling hardware trilinear interpolation.
  4. Data is uploaded as RGB32F (32-bit float per channel) using texImage3D().

For 1D LUTs, a TEXTURE_2D is created with dimensions size x 3 (width x height), where each row stores one channel (R, G, B). The interleaved source data is reorganized into separate rows before upload.

Float Precision Detection

The WebGLLUTProcessor automatically detects the best available float precision for LUT processing:

PrecisionInternal FormatRequirements
Float32RGBA32FEXT_color_buffer_float + OES_texture_float_linear
Float16RGBA16FEXT_color_buffer_float (WebGL2 always supports half-float filtering)
Uint8RGBA8Always available (fallback)

The processor tests framebuffer completeness for each format and selects the highest available precision. This ensures HDR content is processed without quantization artifacts on capable hardware, while still functioning on lower-end devices.


LUT Pipeline

OpenRV Web provides three LUT insertion points in the fragment shader, closely matching the original OpenRV's multi-point model. Each slot serves a distinct purpose in the color pipeline.

mermaid
flowchart LR
    subgraph "OpenRV Web LUT Pipeline"
        A["Source\nPixels"] --> B["EOTF /\nLinearize"]
        B --> C["File LUT\n(u_fileLUT3D)"]
        C --> D["Color\nCorrections"]
        D --> E["Look LUT\n(u_lookLUT3D)"]
        E --> F["Tone Map /\nGamut Map"]
        F --> G["Display LUT\n(u_displayLUT3D)"]
        G --> H["Display\nTransfer"]
    end
mermaid
flowchart LR
    subgraph "OpenRV Desktop LUT Pipeline"
        I["Source\nPixels"] --> J["Pre-Cache LUT\n(CPU)"]
        J --> K["File LUT\n(GPU)"]
        K --> L["Color\nCorrections"]
        L --> M["Look LUT\n(GPU)"]
        M --> N["Display LUT\n(GPU)"]
        N --> O["Display\nTransfer"]
    end

File LUT (u_fileLUT3D)

  • Position: After EOTF / linearization, before color corrections (stage 0e-alt).
  • Purpose: Input Device Transform (IDT). Converts from the source color space to the working space.
  • Scope: Per-source. Each media clip can have its own File LUT.
  • Behavior: When active, bypasses the automatic input primaries conversion. The LUT is assumed to encode the complete source-to-working-space transform.
  • Example use: A camera manufacturer's LUT that converts from a proprietary log encoding to a standard working space.

Look LUT (u_lookLUT3D)

  • Position: After CDL, curves, and HSL qualifier; before tone mapping (stage 6d).
  • Purpose: Creative grade. Applies a "look" or artistic color treatment.
  • Scope: Per-source. Different clips can have different creative looks.
  • Example use: A film emulation LUT, a dailies grade, or a show-specific color treatment.

Display LUT (u_displayLUT3D)

  • Position: After output primaries conversion, before display transfer function (stage 7d).
  • Purpose: Display calibration. Compensates for display-specific color characteristics.
  • Scope: Session-wide. Applied to all sources uniformly.
  • Example use: A monitor calibration LUT generated by a color probe, or a projection room LUT.

Common LUT Properties

All three LUT slots share the same set of uniforms:

UniformTypeDescription
u_{slot}LUT3DEnabledboolEnable/disable the LUT
u_{slot}LUT3DIntensityfloatBlend factor (0.0 = bypass, 1.0 = full)
u_{slot}LUT3DSizefloatCube dimension (e.g., 33.0)
u_{slot}LUT3DDomainMinvec3Input domain minimum (default 0,0,0)
u_{slot}LUT3DDomainMaxvec3Input domain maximum (default 1,1,1)

The generic application function normalizes the input color to the LUT's domain, computes the texture coordinate with proper half-texel offset for center sampling, and blends the result with the original color based on intensity:

glsl
vec3 applyLUT3DGeneric(sampler3D lut, vec3 color, float lutSize,
                       float intensity, vec3 domainMin, vec3 domainMax) {
    vec3 normalized = (color - domainMin) / (domainMax - domainMin);
    normalized = clamp(normalized, 0.0, 1.0);
    float offset = 0.5 / lutSize;
    float scale = (lutSize - 1.0) / lutSize;
    vec3 lutCoord = normalized * scale + offset;
    vec3 lutColor = texture(lut, lutCoord).rgb;
    return mix(color, lutColor, intensity);
}

The intensity control (0-100%) is a feature unique to OpenRV Web, not available in the original OpenRV. It enables partial LUT application for subtle creative adjustments.


Format Support

.cube (Adobe / Resolve)

The .cube format is the primary LUT format in OpenRV Web, supported by virtually all color grading applications (DaVinci Resolve, Nuke, Flame, Photoshop).

Supported keywords:

KeywordDescription
TITLE "name"LUT title (optional)
LUT_3D_SIZE NDeclares a 3D LUT of size N (typically 17, 33, or 65)
LUT_1D_SIZE NDeclares a 1D LUT of size N (typically 256 or 1024)
DOMAIN_MIN r g bInput domain minimum (default 0.0 0.0 0.0)
DOMAIN_MAX r g bInput domain maximum (default 1.0 1.0 1.0)
# commentComment lines (ignored)

Data lines contain three space-separated floating-point values (R G B). For 3D LUTs, values are stored in B-fastest order (blue channel varies fastest).

Parser implementation: src/color/LUTLoader.ts (parseCubeLUT())

.3dl (Autodesk Lustre / Flame)

The .3dl format uses integer output values (10-bit or 12-bit) and supports both 1D and 3D LUTs.

  • The first non-comment line is either a single integer (mesh size) or a series of integers (input range).
  • Data lines contain three space-separated integers.
  • The parser auto-detects the output bit depth (4095 for 12-bit, 1023 for 10-bit, 65535 for 16-bit) and normalizes to floating point.
  • 3D data is reordered from R-fastest to B-fastest to match the .cube convention.

Parser implementation: src/color/LUTFormats.ts (parse3DLLUT())

.csp (Rising Sun cineSpace)

The .csp format includes pre-LUT shaper curves for each channel, enabling extended dynamic range input.

  • Header starts with CSPLUTV100 magic.
  • Type line declares 1D or 3D.
  • Three pre-LUT shaper sections (one per channel) define input linearization curves.
  • Data section contains the main LUT values.

The pre-LUT shaper allows the 3D LUT to cover a wider input range by first compressing the input through per-channel curves.

Parser implementation: src/color/LUTFormats.ts (parseCSPLUT())

Format Comparison

Feature.cube.3dl.csp
1D LUTYesYesYes
3D LUTYesYesYes
Float dataYesNo (integers)Yes
Domain controlYesNoVia shaper
Pre-LUT shaperNoNoYes
Industry adoptionVery wideAutodesk toolsNiche

Unsupported Formats

The following formats from the original OpenRV are not supported in OpenRV Web:

  • .rv3dlut (RV-proprietary 3D format)
  • .rvchlut (RV-proprietary channel/1D format)
  • Shake LUT format

Workflow Examples

Loading a LUT File

LUT files can be loaded through the Color Controls panel in the user interface:

  1. Open the Color Controls panel.
  2. In the LUT section, use the file picker or drag-and-drop a .cube, .3dl, or .csp file onto the viewer.
  3. The LUT title and format are displayed in the panel.
  4. Adjust the Intensity slider (0-100%) to control the strength of the LUT effect.
  5. Use the Clear button to remove the LUT.

Combining LUT with Other Corrections

LUTs work alongside all other color correction tools. A typical grading workflow might be:

  1. Apply a File LUT for the camera's IDT (e.g., ARRI LogC3 to Rec.709).
  2. Adjust Exposure and White Balance (temperature/tint) for the shot.
  3. Apply CDL adjustments for slope, offset, and power.
  4. Apply a Look LUT for the show's creative grade.
  5. Enable a Display LUT for the review room's monitor calibration.

Using Film Emulation Presets

OpenRV Web includes 10 built-in film emulation presets that combine a response curve LUT with stock-specific saturation and grain characteristics. These are applied at stage 6f in the pipeline (after CDL/curves, before tone mapping) and are independent of the three LUT slots.

Performance Notes

  • LUT application is GPU-accelerated and has negligible frame rate impact.
  • LUT texture upload occurs once when the LUT is loaded; subsequent frames reuse the cached texture.
  • Float32 precision LUT textures (RGB32F) are used by default, with automatic fallback to float16 or uint8 if the GPU does not support float rendering.
  • The WebGLLUTProcessor class provides a standalone GPU processing path with optional pre/post LUT color matrices (u_inMatrix, u_outMatrix) for workflows that require matrix transforms around the LUT.

Output Color Space Metadata Cascade

The GPU LUT chain transforms pixels but does not, by itself, advertise what those pixels now represent. A Display LUT that bakes a PQ-to-sRGB transform leaves the source's transferFunction = 'pq' metadata untouched on the underlying IPImage, so any downstream consumer that branches on color space (scopes, the HDR shader path selector, observability surfaces) would see stale information.

The pipeline addresses this with a per-stage declared output color space plus a cascade walker that propagates the declaration onto a metadata-only clone of the IPImage handed to the renderer.

Per-Stage Declarations

Each LUTStageState (Pre-Cache, File, Look) and the session-wide Display LUT carries two optional fields:

FieldTypeSemantics
outputColorPrimaries'bt709' | 'bt2020' | 'p3' | nullnull = preserve the running primaries, non-null = override
outputTransferFunction'srgb' | 'hlg' | 'pq' | 'smpte240m' | nullnull = preserve the running transfer function, non-null = override

null is the default and yields a no-op cascade — the common case where no LUT has been loaded incurs zero metadata work.

Cascade Order

LUTPipeline.computeOutputMetadata(sourceId, input) walks the four stages in pipeline order:

input metadata
  -> Pre-Cache LUT  (per-source, software)
  -> File LUT       (per-source, GPU)
  -> Look LUT       (per-source, GPU)
  -> Display LUT    (session-wide, GPU)

For each stage, the declared output is applied only when the stage actually contributes pixels: enabled, has a LUT loaded, and intensity > 0. Disabled, empty, or zero-intensity stages are bypassed at render time and therefore must not contribute to the metadata either — the cascade enforces that invariant.

The function is pure: it never mutates input metadata or pipeline state, and when every stage is metadata-preserving it returns the input unchanged.

Materializing onto an IPImage

LUTPipeline.applyToIPImage(sourceId, image) is the seam used at render time. It computes the cascaded metadata, returns the input image by reference if the cascade is a no-op (allocation-free steady state), and otherwise produces a metadata-only clone via IPImage.cloneMetadataOnly().

cloneMetadataOnly() is a deliberately narrow primitive: it shares the underlying pixel buffer and GPU resources (managedVideoFrame, imageBitmap) with the source, marks itself non-owning so close() does not release shared resources, and gives the caller a fresh metadata object to mutate. This is critical for HDR video, where the real pixel source is a VideoFrame and the IPImage.data field holds only a 4-byte placeholder — a naive clone() would drop the VideoFrame reference and the renderer would read the placeholder as if it were the full pixel buffer.

Render-Time Wiring

The Viewer applies the cascade in all three HDR render branches (HDR file source, HDR procedural source, HDR video source) immediately before the renderHDRWithWebGL call, via the private applyLUTMetadataCascade helper. SDR paths inherit the source metadata directly because they do not branch on post-pipeline color space.

Where the Output Metadata Is Consumed

After cascade, the IPImage handed to the renderer carries the post-pipeline color space. Current and future consumers:

  • HDR shader path selection in Renderer.ts reads metadata.transferFunction to pick the input EOTF uniform (sRGB / HLG / PQ).
  • Scopes and observability surfaces that report the displayed color space see the effective post-LUT state, not the raw source state.
  • OCIO seam (future): allows OCIO-baked LUTs to declare what working space they output into, so downstream OCIO transforms can assume a known starting point.

Setter API

The setters that drive these declarations live on LUTPipeline:

typescript
pipeline.setStageOutputColorPrimaries(sourceId, 'precache' | 'file' | 'look', primaries);
pipeline.setStageOutputTransferFunction(sourceId, 'precache' | 'file' | 'look', transfer);
pipeline.setDisplayOutputColorPrimaries(primaries);
pipeline.setDisplayOutputTransferFunction(transfer);

These are not currently exposed in the LUT panel UI or the public scripting API — declarations are produced today only by tests and by the OCIO baking path (see OCIO Color Management). The data model is in place so that user-facing controls and OCIO auto-population can be added without touching the cascade machinery.


Released under the MIT License.