Files
effectcraft/crates/gpu/src/fx_lut.rs
T
bflatasticandClaude Opus 5.5 492fa6de0a Effects: OCIO custom configs read Blender's v2 transforms and warn (#410)
Cause: the config reader didn't know ColorSpaceTransform or
ExponentWithLinearTransform and passed colours through them silently.
Blender's Linear Rec.709 -> sRGB therefore stayed in its E-white XYZ
reference space (the blue cast) without the sRGB curve. Display
colorspaces, view transforms, v2 views, looks, AllocationTransform,
BuiltinTransform and .spi1d / .spimtx files were missing too, and
nothing told the user.

Fix (from the OCIO v2 documentation and the CLF "moncurve", not OCIO's
source): ExponentWithLinear, ColorSpace, Look, DisplayView, Allocation
and Builtin transforms (identity, ACES AP0/AP1 matrices, ACEScct /
ACEScc, the sRGB / P3 / Rec.1886 / gamma / PQ displays). RangeTransform
now clamps and takes one-sided ranges. Display colorspaces meet the
scene reference through the default view transform. Added v2 views,
view looks, the looks section and .spi1d / .spi3d / .spimtx files. A
conversion is built once as a flat list of steps (Config::path /
display_path) for both the CPU effect and the GPU colour program (new
moncurve and clamp ops and the ACEScc curve; a program over the
kernel's 255 ops renders on the CPU). Named-transform resolution is
limited to 16 levels and 4096 steps, and YAML nesting to 64 levels, so
hostile or looping configs stop. Transforms that are still unsupported
(Grading Primary / Tone, ACES output, HLG…), unreadable configs and
unknown spaces are now reported: a warning under the effect in Effect
Controls, and the new effect.warning query.

Tests: ocio_config::blender_like_config_converts_linear_rec709_to_srgb
(CST, display colorspaces, v1 / v2 views, allocation, looks, builtin,
range, loops), moncurve_matches_srgb_and_inverts, builtin_styles,
yaml_nesting_is_bounded, ocio::spi_files_parse,
tests_effects::ocio_effect_warns_about_unsupported_config_transforms,
gpu tests_fx_lut::ocio_custom_config_v2 (GPU = CPU).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:57:12 +07:00

184 lines
7.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! GPU effects, colour management (kernels in `shaders/fx_lut.wgsl`, every entry point
//! prefixed `fxl_`): Apply Color LUT, OCIO CDL / Color Space / Display / File / Look Transform
//! and Color Profile Converter; Lumetri Color's Input LUT and Look also use the LUT helpers.
//!
//! Each effect compiles to a colour program ([`effectcraft_effects::color_program`]: transfer
//! functions, matrices, ASC CDLs, tone mapping, gamut compression, LUTs) built next to the CPU
//! effect, which `fxl_point` interprets per pixel. LUTs (1D tables and 3D lattices, with a
//! cineSpace shaper) travel in the kernel's storage buffer and are interpolated in the kernel
//! exactly as the CPU does (nearest, trilinear or tetrahedral; inverse 1D tables and the
//! iterative 3D inverse): hardware 3D-texture filtering would round the interpolation weights
//! and cannot do tetrahedral interpolation. The CPU evaluates transfer functions, CDLs and
//! Color Profile Converter's matrix in f64; the kernel in f32 (within the 1e-3 tolerance).
//! Custom `.ocio` configurations compile their conversions (matrix and offset, exponent,
//! exponent with linear segment, log / log-affine, allocation, range with its clamp, CDL, file
//! LUTs and matrices, built-in curves, with colour space / look / display-view transforms
//! resolved) to the same program; one longer than the kernel's 255 ops renders on the CPU.
use effectcraft_effects::{ColorOp, EffectCtx, Lut, Straight, Tf};
use crate::context::{Enc, Params};
use crate::effects::GBuf;
/// Compute entry points in `fx_lut.wgsl`.
pub(crate) const KERNELS: &[&str] = &["fxl_point"];
/// Effect ids implemented here.
pub(crate) const IDS: &[&str] = &[
"ec.utility.applylut",
"ec.color.ociocdl",
"ec.color.ociocolorspace",
"ec.color.ociodisplay",
"ec.color.ociofile",
"ec.color.ociolook",
"ec.utility.colorprofileconverter",
];
/// Largest LUT the kernels take (floats in the storage buffer); bigger ones render on the CPU.
const MAX_FLOATS: usize = 1 << 24;
/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU.
pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let (ops, straight) = effectcraft_effects::color_program(id, ctx)?;
if ops.is_empty() {
return Some(b);
}
let data = encode(&ops)?;
let buf = e.data(&data);
let mut p = Params::default();
p.u[0][0] = (straight == Straight::Divide) as u32;
let out = e.scratch(b.img.width, b.img.height);
e.pixels("fxl_point", &p, &b.img, None, &out, Some(&buf));
Some(GBuf { img: out, ..b })
}
fn tf_code(t: Tf) -> [f32; 2] {
match t {
Tf::Linear => [0.0, 0.0],
Tf::Srgb => [1.0, 0.0],
Tf::Gamma(g) => [2.0, g as f32],
Tf::AcesCct => [3.0, 0.0],
Tf::Pq => [4.0, 0.0],
Tf::AcesCc => [5.0, 0.0],
}
}
/// Most ops `fxl_run` interprets (its loop guard); longer programs render on the CPU.
const MAX_OPS: usize = 255;
/// A LUT in the layout `fxl_lut_*` read at the returned offset: n1, n3, domain min (3), domain
/// max (3), the 1D table (n1 × RGB), then the lattice (n3³ × RGB, red fastest).
pub(crate) fn push_lut(d: &mut Vec<f32>, lut: &Lut) -> f32 {
let at = d.len() as f32;
d.extend([lut.n1 as f32, lut.n3 as f32]);
d.extend(lut.dmin);
d.extend(lut.dmax);
for v in lut.d1.iter().chain(&lut.d3) {
d.extend(v);
}
at
}
/// Program layout: a stream of (opcode, arguments…) ending in 0, followed by LUT data.
fn encode(ops: &[ColorOp]) -> Option<Vec<f32>> {
if ops.len() > MAX_OPS {
return None;
}
let mut prog: Vec<f32> = vec![];
let mut tables: Vec<Vec<f32>> = vec![];
// LUT offsets are patched once the program's length is known.
let mut patches: Vec<(usize, usize)> = vec![];
for op in ops {
match op {
ColorOp::Decode(Tf::Linear) | ColorOp::Encode(Tf::Linear) => {}
ColorOp::Decode(t) => {
prog.push(1.0);
prog.extend(tf_code(*t));
}
ColorOp::Encode(t) => {
prog.push(2.0);
prog.extend(tf_code(*t));
}
ColorOp::Matrix(m) => {
prog.push(3.0);
for r in m {
prog.extend(r);
}
}
ColorOp::Cdl { cdl, clamp, inverse } => {
prog.push(4.0);
for v in cdl.slope.iter().chain(&cdl.offset).chain(&cdl.power) {
prog.push(*v as f32);
}
prog.extend([cdl.sat as f32, *clamp as u32 as f32, *inverse as u32 as f32]);
}
ColorOp::Tonemap { inverse } => prog.extend([5.0, *inverse as u32 as f32]),
ColorOp::Gamut(l) => prog.extend([6.0, l[0] as f32, l[1] as f32, l[2] as f32]),
ColorOp::Max0 => prog.push(7.0),
ColorOp::Affine { m, pre, post } => {
prog.push(9.0);
for r in m {
prog.extend(r);
}
prog.extend(pre);
prog.extend(post);
}
ColorOp::Pow(e) => {
prog.push(10.0);
prog.extend(e);
}
ColorOp::LogAffine { base, log_slope, log_offset, lin_slope, lin_offset, inverse } => {
prog.extend([11.0, *base]);
prog.extend(log_slope.iter().chain(log_offset).chain(lin_slope).chain(lin_offset));
prog.extend([*inverse as u32 as f32, f64::MIN_POSITIVE.ln() as f32]);
}
ColorOp::MonCurve { gamma, offset, inverse, mirror } => {
prog.push(12.0);
prog.extend(gamma.iter().chain(offset));
prog.extend([*inverse as u32 as f32, *mirror as u32 as f32]);
}
ColorOp::Clamp { lo, hi } => {
prog.push(13.0);
prog.extend(lo.iter().chain(hi));
}
ColorOp::Lut { lut, shaper, interp, inverse } => {
prog.extend([8.0, 0.0, *interp as f32, *inverse as u32 as f32, -1.0]);
let mut t = vec![];
push_lut(&mut t, lut);
let lut_at = prog.len() - 4;
patches.push((lut_at, tables.len()));
tables.push(t);
if let Some(s) = shaper {
// Shaper: three point counts, then each channel's (x, y) points.
let mut t: Vec<f32> = s.iter().map(|c| c.len() as f32).collect();
for c in s.iter() {
for (x, y) in c {
t.extend([*x, *y]);
}
}
patches.push((prog.len() - 1, tables.len()));
tables.push(t);
}
}
}
}
prog.push(0.0);
let mut starts = vec![];
let mut at = prog.len();
for t in &tables {
starts.push(at);
at += t.len();
}
if at > MAX_FLOATS {
return None;
}
// Offsets travel as f32: exact below 2^24.
for (slot, table) in patches {
prog[slot] = starts[table] as f32;
}
for t in tables {
prog.extend(t);
}
Some(prog)
}