Lutra's LUT pipeline

Color cubes from file to film, explained from the ground up — what a LUT is, how the files are parsed, how the GPU and CPU apply them, and what a film emulation does to your pixels

“Graphical excellence is that which gives to the viewer the greatest number of ideas in the shortest time with the least ink in the smallest space.”

— Edward Tufte, The Visual Display of Quantitative Information

Every effect in Lutra is a formula. Exposure multiplies, contrast applies an S-curve, grain adds noise. The LUT layer is the odd one out: it is not a formula at all, but a table — 2,197 sampled colors that, together, describe a whole film stock's response. This document follows that table from the text files on disk, through the parser, up onto the GPU as a 3D texture, into the CPU sampler that powers the thumbnail filmstrip, and finally to the perceptual changes you see on screen. It assumes you know what a pixel is and nothing else; the terms the rest of the document leans on are defined up front in §0.

0 · A LUT is a function in table form

A pixel's color is three numbers: how much red, how much green, how much blue — each stored as a byte (0–255) in an image file, or as a fraction of 1 (0–1) in shader math. A color transform is any rule that takes those three numbers and produces three new ones. Most transforms are formulas. A lookup table (LUT) is a transform stored as a table of samples instead: for certain input colors it records the output color, and any input that falls between two samples is interpolated — blended between the nearest recorded ones.

Because the input has three channels, the table is three-dimensional: imagine a cube with red along one axis, green along the second, blue along the third. Each axis is sampled at 13 evenly spaced values (0, 1/12, 2/12, …, 1), giving 13³ = 2,197 grid points, and each grid point holds the output color for the input color at that spot. An arbitrary input color almost never lands exactly on a grid point — it lands inside one of the 12³ = 1,728 little cells, and the output is a weighted blend of the eight grid points at the cell's corners: the closer a corner, the more it counts. Because the blending happens along all three axes, it is called trilinear interpolation. (The alternative — storing every possible 256³ ≈ 16.7 million colors — would make the files a thousand times bigger for no visible gain.)

Why a table for film emulation? Because film's response is not a formula. Real film has per-channel tone curves with a shoulder (highlights roll off smoothly instead of clipping to white) and a toe (blacks lift off pure black) — an S-shaped response per color channel. And decisively, the three dye layers of film contaminate each other: a strong red exposure also shifts how the blue record develops. That cross-channel behavior is what makes a film look like film, and it cannot be expressed as three independent per-channel curves. A 1D LUT (one curve per channel, each channel transformed on its own) cannot do it; a 3D table can encode any function of (r, g, b), however gnarly, as long as it is smooth enough to sample at 13 points per axis. G'MIC's film presets are rendered into exactly this form, and the table is what Lutra ships.

The terms used throughout, in one place:

TermMeaning
Channelone of the three color components of a pixel: red, green, or blue
Grid pointone of the 13³ sampled colors inside the cube; the table's “rows”
Axisone of the cube's three dimensions; moving along it changes one channel of the input color
Interpolationblending between known samples to produce an in-between value; trilinear = along all three axes at once
Texelone grid point viewed as an element of a GPU texture (§3) — “texture pixel”
Passa small GPU program that runs once per pixel of the image: read a texture, compute, write a texture (§3)
sRGBthe standard brightness encoding of image files and screens (§4)
Linear lightphysically proportional brightness — twice the number, twice the light; what effects compute in (§4)
Amount / strengththe LUT layer's slider: 0 = no effect (identity), 1 = the full film look

1 · The film library

Lutra vendors 296 film-emulation cubes — ships copies of them inside the app, rather than downloading them from a third party at runtime — from the G'MIC film presets (YahiaAngelo/Film-Luts, mirrored at a pinned commit; docs/adr/0004). They are static assets in packages/frontend/public/luts/, split across nine categories:

