From e3e35a7ec2303b86926802e7f9c8f183b58e9b93 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 17:43:24 -0400 Subject: [PATCH] M13.23: GPU colour management (LUTs, OCIO, Color Profile Converter, Lumetri LUT/look) Apply Color LUT, OCIO CDL / Color Space / Display / File / Look Transform and Color Profile Converter compile to colour programs (effects::color_program, built next to the CPU effects) that fx_lut.wgsl interprets per pixel; LUTs (1D, 3D, cineSpace shapers, inverses) are read from the storage buffer with the CPU's nearest / trilinear / tetrahedral interpolation. Lumetri's Input LUT and Look no longer force the CPU. Custom .ocio configs stay on the CPU. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/catalog.rs | 5 +- crates/effects/src/lib.rs | 10 + crates/effects/src/ocio.rs | 171 ++++++++++ crates/effects/src/utility.rs | 2 +- crates/gpu/src/context.rs | 2 + crates/gpu/src/effects.rs | 1 + crates/gpu/src/fx_color.rs | 11 + crates/gpu/src/fx_lut.rs | 147 +++++++++ crates/gpu/src/lib.rs | 3 + crates/gpu/src/shaders/fx_color.wgsl | 49 ++- crates/gpu/src/shaders/fx_lut.wgsl | 474 +++++++++++++++++++++++++++ crates/gpu/src/tests_fx_lut.rs | 175 ++++++++++ docs/effects.md | 16 +- 13 files changed, 1052 insertions(+), 14 deletions(-) create mode 100644 crates/gpu/src/fx_lut.rs create mode 100644 crates/gpu/src/shaders/fx_lut.wgsl create mode 100644 crates/gpu/src/tests_fx_lut.rs diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 102bd84e6..ca580b9db 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -63,10 +63,7 @@ pub fn param_shown(effect: &str, param: &str, value: &dyn Fn(&str) -> Option Buf { b } +// ---- Colour programs (the GPU compositor's colour-management kernels) + +/// One per-pixel step of a colour transform, in the order the CPU effect applies them (see +/// [`color_program`]). +#[derive(Clone, Debug)] +pub enum ColorOp { + /// Transfer function, encoded → linear. + Decode(Tf), + /// Transfer function, linear → encoded. + Encode(Tf), + /// Linear-RGB matrix. + Matrix([[f32; 3]; 3]), + /// ASC CDL (forward or exact inverse; `clamp` = the v1.2 style). + Cdl { cdl: Cdl, clamp: bool, inverse: bool }, + /// The Tone Mapped view's roll-off (or its inverse). + Tonemap { inverse: bool }, + /// Color Profile Converter's perceptual gamut compression toward luminance `luma`. + Gamut([f64; 3]), + /// Negative channels to 0 (the saturation intent). + Max0, + /// A LUT (0 nearest, 1 trilinear, 2 tetrahedral) with an optional per-channel shaper, forward + /// or inverse (as [`FileXform::apply`]). + Lut { lut: Arc, shaper: Option; 3]>>, interp: u32, inverse: bool }, +} + +/// How a colour program meets premultiplied pixels. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Straight { + /// `unpremul` (colour 0 below alpha 1e-6); fully transparent pixels untouched (OCIO effects). + Unpremul, + /// Colour ÷ alpha; fully transparent pixels untouched (`Image::map_straight`, Apply Color LUT). + Divide, +} + +fn xform_ops(x: &Xform, ops: &mut Vec) { + if x.dec != Tf::Linear { + ops.push(ColorOp::Decode(x.dec)); + } + if !x.ident { + ops.push(ColorOp::Matrix(x.m)); + } + if x.enc != Tf::Linear { + ops.push(ColorOp::Encode(x.enc)); + } +} + +/// Effect `id`'s per-pixel transform as a list of [`ColorOp`]s that reproduces the CPU effect +/// (empty = the layer passes through). `None` when it has no such form (a custom OCIO config, +/// another effect). +pub fn color_program(id: &str, ctx: &EffectCtx) -> Option<(Vec, Straight)> { + let pr = ctx.params; + let mut ops = vec![]; + match id { + "ec.utility.applylut" => { + if let Some(lut) = crate::utility::load_lut(pr.s("lut")) { + ops.push(ColorOp::Lut { lut, shaper: None, interp: 2, inverse: false }); + } + return Some((ops, Straight::Divide)); + } + "ec.color.ociocolorspace" => { + if pr.e("config") == 1 { + return custom_config(ctx).is_none().then_some((ops, Straight::Unpremul)); + } + let (a, z) = (space_at(pr.e("source")), space_at(pr.e("destination"))); + let (a, z) = if pr.e("direction") == 1 { (z, a) } else { (a, z) }; + if a.name != z.name { + xform_ops(&Xform::new(a, z), &mut ops); + } + } + "ec.color.ociocdl" => { + let cdl = cdl_from_params(pr); + if cdl != Cdl::default() { + ops.push(ColorOp::Cdl { cdl, clamp: pr.e("style") == 0, inverse: pr.e("direction") == 1 }); + } + } + "ec.color.ociodisplay" => { + if pr.e("config") == 1 { + return custom_config(ctx).is_none().then_some((ops, Straight::Unpremul)); + } + let input = space_at(pr.e("source")); + let (display, view, inverse) = (pr.e("display") as usize, pr.e("view") as usize, pr.e("direction") == 1); + let (_, prims, tf) = DISPLAYS[display.min(DISPLAYS.len() - 1)]; + if view != 2 && !input.raw { + let disp = Space { name: "display", prims: Some((prims, D65)), tf: Tf::Linear, raw: false }; + let lin = Space { tf: Tf::Linear, ..*input }; + if !inverse { + ops.push(ColorOp::Decode(input.tf)); + xform_ops(&Xform::new(&lin, &disp), &mut ops); + if view == 1 { + ops.push(ColorOp::Tonemap { inverse: false }); + } + ops.push(ColorOp::Encode(tf)); + } else { + ops.push(ColorOp::Decode(tf)); + if view == 1 { + ops.push(ColorOp::Tonemap { inverse: true }); + } + xform_ops(&Xform::new(&disp, &lin), &mut ops); + ops.push(ColorOp::Encode(input.tf)); + } + } + } + "ec.color.ociofile" => { + if let Some(x) = load_file(pr.s("file")) { + let interp = match pr.e("interpolation") { + 0 => 0, + 1 => 1, + _ => 2, + }; + let inverse = pr.e("direction") == 1; + match &*x { + FileXform::Cdl(list) => { + let id = pr.s("cccId"); + let cdl = list.iter().find(|(i, _)| !id.is_empty() && i == id).or(list.first()).map(|(_, c)| *c).unwrap_or_default(); + ops.push(ColorOp::Cdl { cdl, clamp: true, inverse }); + } + FileXform::Lut { lut, shaper } => { + ops.push(ColorOp::Lut { lut: Arc::new(lut.clone()), shaper: shaper.clone().map(Arc::new), interp, inverse }); + } + } + } + } + "ec.color.ociolook" => { + let mut list = parse_looks(pr.s("looks")); + let pick = pr.e("look"); + if pick > 0 + && let Some((_, c)) = looks().get(pick as usize - 1) + { + list.insert(0, (*c, false)); + } + if !list.is_empty() { + let (src, dst) = (space_at(pr.e("source")), space_at(pr.e("destination"))); + let inv = pr.e("direction") == 1; + let cct = space_by_name("ACEScct").expect("built-in"); + let (a, z) = if inv { (dst, src) } else { (src, dst) }; + if inv { + list.reverse(); + } + xform_ops(&Xform::new(a, cct), &mut ops); + for (cdl, li) in &list { + ops.push(ColorOp::Cdl { cdl: *cdl, clamp: false, inverse: *li != inv }); + } + xform_ops(&Xform::new(cct, z), &mut ops); + } + } + "ec.utility.colorprofileconverter" => { + let (inp, in_lin_default) = profile(ctx, pr.e("inputProfile")); + let (out, out_lin_default) = profile(ctx, pr.e("outputProfile")); + let in_lin = pr.b("linearizeInputProfile") || in_lin_default && pr.e("inputProfile") == 0; + let out_lin = pr.b("linearizeOutputProfile") || out_lin_default && pr.e("outputProfile") == 0; + let intent = pr.e("intent"); + let dec = if in_lin { Tf::Linear } else { inp.tf }; + let enc = if out_lin { Tf::Linear } else { out.tf }; + let m = if intent == 3 { mul(&invert(&out.to_ref(false)), &inp.to_ref(false)) } else { matrix(&inp, &out) }; + let identity = dec == enc && (0..3).all(|i| (0..3).all(|j| (m[i][j] - IDENTITY[i][j]).abs() < 1e-9)); + if !identity { + ops.push(ColorOp::Decode(dec)); + ops.push(ColorOp::Matrix(m.map(|r| r.map(|v| v as f32)))); + match intent { + 0 => ops.push(ColorOp::Gamut(out.to_ref(true)[1])), + 2 => ops.push(ColorOp::Max0), + _ => {} + } + ops.push(ColorOp::Encode(enc)); + } + } + _ => return None, + } + Some((ops, Straight::Unpremul)) +} + // ---- Color Stabilizer /// Mean straight colour of a square (half-size `r` layer pixels) around layer point `pt`. diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index 7689050dc..46780aec5 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -326,7 +326,7 @@ impl Lut { type LutCache = Mutex>>>; /// Load (and cache) a LUT from inline `.cube` text or a file path. -pub(crate) fn load_lut(src: &str) -> Option> { +pub fn load_lut(src: &str) -> Option> { if src.trim().is_empty() { return None; } diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index a0f64b240..34f3204f5 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -315,6 +315,7 @@ impl GpuContext { include_str!("shaders/fx_warp.wgsl"), include_str!("shaders/fx_extra.wgsl"), include_str!("shaders/fx_depth.wgsl"), + include_str!("shaders/fx_lut.wgsl"), ] .concat(); let module = @@ -375,6 +376,7 @@ impl GpuContext { .chain(crate::fx_warp::KERNELS) .chain(crate::fx_extra::KERNELS) .chain(crate::fx_depth::KERNELS) + .chain(crate::fx_lut::KERNELS) .map(|e| (e, &layout)) .chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext))) .map(|(e, layout)| { diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 02012f117..e8ee547f6 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -134,6 +134,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { _ if crate::fx_warp::IDS.contains(&id) => crate::fx_warp::apply(e, id, ctx, b), _ if crate::fx_extra::IDS.contains(&id) => crate::fx_extra::apply(e, id, ctx, b), _ if crate::fx_depth::IDS.contains(&id) => crate::fx_depth::apply(e, id, ctx, b), + _ if crate::fx_lut::IDS.contains(&id) => crate::fx_lut::apply(e, id, ctx, b), _ => pointwise(e, id, ctx, b), } } diff --git a/crates/gpu/src/fx_color.rs b/crates/gpu/src/fx_color.rs index 6a1a16607..b9ae5282c 100644 --- a/crates/gpu/src/fx_color.rs +++ b/crates/gpu/src/fx_color.rs @@ -197,6 +197,17 @@ fn lumetri(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { (lw / lh.max(1e-6)) as f32, ]); debug_assert_eq!(d.len(), 50); + // Input LUT and Look (data[50..53]: look, input LUT offset, look LUT offset; tables after). + let look = pr.e("creative/look"); + let input_lut = if pr.e("basicCorrection/inputLut") == 1 { effectcraft_effects::load_lut(pr.s("basicCorrection/inputLutFile")) } else { None }; + let look_lut = if look == 1 { effectcraft_effects::load_lut(pr.s("creative/lookFile")) } else { None }; + d.extend([look as f32, -1.0, -1.0]); + if let Some(l) = &input_lut { + d[51] = crate::fx_lut::push_lut(&mut d, l); + } + if let Some(l) = &look_lut { + d[52] = crate::fx_lut::push_lut(&mut d, l); + } let buf = e.data(&d); let out = e.scratch(b.img.width, b.img.height); e.pixels("fxc_lumetri", &Params::default(), &b.img, None, &out, Some(&buf)); diff --git a/crates/gpu/src/fx_lut.rs b/crates/gpu/src/fx_lut.rs new file mode 100644 index 000000000..0e454a4e0 --- /dev/null +++ b/crates/gpu/src/fx_lut.rs @@ -0,0 +1,147 @@ +//! 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 render 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 { + 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], + } +} + +/// 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, 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> { + let mut prog: Vec = vec![]; + let mut tables: Vec> = 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::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 = 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) +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index b18d09d85..4cfd9ebe9 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -53,6 +53,7 @@ mod fx_distort; mod fx_extra; mod fx_generate; mod fx_key; +mod fx_lut; mod fx_noise; mod fx_stylize; mod fx_tone; @@ -212,6 +213,8 @@ mod tests_fx_generate; #[cfg(test)] mod tests_fx_key; #[cfg(test)] +mod tests_fx_lut; +#[cfg(test)] mod tests_fx_noise; #[cfg(test)] mod tests_fx_stylize; diff --git a/crates/gpu/src/shaders/fx_color.wgsl b/crates/gpu/src/shaders/fx_color.wgsl index 97771a46d..c7d6010e5 100644 --- a/crates/gpu/src/shaders/fx_color.wgsl +++ b/crates/gpu/src/shaders/fx_color.wgsl @@ -310,6 +310,43 @@ fn fxc_d3(i: u32) -> vec3 { // green, blue × shadows / midtones / highlights), 32..41 wheels (shadows, midtones, highlights), // 41 vignette amount, 42 vignette start, 43 roundness, 44 feather, 45 cx, 46 cy, 47 lw, 48 lh, // 49 aspect. +// color3::builtin_look. +fn fxc_builtin_look(look: u32, c: vec3) -> vec3 { + let l = luminance(c); + switch (look) { + case 2u: { + return vec3(c.x * 1.06 + 0.015, c.y * 1.01 + 0.01, c.z * 0.88); + } + case 3u: { + return vec3(c.x * 0.82, c.y * 0.92, c.z * 1.08 + 0.02) * 0.92; + } + case 4u: { + let d = l + (c - l) * 0.45; + var o: vec3; + if (l < 0.5) { + o = 2.0 * d * l; + } else { + o = 1.0 - 2.0 * (1.0 - d) * (1.0 - l); + } + return d * 0.5 + o * 0.5; + } + case 5u: { + let t = clamp(l - 0.5, -0.5, 0.5); + return vec3(c.x + 0.18 * t, c.y + 0.03 * t, c.z - 0.16 * t); + } + case 6u: { + let v = 0.08 + c * 0.84; + return l + (v - l) * 0.8; + } + case 7u: { + return vec3(l); + } + default: { + return c; + } + } +} + @compute @workgroup_size(16, 16) fn fxc_lumetri(@builtin(global_invocation_id) gid: vec3) { let p = fxc_pixel(gid); @@ -323,6 +360,9 @@ fn fxc_lumetri(@builtin(global_invocation_id) gid: vec3) { return; } var c = fxc_unpremul(px); + if (data[51] >= 0.0) { + c = fxl_lut_apply(u32(data[51]), c, 2u); + } let temp = data[0]; let tint = data[1]; if (temp != 0.0 || tint != 0.0) { @@ -359,8 +399,15 @@ fn fxc_lumetri(@builtin(global_invocation_id) gid: vec3) { let st = fxc_d3(14u); let ht = fxc_d3(17u); let zero = vec3(0.0); - if (intensity != 0.0 && (faded != 0.0 || vib != 0.0 || csat != 1.0 || any(st != zero) || any(ht != zero))) { + let look = u32(data[50]); + let has_look = look >= 2u || data[52] >= 0.0; + if (intensity != 0.0 && (has_look || faded != 0.0 || vib != 0.0 || csat != 1.0 || any(st != zero) || any(ht != zero))) { var d = c; + if (data[52] >= 0.0) { + d = fxl_lut_apply(u32(data[52]), c, 2u); + } else { + d = fxc_builtin_look(look, c); + } if (faded != 0.0) { d = d * (1.0 - 0.25 * faded) + 0.12 * faded; } diff --git a/crates/gpu/src/shaders/fx_lut.wgsl b/crates/gpu/src/shaders/fx_lut.wgsl new file mode 100644 index 000000000..3075bc81f --- /dev/null +++ b/crates/gpu/src/shaders/fx_lut.wgsl @@ -0,0 +1,474 @@ +// ---------------------------------------------------------------- colour management (fx_lut.rs) +// +// A colour program in `data`: opcodes with their arguments, ending in 0, then LUT tables +// (layout in fx_lut.rs::push_lut). Every step mirrors effects::ocio / effects::utility. + +fn fxl_d3(i: u32) -> vec3 { + return vec3(data[i], data[i + 1u], data[i + 2u]); +} + +// ---- LUTs (utility::Lut) + +fn fxl_lut_norm(o: u32, c: vec3) -> vec3 { + let dmin = fxl_d3(o + 2u); + let dmax = fxl_d3(o + 5u); + return clamp((c - dmin) / max(dmax - dmin, vec3(1e-9)), vec3(0.0), vec3(1.0)); +} + +fn fxl_lut_3d(base: u32, n: u32, r: u32, g: u32, b: u32) -> vec3 { + return fxl_d3(base + (r + g * n + b * n * n) * 3u); +} + +// Lut::apply_interp: 0 nearest, 1 trilinear, otherwise tetrahedral (1D tables linear unless 0). +fn fxl_lut_apply(o: u32, c0: vec3, interp: u32) -> vec3 { + let n1 = u32(data[o]); + let n3 = u32(data[o + 1u]); + let b1 = o + 8u; + let b3 = b1 + n1 * 3u; + var c = c0; + if (n1 > 1u) { + let t = fxl_lut_norm(o, c); + for (var i = 0u; i < 3u; i++) { + let x = t[i] * f32(n1 - 1u); + if (interp == 0u) { + c[i] = data[b1 + min(u32(round_away(x)), n1 - 1u) * 3u + i]; + } else { + let i0 = min(u32(floor(x)), n1 - 2u); + let f = x - f32(i0); + let a = data[b1 + i0 * 3u + i]; + let b = data[b1 + (i0 + 1u) * 3u + i]; + c[i] = a + (b - a) * f; + } + } + } + if (n3 > 1u) { + let t = fxl_lut_norm(o, c); + let s = f32(n3 - 1u); + if (interp == 0u) { + let ir = min(u32(round_away(t.x * s)), n3 - 1u); + let ig = min(u32(round_away(t.y * s)), n3 - 1u); + let ib = min(u32(round_away(t.z * s)), n3 - 1u); + return fxl_lut_3d(b3, n3, ir, ig, ib); + } + let v = t * s; + let r = min(u32(floor(v.x)), n3 - 2u); + let g = min(u32(floor(v.y)), n3 - 2u); + let b = min(u32(floor(v.z)), n3 - 2u); + let fr = v.x - f32(r); + let fg = v.y - f32(g); + let fb = v.z - f32(b); + if (interp == 1u) { + let c00 = mix_exact(fxl_lut_3d(b3, n3, r, g, b), fxl_lut_3d(b3, n3, r + 1u, g, b), fr); + let c10 = mix_exact(fxl_lut_3d(b3, n3, r, g + 1u, b), fxl_lut_3d(b3, n3, r + 1u, g + 1u, b), fr); + let c01 = mix_exact(fxl_lut_3d(b3, n3, r, g, b + 1u), fxl_lut_3d(b3, n3, r + 1u, g, b + 1u), fr); + let c11 = mix_exact(fxl_lut_3d(b3, n3, r, g + 1u, b + 1u), fxl_lut_3d(b3, n3, r + 1u, g + 1u, b + 1u), fr); + return mix_exact(mix_exact(c00, c10, fg), mix_exact(c01, c11, fg), fb); + } + let c000 = fxl_lut_3d(b3, n3, r, g, b); + let c111 = fxl_lut_3d(b3, n3, r + 1u, g + 1u, b + 1u); + var w1: f32; + var w2: f32; + var w3: f32; + var p1: vec3; + var p2: vec3; + if (fr > fg) { + if (fg > fb) { + w1 = fr; + w2 = fg; + w3 = fb; + p1 = fxl_lut_3d(b3, n3, r + 1u, g, b); + p2 = fxl_lut_3d(b3, n3, r + 1u, g + 1u, b); + } else if (fr > fb) { + w1 = fr; + w2 = fb; + w3 = fg; + p1 = fxl_lut_3d(b3, n3, r + 1u, g, b); + p2 = fxl_lut_3d(b3, n3, r + 1u, g, b + 1u); + } else { + w1 = fb; + w2 = fr; + w3 = fg; + p1 = fxl_lut_3d(b3, n3, r, g, b + 1u); + p2 = fxl_lut_3d(b3, n3, r + 1u, g, b + 1u); + } + } else if (fb > fg) { + w1 = fb; + w2 = fg; + w3 = fr; + p1 = fxl_lut_3d(b3, n3, r, g, b + 1u); + p2 = fxl_lut_3d(b3, n3, r, g + 1u, b + 1u); + } else if (fb > fr) { + w1 = fg; + w2 = fb; + w3 = fr; + p1 = fxl_lut_3d(b3, n3, r, g + 1u, b); + p2 = fxl_lut_3d(b3, n3, r, g + 1u, b + 1u); + } else { + w1 = fg; + w2 = fr; + w3 = fb; + p1 = fxl_lut_3d(b3, n3, r, g + 1u, b); + p2 = fxl_lut_3d(b3, n3, r + 1u, g + 1u, b); + } + return c000 + w1 * (p1 - c000) + w2 * (p2 - p1) + w3 * (c111 - p2); + } + return c; +} + +// a + (b - a) t, per component (the CPU's lerp, not WGSL's mix). +fn mix_exact(a: vec3, b: vec3, t: f32) -> vec3 { + return a + (b - a) * t; +} + +// ocio::shape over `n` (x, y) points at `at` (`flip`: the curve with x and y swapped). +fn fxl_shape(at: u32, n: u32, x: f32, flip: bool) -> f32 { + if (n < 2u) { + return x; + } + let kx = select(0u, 1u, flip); + let ky = 1u - kx; + // partition_point(|p| p.x < x). + var lo = 0u; + var hi = n; + while (lo < hi) { + let mid = (lo + hi) / 2u; + if (data[at + mid * 2u + kx] < x) { + lo = mid + 1u; + } else { + hi = mid; + } + } + let i = clamp(lo, 1u, n - 1u); + let ax = data[at + (i - 1u) * 2u + kx]; + let ay = data[at + (i - 1u) * 2u + ky]; + let bx = data[at + i * 2u + kx]; + let by = data[at + i * 2u + ky]; + let t = clamp((x - ax) / max(bx - ax, 1e-12), 0.0, 1.0); + return ay + (by - ay) * t; +} + +// Shaper channel k's points (three counts, then the channels' (x, y) pairs). +fn fxl_shaper_at(s: u32, k: u32) -> vec2 { + var at = s + 3u; + for (var j = 0u; j < k; j++) { + at += u32(data[s + j]) * 2u; + } + return vec2(at, u32(data[s + k])); +} + +fn fxl_file_fwd(o: u32, sh: i32, c0: vec3, interp: u32) -> vec3 { + var c = c0; + if (sh >= 0) { + for (var k = 0u; k < 3u; k++) { + let a = fxl_shaper_at(u32(sh), k); + c[k] = fxl_shape(a.x, a.y, c[k], false); + } + } + return fxl_lut_apply(o, c, interp); +} + +// The inverse of a 1D table channel (ocio::FileXform::apply: points (table, domain)). +fn fxl_inv_1d(o: u32, i: u32, y: f32) -> f32 { + let n = u32(data[o]); + let dmin = data[o + 2u + i]; + let dmax = data[o + 5u + i]; + let b1 = o + 8u; + // partition_point over the table's values. + var lo = 0u; + var hi = n; + while (lo < hi) { + let mid = (lo + hi) / 2u; + if (data[b1 + mid * 3u + i] < y) { + lo = mid + 1u; + } else { + hi = mid; + } + } + let k = clamp(lo, 1u, n - 1u); + let ax = data[b1 + (k - 1u) * 3u + i]; + let bx = data[b1 + k * 3u + i]; + let ay = dmin + (dmax - dmin) * f32(k - 1u) / f32(n - 1u); + let by = dmin + (dmax - dmin) * f32(k) / f32(n - 1u); + let t = clamp((y - ax) / max(bx - ax, 1e-12), 0.0, 1.0); + return ay + (by - ay) * t; +} + +fn fxl_file(o: u32, sh: i32, c: vec3, interp: u32, inverse: bool) -> vec3 { + if (!inverse) { + return fxl_file_fwd(o, sh, c, interp); + } + let n1 = u32(data[o]); + let n3 = u32(data[o + 1u]); + if (n3 == 0u) { + var x = c; + if (n1 > 1u) { + x = vec3(fxl_inv_1d(o, 0u, c.x), fxl_inv_1d(o, 1u, c.y), fxl_inv_1d(o, 2u, c.z)); + } + if (sh >= 0) { + for (var k = 0u; k < 3u; k++) { + let a = fxl_shaper_at(u32(sh), k); + x[k] = fxl_shape(a.x, a.y, x[k], true); + } + } + return x; + } + // Damped fixed point from the target, as the CPU. + var x = c; + for (var it = 0; it < 40; it++) { + let y = fxl_file_fwd(o, sh, x, interp); + let e = c - y; + if (all(abs(e) < vec3(1e-6))) { + break; + } + x = x + e; + } + return x; +} + +// ---- transfer functions (ocio::Tf) + +const FXL_PQ_M1: f32 = 0.1593017578125; +const FXL_PQ_M2: f32 = 78.84375; +const FXL_PQ_C1: f32 = 0.8359375; +const FXL_PQ_C2: f32 = 18.8515625; +const FXL_PQ_C3: f32 = 18.6875; + +fn fxl_copysign(m: f32, v: f32) -> f32 { + return select(abs(m), -abs(m), v < 0.0 || (v == 0.0 && bitcast(v) != 0u)); +} + +fn fxl_decode(tf: u32, g: f32, v: f32) -> f32 { + switch (tf) { + case 1u: { + let a = abs(v); + var l: f32; + if (a <= 0.04045) { + l = a / 12.92; + } else { + l = pow((a + 0.055) / 1.055, 2.4); + } + return fxl_copysign(l, v); + } + case 2u: { + return fxl_copysign(powz(abs(v), g), v); + } + case 3u: { + if (v <= 0.155251141552511) { + return (v - 0.0729055341958355) / 10.5402377416545; + } + if (v < (log2(65504.0) + 9.72) / 17.52) { + return exp2(v * 17.52 - 9.72); + } + return 65504.0; + } + case 4u: { + let e = powz(max(v, 0.0), 1.0 / FXL_PQ_M2); + let l = powz(max(e - FXL_PQ_C1, 0.0) / (FXL_PQ_C2 - FXL_PQ_C3 * e), 1.0 / FXL_PQ_M1); + return l * 100.0; + } + default: { + return v; + } + } +} + +fn fxl_encode(tf: u32, g: f32, v: f32) -> f32 { + switch (tf) { + case 1u: { + let a = abs(v); + var e: f32; + if (a <= 0.0031308) { + e = a * 12.92; + } else { + e = 1.055 * pow(a, 1.0 / 2.4) - 0.055; + } + return fxl_copysign(e, v); + } + case 2u: { + return fxl_copysign(powz(abs(v), 1.0 / g), v); + } + case 3u: { + if (v <= 0.0078125) { + return 10.5402377416545 * v + 0.0729055341958355; + } + return (log2(v) + 9.72) / 17.52; + } + case 4u: { + let y = powz(max(v / 100.0, 0.0), FXL_PQ_M1); + return powz((FXL_PQ_C1 + FXL_PQ_C2 * y) / (1.0 + FXL_PQ_C3 * y), FXL_PQ_M2); + } + default: { + return v; + } + } +} + +// ---- ASC CDL (ocio::Cdl), args at `a`: slope (3), offset (3), power (3), sat, clamp, inverse. + +const FXL_LUMA: vec3 = vec3(0.2126, 0.7152, 0.0722); + +fn fxl_cdl(a: u32, c0: vec3) -> vec3 { + let slope = fxl_d3(a); + let offset = fxl_d3(a + 3u); + let power = fxl_d3(a + 6u); + let sat = data[a + 9u]; + let clampo = data[a + 10u] != 0.0; + let zero = vec3(0.0); + let one = vec3(1.0); + if (data[a + 11u] == 0.0) { + var o = vec3(0.0); + for (var i = 0u; i < 3u; i++) { + let v = c0[i] * slope[i] + offset[i]; + if (clampo) { + o[i] = powz(clamp(v, 0.0, 1.0), power[i]); + } else if (v >= 0.0) { + o[i] = powz(v, power[i]); + } else { + o[i] = v; + } + } + let l = o.x * FXL_LUMA.x + o.y * FXL_LUMA.y + o.z * FXL_LUMA.z; + o = l + sat * (o - l); + if (clampo) { + o = clamp(o, zero, one); + } + return o; + } + var c = c0; + if (clampo) { + c = clamp(c, zero, one); + } + let l = c.x * FXL_LUMA.x + c.y * FXL_LUMA.y + c.z * FXL_LUMA.z; + var s = sat; + if (abs(s) < 1e-9) { + s = 1e-9; + } + let o = l + (c - l) / s; + var r = vec3(0.0); + for (var i = 0u; i < 3u; i++) { + var v = o[i]; + if (clampo) { + v = clamp(v, 0.0, 1.0); + } + let pw = max(power[i], 1e-9); + var u = v; + if (v >= 0.0) { + u = powz(v, 1.0 / pw); + } + var sl = slope[i]; + if (abs(sl) < 1e-9) { + sl = 1e-9; + } + r[i] = (u - offset[i]) / sl; + } + if (clampo) { + r = clamp(r, zero, one); + } + return r; +} + +fn fxl_tonemap(v: f32, inverse: bool) -> f32 { + if (!inverse) { + if (v <= 0.0) { + return v; + } + return v * (1.0 + v / 256.0) / (1.0 + v); + } + if (v <= 0.0) { + return v; + } + let a = 1.0 / 256.0; + let b = 1.0 - v; + let c = -v; + return (-b + sqrt(b * b - 4.0 * a * c)) / (2.0 * a); +} + +// Run the colour program at data[0..] on a straight colour. +fn fxl_run(c0: vec3) -> vec3 { + var c = c0; + var pc = 0u; + for (var guard = 0; guard < 256; guard++) { + let op = u32(data[pc]); + switch (op) { + case 1u, 2u: { + let tf = u32(data[pc + 1u]); + let g = data[pc + 2u]; + for (var i = 0u; i < 3u; i++) { + if (op == 1u) { + c[i] = fxl_decode(tf, g, c[i]); + } else { + c[i] = fxl_encode(tf, g, c[i]); + } + } + pc += 3u; + } + case 3u: { + let r0 = fxl_d3(pc + 1u); + let r1 = fxl_d3(pc + 4u); + let r2 = fxl_d3(pc + 7u); + c = vec3(r0.x * c.x + r0.y * c.y + r0.z * c.z, r1.x * c.x + r1.y * c.y + r1.z * c.z, r2.x * c.x + r2.y * c.y + r2.z * c.z); + pc += 10u; + } + case 4u: { + c = fxl_cdl(pc + 1u, c); + pc += 13u; + } + case 5u: { + let inv = data[pc + 1u] != 0.0; + c = vec3(fxl_tonemap(c.x, inv), fxl_tonemap(c.y, inv), fxl_tonemap(c.z, inv)); + pc += 2u; + } + case 6u: { + let luma = fxl_d3(pc + 1u); + let l = c.x * luma.x + c.y * luma.y + c.z * luma.z; + if (l <= 0.0) { + c = max(c, vec3(0.0)); + } else { + let mn = min(min(c.x, c.y), c.z); + if (mn < 0.0) { + let t = clamp(l / (l - mn), 0.0, 1.0); + c = l + (c - l) * t; + } + } + pc += 4u; + } + case 7u: { + c = max(c, vec3(0.0)); + pc += 1u; + } + case 8u: { + let o = u32(data[pc + 1u]); + let interp = u32(data[pc + 2u]); + let inv = data[pc + 3u] != 0.0; + let sh = i32(data[pc + 4u]); + c = fxl_file(o, sh, c, interp, inv); + pc += 5u; + } + default: { + return c; + } + } + } + return c; +} + +// u[0].x = 0: OCIO pixels (unpremul); 1: colour ÷ alpha (Image::map_straight). +@compute @workgroup_size(16, 16) +fn fxl_point(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let px = textureLoad(src, p, 0); + let a = px.w; + if (a <= 0.0) { + textureStore(out, p, px); + return; + } + var c = px.xyz / a; + if (P.u[0].x == 0u && a <= 1e-6) { + c = vec3(0.0); + } + textureStore(out, p, vec4(fxl_run(c) * a, a)); +} diff --git a/crates/gpu/src/tests_fx_lut.rs b/crates/gpu/src/tests_fx_lut.rs new file mode 100644 index 000000000..96beaa8d3 --- /dev/null +++ b/crates/gpu/src/tests_fx_lut.rs @@ -0,0 +1,175 @@ +//! GPU colour management vs the CPU effects (the oracle): Apply Color LUT, the OCIO effects, +//! Color Profile Converter and Lumetri's Input LUT / Look, direct on a buffer (full and half +//! resolution, as adjustment) and composited at 8 and 32 bpc. + +use effectcraft_keyframe::Value; + +use effectcraft_project::BitDepth; +use effectcraft_render::RenderOpts; +use effectcraft_time::Tick; + +use crate::tests::{Scene, check, compare_at, effect_case, effect_direct, n, opts, set}; + +/// [`effect_case`] for nearest-neighbour lattice lookups: an 8 bpc input that lands exactly on a +/// rounding boundary between two lattice points may pick the other one (the GPU's division is +/// not correctly rounded), so up to `allow` of the composited pixels may differ. +fn case_allow(id: &str, vals: &[(&str, Value)], allow: f64) { + effect_direct(id, vals, false); + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let mut l = s.footage(70, 44); + s.effect(&mut l, id, vals); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + check(&format!("{id} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), allow); + check(&format!("{id} {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), allow); + } +} + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn s(v: &str) -> Value { + Value::Str(v.to_string()) +} + +/// A graded lattice: `f` over an `n`³ grid (red fastest). +fn lattice(n: usize, f: impl Fn([f32; 3]) -> [f32; 3]) -> String { + let mut out = String::new(); + for b in 0..n { + for g in 0..n { + for r in 0..n { + let c = f([r, g, b].map(|i| i as f32 / (n - 1) as f32)); + out += &format!("{} {} {}\n", c[0], c[1], c[2]); + } + } + } + out +} + +fn grade(c: [f32; 3]) -> [f32; 3] { + [c[0].powf(0.8) * 1.05, c[1] * 0.9 + 0.08 * (c[0] * 5.0).sin(), c[2] * 0.7 + c[0] * 0.2 + 0.03] +} + +fn cube_3d() -> String { + format!("TITLE \"grade\"\nLUT_3D_SIZE 9\n{}", lattice(9, grade)) +} + +fn cube_1d_3d() -> String { + let mut s = String::from("LUT_1D_SIZE 16\nLUT_3D_SIZE 7\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 1.2 1.2 1.2\n"); + for i in 0..16 { + let v = (i as f32 / 15.0).powf(0.6); + s += &format!("{} {} {}\n", v, v * 0.95, v.powf(1.2)); + } + s + &lattice(7, grade) +} + +fn cube_1d() -> String { + let mut s = String::from("LUT_1D_SIZE 32\n"); + for i in 0..32 { + let v = i as f32 / 31.0; + s += &format!("{} {} {}\n", v.powf(0.7), v.powf(1.3), 0.05 + v * 0.9); + } + s +} + +fn csp() -> String { + let mut s = String::from("CSPLUTV100\n3D\n\n3\n0 0.5 1\n0 0.3 1\n2\n0 1\n0 1\n4\n0 0.2 0.6 1\n0 0.4 0.7 1\n\n5 5 5\n"); + for b in 0..5 { + for g in 0..5 { + for r in 0..5 { + let c = grade([r, g, b].map(|i| i as f32 / 4.0)); + s += &format!("{} {} {}\n", c[0], c[1], c[2]); + } + } + } + s +} + +#[test] +fn apply_color_lut() { + effect_case("ec.utility.applylut", &[("lut", s(&cube_3d()))]); + effect_case("ec.utility.applylut", &[("lut", s(&cube_1d_3d()))]); + effect_case("ec.utility.applylut", &[("lut", s(&cube_1d()))]); + effect_case("ec.utility.applylut", &[("lut", s("/nonexistent/grade.cube"))]); +} + +#[test] +fn ocio_file_transform() { + for interp in 1..4 { + effect_case("ec.color.ociofile", &[("file", s(&cube_3d())), ("interpolation", e(interp))]); + } + case_allow("ec.color.ociofile", &[("file", s(&cube_3d())), ("interpolation", e(0))], 0.01); + case_allow("ec.color.ociofile", &[("file", s(&cube_1d_3d())), ("interpolation", e(0))], 0.01); + effect_case("ec.color.ociofile", &[("file", s(&cube_1d())), ("direction", e(1))]); + effect_case("ec.color.ociofile", &[("file", s(&cube_3d())), ("direction", e(1))]); + effect_case("ec.color.ociofile", &[("file", s(&csp())), ("interpolation", e(1))]); + effect_case("ec.color.ociofile", &[("file", s(&csp())), ("direction", e(1))]); + let ccc = r#"1.2 1 0.90.02 0 -0.011.1 0.9 1 + 0.8 0.8 0.81.4"#; + effect_case("ec.color.ociofile", &[("file", s(ccc))]); + effect_case("ec.color.ociofile", &[("file", s(ccc)), ("cccId", s("b")), ("direction", e(1))]); +} + +#[test] +fn ocio_cdl_space_display_look() { + let cdl = [("slopeRed", n(1.3)), ("offsetGreen", n(0.05)), ("powerBlue", n(1.4)), ("saturation", n(1.3))]; + effect_case("ec.color.ociocdl", &cdl); + effect_case("ec.color.ociocdl", &[cdl.as_slice(), &[("style", e(1)), ("offsetRed", n(-0.1))]].concat()); + effect_case("ec.color.ociocdl", &[cdl.as_slice(), &[("direction", e(1))]].concat()); + effect_case("ec.color.ociocdl", &[cdl.as_slice(), &[("direction", e(1)), ("style", e(1))]].concat()); + let spaces = effectcraft_effects::find("ec.color.ociocolorspace").unwrap(); + let names = match &spaces.params.iter().find(|p| p.id == "source").unwrap().ui { + effectcraft_project::ParamUi::Popup { options } => options.clone(), + _ => vec![], + }; + for (a, z) in [("sRGB", "ACEScg"), ("ACEScct", "Display P3"), ("Gamma 2.4 Rec.2020", "ACES2065-1"), ("CIE-XYZ-D65", "sRGB"), ("Raw", "sRGB")] { + let i = |n: &str| e(names.iter().position(|x| x == n).unwrap() as u32); + effect_case("ec.color.ociocolorspace", &[("source", i(a)), ("destination", i(z))]); + effect_case("ec.color.ociocolorspace", &[("source", i(a)), ("destination", i(z)), ("direction", e(1))]); + } + for display in 0..4 { + for view in 0..2 { + effect_case("ec.color.ociodisplay", &[("display", e(display)), ("view", e(view))]); + } + } + // (Inverting PQ and the roll-off together reaches values near 1000 whose matrix mix cancels + // in the small channels: f32 agrees to ~1e-5 of the pixel's largest channel there.) + effect_case("ec.color.ociodisplay", &[("display", e(3)), ("direction", e(1))]); + effect_case("ec.color.ociodisplay", &[("display", e(2)), ("view", e(1)), ("direction", e(1))]); + effect_case("ec.color.ociodisplay", &[("display", e(1)), ("direction", e(1))]); + effect_case("ec.color.ociolook", &[("look", e(3))]); + effect_case("ec.color.ociolook", &[("look", e(1)), ("looks", s("Vivid, -Cool"))]); + effect_case("ec.color.ociolook", &[("looks", s("High Contrast")), ("direction", e(1))]); +} + +#[test] +fn color_profile_converter() { + for (inp, out, intent) in [(0, 1, 1), (1, 3, 0), (4, 5, 2), (6, 2, 3), (5, 1, 0), (3, 4, 1)] { + effect_case("ec.utility.colorprofileconverter", &[("inputProfile", e(inp)), ("outputProfile", e(out)), ("intent", e(intent))]); + } + effect_case( + "ec.utility.colorprofileconverter", + &[("inputProfile", e(1)), ("outputProfile", e(3)), ("linearizeInputProfile", Value::Bool(true)), ("linearizeOutputProfile", Value::Bool(true))], + ); +} + +#[test] +fn lumetri_luts_and_looks() { + for look in 2..8 { + effect_case("ec.color.lumetri", &[("creative/look", e(look))]); + } + effect_case("ec.color.lumetri", &[("creative/look", e(4)), ("creative/lookIntensity", n(60.0)), ("basicCorrection/tone/exposure", n(0.3))]); + effect_case("ec.color.lumetri", &[("creative/look", e(1)), ("creative/lookFile", s(&cube_3d()))]); + effect_case( + "ec.color.lumetri", + &[("basicCorrection/inputLut", e(1)), ("basicCorrection/inputLutFile", s(&cube_1d_3d())), ("basicCorrection/saturation", n(120.0))], + ); + effect_case( + "ec.color.lumetri", + &[("basicCorrection/inputLut", e(1)), ("basicCorrection/inputLutFile", s(&cube_1d())), ("creative/look", e(1)), ("creative/lookFile", s(&cube_3d()))], + ); +} diff --git a/docs/effects.md b/docs/effects.md index 0568fb218..07219d9c0 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -5,7 +5,7 @@ EffectCraft ships 306 effects with 3009 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. -- **GPU**: 174 effects also run on the GPU compositor with identical results. +- **GPU**: 181 effects also run on the GPU compositor with identical results. - **32**: 306 effects process 32-bit float (HDR, overbright) pixels without clamping. - **Status**: 306 implemented in full, 0 partial (what is missing is listed). @@ -112,11 +112,11 @@ Effects are addressed by id (`ec..`) or display name in commands | Levels | `ec.color.levels` | 36 | GPU | 32 | Implemented | | Levels (Individual Controls) | `ec.color.levelsic` | 27 | GPU | 32 | Implemented | | Lumetri Color | `ec.color.lumetri` | 66 | GPU | 32 | Implemented | -| OCIO CDL Transform | `ec.color.ociocdl` | 12 | | 32 | Implemented | -| OCIO Color Space Transform | `ec.color.ociocolorspace` | 7 | | 32 | Implemented | -| OCIO Display Transform | `ec.color.ociodisplay` | 9 | | 32 | Implemented | -| OCIO File Transform | `ec.color.ociofile` | 4 | | 32 | Implemented | -| OCIO Look Transform | `ec.color.ociolook` | 6 | | 32 | Implemented | +| OCIO CDL Transform | `ec.color.ociocdl` | 12 | GPU | 32 | Implemented | +| OCIO Color Space Transform | `ec.color.ociocolorspace` | 7 | GPU | 32 | Implemented | +| OCIO Display Transform | `ec.color.ociodisplay` | 9 | GPU | 32 | Implemented | +| OCIO File Transform | `ec.color.ociofile` | 4 | GPU | 32 | Implemented | +| OCIO Look Transform | `ec.color.ociolook` | 6 | GPU | 32 | Implemented | | PS Arbitrary Map | `ec.color.psarbitrarymap` | 2 | GPU | 32 | Implemented | | Photo Filter | `ec.color.photofilter` | 4 | GPU | 32 | Implemented | | Selective Color | `ec.color.selectivecolor` | 38 | GPU | 32 | Implemented | @@ -412,10 +412,10 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Apply Color LUT | `ec.utility.applylut` | 0 | | 32 | Implemented | +| Apply Color LUT | `ec.utility.applylut` | 0 | GPU | 32 | Implemented | | CC Overbrights | `ec.utility.ccoverbrights` | 2 | | 32 | Implemented | | Cineon Converter | `ec.utility.cineon` | 7 | | 32 | Implemented | -| Color Profile Converter | `ec.utility.colorprofileconverter` | 7 | | 32 | Implemented | +| Color Profile Converter | `ec.utility.colorprofileconverter` | 7 | GPU | 32 | Implemented | | Face Measurements | `ec.utility.facemeasurements` | 14 | | 32 | Implemented | | Face Track Points | `ec.utility.facetrackpoints` | 26 | | 32 | Implemented | | Grow Bounds | `ec.utility.growbounds` | 1 | | 32 | Implemented |