Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit61a33237e0

README.md

lut module

require("@builtin::modules.lut") — colour lookup tables: build one, import one a colourist exported, read one back, and install it as the .texture asset the lut post-process effect binds.

What a LUT is here

An N-entry colour cube: for N³ input colours the table stores what each becomes, and everything between them is read by trilinear interpolation. A grade of any complexity therefore costs the same eight texel reads — which is why a look is delivered as a table rather than as a chain of operations.

The cube is stored as a strip: N square tiles in one row, so the image measures N*N wide by N tall. Tile b holds the plane of constant blue index, red runs across a tile, green runs down it. That is the layout every grading tool means by "2D LUT", and it is what lut.install writes.

The lut post shader reads N from the image's own height, so one shader serves a 16-, 32- or 64-entry cube with nothing to configure. A texture that is not shaped like a strip — including the 1×1 fallback bound when the effect has no table — leaves the frame exactly as it arrived.

Applying one

local lut = require("@builtin::modules.lut")

local ref = lut.install("warm_evening", lut.fromGrade({
    exposure = 0.3,
    temperature = 0.25,
    saturation = 1.1,
    lift = { 0.01, 0.01, 0.03 },
}))

tools.use("pp", "add", "lut", "lut", { texture = ref.guid, amount = 1.0 })

amount cross-fades between the ungraded frame and the table's answer, so a look can be dialled in or tweened.

The effect works on the display's code values — the usual place for a look exported from a grading tool, since the chain applies it after __display__, the chain's own display transform, has already run.

Surface

CallWhat it does
lut.build(size, fn)Ask fn(r, g, b) what each lattice colour becomes.
lut.identity(size?)The table that changes nothing — the A/B control.
lut.grade(spec)The GradeSpec transform as a plain function.
lut.fromGrade(spec, size?)Bake that transform into a table.
lut.fromImage(bytes)Import a strip PNG/JPEG/WebP.
lut.fromCube(text)Import .cube text (Resolve, Photoshop, …).
lut.toCube(table, title?)Write .cube text back out.
lut.sample(table, r, g, b)The trilinear read the shader performs.
lut.strip(table)The channel values, its width and its height.
lut.install(name, table, opts?)Write it as a .texture asset.

The grade

GradeSpec fields compose in the order a colourist works in: exposure, white balance (temperature / tint), contrast about contrastPivot, saturation about Rec. 709 luminance, then lift / gamma / gain / offset per channel, then a curve hook applied last. Every field is optional and every omitted field is the identity, so {} is the grade that changes nothing.

White balance is a von Kries adaptation: the spec's temperature and tint name an illuminant, and the table carries the LMS ratio between it and D65 — the same model a grading tool's temperature slider drives.

Storage

A table's entries are addresses, not an image, so lut.install writes them as a float raster (rgba32f, nearest, no mip chain) and uploads the same strip to the GPU under that asset's guid as rgba16f, the float format a texture slot samples on every device. The entries reach the shader as they were written, and the shader reads its corners with textureLoad at exact texel coordinates, so each blue plane is read from its own tile.

The upload is also what makes the guid lut.install returns bindable from the next frame — the .texture write itself settles at the end of the step that made it — and what puts a re-baked look in front of the camera under the name it already had.

A 16-entry cube is the common default and is enough for most looks; pass a larger size where a grade has a steep region and the interpolation between entries shows. The largest is 90: the strip is size*size wide, and 8192 is the texture width every device guarantees. A strip imported from a colourist's 8-bit PNG carries that image's precision, which the float raster then preserves exactly.