CategoryCubesVibe
Instant Pro / Instant Consumer68 / 54Polaroid-style: lifted blacks, soft pastel casts
Negative Old / Negative New44 / 39Color-negative film: contrasty, saturated, warm highlights
Colorslide26Slide film: punchy, cool-balanced
Bw25Black-and-white stocks, including infrared
Fujixtransiii / Negative Color / Print15 / 13 / 12Digital camera simulation, generic negatives, print emulsions

Three files describe the library. film_luts.json is the catalog — fetched once at startup — with one entry per LUT: a display name, a category, a preview thumbnail path, and lut_file, the path of the cube inside the library (luts/print/kodak_2393_cuspclip.cube). That path is also the layer's lutId: a stable string id that travels through the schema, the model, and the render request. The cubes themselves — 296 files, ~131KB each, 38MB in total — are fetched on demand: the first time a layer references a lutId (or a filmstrip group needs previews, §8), its text is fetched, parsed once, and memoized for the session.

2 · From .cube text to a cube in memory

A cube file is deliberately plain text. The header names the LUT and declares the grid; the rest is 2,197 lines of three floats — one line per grid point:

TITLE "kodak_2393_cuspclip"
LUT_3D_SIZE 13
DOMAIN_MIN 0.0 0.0 0.0
DOMAIN_MAX 1.0 1.0 1.0

0.065090090036392212 0.065090090036392212 0.065090090036392212
0.078139059245586395 0.078139059245586395 0.078139059245586395
…

Three numbers per line: the output red, green, and blue for one grid point. The DOMAIN lines say the cube is meant for input colors between 0 and 1 — exactly the normalized pixel values the pipeline feeds it — so no scaling happens anywhere: the cube is used as-is.

parseCube (engine, src/luts/cube.ts) is tolerant of the format's noise and strict about its one invariant. Comments, blank lines, TITLE, and DOMAIN_* are skipped; CRLF line endings and stray extra columns are tolerated; but the data must contain exactly size³ points, each with at least three floats, or the parse fails with a LutParseError — a corrupt cube is never half-loaded.

The one subtle part: which line belongs to which input?

Nothing on a line says what input color it answers for — the order of the lines is the only connection. The first data line is the output for the darkest input (0,0,0), the last for (1,1,1), and every line in between is assigned by walking the grid in a fixed pattern, changing one channel at a time. The channel that changes on every consecutive line varies fastest; the one that changes only after all the others have been cycled varies slowest. For a tiny 2×2×2 cube (two samples per axis), the two common conventions read:

