From 05b229825e2c886a5d15b521e3d49c26a0bfdd50 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 08:41:11 -0400 Subject: [PATCH] M13.9: gpu Refine Soft / Hard Matte (single-frame refinement) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/catalog.rs | 2 + crates/effects/src/lib.rs | 2 + crates/gpu/src/fx_key.rs | 81 +++++++++++++++++++++++++++++- crates/gpu/src/shaders/fx_key.wgsl | 71 ++++++++++++++++++++++++++ crates/gpu/src/tests_fx_key.rs | 12 +++++ docs/effects.md | 6 +-- 6 files changed, 170 insertions(+), 4 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 7a01309ae..a711ce43c 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -89,6 +89,8 @@ const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[ ("ec.blur.cameralens", &["irisProperties/diffractionFringe", "blurMap/blurMapLayer"]), ("ec.noise.removegrain", &["temporalFiltering/enabled"]), ("ec.distort.liquify", &["viewFreezeAreaMask", "viewMesh"]), + ("ec.matte.refinesoft", &["reduceChatter", "useMotionBlur", "invert"]), + ("ec.matte.refinehard", &["reduceChatter", "useMotionBlur", "invert"]), ]; /// Whether the GPU kernel of effect `id` covers the instance's current settings (see diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 078612615..fa1c96632 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -829,6 +829,8 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.key.unmult", "ec.matte.simplechoker", "ec.matte.mattechoker", + "ec.matte.refinesoft", + "ec.matte.refinehard", "ec.channel.setmatte", "ec.channel.setchannels", "ec.channel.shiftchannels", diff --git a/crates/gpu/src/fx_key.rs b/crates/gpu/src/fx_key.rs index b47095200..8aedbd3d2 100644 --- a/crates/gpu/src/fx_key.rs +++ b/crates/gpu/src/fx_key.rs @@ -15,7 +15,7 @@ use effectcraft_effects::EffectCtx; use effectcraft_effects::util::{SRC_ORDER, Src}; use crate::context::{Enc, GpuImage, Params}; -use crate::effects::{GBuf, gaussian_blur}; +use crate::effects::{GBuf, box_passes, gaussian_blur}; use crate::fx_color::{DILATE_X, ERODE_X, morph_frac}; /// Compute entry points in `fx_key.wgsl`. @@ -36,6 +36,8 @@ pub(crate) const IDS: &[&str] = &[ "ec.key.unmult", "ec.matte.simplechoker", "ec.matte.mattechoker", + "ec.matte.refinesoft", + "ec.matte.refinehard", "ec.channel.setmatte", "ec.channel.setchannels", "ec.channel.shiftchannels", @@ -58,6 +60,8 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option key_cleaner(e, ctx, b), "ec.matte.simplechoker" => simple_choker(e, ctx, b), "ec.matte.mattechoker" => matte_choker(e, ctx, b), + "ec.matte.refinesoft" => refine(e, ctx, b, false), + "ec.matte.refinehard" => refine(e, ctx, b, true), "ec.channel.setchannels" => set_channels(e, ctx, b), _ => point(e, id, ctx, b), } @@ -355,6 +359,81 @@ fn matte_choker(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { Some(b) } +/// matte::refine (Refine Soft / Hard Matte) for one frame: Reduce Chatter, the matte motion +/// blur (they read neighbouring frames) and Invert run on the CPU. +fn refine(e: &mut Enc, ctx: &EffectCtx, b: GBuf, hard: bool) -> Option { + let pr = ctx.params; + // Invert gives alpha to the empty areas, whose colour then comes from a blur of nearly + // transparent pixels (util::set_alpha): ill-conditioned, so it renders on the CPU. + if pr.f("reduceChatter") > 0.0 || pr.b("useMotionBlur") || pr.b("invert") { + return None; + } + let dk = |id: &str| if hard { id.to_string() } else { format!("decontamination/{id}") }; + let r = (pr.f("radius") * b.scale).round().max(1.0) as usize; + let smooth = pr.f("smooth") as f32 / 100.0; + let feather = pr.f("feather") / 100.0 * r as f64 * 0.5; + let choke = (pr.f("choke") - if hard { 0.0 } else { pr.f("shiftEdge") }).clamp(-100.0, 100.0) as f32 / 100.0 * 0.5; + let decon = pr.b("decontaminate"); + let decon_amt = pr.f(&dk("decontaminationAmount")) as f32 / 100.0; + let extend = pr.b(&dk("extendWhereSmoothed")); + let decon_r = r as f64 + pr.f(&dk("increaseDecontaminationRadius")).max(0.0) * b.scale; + let view_map = pr.b(&dk("viewDecontaminationMap")); + let contrast = if hard { pr.f("alphaContrast") as f32 / 100.0 } else { 1.0 + pr.f("contrast").max(0.0) as f32 / 100.0 * 3.0 }; + let edge_details = hard || pr.b("calculateEdgeDetails"); + let view_edges = !hard && pr.b("viewEdgeRegion"); + let (w, h) = (b.img.width, b.img.height); + + // (luminance guide, alpha, guide × alpha, guide²); the edge band from the alpha's range. + let pack = run(e, &op(29), &b.img, None, None); + let hi = morph_frac(e, &pack, r as f64, [2, 1, 2, 2]); + let lo = morph_frac(e, &pack, r as f64, [2, 0, 2, 2]); + let band = run(e, &op(32), &hi, Some(&lo), None); + let mut na = if edge_details { + let eps = 1e-4 + smooth * smooth * 0.05; + let means = box_passes(e, &pack, &[r], &[r], true); + let mut p = op(30); + p.f[0][0] = eps; + let coef = run(e, &p, &means, None, None); + let coef = box_passes(e, &coef, &[r], &[r], true); + let (rows, row) = e.image_rows(&band); + let mut p = op(31); + p.u[3][0] = row; + run(e, &p, &coef, Some(&pack), Some(&rows)) + } else { + let a = run(e, &op(24), &b.img, None, None); + if smooth > 0.0 { + let s = smooth as f64 * r as f64 * 0.5; + gaussian_blur(e, &a, s, s, true) + } else { + a + } + }; + if feather > 0.0 { + na = gaussian_blur(e, &na, feather, feather, true); + } + let mut p = op(33); + p.u[0][1] = pr.b("invert") as u32; + p.f[0] = [choke, contrast, 0.0, 0.0]; + let na = run(e, &p, &na, None, None); + if view_edges { + let img = run(e, &op(34), &na, Some(&band), None); + return Some(GBuf { img, ..b }); + } + let est = (decon && decon_amt > 0.0 && !view_map).then(|| gaussian_blur(e, &b.img, decon_r * 0.5, decon_r * 0.5, true)); + let rows = est.as_ref().map(|img| e.image_rows(img)); + let mut p = op(35); + p.u[0] = [35, view_map as u32, (decon && (view_map || decon_amt > 0.0)) as u32, extend as u32]; + p.u[3] = [rows.as_ref().map_or(0, |r| r.1), rows.is_some() as u32, 0, 0]; + p.f[0][0] = decon_amt; + let img = run(e, &p, &b.img, Some(&na), rows.as_ref().map(|r| &r.0)); + if view_map { + return Some(GBuf { img, ..b }); + } + debug_assert_eq!((img.width, img.height), (w, h)); + let img = set_alpha(e, &img, &na); + Some(GBuf { img, ..b }) +} + // ---------------------------------------------------------------- channel const SET_CHANNEL_LAYERS: [&str; 4] = ["sourceLayer1", "sourceLayer2", "sourceLayer3", "sourceLayer4"]; diff --git a/crates/gpu/src/shaders/fx_key.wgsl b/crates/gpu/src/shaders/fx_key.wgsl index 0ac626398..261e8aca7 100644 --- a/crates/gpu/src/shaders/fx_key.wgsl +++ b/crates/gpu/src/shaders/fx_key.wgsl @@ -163,6 +163,10 @@ fn fxk_overflow(op: u32, v: f32, how: u32) -> f32 { // 22 Screen Key matte 23 Screen Key output 24 alpha plane 25 Key Cleaner output // 26 Matte Choker ramp 27 util::set_alpha (aux.x = alpha, data = fill) // 28 matte view (alpha as opaque grey) +// Refine Soft / Hard Matte: 29 guide pack (luminance, alpha, products); 30 guided-filter +// coefficients from the box means (f[0].x = eps); 31 refined alpha (src = boxed coefficients, +// aux = pack, data = edge band); 32 edge band (src.y − aux.y > 1e-3); 33 choke / contrast / +// invert; 34 View Edge Region; 35 decontamination (aux.x = refined alpha, data = colour estimate) @compute @workgroup_size(16, 16) fn fxk_point(@builtin(global_invocation_id) gid: vec3) { let dims = out_dims(); @@ -737,6 +741,73 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3) { } o = fxk_premul(cc, na); } + case 29u: { + let g = luminance(px.xyz); + o = vec4(g, px.w, g * px.w, g * g); + } + case 30u: { + let var_g = px.w - px.x * px.x; + let cov = px.z - px.x * px.y; + let ca = cov / (var_g + f0.x); + o = vec4(ca, px.y - ca * px.x, 0.0, 0.0); + } + case 31u: { + let q = textureLoad(aux, p, 0); + if (fxk_rows(p).x != 0.0) { + o = vec4(clamp(px.x * q.x + px.y, 0.0, 1.0)); + } else { + o = vec4(q.y); + } + } + case 32u: { + o = vec4(select(0.0, 1.0, px.y - textureLoad(aux, p, 0).y > 1e-3)); + } + case 33u: { + var v = px.x; + let choke = f0.x; + if (choke > 0.0) { + v = (v - choke) / (1.0 - choke); + } else if (choke < 0.0) { + v = v / (1.0 + choke); + } + if (abs(f0.y - 1.0) > 1e-6) { + v = (v - 0.5) * f0.y + 0.5; + } + v = clamp(v, 0.0, 1.0); + if (u0.y != 0u) { + v = 1.0 - v; + } + o = vec4(v); + } + case 34u: { + let v = px.x; + if (textureLoad(aux, p, 0).x != 0.0) { + o = vec4(0.5 + 0.5 * v, 0.5 * v, 0.5 * v, 1.0); + } else { + o = vec4(v, v, v, 1.0); + } + } + case 35u: { + let n = textureLoad(aux, p, 0).x; + let a0 = px.w; + var t = 0.0; + if (a0 > 1e-4 && a0 < 0.999) { + t = 1.0 - a0; + } else if (u0.w != 0u && n > 1e-4 && n < 0.999) { + t = 1.0 - n; + } + let strength = f0.x * t; + if (u0.y != 0u) { + let v = select(0.0, strength, u0.z != 0u); + o = vec4(v, v, v, 1.0); + } else if (P.u[3].y != 0u && strength > 0.0 && a0 > 1e-4) { + let q = fxk_rows(p); + if (q.w > 1e-4) { + let cc = c + (q.xyz / q.w - c) * strength; + o = fxk_premul(cc, a0); + } + } + } case 28u: { let v = clamp(px.w, 0.0, 1.0); o = vec4(v, v, v, 1.0); diff --git a/crates/gpu/src/tests_fx_key.rs b/crates/gpu/src/tests_fx_key.rs index 25f2d9fe4..76c7e831e 100644 --- a/crates/gpu/src/tests_fx_key.rs +++ b/crates/gpu/src/tests_fx_key.rs @@ -139,6 +139,18 @@ fn chokers() { ); } +#[test] +fn refine_mattes() { + effect_case("ec.matte.refinesoft", &[]); + effect_case("ec.matte.refinesoft", &[("radius", n(4.0)), ("smooth", n(40.0)), ("feather", n(30.0)), ("contrast", n(20.0)), ("shiftEdge", n(-25.0))]); + effect_case("ec.matte.refinesoft", &[("calculateEdgeDetails", off()), ("smooth", n(50.0)), ("shiftEdge", n(30.0))]); + effect_case("ec.matte.refinesoft", &[("radius", n(3.0)), ("viewEdgeRegion", on())]); + effect_case("ec.matte.refinesoft", &[("decontamination/viewDecontaminationMap", on()), ("decontamination/extendWhereSmoothed", off())]); + effect_case("ec.matte.refinesoft", &[("decontamination/increaseDecontaminationRadius", n(4.0)), ("decontamination/decontaminationAmount", n(60.0))]); + effect_case("ec.matte.refinehard", &[]); + effect_case("ec.matte.refinehard", &[("choke", n(20.0)), ("feather", n(20.0)), ("decontaminate", off())]); +} + #[test] fn channel_effects() { effect_case("ec.channel.setchannels", &[("setRedTo", e(2)), ("setGreenTo", e(4)), ("setBlueTo", e(5)), ("setAlphaTo", e(6))]); diff --git a/docs/effects.md b/docs/effects.md index fe9c624fc..32eabfa15 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**: 150 effects also run on the GPU compositor with identical results. +- **GPU**: 152 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). @@ -255,8 +255,8 @@ Effects are addressed by id (`ec..`) or display name in commands |---|---|---:|:-:|:-:|---| | Matte Choker | `ec.matte.mattechoker` | 7 | GPU | 32 | Implemented | | Mocha shape | `ec.obsolete.mochashape` | 6 | | 32 | Implemented | -| Refine Hard Matte | `ec.matte.refinehard` | 16 | | 32 | Implemented | -| Refine Soft Matte | `ec.matte.refinesoft` | 18 | | 32 | Implemented | +| Refine Hard Matte | `ec.matte.refinehard` | 16 | GPU | 32 | Implemented | +| Refine Soft Matte | `ec.matte.refinesoft` | 18 | GPU | 32 | Implemented | | Roto Brush & Refine Edge | `ec.matte.rotobrush` | 24 | | 32 | Implemented | | Simple Choker | `ec.matte.simplechoker` | 2 | GPU | 32 | Implemented |