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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 17:43:24 -04:00
co-authored by Claude Opus 5.5
parent 6b91afc087
commit e3e35a7ec2
13 changed files with 1052 additions and 14 deletions
+1 -4
View File
@@ -63,10 +63,7 @@ pub fn param_shown(effect: &str, param: &str, value: &dyn Fn(&str) -> Option<eff
/// Controls of GPU-accelerated effects that only the CPU implements: while any of them is away
/// from its default the effect renders on the CPU. (Prefixes ending in `/` cover a whole group.)
const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[
(
"ec.color.lumetri",
&["highDynamicRange", "basicCorrection/inputLut", "creative/look", "curves/rgbCurves/", "curves/hueSaturationCurves/", "hslSecondary/"],
),
("ec.color.lumetri", &["highDynamicRange", "curves/rgbCurves/", "curves/hueSaturationCurves/", "hslSecondary/"]),
(
"ec.color.colorama",
&["inputPhase/addPhase", "outputCycle/usePresetPalette", "modify/", "pixelSelection/matchingMode", "masking/maskingMode", "masking/compositeOverLayer"],
+10
View File
@@ -99,6 +99,8 @@ pub use time_fx::{posterized_time, time_frames};
pub use ocio::color_stabilizer_maps;
// GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes).
pub use channel3d::{CRYPTO_LAYERS, id_color, selection_hashes};
pub use ocio::{ColorOp, Straight, Tf, color_program};
pub use utility::{Lut, load_lut};
/// Effect categories in Effects & Presets order.
pub const CATEGORIES: &[&str] = &[
@@ -942,6 +944,14 @@ pub const GPU_EFFECTS: &[&str] = &[
"ec.3d.fog3d",
"ec.3d.idmatte",
"ec.3d.identifier",
// Colour management.
"ec.utility.applylut",
"ec.color.ociocdl",
"ec.color.ociocolorspace",
"ec.color.ociodisplay",
"ec.color.ociofile",
"ec.color.ociolook",
"ec.utility.colorprofileconverter",
];
/// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated
+171
View File
@@ -865,6 +865,177 @@ fn profile_converter(ctx: &EffectCtx, mut b: Buf) -> 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<Lut>, shaper: Option<Arc<[Vec<(f32, f32)>; 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<ColorOp>) {
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<ColorOp>, 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`.
+1 -1
View File
@@ -326,7 +326,7 @@ impl Lut {
type LutCache = Mutex<HashMap<String, Option<Arc<Lut>>>>;
/// Load (and cache) a LUT from inline `.cube` text or a file path.
pub(crate) fn load_lut(src: &str) -> Option<Arc<Lut>> {
pub fn load_lut(src: &str) -> Option<Arc<Lut>> {
if src.trim().is_empty() {
return None;
}
+2
View File
@@ -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)| {
+1
View File
@@ -134,6 +134,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
_ 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),
}
}
+11
View File
@@ -197,6 +197,17 @@ fn lumetri(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
(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));
+147
View File
@@ -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<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],
}
}
/// 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>> {
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::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)
}
+3
View File
@@ -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;
+48 -1
View File
@@ -310,6 +310,43 @@ fn fxc_d3(i: u32) -> vec3<f32> {
// 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<f32>) -> vec3<f32> {
let l = luminance(c);
switch (look) {
case 2u: {
return vec3<f32>(c.x * 1.06 + 0.015, c.y * 1.01 + 0.01, c.z * 0.88);
}
case 3u: {
return vec3<f32>(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<f32>;
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<f32>(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<f32>(l);
}
default: {
return c;
}
}
}
@compute @workgroup_size(16, 16)
fn fxc_lumetri(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = fxc_pixel(gid);
@@ -323,6 +360,9 @@ fn fxc_lumetri(@builtin(global_invocation_id) gid: vec3<u32>) {
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<u32>) {
let st = fxc_d3(14u);
let ht = fxc_d3(17u);
let zero = vec3<f32>(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;
}
+474
View File
@@ -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<f32> {
return vec3<f32>(data[i], data[i + 1u], data[i + 2u]);
}
// ---- LUTs (utility::Lut)
fn fxl_lut_norm(o: u32, c: vec3<f32>) -> vec3<f32> {
let dmin = fxl_d3(o + 2u);
let dmax = fxl_d3(o + 5u);
return clamp((c - dmin) / max(dmax - dmin, vec3<f32>(1e-9)), vec3<f32>(0.0), vec3<f32>(1.0));
}
fn fxl_lut_3d(base: u32, n: u32, r: u32, g: u32, b: u32) -> vec3<f32> {
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<f32>, interp: u32) -> vec3<f32> {
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<f32>;
var p2: vec3<f32>;
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<f32>, b: vec3<f32>, t: f32) -> vec3<f32> {
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<u32> {
var at = s + 3u;
for (var j = 0u; j < k; j++) {
at += u32(data[s + j]) * 2u;
}
return vec2<u32>(at, u32(data[s + k]));
}
fn fxl_file_fwd(o: u32, sh: i32, c0: vec3<f32>, interp: u32) -> vec3<f32> {
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<f32>, interp: u32, inverse: bool) -> vec3<f32> {
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<f32>(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<f32>(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<u32>(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<f32> = vec3<f32>(0.2126, 0.7152, 0.0722);
fn fxl_cdl(a: u32, c0: vec3<f32>) -> vec3<f32> {
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<f32>(0.0);
let one = vec3<f32>(1.0);
if (data[a + 11u] == 0.0) {
var o = vec3<f32>(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<f32>(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<f32>) -> vec3<f32> {
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<f32>(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<f32>(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<f32>(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<f32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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<f32>(0.0);
}
textureStore(out, p, vec4<f32>(fxl_run(c) * a, a));
}
+175
View File
@@ -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#"<ColorCorrectionCollection><ColorCorrection id="a"><SOPNode><Slope>1.2 1 0.9</Slope><Offset>0.02 0 -0.01</Offset><Power>1.1 0.9 1</Power></SOPNode></ColorCorrection>
<ColorCorrection id='b'><SOPNode><Slope>0.8 0.8 0.8</Slope></SOPNode><SatNode><Saturation>1.4</Saturation></SatNode></ColorCorrection></ColorCorrectionCollection>"#;
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()))],
);
}
+8 -8
View File
@@ -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.<category>.<name>`) 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.<category>.<name>`) 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 |