LineRed-fastest order
(what the G'MIC files use)
Red-slowest order
(the textbook convention)
0input (0, 0, 0)input (0, 0, 0)
1input (1, 0, 0) — red steppedinput (0, 0, 1) — blue stepped
2input (0, 1, 0)input (0, 1, 0)
3input (1, 1, 0)input (0, 1, 1)
4input (0, 0, 1) — blue first changes hereinput (1, 0, 0) — red first changes here
…red changes every lineblue changes every line

As formulas: red-fastest indexes points as (b·size + g)·size + r (blue slowest), red-slowest as (r·size + g)·size + b. Same 2,197 outputs, different file — and mixing the two up swaps red and blue in every result.

The layout gotcha: the G'MIC files are red-fastest. The commonly quoted .cube documentation describes the red-slowest convention. The vendored G'MIC files are generated the other way — red varies fastest, blue slowest. You can read it straight from the data: in fuji_astia_100f.cube, the line at index 12 (which red-fastest order assigns to the input one full step up the red axis: pure red, (1,0,0)) holds (0.946, 0.086, 0.122) — a saturated red result. The line at index 2028 (red-fastest's pure-blue input, (0,0,1)) holds (0.066, 0.006, 0.439) — a deep blue. Under the red-slowest reading those two lines swap meanings: pure red in would read the deep-blue output, and a slide film that turns red into blue is absurd. This cost us a real bug: the first version of the CPU sampler assumed the textbook order, every filmstrip preview came out with red and blue swapped (warm LUTs looked blue-ish), and a dedicated engine test — pure red input must come out red — now locks the order forever.

The parsed cube is a LutCube: size plus a flat Float32Array of size³ × 3 floats in file order. This is the engine's contract — the GPU upload strides the same array into the texture (§3) and the CPU sampler indexes the same file order (§5), so there is exactly one layout and the two renderers can never disagree about which point is which.

3 · Applying a LUT on the GPU

A LUT layer renders as its own compute pass — a small GPU program that runs once per pixel of the image: it reads one texture (an image stored on the GPU), applies its logic, and writes another texture — like every other layer (architecture.html §3). The pass differs from the others in one binding (a numbered slot through which the frontend hands the shader its data) and in its body. It declares lutTex, a 3D texture — a cube of values rather than a flat image — at binding 6, and it declares no sampler:

// color is sRGB-encoded here, by pass contract (§4)
let p = clamp(color, vec3<f32>(0.0), vec3<f32>(1.0)) * (LUT_SIZE - 1.0);
let x0 = vec3<i32>(floor(p));
let f = p - vec3<f32>(x0);
let maxT = i32(LUT_SIZE) - 1;
let x1 = min(x0 + vec3<i32>(1), vec3<i32>(maxT));
let c000 = textureLoad(lutTex, vec3<i32>(x0.x, x0.y, x0.z), 0).rgb;
// …seven more corners…
let lutColor = mix(mix(mix(c000, c100, f.x), mix(c010, c110, f.x), f.y),
                   mix(mix(c001, c101, f.x), mix(c011, c111, f.x), f.y), f.z);
color = mix(color, lutColor, l0_amount);

The body is the whole algorithm, in four moves. Map the color into texel space — the cube's own coordinate system, where each grid point is one texel: multiplying by SIZE−1 puts input 0 and 1 on the cube's faces and input 0.5 exactly in the middle grid point. Find the enclosing cell: x0 is the low corner's texel, f the fraction of the way to the high corner, x1 the high corner clamped to the last plane (so the top face never reads past the cube). Read the eight corners with textureLoad and lerp — linear interpolation, a weighted average between two values — three times along x, two along y, one along z. Finally amount mixes the graded color with the original, so 0 is identity and 1 is the full look.

The texture is rgba32float, and that choice is load-bearing. WebGPU cannot filter 32-bit float textures (filtering = the GPU's built-in interpolation, normally available for “image” textures), so the manual trilinear above is not an optimization but a necessity — and the format itself exists because the cheap alternative is broken: rgba16float would need an f32→f16 conversion on upload, and Chrome's writeTexture conversion path writes raw f32 bytes into f16 textures, corrupting rows. rgba32float matches the Float32Array byte-for-byte. The upload strides the file order 1:1 into the texture — flat texel index (z·size + y)·size + x — so the texture's X, Y, Z axes are the file's red, green, blue channels (the red-fastest order from §2), and the shader can hand color.rgb straight to the texture coordinate. A cube is uploaded once per lutId and cached device-wide; textures are never re-uploaded per render.

4 · The sRGB contract (why the pass flips its boundaries)

Two ways of storing brightness matter here. sRGB is what image files and screens use: brightness is stored with a curve baked in, roughly matching how displays and human vision behave. Linear light is the physical version: twice the number means twice the actual light. The two are related by a curve (the formula lives in colorspace.ts; architecture.html §4 explains it). Lutra's pipeline works in linear light — passes decode sRGB to linear, effects run, and the last pass encodes back to sRGB for the display — because adding, multiplying, and blending colors in sRGB space gives results that feel wrong (darkening by 50% would crush shadows differently than it should).

The LUT layer is the deliberate exception, and the reason is upstream: G'MIC's film presets are authored against sRGB-encoded values, because sRGB is G'MIC's working space (docs/adr/0003). Apply a film cube to linear values and the film curves land in the wrong place — the toe and shoulder shift, the strength of every cast changes. So the LUT pass inverts the usual boundaries: it decodes its linear input to sRGB before the body and re-encodes from sRGB after, like the other passes do in reverse.

Both ends of that round-trip are conditional. When the LUT pass reads the source texture directly (a LUT layer first in the chain), the input is already sRGB — no decode. When it is the last pass (writing the display texture), the output is already sRGB — no re-encode. A LUT-only chain — the exact configuration the filmstrip previews render (§5) — is both, which means zero colorspace conversions: the pass becomes literally “sample the cube, mix by amount.” That is the fact that makes a byte-exact CPU implementation possible, and the strength mix happens in sRGB space either way, which is what “50% film look” perceptually means.

5 · Applying a LUT on the CPU

The filmstrip at the bottom of the editor shows the user's photo under every LUT they browse — 296 previews of the current image, one per cube (docs/adr/0013). Rendering those through the GPU pipeline would mean a new offscreen render path sharing the interactive device queue, or a second WebGPU device in a worker (a browser-support gamble). The LUT body's simplicity makes a third path obvious: applyLutCpu in the engine (src/luts/apply.ts) — the WGSL body above, translated line-for-line to JavaScript.

Because a LUT-only chain has no colorspace boundary (§4), the CPU function is exactly the shader body: the same texel-space mapping, the same eight corners, the same mixes, the same file-order indexing. The shader's clamp guards against out-of-range values (GPU textures can hold numbers beyond 0–1); the CPU input is 8-bit pixel bytes divided by 255, which are already inside the cube's domain, so the clamp is a no-op and is omitted. Alpha passes through untouched, and writing through a Uint8ClampedArray (a byte array that rounds and clamps on assignment) reproduces the rgba8unorm store's rounding. The translation is so direct that it was cross-checked against a JS reimplementation of the GPU lookup over 500 random colors — zero mismatches — and the axis-order test from §2 guards the one place the two paths could drift.

The sampler runs in a dedicated worker (src/thumbs/worker.ts), one request per LUT: the main thread downscales the photo to a 200×200 square (cropped from the center), transfers the 160KB ImageData (a plain array of pixel bytes), and the worker applies the cube and JPEG-encodes the result with the engine's encodeImage — the same codec path exports use, at quality 85. The work is cheap enough that the original “is CPU fast enough?” question answered itself: ~1.7ms per LUT at 200×200, so a whole 68-cube category renders in roughly a tenth of a second, progressively filling the strip as each thumb lands.

6 · GPU vs CPU, side by side

GPU pass (lut.ts body)CPU sampler (applyLutCpu)
Where it runs WebGPU compute shader, in the interactive render pipeline Plain JavaScript in a dedicated worker
Used for The canvas — the committed chain, slider drags, hover previews. Authoritative. Static 200×200 filmstrip previews — the “poster,” presentation-only
Colorspace sRGB by pass contract: decodes/re-encodes at the chain boundaries, skips both when the chain is LUT-only None — always runs the LUT-only configuration, so the boundary is a no-op by construction
Interpolation Manual trilinear via textureLoad (32-bit float textures aren't filterable) The same manual trilinear, same texel-space math
Cube data rgba32float 3D texture, uploaded once per lutId, cached device-wide The parsed Float32Array, indexed in file order
Resolution Full image, one invocation per pixel 200×200 preview, ~1.7ms per LUT
Strength Uniform amount, mixed in sRGB space Fixed at full strength — a preview shows the look, not a partial mix

The division of authority is deliberate. The GPU path is the truth: it renders what you see, and the hover preview in the filmstrip grades the actual chain live. The CPU sampler exists to make browsing cheap and never pretends to be more — if the two ever disagreed, the canvas wins. In practice they cannot: same data, same math, same order, and the cross-checks in §5.

7 · What a film LUT does to an image

Here is what three real cubes from the library do to three probe colors — pure red, mid-gray, and pure blue. Each cell shows the three output numbers (red, green, blue) the cube produces for that input, read straight from the grid (integer coordinates, so these are exact table values, not interpolations):

LUTInput red (1,0,0)Input mid-gray (0.5)Input blue (0,0,1)
Kodak 2393 (Print) (0.621, 0.130, 0.021) — reds pushed to amber (0.552, 0.550, 0.554) — near-neutral, warm side (0.000, 0.275, 0.529) — blues lean teal-green
Fuji Astia (Colorslide) (0.946, 0.086, 0.122) — saturated red, slightly orange (0.504, 0.489, 0.484) — neutral, slightly cool (0.066, 0.006, 0.439) — deep, desaturated blue
Kodak Tri-X (Bw) gray 0.467 gray 0.580 gray 0.783 — blue skies render light

Read across the rows, the table is the look. Print emulation rolls reds toward amber and shadows toward teal — the warm-orange/teal balance you recognize from cinema. Slide film keeps reds saturated and pulls blue deep and cool. Black-and-white collapses every channel to a gray chosen by the stock's spectral sensitivity — how strongly the film responds to each wavelength of light — not by the standard HD-video formula for brightness (Rec. 709 luma). Note that Tri-X is panchromatic (sensitive to all colors, unlike early films that were blind to red) yet still renders pure blue lighter than pure red: the gray a color becomes is a property of the emulsion, baked into the table. The same transform applies to every pixel uniformly — LUTs have no spatial structure (no vignette falloff, no grain field) — so the whole image is regraded by the same function, and the perceptual result on a photo is the sum of these per-color behaviors: skin tones shift one way, skies another, shadows a third. That is why the filmstrip previews exist: a photo's worth of colors shows the look in one glance where a probe table never could.

8 · The filmstrip previews

The bar's thumbnails used to be one generic stock photo graded with each LUT — the same preview for every user, for every photo. The bar's whole job is “see it on your photo” (docs/adr/0012), so the previews are now generated per photo, per LUT (§5, docs/adr/0013):

Bar opens / tab selectedthe visible group's missing thumbs are dispatched, one command per LUT
→
Downscalemain thread: 200×200 center crop of the photo
→
Thumb workerapplyLutCpu + JPEG q85, one request per LUT
→
Blob URLstored in model.lutThumbs[lutId]
→
Swapthe thumb's img src replaces the generic jpg

Generation is lazy per group — on tab select or bar-open, only the visible category's missing thumbs — so unvisited categories cost zero bandwidth: all 296 cubes would be 38MB of fetches at import; a single category is 4–8MB. The Recents tab reuses whatever its entries' categories have already produced. While a thumb renders (or if its cube fetch fails), the thumb keeps the vendored generic jpg — the placeholder and the failure fallback are the same thing, so the model only ever records ready previews and no status enum exists. A failure is retried on the group's next visit, never automatically.

Two correctness details are worth naming. Staleness: each result message carries the photo it was rendered for; if the user switched images while the worker was busy, the result is revoked and dropped, so a preview can never show the wrong photo's grade. Dedupe: the worker layer tracks in-flight (lutId, photo) pairs, so a mid-batch tab switch-away-and-back does not render the same thumb twice. And when a new image loads, the old previews' blob URLs — temporary in-browser web addresses pointing at data that lives only in memory — are revoked explicitly, the same hygiene the export dialog applies to its blobs.

9 · Reference tables

ConstantValueNotes
Cube grid13³ = 2,197 points13 samples per channel axis
Point order(b·size + g)·size + rred fastest — the G'MIC file order, not the textbook one (§2)
Domain0 … 1matches normalized sRGB; no scaling anywhere
File size / library~131KB each / 38MB totalhigh-precision floats, 17–18 significant digits
Texturergba32float 3Dbyte-exact upload; unfilterable, hence manual trilinear (§3)
Texel scalec · (SIZE−1)0.5 lands exactly on the middle grid point
Strengthamount ∈ [0, 1], default 1mixed in sRGB space; 0 = identity
Preview size200×200 JPEG, quality 85bar thumbs are 96px CSS; 200px stays sharp on 2× displays
CPU cost~1.7ms per 200×200 LUTa 68-cube category ≈ 0.1s in the worker
Library296 cubes, 9 categoriesvendored from G'MIC film presets (docs/adr/0004)