diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index f41b4b2e..a711ce43 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -87,6 +87,10 @@ const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[ ), ("ec.noise.addgrain", &["viewingMode", "preset", "color/redBalance", "color/greenBalance", "color/blueBalance", "previewRegion/"]), ("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/color2.rs b/crates/effects/src/color2.rs index 67063d31..8f929874 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -330,10 +330,21 @@ fn stretch(v: f32, lo: f32, hi: f32) -> f32 { /// 0 = Auto Levels (per channel), 1 = Auto Contrast (shared), 2 = Auto Color (per channel + neutral mids). fn auto_correct(ctx: &EffectCtx, mut b: Buf, kind: u32) -> Buf { + let blend = (ctx.params.f("blend") / 100.0).clamp(0.0, 1.0) as f32; + let (ranges, gam) = auto_correct_settings(ctx, &b.img, kind); + map_ca(&mut b.img, |c, a| { + let o = [0, 1, 2].map(|k| stretch(c[k], ranges[k].0, ranges[k].1).powf(gam[k])); + (lerp3(o, c, blend), a) + }); + b +} + +/// What Auto Levels (`kind` 0), Auto Contrast (1) and Auto Color (2) measure on `img`: each +/// channel's input range (low, high) and gamma. (The GPU effects apply them.) +pub fn auto_correct_settings(ctx: &EffectCtx, img: &Image, kind: u32) -> ([(f32, f32); 3], [f32; 3]) { let black = ctx.params.f("blackClip").clamp(0.0, 49.0) / 100.0; let white = ctx.params.f("whiteClip").clamp(0.0, 49.0) / 100.0; - let blend = (ctx.params.f("blend") / 100.0).clamp(0.0, 1.0) as f32; - let h = smoothed_histograms(ctx, &b.img); + let h = smoothed_histograms(ctx, img); let ranges: [(f32, f32); 3] = if kind == 1 { let mut combined = vec![0.0; BINS]; for k in 0..3 { @@ -350,7 +361,7 @@ fn auto_correct(ctx: &EffectCtx, mut b: Buf, kind: u32) -> Buf { let mut gam = [1.0f32; 3]; if kind == 2 && ctx.params.b("snapNeutralMidtones") { let mut sums = [0.0f64; 4]; - for px in &b.img.data { + for px in &img.data { let (c, a) = unpremul(*px); if a > 1e-4 { for k in 0..3 { @@ -369,11 +380,7 @@ fn auto_correct(ctx: &EffectCtx, mut b: Buf, kind: u32) -> Buf { } } } - map_ca(&mut b.img, |c, a| { - let o = [0, 1, 2].map(|k| stretch(c[k], ranges[k].0, ranges[k].1).powf(gam[k])); - (lerp3(o, c, blend), a) - }); - b + (ranges, gam) } fn auto_levels(ctx: &EffectCtx, b: Buf) -> Buf { @@ -397,24 +404,12 @@ fn cdf(h: &[f64]) -> Vec { .collect() } -fn equalize(ctx: &EffectCtx, mut b: Buf) -> Buf { - let amt = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) as f32; - if amt <= 0.0 { - return b; - } - let style = ctx.params.e("style"); - let h = histograms(&b.img); - let look = |c: &Vec, v: f32| c[((v.clamp(0.0, 1.0) * (BINS - 1) as f32).round() as usize).min(BINS - 1)]; +/// Equalize's lookup tables (`BINS` entries each) for `img`: the R, G and B cumulative +/// histograms (Style RGB), the luminance one (Brightness) or the combined one (Photoshop Style). +pub fn equalize_tables(img: &Image, style: u32) -> Vec> { + let h = histograms(img); match style { - 1 => { - let c = cdf(&h[3]); - map_ca(&mut b.img, |col, a| { - let l = luminance(col[0], col[1], col[2]); - let nl = look(&c, l); - let o = if l > 1e-5 { col.map(|v| v * nl / l) } else { [nl; 3] }; - (lerp3(col, o, amt), a) - }); - } + 1 => vec![cdf(&h[3])], 2 => { let mut combined = vec![0.0; BINS]; for k in 0..3 { @@ -422,12 +417,34 @@ fn equalize(ctx: &EffectCtx, mut b: Buf) -> Buf { combined[i] += h[k][i]; } } - let c = cdf(&combined); - map_ca(&mut b.img, |col, a| (lerp3(col, col.map(|v| look(&c, v)), amt), a)); + vec![cdf(&combined)] + } + _ => vec![cdf(&h[0]), cdf(&h[1]), cdf(&h[2])], + } +} + +fn equalize(ctx: &EffectCtx, mut b: Buf) -> Buf { + let amt = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) as f32; + if amt <= 0.0 { + return b; + } + let style = ctx.params.e("style"); + let t = equalize_tables(&b.img, style); + let look = |c: &Vec, v: f32| c[((v.clamp(0.0, 1.0) * (BINS - 1) as f32).round() as usize).min(BINS - 1)]; + match style { + 1 => { + map_ca(&mut b.img, |col, a| { + let l = luminance(col[0], col[1], col[2]); + let nl = look(&t[0], l); + let o = if l > 1e-5 { col.map(|v| v * nl / l) } else { [nl; 3] }; + (lerp3(col, o, amt), a) + }); + } + 2 => { + map_ca(&mut b.img, |col, a| (lerp3(col, col.map(|v| look(&t[0], v)), amt), a)); } _ => { - let cs = [cdf(&h[0]), cdf(&h[1]), cdf(&h[2])]; - map_ca(&mut b.img, |col, a| (lerp3(col, [0, 1, 2].map(|k| look(&cs[k], col[k])), amt), a)); + map_ca(&mut b.img, |col, a| (lerp3(col, [0, 1, 2].map(|k| look(&t[k], col[k])), amt), a)); } } b @@ -436,17 +453,29 @@ fn equalize(ctx: &EffectCtx, mut b: Buf) -> Buf { // --------------------------------------------------------------------------------------------- // Shadow/Highlight -fn shadow_highlight(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// Shadow/Highlight's shadow and highlight amounts for `img` (Auto Amounts measures them). +pub fn shadow_highlight_amounts(ctx: &EffectCtx, img: &Image) -> (f32, f32) { let (mut s_amt, mut h_amt) = (ctx.params.f("shadowAmount") as f32 / 100.0, ctx.params.f("highlightAmount") as f32 / 100.0); - let luma = Plane::luma(&b.img); - let src_hist = histograms(&b.img); if ctx.params.b("autoAmounts") { // Temporal Smoothing / Scene Detect average the analysis over neighbouring frames. - let mean = - if ctx.params.f("temporalSmoothing") > 0.0 { hist_mean(&smoothed_histograms(ctx, &b.img)[3]) as f32 } else { hist_mean(&src_hist[3]) as f32 }; + let mean = if ctx.params.f("temporalSmoothing") > 0.0 { hist_mean(&smoothed_histograms(ctx, img)[3]) } else { hist_mean(&histograms(img)[3]) } as f32; s_amt = ((0.6 - mean) * 1.5).clamp(0.0, 1.0) * 0.8 + 0.1; h_amt = ((mean - 0.6) * 1.5).clamp(0.0, 1.0) * 0.8; } + (s_amt, h_amt) +} + +/// The luminance values clipping `black` / `white` fractions of `img`'s alpha-weighted +/// luminance histogram (Shadow/Highlight's Black / White Clip). +pub fn luma_clip_points(img: &Image, black: f64, white: f64) -> (f32, f32) { + clip_points(&histograms(img)[3], black, white) +} + +fn shadow_highlight(ctx: &EffectCtx, mut b: Buf) -> Buf { + let (s_amt, h_amt) = shadow_highlight_amounts(ctx, &b.img); + let luma = Plane::luma(&b.img); + let (bc, wc) = (ctx.params.f("moreOptions/blackClip").clamp(0.0, 49.0) / 100.0, ctx.params.f("moreOptions/whiteClip").clamp(0.0, 49.0) / 100.0); + let clip0 = (bc > 0.0 || wc > 0.0).then(|| luma_clip_points(&b.img, bc, wc)); let s_tw = (ctx.params.f("moreOptions/shadowTonalWidth") as f32 / 100.0).max(0.01); let h_tw = (ctx.params.f("moreOptions/highlightTonalWidth") as f32 / 100.0).max(0.01); let s_r = ctx.params.f("moreOptions/shadowRadius").max(0.0) * b.scale / 2.0; @@ -478,10 +507,8 @@ fn shadow_highlight(ctx: &EffectCtx, mut b: Buf) -> Buf { // Black / White Clip: the adjusted image's extremes (ignoring those fractions of pixels) // are mapped back onto the original's, so lifting shadows or recovering highlights doesn't // flatten the ends of the tonal range. - let (bc, wc) = (ctx.params.f("moreOptions/blackClip").clamp(0.0, 49.0) / 100.0, ctx.params.f("moreOptions/whiteClip").clamp(0.0, 49.0) / 100.0); - if bc > 0.0 || wc > 0.0 { - let (lo0, hi0) = clip_points(&src_hist[3], bc, wc); - let (lo1, hi1) = clip_points(&histograms(&b.img)[3], bc, wc); + if let Some((lo0, hi0)) = clip0 { + let (lo1, hi1) = luma_clip_points(&b.img, bc, wc); if (lo0, hi0) != (lo1, hi1) && hi1 - lo1 > 1e-3 { let k = (hi0 - lo0) / (hi1 - lo1); map_ca(&mut b.img, |c, a| (c.map(|v| (lo0 + (v - lo1) * k).max(0.0)), a)); diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index 43378528..8f430a96 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -354,7 +354,7 @@ fn magnify(ctx: &EffectCtx, mut b: Buf) -> Buf { } /// Magnify's Blending Mode choices after "None". -const MAGNIFY_MODES: [BlendMode; 17] = [ +pub const MAGNIFY_MODES: [BlendMode; 17] = [ BlendMode::Normal, BlendMode::Add, BlendMode::Multiply, diff --git a/crates/effects/src/distort4.rs b/crates/effects/src/distort4.rs index c576fead..d1d1b0c2 100644 --- a/crates/effects/src/distort4.rs +++ b/crates/effects/src/distort4.rs @@ -325,7 +325,9 @@ pub fn build_mesh(strokes: &[LiquifyStroke], w: f64, h: f64, frozen: &(dyn Fn(f6 type MeshCache = Mutex>>; -fn cached_mesh(ctx: &EffectCtx) -> Arc { +/// Liquify's displacement mesh for the instance (replayed strokes, cached; also used by the +/// GPU kernels). +pub fn liquify_mesh(ctx: &EffectCtx) -> Arc { use std::hash::{Hash, Hasher}; let text = ctx.params.s("distortionMesh"); let mask = ctx.params.f("freezeAreaMask").round() as usize; @@ -369,7 +371,7 @@ fn liquify(ctx: &EffectCtx, mut b: Buf) -> Buf { if ctx.params.s("distortionMesh").trim().is_empty() && !show_freeze && !show_mesh { return b; } - let mesh = cached_mesh(ctx); + let mesh = liquify_mesh(ctx); let off = ctx.params.v2("distortionMeshOffset"); let mesh_color = ctx.params.color("meshColor"); let src = b.img.clone(); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 6f671630..597eed8a 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -67,11 +67,13 @@ use std::sync::OnceLock; pub use card3d::{CompLight, CompScene}; pub use color_fx::{ - HUESAT_CHANNELS, LEVELS_CHANNELS, exposure_settings, fill_uses_masks, huesat_ranges_identity, levels_channel_ids, levels_channels_identity, levels_clip, + HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings, fill_uses_masks, huesat_ranges_identity, + levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip, }; -pub use color2::Curve; +pub use color2::{Curve, auto_correct_settings, equalize_tables, luma_clip_points, shadow_highlight_amounts}; pub use distort::transform_shutter; -pub use distort2::parse_mesh; +pub use distort2::{MAGNIFY_MODES, parse_mesh}; +pub use distort4::liquify_mesh; use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; @@ -79,12 +81,19 @@ pub use effectcraft_raster::{AuxChannels, Image}; pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_operation}; pub use sim::particle_state; pub use sim3::playground_state; +pub use stylize2::strobe_on; // CPU helpers the GPU kernels share (effectcraft-gpu). pub use blur2::{camera_lens_plain, camera_lens_spans}; pub use generate::gen_mode; pub use generate2::pattern_kind as cell_pattern_kind; pub use noise::GrainLook; pub use transition::place_layer; +// effectcraft-gpu fx_noise. +pub use noise::remove_grain_level; +pub use noise3::{FractalGpu, fractal_gpu}; +pub use time_fx::{posterized_time, time_frames}; +// effectcraft-gpu fx_tone. +pub use ocio::color_stabilizer_maps; /// Effect categories in Effects & Presets order. pub const CATEGORIES: &[&str] = &[ @@ -806,6 +815,103 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.color.selectivecolor", "ec.key.linearcolor", "ec.keying.keylight", + // effectcraft-gpu fx_key (keying, matte, channel) + "ec.key.colorkey", + "ec.key.luma", + "ec.key.colorrange", + "ec.key.extract", + "ec.key.differencematte", + "ec.key.colordifference", + "ec.key.screen", + "ec.key.spill", + "ec.key.advancedspill", + "ec.key.keycleaner", + "ec.key.unmult", + "ec.matte.simplechoker", + "ec.matte.mattechoker", + "ec.matte.refinesoft", + "ec.matte.refinehard", + "ec.channel.setmatte", + "ec.channel.setchannels", + "ec.channel.shiftchannels", + "ec.channel.removecolormatting", + "ec.channel.arithmetic", + "ec.channel.solidcomposite", + "ec.channel.combiner", + "ec.channel.blend", + "ec.channel.calculations", + "ec.channel.compoundarithmetic", + // effectcraft-gpu fx_tone (colour correction) + "ec.color.levelsic", + "ec.color.gammapedestalgain", + "ec.color.photofilter", + "ec.color.changecolor", + "ec.color.changetocolor", + "ec.color.leavecolor", + "ec.color.broadcast", + "ec.color.colorbalancehls", + "ec.color.videolimiter", + "ec.color.psarbitrarymap", + "ec.color.cctoner", + "ec.color.cccoloroffset", + "ec.color.cckernel", + "ec.color.autolevels", + "ec.color.autocontrast", + "ec.color.autocolor", + "ec.color.equalize", + "ec.color.shadowhighlight", + "ec.color.cccolorneutralizer", + "ec.color.colorstabilizer", + // effectcraft-gpu fx_stylize (stylize, distort) + "ec.stylize.posterize", + "ec.stylize.threshold", + "ec.stylize.ccthreshold", + "ec.stylize.ccthresholdrgb", + "ec.stylize.strobe", + "ec.stylize.ccvignette", + "ec.stylize.scatter", + "ec.stylize.brushstrokes", + "ec.stylize.roughenedges", + "ec.stylize.texturize", + "ec.stylize.motiontile", + "ec.stylize.cckaleida", + "ec.stylize.ccrepetile", + "ec.distort.mirror", + "ec.distort.offset", + "ec.distort.polar", + "ec.distort.spherize", + "ec.distort.cornerpin", + "ec.distort.opticscompensation", + "ec.distort.magnify", + "ec.distort.ccslant", + "ec.distort.ccsmear", + "ec.distort.ccsplit", + "ec.distort.ccsplit2", + "ec.distort.cctiler", + "ec.distort.ccgriddler", + "ec.distort.liquify", + "ec.distort.twirllegacy", + "ec.distort.ccripplepulse", + "ec.distort.ccpowerpin", + "ec.distort.ccflomotion", + // effectcraft-gpu fx_noise (noise, blur, time) + "ec.noise.turbulent", + "ec.noise.median", + "ec.noise.medianlegacy", + "ec.noise.dustscratches", + "ec.noise.removegrain", + "ec.noise.noisealpha", + "ec.noise.noisehls", + "ec.noise.noisehlsauto", + "ec.blur.smart", + "ec.blur.bilateral", + "ec.blur.sharpen", + "ec.blur.unsharp", + "ec.blur.compound", + "ec.blur.channel", + "ec.channel.minimax", + "ec.time.echo", + "ec.time.posterizetime", ]; /// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index 4681fd99..d39fd652 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -477,6 +477,28 @@ pub(crate) fn sampling_boxes(ctx: &EffectCtx, img: &Image, scale: f64) -> Vec<(u sample_boxes(img, n, s) } +/// The grain level (0.5..2) measured in the sample boxes of `img`. +fn grain_level(img: &Image, boxes: &[(usize, usize, usize)]) -> f32 { + let mut m = vec![false; img.data.len()]; + let w = img.width as usize; + for &(x, y, s) in boxes { + for yy in y..y + s { + for xx in x..x + s { + m[yy * w + xx] = true; + } + } + } + let st = crate::noise2::grain_stats_masked(img, Some(&m)); + let s = (st.std[0] + st.std[1] + st.std[2]) / 3.0; + if boxes.is_empty() { 1.0 } else { (s / 0.02).clamp(0.5, 2.0) } +} + +/// Remove Grain's measured grain level for buffer `b` without Temporal Filtering (the GPU +/// kernels' spatial pass, effectcraft-gpu `fx_noise`). +pub fn remove_grain_level(ctx: &EffectCtx, b: &Buf) -> f32 { + grain_level(&b.img, &sampling_boxes(ctx, &b.img, b.scale)) +} + fn remove_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let amt = pr.f("noiseReductionSettings/noiseReduction") as f32; @@ -535,20 +557,7 @@ fn remove_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { } } // The grain level measured in the sample boxes scales the smoothing. - let level = { - let mut m = vec![false; b.img.data.len()]; - let w = b.img.width as usize; - for &(x, y, s) in &boxes { - for yy in y..y + s { - for xx in x..x + s { - m[yy * w + xx] = true; - } - } - } - let st = crate::noise2::grain_stats_masked(&b.img, Some(&m)); - let s = (st.std[0] + st.std[1] + st.std[2]) / 3.0; - if boxes.is_empty() { 1.0 } else { (s / 0.02).clamp(0.5, 2.0) } - }; + let level = grain_level(&b.img, &boxes); let passes = pr.f("noiseReductionSettings/passes").round().clamp(1.0, 8.0) as usize; let multichannel = pr.e("noiseReductionSettings/mode") == 0; let texture = (pr.f("fineTuning/texture") as f32).clamp(0.0, 1.0); diff --git a/crates/effects/src/noise3.rs b/crates/effects/src/noise3.rs index 6a952be1..37bf853a 100644 --- a/crates/effects/src/noise3.rs +++ b/crates/effects/src/noise3.rs @@ -326,6 +326,89 @@ impl Fractal { } } +/// Fractal / Turbulent Noise resolved for the GPU kernel (effectcraft-gpu `fx_noise`): the +/// per-pixel constants and, per octave, the terms [`Fractal::raw`] computes before touching the +/// pixel, in the same f32 order. +pub struct FractalGpu { + pub kind: u32, + pub noise_type: u32, + pub seed: u32, + pub invert: bool, + pub overflow: u32, + pub contrast: f32, + pub brightness: f32, + /// Rotation (sin, cos). + pub rot: (f32, f32), + pub size: [f32; 2], + /// Cycle Evolution: (t, cycle, t / cycle). + pub cycle: Option<[f32; 3]>, + pub sub_rotation: bool, + /// Σ amplitude × weight (the normaliser). + pub norm: f32, + /// Per octave: origin x, y, sub-rotation sin, cos, frequency, amplitude, weight, evolution + /// (turbulence applied), z offset, Dynamic Progressive warp factor. + pub octaves: Vec<[f32; 10]>, + /// Blending Mode (`None` = the noise alone), Opacity and Blend With Original. + pub mode: Option, + pub opacity: f32, + pub blend: f32, +} + +/// [`FractalGpu`] for a buffer with `offset` / `scale`. +pub fn fractal_gpu(ctx: &EffectCtx, offset: [f64; 2], scale: f64, turbulent: bool) -> FractalGpu { + let b = Buf { img: effectcraft_raster::Image::new(0, 0), offset, scale }; + let fr = Fractal::from_params(ctx, &b, turbulent); + let n_oct = fr.octaves.ceil() as usize; + let frac = fr.octaves - fr.octaves.floor(); + let (mut norm, mut amp, mut f, mut depth) = (0.0f32, 1.0f32, 1.0f32, 1.0f32); + let mut octaves = Vec::with_capacity(n_oct); + for o in 0..n_oct { + let w = if o + 1 == n_oct && frac > 0.0 { frac } else { 1.0 }; + let of = o as f32; + let k = if fr.perspective { depth } else { 1.0 } * (1.0 + fr.turbulence * of); + let mut org = fr.origin; + if k != 1.0 { + org = [fr.center[0] + (fr.origin[0] - fr.center[0]) * k, fr.center[1] + (fr.origin[1] - fr.center[1]) * k]; + } + if !fr.center_subscale { + org = [org[0] + fr.sub_offset[0] * of, org[1] + fr.sub_offset[1] * of]; + } + let (s, c) = (fr.sub_rotation * of).sin_cos(); + let evo = fr.evolution * (1.0 + fr.turbulence * of); + let dk = if fr.kind == 6 { 0.5 + of * 0.35 } else { 1.0 }; + octaves.push([org[0], org[1], s, c, f, amp, w, evo, of * 7.31, dk]); + norm += amp * w; + amp *= fr.influence; + f /= fr.sub_scaling; + depth *= fr.sub_scaling; + } + let cycle = fr.cycle.map(|c| { + let t = fr.evolution.rem_euclid(c); + [t, c, t / c] + }); + FractalGpu { + kind: fr.kind, + noise_type: fr.noise_type, + seed: fr.seed, + invert: fr.invert, + overflow: fr.overflow, + contrast: fr.contrast, + brightness: fr.brightness, + rot: fr.rot, + size: fr.size, + cycle, + sub_rotation: fr.sub_rotation != 0.0, + norm, + octaves, + mode: match ctx.params.get("blendingMode").map(Value::as_enum).unwrap_or(BLEND_NONE) { + BLEND_NONE => None, + m => Some(BLEND_MODES.get(m as usize).and_then(|l| BlendMode::from_name(l)).unwrap_or(BlendMode::Normal)), + }, + opacity: (ctx.params.f("opacity") as f32 / 100.0).clamp(0.0, 1.0), + blend: ctx.params.f("blend") as f32 / 100.0, + } +} + /// Map a value by an [`OVERFLOWS`] option. pub fn overflow(v: f32, mode: u32) -> f32 { match mode { diff --git a/crates/effects/src/ocio.rs b/crates/effects/src/ocio.rs index 6dd17c4f..1969f50a 100644 --- a/crates/effects/src/ocio.rs +++ b/crates/effects/src/ocio.rs @@ -905,6 +905,46 @@ fn curve3(xs: [f64; 3], ys: [f64; 3], v: f64) -> f64 { a.1 + (c.1 - a.1) * (v - a.0) / (c.0 - a.0).max(1e-9) } +/// Color Stabilizer's correction of buffer `b` as one piecewise-linear map per channel (points +/// with increasing x; the end segments extend), the same maps [`color_stabilizer`] applies. +/// `None` without a host or reference frame (the layer passes through). (The GPU effect.) +pub fn color_stabilizer_maps(ctx: &EffectCtx, b: &Buf) -> Option<[Vec<(f64, f64)>; 3]> { + let reference = ctx.env.host?.self_at(ctx.params.f("referenceFrame"), ctx.env.effect_index)?; + let r = ctx.params.f("sampleSize").max(0.5); + let pts = [ctx.params.v2("blackPoint"), ctx.params.v2("midPoint"), ctx.params.v2("whitePoint")]; + let cur = pts.map(|pt| sample_mean(b, pt, r)); + let refv = pts.map(|pt| sample_mean(&reference, pt, r)); + let mode = ctx.params.e("stabilize"); + Some([0, 1, 2].map(|k| { + let offset = |c0: f64, r0: f64| vec![(0.0, r0 - c0), (1.0, 1.0 + r0 - c0)]; + match mode { + 0 => offset(cur[0][k], refv[0][k]), + 1 => { + let (c0, c1, r0, r1) = (cur[0][k], cur[2][k], refv[0][k], refv[2][k]); + if (c1 - c0).abs() < 1e-6 { + offset(c0, r0) + } else if c0 < c1 { + vec![(c0, r0), (c1, r1)] + } else { + vec![(c1, r1), (c0, r0)] + } + } + _ => { + let mut v = vec![(0.0, 0.0)]; + let mut pairs: Vec<(f64, f64)> = (0..3).map(|i| (cur[i][k], refv[i][k])).collect(); + pairs.sort_by(|a, b| a.0.total_cmp(&b.0)); + for (x, y) in pairs { + if x > v.last().expect("non-empty").0 + 1e-4 && x < 1.0 - 1e-4 { + v.push((x, y)); + } + } + v.push((1.0, 1.0)); + v + } + } + })) +} + fn color_stabilizer(ctx: &EffectCtx, mut b: Buf) -> Buf { let Some(host) = ctx.env.host else { return b }; let Some(reference) = host.self_at(ctx.params.f("referenceFrame"), ctx.env.effect_index) else { return b }; diff --git a/crates/effects/src/stylize2.rs b/crates/effects/src/stylize2.rs index 06c09d2e..b957b4e4 100644 --- a/crates/effects/src/stylize2.rs +++ b/crates/effects/src/stylize2.rs @@ -223,7 +223,8 @@ fn scatter(ctx: &EffectCtx, mut b: Buf) -> Buf { // ---- Strobe Light ---- -pub(crate) fn strobe_on(time: f64, duration: f64, period: f64, prob: f64, seed: u32) -> bool { +/// Whether Strobe Light flashes at `time` (also used by the GPU kernels). +pub fn strobe_on(time: f64, duration: f64, period: f64, prob: f64, seed: u32) -> bool { if period <= 0.0 { return false; } diff --git a/crates/effects/src/time_fx.rs b/crates/effects/src/time_fx.rs index e0c1aad7..e01283b9 100644 --- a/crates/effects/src/time_fx.rs +++ b/crates/effects/src/time_fx.rs @@ -219,6 +219,12 @@ fn fetch(ctx: &EffectCtx, times: &[f64]) -> Option { Some(Frames { grid, imgs }) } +/// The layer's frames at layer times `times` placed on one grid (the GPU Echo / Posterize Time, +/// effectcraft-gpu `fx_noise`): (grid buffer, one image per time). `None` without a host. +pub fn time_frames(ctx: &EffectCtx, times: &[f64]) -> Option<(Buf, Vec)> { + fetch(ctx, times).map(|f| (f.grid, f.imgs)) +} + fn with_img(grid: Buf, img: Image) -> Buf { Buf { img, ..grid } } diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index cb90fd9b..eac24208 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -154,6 +154,10 @@ impl GpuContext { include_str!("shaders/fx_color.wgsl"), include_str!("shaders/fx_distort.wgsl"), include_str!("shaders/fx_generate.wgsl"), + include_str!("shaders/fx_key.wgsl"), + include_str!("shaders/fx_stylize.wgsl"), + include_str!("shaders/fx_noise.wgsl"), + include_str!("shaders/fx_tone.wgsl"), ] .concat(); let module = @@ -207,6 +211,10 @@ impl GpuContext { .chain(crate::fx_color::KERNELS) .chain(crate::fx_distort::KERNELS) .chain(crate::fx_generate::KERNELS) + .chain(crate::fx_key::KERNELS) + .chain(crate::fx_stylize::KERNELS) + .chain(crate::fx_noise::KERNELS) + .chain(crate::fx_tone::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 3691ae17..0b97bc7a 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -113,14 +113,24 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { "ec.blur.gaussian" => gaussian(e, ctx, b), "ec.blur.fastbox" => box_blur(e, ctx, b), "ec.blur.directional" => directional(e, ctx, b), + "ec.stylize.glow" if crate::fx_stylize::glow_extra(ctx) => crate::fx_stylize::glow(e, ctx, b), + "ec.distort.transform" if crate::fx_stylize::transform_blur(ctx) => crate::fx_stylize::transform(e, ctx, b), "ec.stylize.glow" => glow(e, ctx, b), "ec.perspective.dropshadow" => drop_shadow(e, ctx, b), "ec.distort.transform" => transform(e, ctx, b), "ec.color.curves" => curves(e, ctx, b), + "ec.color.huesaturation" if !effectcraft_effects::huesat_ranges_identity(ctx) => crate::fx_tone::hue_saturation(e, ctx, b), + "ec.color.levels" if !effectcraft_effects::levels_channels_identity(ctx) => crate::fx_tone::levels(e, ctx, b), + "ec.channel.invert" if (4..=11).contains(&ctx.params.e("channel")) => crate::fx_tone::invert(e, ctx, b), + "ec.noise.fractal" if crate::fx_noise::fractal_extra(ctx) => crate::fx_noise::fractal(e, ctx, b, false), _ if id.starts_with("ec.control.") => Some(b), _ if crate::fx_color::IDS.contains(&id) => crate::fx_color::apply(e, id, ctx, b), _ if crate::fx_distort::IDS.contains(&id) => crate::fx_distort::apply(e, id, ctx, b), _ if crate::fx_generate::IDS.contains(&id) => crate::fx_generate::apply(e, id, ctx, b), + _ if crate::fx_key::IDS.contains(&id) => crate::fx_key::apply(e, id, ctx, b), + _ if crate::fx_stylize::IDS.contains(&id) => crate::fx_stylize::apply(e, id, ctx, b), + _ if crate::fx_noise::IDS.contains(&id) => crate::fx_noise::apply(e, id, ctx, b), + _ if crate::fx_tone::IDS.contains(&id) => crate::fx_tone::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 ee6ab7e8..f411e97e 100644 --- a/crates/gpu/src/fx_color.rs +++ b/crates/gpu/src/fx_color.rs @@ -235,7 +235,7 @@ fn morph(e: &mut Enc, img: &GpuImage, r: u32, modes: [u32; 4]) -> GpuImage { } /// util::morph_frac (blend between the two nearest integer radii). -fn morph_frac(e: &mut Enc, img: &GpuImage, radius: f64, modes: [u32; 4]) -> GpuImage { +pub(crate) fn morph_frac(e: &mut Enc, img: &GpuImage, radius: f64, modes: [u32; 4]) -> GpuImage { if radius <= 0.0 { return img.clone(); } @@ -253,8 +253,8 @@ fn morph_frac(e: &mut Enc, img: &GpuImage, radius: f64, modes: [u32; 4]) -> GpuI out } -const ERODE_X: [u32; 4] = [0, 2, 2, 2]; -const DILATE_X: [u32; 4] = [1, 2, 2, 2]; +pub(crate) const ERODE_X: [u32; 4] = [0, 2, 2, 2]; +pub(crate) const DILATE_X: [u32; 4] = [1, 2, 2, 2]; // ---------------------------------------------------------------- Key Light diff --git a/crates/gpu/src/fx_key.rs b/crates/gpu/src/fx_key.rs new file mode 100644 index 00000000..8aedbd3d --- /dev/null +++ b/crates/gpu/src/fx_key.rs @@ -0,0 +1,630 @@ +//! GPU effects, keying, matte and channel family (kernels in `shaders/fx_key.wgsl`, every entry +//! point prefixed `fxk_`): the CPU effects' exact steps with the pixel loops as compute kernels. +//! +//! The keyers (Color Key, Luma Key, Color Range, Extract, Difference Matte, Color Difference +//! Key, Screen Key) build their matte in a plane pass, run the CPU's edge thin (morphology) and +//! feather (Gaussian) on it, and apply it in a second pass; spill suppression, Unmult and the +//! channel effects are one per-pixel pass (`fxk_point`). Simple / Matte Choker and Key Cleaner +//! work on the alpha plane and put it back with `util::set_alpha` (straight colour kept, the +//! transparent pixels' colour from a small blur). Advanced Spill Suppressor's Standard method +//! finds the dominant key primary with a GPU sum reduction (`fxk_reduce`). Other layers (layer +//! parameters) are resampled into the buffer like `util::fit_layer` (`fxk_fit`). + +use effectcraft_color::rgb_to_hsl; +use effectcraft_effects::EffectCtx; +use effectcraft_effects::util::{SRC_ORDER, Src}; + +use crate::context::{Enc, GpuImage, Params}; +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`. +pub(crate) const KERNELS: &[&str] = &["fxk_point", "fxk_fit", "fxk_reduce"]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[ + "ec.key.colorkey", + "ec.key.luma", + "ec.key.colorrange", + "ec.key.extract", + "ec.key.differencematte", + "ec.key.colordifference", + "ec.key.screen", + "ec.key.spill", + "ec.key.advancedspill", + "ec.key.keycleaner", + "ec.key.unmult", + "ec.matte.simplechoker", + "ec.matte.mattechoker", + "ec.matte.refinesoft", + "ec.matte.refinehard", + "ec.channel.setmatte", + "ec.channel.setchannels", + "ec.channel.shiftchannels", + "ec.channel.removecolormatting", + "ec.channel.arithmetic", + "ec.channel.solidcomposite", + "ec.channel.combiner", + "ec.channel.blend", + "ec.channel.calculations", + "ec.channel.compoundarithmetic", +]; + +/// 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 { + match id { + "ec.key.colorkey" | "ec.key.luma" | "ec.key.colorrange" | "ec.key.extract" => matte_key(e, id, ctx, b), + "ec.key.differencematte" => difference_matte(e, ctx, b), + "ec.key.screen" => screen_key(e, ctx, b), + "ec.key.advancedspill" => advanced_spill(e, ctx, b), + "ec.key.keycleaner" => 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), + } +} + +// ---------------------------------------------------------------- helpers + +/// One `fxk_point` pass over `src`'s size. +fn run(e: &mut Enc, p: &Params, src: &GpuImage, aux: Option<&GpuImage>, data: Option<&wgpu::Buffer>) -> GpuImage { + let out = e.image(src.width, src.height); + e.pixels("fxk_point", p, src, aux, &out, data); + out +} + +fn op(code: u32) -> Params { + let mut p = Params::default(); + p.u[0][0] = code; + p +} + +fn src_index(s: Src) -> u32 { + SRC_ORDER.iter().position(|x| *x == s).unwrap_or(10) as u32 +} + +/// Layer parameter `id` resampled into the buffer's pixel grid (`util::fit_layer`): +/// `Some(None)` when no layer is chosen (the effect then uses its own layer). +fn fitted(e: &mut Enc, ctx: &EffectCtx, b: &GBuf, id: &str, masks_and_effects: bool, stretch: bool) -> Option> { + let Some(o) = ctx.layer_param(id, masks_and_effects) else { return Some(None) }; + let (w, h) = (b.img.width, b.img.height); + if o.buf.img.is_empty() { + return Some(Some(e.image(w, h))); + } + let ls = ctx.layer_size; + let os = o.size; + let (sx, sy) = if stretch && ls[0] > 0.0 && ls[1] > 0.0 { (os[0] / ls[0], os[1] / ls[1]) } else { (1.0, 1.0) }; + let (dx, dy) = if stretch { (0.0, 0.0) } else { ((os[0] - ls[0]) * 0.5, (os[1] - ls[1]) * 0.5) }; + let inv = 1.0 / b.scale.max(1e-9); + // Per column / row: the other layer's buffer coordinate. + let mut data = Vec::with_capacity((w + h) as usize); + data.extend((0..w).map(|x| (((x as f64 + 0.5 - b.offset[0]) * inv * sx + dx) * o.buf.scale + o.buf.offset[0]) as f32)); + data.extend((0..h).map(|y| (((y as f64 + 0.5 - b.offset[1]) * inv * sy + dy) * o.buf.scale + o.buf.offset[1]) as f32)); + let src = e.g.upload_image(&o.buf.img)?; + let buf = e.data(&data); + let out = e.image(w, h); + e.pixels("fxk_fit", &Params::default(), &src, None, &out, Some(&buf)); + Some(Some(out)) +} + +/// `util::layer_or_self`. +fn layer_or_self(e: &mut Enc, ctx: &EffectCtx, b: &GBuf, id: &str, stretch: bool) -> Option { + Some(fitted(e, ctx, b, id, true, stretch)?.unwrap_or_else(|| b.img.clone())) +} + +/// keying::thin_feather on a matte plane (x channel). +fn thin_feather(e: &mut Enc, m: GpuImage, thin: f64, feather: f64) -> GpuImage { + let m = if thin > 0.0 { + morph_frac(e, &m, thin, ERODE_X) + } else if thin < 0.0 { + morph_frac(e, &m, -thin, DILATE_X) + } else { + m + }; + if feather > 0.0 { gaussian_blur(e, &m, feather * 0.5, feather * 0.5, true) } else { m } +} + +/// keying::apply_matte (view 0 final, 1 source, 2 matte). +fn apply_matte(e: &mut Enc, b: GBuf, m: &GpuImage, view: u32) -> GBuf { + if view == 1 { + return b; + } + let mut p = op(5); + p.u[0][1] = view; + let img = run(e, &p, &b.img, Some(m), None); + GBuf { img, ..b } +} + +/// keying::primary. +fn primary(c: [f32; 4]) -> [u32; 3] { + if c[1] >= c[0] && c[1] >= c[2] { + [1, 0, 2] + } else if c[2] >= c[0] && c[2] >= c[1] { + [2, 0, 1] + } else { + [0, 1, 2] + } +} + +/// util::set_alpha with the new alpha in `na`'s x channel. +fn set_alpha(e: &mut Enc, img: &GpuImage, na: &GpuImage) -> GpuImage { + let fill = gaussian_blur(e, img, 3.0, 3.0, true); + let (rows, row) = e.image_rows(&fill); + let mut p = op(27); + p.u[3][0] = row; + run(e, &p, img, Some(na), Some(&rows)) +} + +// ---------------------------------------------------------------- keyers + +fn matte_key(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let f = |k: &str| pr.f(k) as f32; + let mut p = Params::default(); + let mut thin = 0.0; + let mut feather = 0.0; + match id { + "ec.key.colorkey" => { + let k = pr.color("keyColor"); + p.u[0][0] = 1; + p.f[0] = [k[0], k[1], k[2], f("colorTolerance") / 255.0]; + thin = pr.f("edgeThin") * b.scale; + feather = pr.f("edgeFeather") * b.scale; + } + "ec.key.luma" => { + p.u[0] = [2, pr.e("keyType"), 0, 0]; + p.f[0] = [f("threshold") / 255.0, f("tolerance") / 255.0, 0.0, 0.0]; + thin = pr.f("edgeThin") * b.scale; + feather = pr.f("edgeFeather") * b.scale; + } + "ec.key.colorrange" => { + p.u[0] = [3, pr.e("colorSpace"), 0, 0]; + p.f[0] = [f("minL"), f("minA"), f("minB"), 0.0]; + p.f[1] = [f("maxL"), f("maxA"), f("maxB"), 0.0]; + p.f[2][0] = f("fuzziness"); + } + _ => { + p.u[0] = [4, pr.e("channel"), pr.b("invert") as u32, 0]; + p.f[0] = [f("blackPoint"), f("whitePoint"), f("blackSoftness"), f("whiteSoftness")]; + } + } + let m = run(e, &p, &b.img, None, None); + let m = thin_feather(e, m, thin, feather); + Some(apply_matte(e, b, &m, 0)) +} + +fn difference_matte(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let view = pr.e("view"); + if view == 1 { + return Some(b); + } + let blur = pr.f("blurBeforeDifference") * b.scale; + let other = layer_or_self(e, ctx, &b, "differenceLayer", pr.e("ifLayerSizesDiffer") == 1)?; + let (src, other) = if blur > 0.05 { + (gaussian_blur(e, &b.img, blur * 0.5, blur * 0.5, true), gaussian_blur(e, &other, blur * 0.5, blur * 0.5, true)) + } else { + (b.img.clone(), other) + }; + let mut p = op(6); + p.f[0] = [(pr.f("matchingTolerance") / 100.0) as f32, (pr.f("matchingSoftness") / 100.0) as f32, 0.0, 0.0]; + let m = run(e, &p, &src, Some(&other), None); + Some(apply_matte(e, b, &m, if view == 2 { 3 } else { 4 })) +} + +/// keying::secondary's screen-key matte and composite. +fn screen_key(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let view = pr.e("view"); + if view == 3 { + return Some(b); + } + let sc = pr.color("screenColor"); + let ix = primary(sc); + let (pi, o1, o2) = (ix[0] as usize, ix[1] as usize, ix[2] as usize); + let gain = pr.f("screenGain") as f32 / 100.0; + let bal = pr.f("screenBalance") as f32 / 100.0; + let cb = pr.f("clipBlack") as f32 / 100.0; + let cw = pr.f("clipWhite") as f32 / 100.0; + let preblur = pr.f("screenPreblur") * b.scale; + let sg = pr.f("screenShrinkGrow") * b.scale; + let soft = pr.f("screenSoftness") * b.scale; + let (hi, lo) = if sc[o1] > sc[o2] { (sc[o1], sc[o2]) } else { (sc[o2], sc[o1]) }; + let sd = (sc[pi] - (bal * hi + (1.0 - bal) * lo)).max(1e-3); + let key_src = if preblur > 0.0 { gaussian_blur(e, &b.img, preblur * 0.5, preblur * 0.5, true) } else { b.img.clone() }; + let mut p = op(22); + p.u[1] = [ix[0], ix[1], ix[2], 0]; + p.f[0] = [sd, gain, bal, cb]; + p.f[1][0] = cw; + let mut m = run(e, &p, &key_src, None, None); + if sg < 0.0 { + m = morph_frac(e, &m, -sg, ERODE_X); + } else if sg > 0.0 { + m = morph_frac(e, &m, sg, DILATE_X); + } + if soft > 0.0 { + m = gaussian_blur(e, &m, soft * 0.5, soft * 0.5, true); + } + let mut p = op(23); + p.u[0] = [23, view, (pr.e("edgeColor") == 1) as u32, 0]; + p.u[1] = [ix[0], ix[1], ix[2], 0]; + p.f[0] = [sc[0], sc[1], sc[2], bal]; + p.f[1][0] = pr.f("despill") as f32 / 100.0; + let img = run(e, &p, &b.img, Some(&m), None); + Some(GBuf { img, ..b }) +} + +fn advanced_spill(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let ultra = pr.e("method") == 1; + let key = pr.color("ultraSettings/keyColor"); + let mut p = op(8); + let sums = if ultra { + let ix = primary(key); + p.u[1] = [ix[0], ix[1], ix[2], 0]; + None + } else { + // Standard: the dominant key primary over the whole buffer (sum reduction). + p.u[1][3] = 1; + let mut cur = b.img.clone(); + let mut first = true; + while first || cur.width > 1 || cur.height > 1 { + let out = e.image(cur.width.div_ceil(16), cur.height.div_ceil(16)); + let mut rp = Params::default(); + rp.u[0][0] = first as u32; + e.pixels("fxk_reduce", &rp, &cur, None, &out, None); + cur = out; + first = false; + } + Some(cur) + }; + let tol = if ultra { pr.get("ultraSettings/tolerance").map(|v| v.as_f64()).unwrap_or(100.0) as f32 / 100.0 } else { 1.0 }; + let f = |k: &str| pr.f(k) as f32 / 100.0; + let (desat, neutral) = if ultra { (f("ultraSettings/desaturate"), f("ultraSettings/spillColorCorrection")) } else { (0.0, 0.0) }; + p.f[0] = [f("suppression"), f("ultraSettings/spillRange"), f("ultraSettings/lumaCorrection"), tol]; + p.f[1] = [desat, neutral, rgb_to_hsl(key[0], key[1], key[2]).0, 0.0]; + let img = run(e, &p, &b.img, sums.as_ref(), None); + Some(GBuf { img, ..b }) +} + +fn key_cleaner(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let r = (pr.f("additionalEdgeRadius") * b.scale).max(0.0); + let strength = pr.f("strength") as f32 / 100.0; + let chatter = pr.b("reduceChatter"); + let mut a = run(e, &op(24), &b.img, None, None); + if chatter { + a = gaussian_blur(e, &a, 1.0, 1.0, true); + } + let est = (r > 0.0 && strength > 0.0).then(|| gaussian_blur(e, &b.img, r * 0.5, r * 0.5, true)); + let rows = est.as_ref().map(|img| e.image_rows(img)); + let mut p = op(25); + p.u[0][1] = chatter as u32; + p.u[3] = [rows.as_ref().map_or(0, |r| r.1), rows.is_some() as u32, 0, 0]; + p.f[0] = [pr.f("alphaContrast") as f32 / 100.0, strength, 0.0, 0.0]; + let img = run(e, &p, &b.img, Some(&a), rows.as_ref().map(|r| &r.0)); + Some(GBuf { img, ..b }) +} + +// ---------------------------------------------------------------- mattes + +fn simple_choker(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let c = ctx.params.f("chokeMatte") * b.scale; + if c.abs() > 1e-6 { + if c < 0.0 && !ctx.adjustment { + b.pad(e, (-c).ceil() as u32 + 1)?; + } + let a = run(e, &op(24), &b.img, None, None); + let na = if c > 0.0 { morph_frac(e, &a, c, ERODE_X) } else { morph_frac(e, &a, -c, DILATE_X) }; + b.img = set_alpha(e, &b.img, &na); + } + if ctx.params.e("view") == 1 { + b.img = run(e, &op(28), &b.img, None, None); + } + Some(b) +} + +fn matte_choker(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let pr = ctx.params; + let g1 = pr.f("geometricSoftness1") * b.scale; + let c1 = pr.f("choke1") as f32; + let s1 = pr.f("grayLevelSoftness1") as f32 / 100.0; + let g2 = pr.f("geometricSoftness2") * b.scale; + let c2 = pr.f("choke2") as f32; + let s2 = pr.f("grayLevelSoftness2") as f32 / 100.0; + let it = pr.f("iterations").round().clamp(1.0, 100.0) as usize; + if (c1 < 0.0 || c2 < 0.0 || g1 > 0.0 || g2 > 0.0) && !ctx.adjustment { + b.pad(e, ((g1 + g2) * 1.5 * it as f64).ceil() as u32 + 1)?; + } + let mut a = run(e, &op(24), &b.img, None, None); + let stage = |e: &mut Enc, a: GpuImage, geo: f64, choke: f32, gray: f32| -> GpuImage { + let a = if geo > 0.0 { gaussian_blur(e, &a, geo * 0.5, geo * 0.5, true) } else { a }; + let t = 0.5 + choke / 255.0 * 0.5; + let w = gray.max(1e-3); + if (t - 0.5).abs() < 1e-6 && (w - 1.0).abs() < 1e-6 { + return a; + } + let mut p = op(26); + p.f[0] = [t, w, 0.0, 0.0]; + run(e, &p, &a, None, None) + }; + for _ in 0..it { + a = stage(e, a, g1, c1, s1); + a = stage(e, a, g2, c2, s2); + } + b.img = set_alpha(e, &b.img, &a); + 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"]; + +const SHIFT_ORDER: [Src; 11] = + [Src::Alpha, Src::Red, Src::Green, Src::Blue, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Half, Src::Off]; + +const MATTE_ORDER: [Src; 10] = [Src::Red, Src::Green, Src::Blue, Src::Alpha, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Off]; + +fn set_channels(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let s = ["setRedTo", "setGreenTo", "setBlueTo", "setAlphaTo"].map(|id| src_index(effectcraft_effects::util::src_at(pr.e(id)))); + let stretch = pr.b("stretchLayersToFit"); + let mut layers = vec![]; + for id in SET_CHANNEL_LAYERS { + layers.push(fitted(e, ctx, &b, id, true, stretch)?); + } + if layers.iter().all(Option::is_none) { + return route(e, b, s); + } + let mut vals = e.image(b.img.width, b.img.height); + for (k, layer) in layers.iter().enumerate() { + let mut p = op(20); + p.u[0] = [20, k as u32, s[k], 0]; + vals = run(e, &p, &vals, Some(layer.as_ref().unwrap_or(&b.img)), None); + } + let img = run(e, &op(21), &vals, None, None); + Some(GBuf { img, ..b }) +} + +/// channel::route. +fn route(e: &mut Enc, b: GBuf, s: [u32; 4]) -> Option { + if s == [0, 1, 2, 3] { + return Some(b); + } + let mut p = op(11); + p.u[1] = s; + let img = run(e, &p, &b.img, None, None); + Some(GBuf { img, ..b }) +} + +fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let f = |k: &str| pr.f(k) as f32; + let mut p = Params::default(); + let mut aux = None; + match id { + "ec.key.spill" => { + let k = pr.color("colorToSuppress"); + let amt = f("suppression") / 100.0; + if pr.e("colorAccuracy") == 1 { + let kg = (k[0] + k[1] + k[2]) / 3.0; + let kc = [k[0] - kg, k[1] - kg, k[2] - kg]; + let kn = (kc[0] * kc[0] + kc[1] * kc[1] + kc[2] * kc[2]).sqrt(); + if kn < 1e-5 { + return Some(b); + } + p.u[0] = [7, 1, 0, 0]; + p.f[0] = [kc[0] / kn, kc[1] / kn, kc[2] / kn, amt]; + } else { + let ix = primary(k); + p.u[0] = [7, 0, 0, 0]; + p.u[1] = [ix[0], ix[1], ix[2], 0]; + p.f[0][3] = amt; + } + } + "ec.key.colordifference" => { + let view = pr.e("view"); + if view == 0 { + return Some(b); + } + let accurate = pr.e("colorMatchingAccuracy") == 1; + let lin = |c: [f32; 3]| if accurate { c.map(|v| effectcraft_color::srgb_to_linear(v.clamp(0.0, 1.0))) } else { c }; + let k = pr.color("keyColor"); + let key = lin([k[0], k[1], k[2]]); + let ix = primary([key[0], key[1], key[2], 1.0]); + let (pi, o1, o2) = (ix[0] as usize, ix[1] as usize, ix[2] as usize); + let lv = |pre: &str| ["InBlack", "InWhite", "Gamma", "OutBlack", "OutWhite"].map(|s| f(&format!("{pre}{s}"))); + let (pa, pb) = (lv("partialA"), lv("partialB")); + p.u[0] = [9, view, accurate as u32, 0]; + p.u[1] = [ix[0], ix[1], ix[2], 0]; + p.f[0] = [(key[pi] - key[o1]).max(1e-3), (key[pi] - key[o2]).max(1e-3), f("blackLevel"), f("whiteLevel")]; + p.f[1] = [pa[0], pa[1], pa[2], pa[3]]; + p.f[2] = [pa[4], pb[4], f("gamma"), 0.0]; + p.f[3] = [pb[0], pb[1], pb[2], pb[3]]; + } + "ec.key.unmult" => { + let white = pr.e("backgroundColor") == 1; + let level = if white { pr.f("whiteLevel") } else { pr.f("blackLevel") }.clamp(0.0, 100.0) as f32 / 100.0; + p.u[0] = [10, white as u32, pr.b("removeColorMatting") as u32, pr.b("clipHdrResults") as u32]; + p.f[0] = [level, pr.f("softness").clamp(0.0, 100.0) as f32 / 100.0, 0.0, 0.0]; + } + "ec.channel.shiftchannels" => { + let s = ["takeRedFrom", "takeGreenFrom", "takeBlueFrom", "takeAlphaFrom"].map(|id| src_index(SHIFT_ORDER[(pr.e(id) as usize).min(10)])); + return route(e, b, s); + } + "ec.channel.removecolormatting" => { + p.u[0] = [12, pr.b("clipHdr") as u32, 0, 0]; + p.f[0] = pr.color("backgroundColor"); + } + "ec.channel.arithmetic" => { + p.u[0] = [13, pr.e("operator"), pr.b("clip") as u32, 0]; + p.f[0] = [f("redValue") / 255.0, f("greenValue") / 255.0, f("blueValue") / 255.0, 0.0]; + } + "ec.channel.solidcomposite" => { + const MODES: [effectcraft_color::BlendMode; 12] = { + use effectcraft_color::BlendMode::*; + [Normal, Add, Multiply, Screen, Overlay, SoftLight, HardLight, Darken, Lighten, Difference, Color, Luminosity] + }; + let c = pr.color("color"); + let opa = (f("opacity") / 100.0 * c[3]).clamp(0.0, 1.0); + p.u[0] = [14, crate::ops::mode_id(MODES[(pr.e("blendingMode") as usize).min(11)]), 0, 0]; + p.f[0] = [c[0] * opa, c[1] * opa, c[2] * opa, opa]; + p.f[1][0] = f("sourceOpacity") / 100.0; + } + "ec.channel.setmatte" => { + let src = MATTE_ORDER[(pr.e("takeMatteFrom") as usize).min(9)]; + aux = fitted(e, ctx, &b, "takeMatteFromLayer", true, pr.b("stretchMatteToFit"))?; + let pre_ok = !matches!(src, Src::Alpha | Src::Full | Src::Off); + let flags = pr.b("invertMatte") as u32 + | (pr.b("compositeMatteWithOriginal") as u32) << 1 + | ((pr.b("premultiplyMatteLayer") && pre_ok) as u32) << 2 + | (aux.is_none() as u32) << 3 + | ((src == Src::Alpha) as u32) << 4; + p.u[0] = [15, src_index(src), flags, 0]; + } + "ec.channel.combiner" => { + if pr.b("useSecondLayer") { + aux = Some(layer_or_self(e, ctx, &b, "sourceLayer", true)?); + } + let flags = pr.b("invert") as u32 | (pr.b("solidAlpha") as u32) << 1 | (aux.is_some() as u32) << 2; + p.u[0] = [16, pr.e("from"), pr.e("to"), flags]; + } + "ec.channel.blend" => { + let orig = (f("blendWithOriginal") / 100.0).clamp(0.0, 1.0); + if orig >= 1.0 { + return Some(b); + } + aux = Some(layer_or_self(e, ctx, &b, "blendWithLayer", pr.e("ifLayerSizesDiffer") == 1)?); + p.u[0] = [17, pr.e("mode"), 0, 0]; + p.f[0][0] = orig; + } + "ec.channel.calculations" => { + const MODES: [effectcraft_color::BlendMode; 22] = { + use effectcraft_color::BlendMode::*; + [ + Normal, + Darken, + Multiply, + ColorBurn, + LinearBurn, + Add, + Lighten, + Screen, + ColorDodge, + LinearDodge, + Overlay, + SoftLight, + HardLight, + LinearLight, + VividLight, + PinLight, + HardMix, + Difference, + Exclusion, + Hue, + Saturation, + Luminosity, + ] + }; + if pr.get("secondLayer").and_then(|v| v.as_layer()).is_some() { + aux = Some(layer_or_self(e, ctx, &b, "secondLayer", pr.b("stretchSecondLayerToFit"))?); + } + let flags = + pr.b("invertInput") as u32 | (pr.b("invertSecondLayer") as u32) << 1 | (pr.b("preserveTransparency") as u32) << 2 | (aux.is_some() as u32) << 3; + let mode = MODES[(pr.e("blendingMode") as usize).min(MODES.len() - 1)]; + p.u[0] = [18, pr.e("inputChannel"), pr.e("secondLayerChannel"), crate::ops::mode_id(mode)]; + p.u[1][0] = flags; + p.f[0][0] = (f("secondLayerOpacity") / 100.0).clamp(0.0, 1.0); + } + "ec.channel.compoundarithmetic" => { + let orig = (f("blendWithOriginal") / 100.0).clamp(0.0, 1.0); + if orig >= 1.0 { + return Some(b); + } + aux = Some(layer_or_self(e, ctx, &b, "secondSource", pr.b("stretchSecondSourceToFit"))?); + p.u[0] = [19, pr.e("operator"), pr.e("operateOnChannels"), pr.e("overflowBehavior")]; + p.f[0][0] = orig; + } + _ => return None, + } + let img = run(e, &p, &b.img, aux.as_ref(), None); + Some(GBuf { img, ..b }) +} diff --git a/crates/gpu/src/fx_noise.rs b/crates/gpu/src/fx_noise.rs new file mode 100644 index 00000000..eaa7324a --- /dev/null +++ b/crates/gpu/src/fx_noise.rs @@ -0,0 +1,581 @@ +//! GPU effects, noise, blur and time family (kernels in `shaders/fx_noise.wgsl`, every entry point prefixed +//! `fxn_`): the CPU effects' exact steps with the pixel loops as compute kernels. +//! +//! Median, Median (Legacy) and Dust & Scratches take the median of the 512-level quantised +//! channels by bisection over the window (radius ≤ [`MEDIAN_MAX_R`] on the GPU). Fractal and +//! Turbulent Noise resolve every per-octave term on the CPU in the CPU's f32 order +//! (`effectcraft_effects::fractal_gpu`) and evaluate the pixels on the GPU. Echo and Posterize +//! Time fetch the layer's other frames through the effect host like the CPU does, then +//! accumulate on the GPU. Remove Grain measures the grain level in its sample boxes on the CPU +//! (one readback) and runs the guided filters, Texture, Unsharp Mask and Preview on the GPU; +//! Temporal Filtering and the Noise Samples / Blending Matte views render on the CPU. + +use effectcraft_color::BlendMode; +use effectcraft_effects::{Buf, EffectCtx}; +use effectcraft_keyframe::Value; + +use crate::context::{Enc, GpuImage, Params}; +use crate::effects::{GBuf, box_passes, gaussian_blur}; + +/// Compute entry points in `fx_noise.wgsl`. +pub(crate) const KERNELS: &[&str] = &[ + "fxn_quant", + "fxn_median", + "fxn_median_out", + "fxn_edge", + "fxn_smart_edges", + "fxn_unsharp", + "fxn_pick", + "fxn_cblur_out", + "fxn_straight", + "fxn_premul", + "fxn_morph", + "fxn_crop", + "fxn_cb_map", + "fxn_cb_level", + "fxn_echo", + "fxn_echo_fin", + "fxn_fractal", + "fxn_noise_alpha", + "fxn_noise_hls", + "fxn_gf_guide", + "fxn_gf", + "fxn_rg_texture", + "fxn_rg_unsharp", + "fxn_rg_out", +]; + +/// Effect ids implemented here (Fractal Noise's other modes come through [`fractal_extra`]). +pub(crate) const IDS: &[&str] = &[ + "ec.noise.turbulent", + "ec.noise.median", + "ec.noise.medianlegacy", + "ec.noise.dustscratches", + "ec.noise.removegrain", + "ec.noise.noisealpha", + "ec.noise.noisehls", + "ec.noise.noisehlsauto", + "ec.blur.smart", + "ec.blur.bilateral", + "ec.blur.sharpen", + "ec.blur.unsharp", + "ec.blur.compound", + "ec.blur.channel", + "ec.channel.minimax", + "ec.time.echo", + "ec.time.posterizetime", +]; + +/// Largest median radius (buffer pixels) the GPU takes; larger ones render on the CPU. +pub(crate) const MEDIAN_MAX_R: usize = 16; + +/// 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 { + match id { + "ec.noise.turbulent" => fractal(e, ctx, b, true), + "ec.noise.median" | "ec.noise.medianlegacy" | "ec.noise.dustscratches" => median(e, id, ctx, b), + "ec.noise.removegrain" => remove_grain(e, ctx, b), + "ec.noise.noisealpha" => noise_alpha(e, ctx, b), + "ec.noise.noisehls" => noise_hls(e, ctx, b, ctx.params.f("noisePhase") as f32 / 360.0), + "ec.noise.noisehlsauto" => noise_hls(e, ctx, b, ctx.time as f32 * ctx.params.f("noiseAnimationSpeed") as f32), + "ec.blur.smart" => smart_blur(e, ctx, b), + "ec.blur.bilateral" => bilateral(e, ctx, b), + "ec.blur.sharpen" => { + let s = b.scale; + unsharp(e, b, ctx.params.f("amount") as f32 / 50.0, s, 0.0) + } + "ec.blur.unsharp" => { + let s = (ctx.params.f("radius") * b.scale).max(0.1); + unsharp(e, b, ctx.params.f("amount") as f32 / 100.0, s, ctx.params.f("threshold") as f32) + } + "ec.blur.compound" => compound_blur(e, ctx, b), + "ec.blur.channel" => channel_blur(e, ctx, b), + "ec.channel.minimax" => minimax(e, ctx, b), + "ec.time.echo" => echo(e, ctx, b), + "ec.time.posterizetime" => posterize_time(e, ctx, b), + _ => None, + } +} + +/// A same-size per-pixel kernel. +fn run(e: &mut Enc, entry: &str, p: &Params, src: &GpuImage, aux: Option<&GpuImage>, data: Option<&wgpu::Buffer>) -> GpuImage { + let out = e.image(src.width, src.height); + e.pixels(entry, p, src, aux, &out, data); + out +} + +fn with_u0(u: [u32; 4]) -> Params { + let mut p = Params::default(); + p.u[0] = u; + p +} + +// ---------------------------------------------------------------- Fractal / Turbulent Noise (noise3.rs) + +/// Fractal Noise settings the `pointwise` kernel (effects.rs) leaves to the CPU, which this +/// family's kernel covers: every Noise and Fractal Type, Cycle Evolution, Perspective Offset, +/// Sub Rotation / Offset and all Blending Modes. +pub(crate) fn fractal_extra(ctx: &EffectCtx) -> bool { + let pr = ctx.params; + pr.e("noiseType") != 2 + || pr.e("fractalType") > 2 + || pr.e("blendingMode") > 1 + || pr.b("evolutionOptions/cycleEvolution") + || pr.b("transform/perspectiveOffset") + || pr.f("subSettings/subRotation") != 0.0 + || (!pr.b("subSettings/centerSubscale") && pr.v2("subSettings/subOffset") != [0.0, 0.0]) +} + +/// noise3::render. +pub(crate) fn fractal(e: &mut Enc, ctx: &EffectCtx, b: GBuf, turbulent: bool) -> Option { + let fr = effectcraft_effects::fractal_gpu(ctx, b.offset, b.scale, turbulent); + let mode = match fr.mode { + None => 255, + Some(m) => BlendMode::ALL.iter().position(|&x| x == m).unwrap_or(0) as u32, + }; + let data: Vec = fr.octaves.iter().flatten().copied().collect(); + let cycle = fr.cycle.unwrap_or([0.0; 3]); + let mut p = Params::default(); + p.u[0] = [fr.kind, fr.noise_type, fr.octaves.len() as u32, fr.seed]; + p.u[1] = [fr.sub_rotation as u32, fr.cycle.is_some() as u32, fr.invert as u32, fr.overflow]; + p.u[2][0] = mode; + p.f[0] = [fr.rot.0, fr.rot.1, fr.size[0], fr.size[1]]; + p.f[1] = [fr.contrast, fr.brightness, fr.opacity, fr.blend]; + p.f[2] = [cycle[0], cycle[1], cycle[2], fr.norm]; + let buf = e.data(&data); + let img = run(e, "fxn_fractal", &p, &b.img, None, Some(&buf)); + Some(GBuf { img, ..b }) +} + +// ---------------------------------------------------------------- Median family (noise.rs, noise2.rs) + +/// noise::median_image (of the straight colour with alpha 1 when `straight`). +fn median_image(e: &mut Enc, img: &GpuImage, r: usize, straight: bool) -> GpuImage { + let q = run(e, "fxn_quant", &with_u0([straight as u32, 0, 0, 0]), img, None, None); + run(e, "fxn_median", &with_u0([r as u32, 0, 0, 0]), &q, None, None) +} + +fn median(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize; + if r == 0 { + return Some(b); + } + if r > MEDIAN_MAX_R { + return None; + } + let (mode, on_alpha, straight) = match id { + "ec.noise.medianlegacy" => { + let on = ctx.params.b("operateOnAlphaChannel"); + (if on { 2 } else { 3 }, on, !on) + } + "ec.noise.dustscratches" => (1, ctx.params.b("operateOnAlpha"), false), + _ => (0, ctx.params.b("operateOnAlpha"), false), + }; + let m = median_image(e, &b.img, r, straight); + let mut p = with_u0([mode, on_alpha as u32, 0, 0]); + if mode == 1 { + p.f[0][0] = ctx.params.f("threshold") as f32 / 255.0; + } + let img = run(e, "fxn_median_out", &p, &b.img, Some(&m), None); + Some(GBuf { img, ..b }) +} + +// ---------------------------------------------------------------- Remove Grain (noise.rs) + +fn remove_grain(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let amt = pr.f("noiseReductionSettings/noiseReduction") as f32; + let um_amount = pr.f("unsharpMask/amount") as f32 / 100.0; + let view = pr.get("viewingMode").map(Value::as_enum).unwrap_or(3); + // Noise Samples / Blending Matte views and Temporal Filtering render on the CPU. + if view == 1 || view == 2 || pr.b("temporalFiltering/enabled") { + return None; + } + if amt <= 0.0 && um_amount <= 0.0 { + return Some(b); + } + // The grain level measured in the sample boxes (CPU; only the smoothing uses it). + let level = if amt > 0.0 { + let img = e.download(&b.img)?; + effectcraft_effects::remove_grain_level(ctx, &Buf { img, offset: b.offset, scale: b.scale }) + } else { + 1.0 + }; + let passes = pr.f("noiseReductionSettings/passes").round().clamp(1.0, 8.0) as usize; + let multichannel = pr.e("noiseReductionSettings/mode") == 0; + let texture = (pr.f("fineTuning/texture") as f32).clamp(0.0, 1.0); + let r = (2.0 * b.scale).round().max(1.0) as usize; + let eps = (0.02 * amt * level).powi(2); + let orig = b.img.clone(); + let mut ch = orig.clone(); + if amt > 0.0 { + for _ in 0..passes { + let g = run(e, "fxn_gf_guide", &with_u0([!multichannel as u32, 0, 0, 0]), &ch, None, None); + ch = guided_filter(e, &g, &ch, r, eps); + } + if texture > 0.0 { + let mut p = Params::default(); + p.f[0][0] = texture; + ch = run(e, "fxn_rg_texture", &p, &ch, Some(&orig), None); + } + } + if um_amount > 0.0 { + let radius = (pr.f("unsharpMask/radius") * b.scale).max(0.1); + let low = gaussian_blur(e, &ch, radius, radius, true); + let mut p = Params::default(); + p.f[0] = [um_amount, pr.f("unsharpMask/threshold") as f32 / 255.0, 0.0, 0.0]; + ch = run(e, "fxn_rg_unsharp", &p, &ch, Some(&low), None); + } + let mut p = Params::default(); + if view == 0 { + let c = b.to_px(pr.v2("previewRegion/center")); + let (hw, hh) = (pr.f("previewRegion/width") * b.scale * 0.5, pr.f("previewRegion/height") * b.scale * 0.5); + let col = pr.color("previewRegion/boxColor"); + p.u[0] = [1, pr.b("previewRegion/showBox") as u32, 0, 0]; + p.f[0] = [(c.0 - hw) as f32, (c.0 + hw) as f32, (c.1 - hh) as f32, (c.1 + hh) as f32]; + p.f[1] = [col[0], col[1], col[2], 1.0]; + } + let img = run(e, "fxn_rg_out", &p, &ch, Some(&orig), None); + Some(GBuf { img, ..b }) +} + +/// One guided-filter arithmetic pass (`fxn_gf`), `d` = an image read through `data`. +fn gf(e: &mut Enc, mode: u32, s: &GpuImage, a: &GpuImage, d: Option<&GpuImage>, eps: f32) -> GpuImage { + let mut p = with_u0([mode, 0, 0, 0]); + p.f[0][0] = eps; + let rows = d.map(|d| e.image_rows(d)); + if let Some((_, row)) = &rows { + p.u[0][1] = *row; + } + run(e, "fxn_gf", &p, s, Some(a), rows.as_ref().map(|r| &r.0)) +} + +/// util::guided_filter on every channel at once (guide `g`, input `p`). +fn guided_filter(e: &mut Enc, g: &GpuImage, p: &GpuImage, r: usize, eps: f32) -> GpuImage { + let bx = |e: &mut Enc, img: &GpuImage| box_passes(e, img, &[r], &[r], true); + let mean_g = bx(e, g); + let mean_p = bx(e, p); + let gp = gf(e, 5, g, p, None, 0.0); + let gg = gf(e, 6, g, g, None, 0.0); + let corr_gp = bx(e, &gp); + let corr_gg = bx(e, &gg); + let cov = gf(e, 0, &corr_gp, &mean_p, Some(&mean_g), 0.0); + let var = gf(e, 1, &corr_gg, &mean_g, None, 0.0); + let a = gf(e, 2, &cov, &var, None, eps); + let bb = gf(e, 3, &a, &mean_p, Some(&mean_g), 0.0); + let ma = bx(e, &a); + let mb = bx(e, &bb); + gf(e, 4, &ma, &mb, Some(g), 0.0) +} + +// ---------------------------------------------------------------- Noise Alpha / Noise HLS (noise.rs) + +/// noise::phased_cycle's two seeds and smoothed fraction for `phase`. +fn phase_terms(seed: u32, phase: f32, cycle: i64) -> (u32, u32, f32) { + let k = phase.floor(); + let t = phase - k; + let k = k as i64; + let wrap = |k: i64| if cycle > 0 { k.rem_euclid(cycle) } else { k } as u32; + (seed.wrapping_add(wrap(k).wrapping_mul(0x632b)), seed.wrapping_add(wrap(k + 1).wrapping_mul(0x632b)), t * t * (3.0 - 2.0 * t)) +} + +fn noise_alpha(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let k = ctx.params.f("amount") as f32 / 100.0; + if k <= 0.0 { + return Some(b); + } + let kind = ctx.params.e("noise"); + let seed = (ctx.params.f("randomSeed") as i64 as u32) ^ ctx.seed.wrapping_mul(0x2f1); + let phase = ctx.params.f("noisePhase") as f32 / 360.0 + if kind >= 2 { ctx.time as f32 } else { 0.0 }; + let cycle = if ctx.params.b("noiseOptions/cycleNoise") { ctx.params.f("noiseOptions/cycle").round().max(1.0) as i64 } else { 0 }; + let ph = if kind >= 2 { phase } else { phase.floor() }; + let (sa, sb, t) = phase_terms(seed, ph, cycle); + let mut p = with_u0([kind % 2, ctx.params.e("originalAlpha"), ctx.params.e("overflow"), sa]); + p.u[1][0] = sb; + p.f[0] = [k, t, 0.0, 0.0]; + let img = run(e, "fxn_noise_alpha", &p, &b.img, None, None); + Some(GBuf { img, ..b }) +} + +fn noise_hls(e: &mut Enc, ctx: &EffectCtx, b: GBuf, phase: f32) -> Option { + let hue = ctx.params.f("hue") as f32 / 100.0; + let light = ctx.params.f("lightness") as f32 / 100.0; + let satv = ctx.params.f("saturation") as f32 / 100.0; + if hue == 0.0 && light == 0.0 && satv == 0.0 { + return Some(b); + } + let gs = (ctx.params.f("grainSize") * b.scale).max(0.1) as f32; + let seed = ctx.seed.wrapping_mul(0x51f3); + let mut seeds = [0u32; 6]; + let mut t = 0.0; + for k in 0..3u32 { + let (sa, sb, tt) = phase_terms(seed.wrapping_add(k * 7919), phase, 0); + seeds[2 * k as usize] = sa; + seeds[2 * k as usize + 1] = sb; + t = tt; + } + let mut p = with_u0([ctx.params.e("noise"), seed, 0, 0]); + p.u[1] = [seeds[0], seeds[1], seeds[2], seeds[3]]; + p.u[2] = [seeds[4], seeds[5], 0, 0]; + p.f[0] = [hue, light, satv, gs]; + p.f[1] = [phase, t, 0.0, 0.0]; + let img = run(e, "fxn_noise_hls", &p, &b.img, None, None); + Some(GBuf { img, ..b }) +} + +// ---------------------------------------------------------------- blurs (blur2.rs, misc.rs) + +/// blur2::edge_preserving (+ Bilateral's grey output with `grey`). +fn edge_preserving(e: &mut Enc, img: &GpuImage, r: f64, range: f32, hard: bool, grey: bool) -> GpuImage { + let ri = r.ceil() as u32; + let stride = ((r / 6.0).ceil() as u32).max(1); + let sig_s2 = 2.0 * (r / 2.0).max(0.5).powi(2); + let sig_r2 = 2.0 * range.max(1e-3).powi(2); + let d2max = (r * r + 0.5).floor() as u32; + let mut p = with_u0([ri, stride, hard as u32, d2max]); + p.u[1][0] = grey as u32; + p.f[0] = [sig_s2 as f32, sig_r2, range, 0.0]; + run(e, "fxn_edge", &p, img, None, None) +} + +fn bilateral(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let r = ctx.params.f("radius") * b.scale; + if r < 0.5 { + return Some(b); + } + let range = ctx.params.f("threshold") as f32 / 100.0; + let img = edge_preserving(e, &b.img, r, range, false, !ctx.params.b("colorize")); + Some(GBuf { img, ..b }) +} + +fn smart_blur(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let r = ctx.params.f("radius") * b.scale; + let thr = ctx.params.f("threshold") as f32 / 100.0; + let mode = ctx.params.e("mode"); + let blurred = if r >= 0.5 { edge_preserving(e, &b.img, r, thr, true, false) } else { b.img.clone() }; + if mode == 0 { + return Some(GBuf { img: blurred, ..b }); + } + let mut p = with_u0([mode, 0, 0, 0]); + p.f[0][0] = thr.max(0.01) * 0.5; + let img = run(e, "fxn_smart_edges", &p, &blurred, None, None); + Some(GBuf { img, ..b }) +} + +/// misc::unsharp_core (Sharpen, Unsharp Mask). +fn unsharp(e: &mut Enc, b: GBuf, amount: f32, sigma: f64, threshold: f32) -> Option { + let blurred = gaussian_blur(e, &b.img, sigma, sigma, true); + let mut p = Params::default(); + p.f[0] = [amount, threshold, 0.0, 0.0]; + let img = run(e, "fxn_unsharp", &p, &b.img, Some(&blurred), None); + Some(GBuf { img, ..b }) +} + +fn dims_xy(dim: u32) -> (f64, f64) { + match dim { + 1 => (1.0, 0.0), + 2 => (0.0, 1.0), + _ => (1.0, 1.0), + } +} + +fn pick(e: &mut Enc, acc: &GpuImage, from: &GpuImage, mask: [bool; 4], alpha: bool) -> GpuImage { + let mut p = with_u0([alpha as u32, 0, 0, 0]); + p.u[1] = mask.map(|m| m as u32); + run(e, "fxn_pick", &p, acc, Some(from), None) +} + +fn channel_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let sig = ["redBlurriness", "greenBlurriness", "blueBlurriness", "alphaBlurriness"].map(|id| ctx.params.f(id).max(0.0) * 0.5 * b.scale); + if sig.iter().all(|&s| s < 0.05) { + return Some(b); + } + let (kx, ky) = dims_xy(ctx.params.e("dimensions")); + let repeat = ctx.params.b("repeatEdge") || ctx.adjustment; + let maxs = sig.iter().cloned().fold(0.0, f64::max); + if !repeat { + b.pad(e, (maxs * 3.0).ceil() as u32 + 1)?; + } + // n: each colour channel blurred by its own amount; a: the alpha blurred by the same amount + // (its divisor), alpha = the Alpha Blurriness result. + let mut n = b.img.clone(); + let mut a = pick(e, &b.img, &b.img, [true; 4], true); + let mut done: Vec = Vec::new(); + for &s in &sig { + if s < 0.05 || done.contains(&s.to_bits()) { + continue; + } + done.push(s.to_bits()); + let bl = gaussian_blur(e, &b.img, s * kx, s * ky, true); + let on = |c: usize| sig[c] >= 0.05 && sig[c].to_bits() == s.to_bits(); + n = pick(e, &n, &bl, [on(0), on(1), on(2), false], false); + a = pick(e, &a, &bl, [on(0), on(1), on(2), on(3)], true); + } + b.img = run(e, "fxn_cblur_out", &Params::default(), &n, Some(&a), None); + Some(b) +} + +fn compound_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let max_sigma = ctx.params.f("maximumBlur").max(0.0) * 0.5 * b.scale; + if max_sigma < 0.05 { + return Some(b); + } + let invert = ctx.params.b("invertBlur"); + if !ctx.adjustment { + b.pad(e, (max_sigma * 3.0).ceil() as u32 + 1)?; + } + let (w, h) = (b.img.width as usize, b.img.height as usize); + // The blur map: the Blur Layer fitted to the buffer (util::fit_layer), else the layer itself. + let map = match ctx.layer_param("blurLayer", true) { + Some(o) => { + if o.buf.img.is_empty() { + return None; + } + let stretch = ctx.params.b("stretchMapToFit"); + let (ls, os) = (ctx.layer_size, o.size); + let (sx, sy) = if stretch && ls[0] > 0.0 && ls[1] > 0.0 { (os[0] / ls[0], os[1] / ls[1]) } else { (1.0, 1.0) }; + let (dx, dy) = if stretch { (0.0, 0.0) } else { ((os[0] - ls[0]) * 0.5, (os[1] - ls[1]) * 0.5) }; + let inv = 1.0 / b.scale.max(1e-9); + let col = |x: usize| (((x as f64 + 0.5 - b.offset[0]) * inv) * sx + dx) * o.buf.scale + o.buf.offset[0]; + let row = |y: usize| (((y as f64 + 0.5 - b.offset[1]) * inv) * sy + dy) * o.buf.scale + o.buf.offset[1]; + let data: Vec = (0..w).map(|x| col(x) as f32).chain((0..h).map(|y| row(y) as f32)).collect(); + let layer = e.g.upload_image(&o.buf.img)?; + let buf = e.data(&data); + let mut p = with_u0([1, invert as u32, 0, 0]); + p.u[1][0] = w as u32; + let out = e.image(b.img.width, b.img.height); + e.pixels("fxn_cb_map", &p, &layer, None, &out, Some(&buf)); + out + } + None => run(e, "fxn_cb_map", &with_u0([0, invert as u32, 0, 0]), &b.img, None, None), + }; + const N: usize = 5; + let levels: Vec = (0..N) + .map(|k| { + if k == 0 { + b.img.clone() + } else { + let s = max_sigma * k as f64 / (N - 1) as f64; + gaussian_blur(e, &b.img, s, s, ctx.adjustment) + } + }) + .collect(); + let (rows, row) = e.image_rows(&map); + let out = e.image(b.img.width, b.img.height); + for k in 0..N - 1 { + e.pixels("fxn_cb_level", &with_u0([k as u32, row, 0, 0]), &levels[k], Some(&levels[k + 1]), &out, Some(&rows)); + } + b.img = out; + Some(b) +} + +// ---------------------------------------------------------------- Minimax (channel.rs) + +/// util::morph_plane per channel (`modes`: 0 min, 1 max, 2 keep) with radii (`rx`, `ry`). +fn morph(e: &mut Enc, img: &GpuImage, rx: usize, ry: usize, modes: [u32; 4]) -> GpuImage { + let mut cur = img.clone(); + for (vertical, r) in [(0, rx), (1, ry)] { + if r == 0 { + continue; + } + let mut p = with_u0([r as u32, vertical, 0, 0]); + p.u[1] = modes; + cur = run(e, "fxn_morph", &p, &cur, None, None); + } + cur +} + +fn minimax(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize; + if r == 0 { + return Some(b); + } + let op = ctx.params.e("operation"); + let dont_shrink = ctx.params.b("dontShrinkEdges"); + let (rx, ry) = match ctx.params.e("direction") { + 1 => (r, 0), + 2 => (0, r), + _ => (r, r), + }; + let sel: [bool; 4] = match ctx.params.e("channel") { + 1 => [false, false, false, true], + 2 => [true, true, true, true], + 3 => [true, false, false, false], + 4 => [false, true, false, false], + 5 => [false, false, true, false], + _ => [true, true, true, false], + }; + if sel[3] && op != 0 && !ctx.adjustment { + b.pad(e, rx.max(ry) as u32)?; + } + let (w, h) = (b.img.width, b.img.height); + let mut planes = run(e, "fxn_straight", &Params::default(), &b.img, None, None); + // Beyond the layer edge is empty (0) unless Don't Shrink Edges repeats the edge pixels. + if !dont_shrink { + let (pw, ph) = (w + 2 * rx as u32, h + 2 * ry as u32); + if !e.g.fits(pw, ph) { + return None; + } + let padded = e.image(pw, ph); + e.copy_into(&planes, &padded, rx as u32, ry as u32); + planes = padded; + } + let modes = |max: bool| sel.map(|s| if s { max as u32 } else { 2 }); + let stages: &[bool] = match op { + 0 => &[false], + 1 => &[true], + 2 => &[false, true], + _ => &[true, false], + }; + for &max in stages { + planes = morph(e, &planes, rx, ry, modes(max)); + } + if !dont_shrink { + let out = e.image(w, h); + e.pixels("fxn_crop", &with_u0([rx as u32, ry as u32, 0, 0]), &planes, None, &out, None); + planes = out; + } + b.img = run(e, "fxn_premul", &Params::default(), &planes, None, None); + Some(b) +} + +// ---------------------------------------------------------------- Echo / Posterize Time (time_fx.rs) + +fn echo(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let n = ctx.params.f("numberOfEchoes").round().clamp(0.0, 255.0) as usize; + let dt = ctx.params.f("echoTime"); + let start = ctx.params.f("startingIntensity").clamp(0.0, 1.0) as f32; + let decay = ctx.params.f("decay").clamp(0.0, 1.0) as f32; + let op = ctx.params.e("echoOperator"); + let times: Vec = (0..=n).map(|i| ctx.time + i as f64 * dt).collect(); + // Without the effect host (or the frames) the layer passes through, as on the CPU. + let Some((grid, imgs)) = effectcraft_effects::time_frames(ctx, ×) else { return Some(b) }; + let (w, h) = (grid.img.width, grid.img.height); + let mut acc = [e.image(w, h), e.image(w, h)]; + for (i, img) in imgs.iter().enumerate() { + let frame = e.g.upload_image(img)?; + let mut p = with_u0([op, (i == 0) as u32, 0, 0]); + p.f[0][0] = start * decay.powi(i as i32); + let [a, o] = &acc; + e.pixels("fxn_echo", &p, a, Some(&frame), o, None); + acc.swap(0, 1); + } + let mut p = with_u0([op, 0, 0, 0]); + p.f[0][0] = (n + 1) as f32; + let img = run(e, "fxn_echo_fin", &p, &acc[0], None, None); + Some(GBuf { img, offset: grid.offset, scale: grid.scale }) +} + +fn posterize_time(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + if ctx.env.host.is_none() { + return Some(b); + } + let tt = effectcraft_effects::posterized_time(ctx.time, ctx.params.f("frameRate")); + match effectcraft_effects::time_frames(ctx, &[tt]) { + Some((grid, mut imgs)) => { + let img = e.g.upload_image(&imgs.pop().unwrap_or_default())?; + Some(GBuf { img, offset: grid.offset, scale: grid.scale }) + } + None => Some(b), + } +} diff --git a/crates/gpu/src/fx_stylize.rs b/crates/gpu/src/fx_stylize.rs new file mode 100644 index 00000000..6e83c7c8 --- /dev/null +++ b/crates/gpu/src/fx_stylize.rs @@ -0,0 +1,1002 @@ +//! GPU effects, stylize and distort family (kernels in `shaders/fx_stylize.wgsl`, every entry point prefixed +//! `fxs_`): the CPU effects' exact steps with the pixel loops as compute kernels. +//! +//! Parameter conversions, padding, output geometry and anything the CPU computes in f64 with a +//! floor or a wrap (tile indices, wrapped coordinates, brush cells) are computed here exactly as +//! the CPU does and uploaded as per-column / per-row / per-cell tables; the kernels do the +//! per-pixel work. Glow's other Glow Operations / Arbitrary Map and Transform's shutter-angle +//! motion blur extend the base kernels in `effects.rs` (see [`glow_extra`], [`transform_blur`]). + +use std::f64::consts::{FRAC_PI_2, TAU}; + +use effectcraft_color::BlendMode; +use effectcraft_effects::EffectCtx; +use effectcraft_geom::{Mat3, vec2}; +use effectcraft_raster::Sampling; + +use crate::context::{Enc, Params}; +use crate::effects::{GBuf, gaussian_blur}; +use crate::ops; + +/// Compute entry points in `fx_stylize.wgsl`. +pub(crate) const KERNELS: &[&str] = &[ + "fxs_point", + "fxs_warp", + "fxs_table", + "fxs_griddler", + "fxs_motiontile", + "fxs_repetile", + "fxs_magnify", + "fxs_scatter", + "fxs_brush", + "fxs_roughen", + "fxs_tex_luma", + "fxs_texturize", + "fxs_glow_map", + "fxs_glow_op", + "fxs_add", + "fxs_div", + "fxs_flomotion", +]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[ + "ec.stylize.posterize", + "ec.stylize.threshold", + "ec.stylize.ccthreshold", + "ec.stylize.ccthresholdrgb", + "ec.stylize.strobe", + "ec.stylize.ccvignette", + "ec.stylize.scatter", + "ec.stylize.brushstrokes", + "ec.stylize.roughenedges", + "ec.stylize.texturize", + "ec.stylize.motiontile", + "ec.stylize.cckaleida", + "ec.stylize.ccrepetile", + "ec.distort.mirror", + "ec.distort.offset", + "ec.distort.polar", + "ec.distort.spherize", + "ec.distort.cornerpin", + "ec.distort.opticscompensation", + "ec.distort.magnify", + "ec.distort.ccslant", + "ec.distort.ccsmear", + "ec.distort.ccsplit", + "ec.distort.ccsplit2", + "ec.distort.cctiler", + "ec.distort.ccgriddler", + "ec.distort.liquify", + "ec.distort.twirllegacy", + "ec.distort.ccripplepulse", + "ec.distort.ccpowerpin", + "ec.distort.ccflomotion", +]; + +/// 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 { + match id { + "ec.stylize.posterize" => posterize(e, ctx, b), + "ec.stylize.threshold" => threshold(e, ctx, b), + "ec.stylize.ccthreshold" => cc_threshold(e, ctx, b), + "ec.stylize.ccthresholdrgb" => cc_threshold_rgb(e, ctx, b), + "ec.stylize.strobe" => strobe(e, ctx, b), + "ec.stylize.ccvignette" => vignette(e, ctx, b), + "ec.stylize.scatter" => scatter(e, ctx, b), + "ec.stylize.brushstrokes" => brush_strokes(e, ctx, b), + "ec.stylize.roughenedges" => roughen_edges(e, ctx, b), + "ec.stylize.texturize" => texturize(e, ctx, b), + "ec.stylize.motiontile" => motion_tile(e, ctx, b), + "ec.stylize.cckaleida" => kaleida(e, ctx, b), + "ec.stylize.ccrepetile" => repetile(e, ctx, b), + "ec.distort.mirror" => mirror(e, ctx, b), + "ec.distort.offset" => offset(e, ctx, b), + "ec.distort.polar" => polar(e, ctx, b), + "ec.distort.spherize" => spherize(e, ctx, b), + "ec.distort.cornerpin" => corner_pin(e, ctx, b), + "ec.distort.opticscompensation" => optics_compensation(e, ctx, b), + "ec.distort.magnify" => magnify(e, ctx, b), + "ec.distort.ccslant" => cc_slant(e, ctx, b), + "ec.distort.ccsmear" => cc_smear(e, ctx, b), + "ec.distort.ccsplit" => { + let s = ctx.params.f("split") / 100.0; + cc_split(e, ctx, b, s, s) + } + "ec.distort.ccsplit2" => cc_split(e, ctx, b, ctx.params.f("split1") / 100.0, ctx.params.f("split2") / 100.0), + "ec.distort.cctiler" => cc_tiler(e, ctx, b), + "ec.distort.ccgriddler" => cc_griddler(e, ctx, b), + "ec.distort.liquify" => liquify(e, ctx, b), + "ec.distort.twirllegacy" => twirl_legacy(e, ctx, b), + "ec.distort.ccripplepulse" => ripple_pulse(e, ctx, b), + "ec.distort.ccpowerpin" => power_pin(e, ctx, b), + "ec.distort.ccflomotion" => flo_motion(e, ctx, b), + _ => None, + } +} + +/// Run a same-size per-pixel kernel over the buffer. +fn run(e: &mut Enc, entry: &str, p: &Params, mut b: GBuf, data: Option<&wgpu::Buffer>) -> Option { + let out = e.image(b.img.width, b.img.height); + e.pixels(entry, p, &b.img, None, &out, data); + b.img = out; + Some(b) +} + +/// util::layer_rect. +fn layer_rect(ctx: &EffectCtx, b: &GBuf) -> (f64, f64, f64, f64) { + (b.offset[0], b.offset[1], ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale) +} + +// ---------------------------------------------------------------- per-pixel (fxs_point) + +fn point(e: &mut Enc, mode: u32, f: impl FnOnce(&mut Params), b: GBuf) -> Option { + let mut p = Params::default(); + p.u[0][0] = mode; + f(&mut p); + run(e, "fxs_point", &p, b, None) +} + +fn posterize(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let lv = ctx.params.f("level").round().clamp(2.0, 255.0) as f32 - 1.0; + point(e, 1, |p| p.f[0][0] = lv, b) +} + +fn threshold(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let t = ctx.params.f("level") as f32 / 255.0; + point(e, 2, |p| p.f[0][0] = t, b) +} + +fn cc_threshold(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let t = ctx.params.f("threshold") as f32 / 255.0; + let blend = ctx.params.f("blend") as f32 / 100.0; + let (ch, inv) = (ctx.params.e("channel"), ctx.params.b("invert") as u32); + point( + e, + 3, + |p| { + p.u[0][1] = ch; + p.u[0][2] = inv; + p.f[0] = [t, blend, 0.0, 0.0]; + }, + b, + ) +} + +fn cc_threshold_rgb(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let t = [ctx.params.f("redThreshold"), ctx.params.f("greenThreshold"), ctx.params.f("blueThreshold")].map(|v| v as f32 / 255.0); + let inv = [ctx.params.b("invertRed"), ctx.params.b("invertGreen"), ctx.params.b("invertBlue")].map(u32::from); + let blend = ctx.params.f("blend") as f32 / 100.0; + point( + e, + 4, + |p| { + p.u[0] = [4, inv[0], inv[1], inv[2]]; + p.f[0] = [t[0], t[1], t[2], blend]; + }, + b, + ) +} + +fn strobe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let on = effectcraft_effects::strobe_on( + ctx.time, + ctx.params.f("strobeDuration"), + ctx.params.f("strobePeriod"), + ctx.params.f("randomStrobeProbability") / 100.0, + ctx.seed, + ); + if !on { + return Some(b); + } + let blend = (ctx.params.f("blendWithOriginal") as f32 / 100.0).clamp(0.0, 1.0); + if ctx.params.e("strobe") == 1 { + // Image::scale_alpha. + if (blend - 1.0).abs() < 1e-7 { + return Some(b); + } + return point(e, 6, |p| p.f[0][0] = blend, b); + } + let s = ctx.params.color("strobeColor"); + let op = ctx.params.e("strobeOperator"); + point( + e, + 5, + |p| { + p.u[0][1] = op; + p.f[0] = [s[0], s[1], s[2], blend]; + }, + b, + ) +} + +fn vignette(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let amt = ctx.params.f("amount") as f32 / 100.0; + let fov = ctx.params.f("angleOfView").clamp(0.0, 179.0).to_radians(); + if amt == 0.0 || fov <= 0.0 { + return Some(b); + } + let c = b.to_px(ctx.params.v2("center")); + let pin = ctx.params.f("pinHighlights") as f32 / 100.0; + let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale); + let half_diag = (lw * lw + lh * lh).sqrt() * 0.5; + let dist = half_diag.max(1.0) / (fov * 0.5).tan(); + point( + e, + 7, + |p| { + p.f[0] = [c.0 as f32, c.1 as f32, dist as f32, amt]; + p.f[1][0] = pin; + }, + b, + ) +} + +// ---------------------------------------------------------------- warps (fxs_warp) + +fn warp(e: &mut Enc, mode: u32, f: impl FnOnce(&mut Params), b: GBuf, data: Option<&wgpu::Buffer>) -> Option { + let mut p = Params::default(); + p.u[0][0] = mode; + f(&mut p); + run(e, "fxs_warp", &p, b, data) +} + +fn mirror(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let c = b.to_px(ctx.params.v2("center")); + let a = ctx.params.f("angle").to_radians(); + warp(e, 1, |p| p.f[0] = [c.0 as f32, c.1 as f32, a.cos() as f32, a.sin() as f32], b, None) +} + +fn spherize(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let r = (ctx.params.f("radius") * b.scale).max(1.0); + let c = b.to_px(ctx.params.v2("center")); + warp(e, 2, |p| p.f[0] = [c.0 as f32, c.1 as f32, r as f32, 0.0], b, None) +} + +fn polar(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let amt = ctx.params.f("interpolation") / 100.0; + let to_polar = ctx.params.e("conversion") == 1; + let (w, h) = (b.img.width as f64, b.img.height as f64); + let (cx, cy) = (w / 2.0, h / 2.0); + let rmax = cx.min(cy); + warp( + e, + 3, + |p| { + p.u[0][1] = to_polar as u32; + p.f[0] = [w as f32, h as f32, cx as f32, cy as f32]; + p.f[1] = [rmax as f32, amt as f32, 0.0, 0.0]; + }, + b, + None, + ) +} + +fn cc_slant(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let k = ctx.params.f("slant") / 100.0; + let height = ctx.params.f("height") / 100.0; + if k == 0.0 && (height - 1.0).abs() < 1e-12 { + return Some(b); + } + let stretch = ctx.params.b("stretching"); + let xs = if stretch { 1.0 / (1.0 + k * k).sqrt() } else { 1.0 }; + let hinv = if height.abs() < 1e-6 { 1e6 } else { 1.0 / height }; + if !ctx.adjustment { + let lean = (k * ctx.layer_size[1] * b.scale * height.max(1.0)).abs(); + let grow = ((height - 1.0).max(0.0) * ctx.layer_size[1] * b.scale).max(lean); + b.pad(e, grow.ceil().min(4096.0) as u32)?; + } + let fl = b.to_px(ctx.params.v2("floor")); + warp( + e, + 4, + |p| { + p.f[0] = [fl.0 as f32, fl.1 as f32, hinv as f32, k as f32]; + p.f[1][0] = xs as f32; + }, + b, + None, + ) +} + +fn cc_smear(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let from = b.to_px(ctx.params.v2("from")); + let to = b.to_px(ctx.params.v2("to")); + let reach = ctx.params.f("reach") / 100.0; + let radius = (ctx.params.f("radius") * b.scale).max(0.5); + let (dx, dy) = (to.0 - from.0, to.1 - from.1); + let l2 = dx * dx + dy * dy; + if l2 < 1e-12 || reach == 0.0 { + return Some(b); + } + warp( + e, + 5, + |p| { + p.f[0] = [from.0 as f32, from.1 as f32, dx as f32, dy as f32]; + p.f[1] = [l2 as f32, reach as f32, radius as f32, 0.0]; + }, + b, + None, + ) +} + +/// distort3::split_impl (CC Split / CC Split 2). +fn cc_split(e: &mut Enc, ctx: &EffectCtx, b: GBuf, s1: f64, s2: f64) -> Option { + if s1 == 0.0 && s2 == 0.0 { + return Some(b); + } + let a = b.to_px(ctx.params.v2("pointA")); + let c = b.to_px(ctx.params.v2("pointB")); + let (dx, dy) = (c.0 - a.0, c.1 - a.1); + let len = (dx * dx + dy * dy).sqrt(); + if len < 1e-6 { + return Some(b); + } + let (ux, uy) = (dx / len, dy / len); + warp( + e, + 6, + |p| { + p.f[0] = [a.0 as f32, a.1 as f32, ux as f32, uy as f32]; + p.f[1] = [len as f32, s1 as f32, s2 as f32, (len * 0.5) as f32]; + }, + b, + None, + ) +} + +fn optics_compensation(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let fov = ctx.params.f("fieldOfView").clamp(0.0, 179.0).to_radians(); + if fov < 1e-6 { + return Some(b); + } + let reverse = ctx.params.b("reverseLensDistortion"); + let (lx, ly, lw, lh) = layer_rect(ctx, &b); + let r_ref = match ctx.params.e("fovOrientation") { + 1 => lh * 0.5, + 2 => (lw * lw + lh * lh).sqrt() * 0.5, + _ => lw * 0.5, + } + .max(1.0); + let half = fov * 0.5; + let f = r_ref / half.tan(); + let optimal = ctx.params.b("optimalPixels"); + let resize = if optimal { 0 } else { ctx.params.e("resize") }; + let corners = [(lx, ly), (lx + lw, ly), (lx, ly + lh), (lx + lw, ly + lh)]; + if reverse && resize > 0 && !ctx.adjustment { + let c = b.to_px(ctx.params.v2("viewCenter")); + let rc = corners.iter().map(|p| ((p.0 - c.0).powi(2) + (p.1 - c.1).powi(2)).sqrt()).fold(0.0, f64::max); + let th = rc / r_ref * half; + let cap = lw.max(lh) * [0.0, 0.5, 1.5, 3.5][resize.min(3) as usize]; + let grow = if th < FRAC_PI_2 - 1e-3 { (f * th.tan() - rc).max(0.0) } else { cap }; + b.pad(e, grow.min(cap).min(4096.0).ceil() as u32)?; + } + let c = b.to_px(ctx.params.v2("viewCenter")); + let fit = if optimal { + let rc = corners.iter().map(|p| ((p.0 - c.0).powi(2) + (p.1 - c.1).powi(2)).sqrt()).fold(1.0, f64::max); + if reverse { + let th = (rc / r_ref * half).min(FRAC_PI_2 - 1e-3); + (f * th.tan() / rc).max(1.0) + } else { + ((rc / f).atan() / half * r_ref / rc).min(1.0) + } + } else { + 1.0 + }; + warp( + e, + 7, + |p| { + p.u[0][1] = reverse as u32; + p.f[0] = [c.0 as f32, c.1 as f32, fit as f32, f as f32]; + p.f[1] = [r_ref as f32, half as f32, 0.0, 0.0]; + }, + b, + None, + ) +} + +fn kaleida(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let c = b.to_px(ctx.params.v2("center")); + let size = ctx.params.f("size").max(1.0) / 100.0; + let mode = ctx.params.e("mirroring"); + let rot = ctx.params.f("rotation").to_radians(); + let n = [6.0, 8.0, 4.0, 12.0, 3.0][mode.min(4) as usize]; + let seg = TAU / n; + warp( + e, + 8, + |p| { + p.u[0][1] = (mode != 3) as u32; + p.f[0] = [c.0 as f32, c.1 as f32, size as f32, rot as f32]; + p.f[1][0] = seg as f32; + }, + b, + None, + ) +} + +fn liquify(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + // View Freeze Area Mask / View Mesh overlays render on the CPU (catalog CPU_ONLY_CONTROLS). + if ctx.params.b("viewFreezeAreaMask") || ctx.params.b("viewMesh") { + return None; + } + if ctx.params.s("distortionMesh").trim().is_empty() { + return Some(b); + } + let pct = ctx.params.f("distortionPercentage") / 100.0; + let mesh = effectcraft_effects::liquify_mesh(ctx); + if mesh.nx < 2 || mesh.ny < 2 { + return None; + } + let off = ctx.params.v2("distortionMeshOffset"); + let data: Vec = mesh.d.iter().flat_map(|d| [d[0] as f32, d[1] as f32]).collect(); + let buf = e.data(&data); + let (scale, o) = (b.scale, b.offset); + warp( + e, + 9, + |p| { + p.u[0] = [9, mesh.nx as u32, mesh.ny as u32, 0]; + p.f[0] = [mesh.cell as f32, off[0] as f32, off[1] as f32, pct as f32]; + p.f[1] = [scale as f32, o[0] as f32, o[1] as f32, 0.0]; + }, + b, + Some(&buf), + ) +} + +fn twirl_legacy(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let ang = ctx.params.f("angle").to_radians(); + if ang == 0.0 { + return Some(b); + } + let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale); + let r = (ctx.params.f("radius") / 100.0 * lw.min(lh) * 0.5).max(1.0); + let c = b.to_px(ctx.params.v2("center")); + warp(e, 10, |p| p.f[0] = [c.0 as f32, c.1 as f32, r as f32, ang as f32], b, None) +} + +fn ripple_pulse(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let level = ctx.params.f("pulseLevel") / 100.0; + let amp = ctx.params.f("amplitude") / 100.0; + if level == 0.0 || amp == 0.0 { + return Some(b); + } + let c = b.to_px(ctx.params.v2("center")); + let span = ctx.params.f("timeSpan").max(0.01); + let size = ctx.layer_size[0].max(ctx.layer_size[1]) * b.scale; + let speed = size * 0.5 / span; + let lambda = (size * 0.06).max(2.0); + let height = level * amp * lambda * 0.5; + let front = speed * ctx.time.max(0.0); + let bump = ctx.params.b("renderBump"); + warp( + e, + 11, + |p| { + p.u[0][1] = bump as u32; + p.f[0] = [c.0 as f32, c.1 as f32, front as f32, speed as f32]; + p.f[1] = [span as f32, lambda as f32, height as f32, 0.0]; + }, + b, + None, + ) +} + +fn power_pin(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + // Perspective below 100 % (a bilinear inverse by Newton iterations with finite + // differences) renders on the CPU. + if ctx.params.f("perspective") / 100.0 < 1.0 { + return None; + } + let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)); + let ex = [ctx.params.f("expandTop"), ctx.params.f("expandLeft"), ctx.params.f("expandRight"), ctx.params.f("expandBottom")]; + let (x0, y0) = (-ex[1] / 100.0 * lw, -ex[0] / 100.0 * lh); + let (x1, y1) = (lw + ex[2] / 100.0 * lw, lh + ex[3] / 100.0 * lh); + let corners = [ctx.params.v2("topLeft"), ctx.params.v2("topRight"), ctx.params.v2("bottomRight"), ctx.params.v2("bottomLeft")]; + if !ctx.adjustment { + let mut need = 0.0f64; + for c in &corners { + let (px, py) = b.to_px(*c); + need = need.max(-px).max(-py).max(px - b.img.width as f64).max(py - b.img.height as f64); + } + if need > 0.0 { + b.pad(e, need.ceil().min(4096.0) as u32 + 1)?; + } + } + let q = corners.map(|c| { + let p = b.to_px(c); + vec2(p.0, p.1) + }); + let Some(inv) = Mat3::square_to_quad(q).inverse() else { return Some(b) }; + let r = inv.0.map(|r| [r[0] as f32, r[1] as f32, r[2] as f32, 0.0]); + let (sc, off) = (b.scale, b.offset); + warp( + e, + 12, + |p| { + p.f[2] = r[0]; + p.f[3] = r[1]; + p.f[4] = r[2]; + p.f[5] = [x0 as f32, y0 as f32, (x1 - x0) as f32, (y1 - y0) as f32]; + p.f[6] = [sc as f32, off[0] as f32, off[1] as f32, 0.0]; + }, + b, + None, + ) +} + +fn flo_motion(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let knots = [(ctx.params.v2("knot1"), ctx.params.f("amount1") / 100.0), (ctx.params.v2("knot2"), ctx.params.f("amount2") / 100.0)]; + if knots.iter().all(|k| k.1 == 0.0) { + return Some(b); + } + let ss = match ctx.params.e("antialiasing") { + 0 => 1, + 1 => 2, + _ => 3, + }; + let falloff = ctx.params.f("falloff").max(0.01); + let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)); + let radius = (lw * lw + lh * lh).sqrt() * 0.5; + let mut p = Params::default(); + p.u[0] = [ss, ctx.params.b("tileEdges") as u32, 0, 0]; + for (i, (k, a)) in knots.iter().enumerate() { + p.f[i] = [k[0] as f32, k[1] as f32, *a as f32, 0.0]; + } + p.f[2] = [falloff as f32, radius as f32, lw as f32, lh as f32]; + p.f[3] = [b.scale as f32, b.offset[0] as f32, b.offset[1] as f32, 0.0]; + run(e, "fxs_flomotion", &p, b, None) +} + +// ---------------------------------------------------------------- separable tables + +/// Per-column `fx(x + 0.5)` then per-row `fy(y + 0.5)`, `k` values each. +fn axis_table(w: u32, h: u32, fx: impl Fn(f64) -> [f64; K], fy: impl Fn(f64) -> [f64; K]) -> Vec { + let mut v = Vec::with_capacity((w + h) as usize * K); + v.extend((0..w).flat_map(|x| fx(x as f64 + 0.5).map(|t| t as f32))); + v.extend((0..h).flat_map(|y| fy(y as f64 + 0.5).map(|t| t as f32))); + v +} + +fn table(e: &mut Enc, b: GBuf, data: &[f32], repeat: bool, blend: f32) -> Option { + let mut p = Params::default(); + p.u[0] = [b.img.width, repeat as u32, 0, 0]; + p.f[0][0] = blend; + let buf = e.data(data); + run(e, "fxs_table", &p, b, Some(&buf)) +} + +fn offset(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let shift = ctx.params.v2("shift"); + let (w, h) = (b.img.width as f64, b.img.height as f64); + let blend = ctx.params.f("blend") as f32 / 100.0; + let (cx, cy) = (w / 2.0, h / 2.0); + let (sx, sy) = b.to_px(shift); + let data = axis_table(b.img.width, b.img.height, |x| [(x - (sx - cx)).rem_euclid(w)], |y| [(y - (sy - cy)).rem_euclid(h)]); + table(e, b, &data, false, blend.max(0.0)) +} + +fn cc_tiler(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let s = (ctx.params.f("scale") / 100.0).max(0.001); + let c = b.to_px(ctx.params.v2("center")); + let blend = (ctx.params.f("blendWithOriginal") / 100.0) as f32; + let (x0, y0, w, h) = layer_rect(ctx, &b); + let (w, h) = (w.max(1.0), h.max(1.0)); + let data = axis_table(b.img.width, b.img.height, |x| [x0 + (c.0 + (x - c.0) / s - x0).rem_euclid(w)], |y| [y0 + (c.1 + (y - c.1) / s - y0).rem_euclid(h)]); + table(e, b, &data, true, blend) +} + +fn cc_griddler(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let hs = (ctx.params.f("horizontalScale") / 100.0).max(0.001); + let vs = (ctx.params.f("verticalScale") / 100.0).max(0.001); + let rot = ctx.params.f("rotation").to_radians(); + let cut = ctx.params.b("cutTiles"); + let (x0, y0, lw, _) = layer_rect(ctx, &b); + let t = (ctx.params.f("tileSize") / 100.0 * lw).max(1.0); + if (hs - 1.0).abs() < 1e-9 && (vs - 1.0).abs() < 1e-9 && rot.abs() < 1e-9 && !cut { + return Some(b); + } + let (sr, cr) = rot.sin_cos(); + let centre = |p: f64, o: f64| { + let tc = o + (((p - o) / t).floor() + 0.5) * t; + [tc, p - tc] + }; + let data = axis_table(b.img.width, b.img.height, |x| centre(x, x0), |y| centre(y, y0)); + let mut p = Params::default(); + p.u[0] = [b.img.width, cut as u32, 0, 0]; + p.f[0] = [cr as f32, sr as f32, hs as f32, vs as f32]; + p.f[1][0] = (0.45 * t) as f32; + let buf = e.data(&data); + run(e, "fxs_griddler", &p, b, Some(&buf)) +} + +fn motion_tile(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let s = b.scale; + let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]); + if lw <= 0.0 || lh <= 0.0 { + return Some(b); + } + let tc = ctx.params.v2("tileCenter"); + let tw = (lw * ctx.params.f("tileWidth") / 100.0).max(0.01); + let th = (lh * ctx.params.f("tileHeight") / 100.0).max(0.01); + let mirror = ctx.params.b("mirrorEdges"); + let phase = ctx.params.f("phase") / 360.0; + let hshift = ctx.params.b("horizontalPhaseShift"); + let (nw, nh, noff) = if ctx.adjustment { + (b.img.width, b.img.height, b.offset) + } else { + let ow = (lw * ctx.params.f("outputWidth") / 100.0).max(1.0 / s); + let oh = (lh * ctx.params.f("outputHeight") / 100.0).max(1.0 / s); + ((ow * s).round().max(1.0) as u32, (oh * s).round().max(1.0) as u32, [(ow - lw) * 0.5 * s, (oh - lh) * 0.5 * s]) + }; + if !e.g.fits(nw, nh) { + return None; + } + let split = |t: f64| [t.floor(), t - t.floor()]; + let data = axis_table(nw, nh, |x| split(((x - noff[0]) / s - tc[0]) / tw + 0.5), |y| split(((y - noff[1]) / s - tc[1]) / th + 0.5)); + let mut p = Params::default(); + p.u[0] = [nw, hshift as u32, mirror as u32, 0]; + p.f[0] = [phase as f32, (lw * s) as f32, (lh * s) as f32, 0.0]; + p.f[1] = [b.offset[0] as f32, b.offset[1] as f32, 0.0, 0.0]; + let buf = e.data(&data); + let out = e.image(nw, nh); + e.pixels("fxs_motiontile", &p, &b.img, None, &out, Some(&buf)); + Some(GBuf { img: out, offset: noff, scale: s }) +} + +fn repetile(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let s = b.scale; + let (r, l, d, u) = ( + ctx.params.f("expandRight").max(0.0) * s, + ctx.params.f("expandLeft").max(0.0) * s, + ctx.params.f("expandDown").max(0.0) * s, + ctx.params.f("expandUp").max(0.0) * s, + ); + if ctx.adjustment || r + l + d + u < 0.5 { + return Some(b); + } + let (x0, y0, w, h) = layer_rect(ctx, &b); + if w < 1.0 || h < 1.0 { + return Some(b); + } + let (nw, nh) = ((w + l + r).round().max(1.0) as u32, (h + u + d).round().max(1.0) as u32); + if !e.g.fits(nw, nh) { + return None; + } + // stylize2::tile_coord. + let tile = |v: f64, size: f64| { + let i = (v / size).floor(); + [i, v - i * size] + }; + let data = axis_table(nw, nh, |x| tile(x - l, w), |y| tile(y - u, h)); + let mut p = Params::default(); + p.u[0] = [nw, ctx.params.e("tiling"), 0, 0]; + p.f[0] = [x0 as f32, y0 as f32, w as f32, h as f32]; + let buf = e.data(&data); + let out = e.image(nw, nh); + e.pixels("fxs_repetile", &p, &b.img, None, &out, Some(&buf)); + Some(GBuf { img: out, offset: [l, u], scale: s }) +} + +fn magnify(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let square = ctx.params.e("shape") == 1; + let c = b.to_px(ctx.params.v2("center")); + let mag = (ctx.params.f("magnification") / 100.0).max(0.01); + let link = ctx.params.e("link"); + let mut size = ctx.params.f("size") * b.scale; + let mut feather = ctx.params.f("feather") * b.scale; + if link >= 1 { + size *= mag; + } + if link == 2 { + feather *= mag; + } + let op = (ctx.params.f("opacity") / 100.0) as f32; + let mode = ctx.params.e("blendingMode") as usize; + let blend = effectcraft_effects::MAGNIFY_MODES.get(mode.wrapping_sub(1)).copied(); + if ctx.params.b("resizeLayer") && link == 0 && !ctx.adjustment { + let (lx, ly, lw, lh) = layer_rect(ctx, &b); + let reach = size.max(0.0); + let over = [lx - (c.0 - reach), (c.0 + reach) - (lx + lw), ly - (c.1 - reach), (c.1 + reach) - (ly + lh)].into_iter().fold(0.0, f64::max); + b.pad(e, over.min(4096.0).ceil() as u32)?; + } + let c = b.to_px(ctx.params.v2("center")); + let axis = |p: f64, c: f64| { + let d = p - c; + let sp = c + d / mag; + [d, sp, sp.floor() + 0.5] + }; + let data = axis_table(b.img.width, b.img.height, |x| axis(x, c.0), |y| axis(y, c.1)); + let (kind, mode_id) = match blend { + None => (0, 0), + Some(BlendMode::Normal) => (1, 0), + Some(m) => (2, ops::mode_id(m)), + }; + let mut p = Params::default(); + p.u[0] = [b.img.width, square as u32, ctx.params.e("scaling"), kind]; + p.u[1] = [mode_id, ctx.seed ^ 0x3a61, 0, 0]; + p.f[0] = [size as f32, feather as f32, op, mag as f32]; + p.f[1][0] = (size - feather) as f32; + let buf = e.data(&data); + run(e, "fxs_magnify", &p, b, Some(&buf)) +} + +// ---------------------------------------------------------------- stylize2.rs + +fn scatter(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let amt = ctx.params.f("amount") * b.scale; + if amt <= 0.0 { + return Some(b); + } + let seed = if ctx.params.b("randomizeEveryFrame") { ctx.seed ^ ((ctx.time * 1000.0) as u32).wrapping_mul(0x9e37_79b9) } else { ctx.seed }; + let mut p = Params::default(); + p.u[0] = [ctx.params.e("grain"), seed, 0, 0]; + p.f[0][0] = amt as f32; + run(e, "fxs_scatter", &p, b, None) +} + +fn brush_strokes(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let ang = ctx.params.f("strokeAngle"); + let size = (ctx.params.f("brushSize") * b.scale).max(0.5); + let len = ctx.params.f("strokeLength") * b.scale; + let density = ctx.params.f("strokeDensity") as f32; + let rnd = ctx.params.f("strokeRandomness"); + let blend = ctx.params.f("blendWithOriginal") as f32 / 100.0; + let seed = ctx.seed ^ 0xb705; + let cell = (size * 2.0).max(1.0); + let (w, h) = (b.img.width, b.img.height); + let cell_of = |i: u32| (i as f64 / cell).floor() as i64 as u32; + let (ncx, ncy) = (cell_of(w.saturating_sub(1)) + 1, cell_of(h.saturating_sub(1)) + 1); + if (ncx as u64 * ncy as u64 * 6 + (w + h) as u64) >= 1 << 24 { + return None; + } + let mut data: Vec = (0..w).map(|x| cell_of(x) as f32).chain((0..h).map(|y| cell_of(y) as f32)).collect(); + data.reserve((ncx * ncy * 6) as usize); + for cy in 0..ncy { + for cx in 0..ncx { + let hh = |k: u32| effectcraft_raster::hash_noise(cx.wrapping_add(k.wrapping_mul(7919)), cy, seed) as f64; + let a = (ang + (hh(0) - 0.5) * rnd * 60.0).to_radians(); + let l = len * (0.5 + hh(1)); + let (dx, dy) = (a.sin(), -a.cos()); + let jit = (hh(2) - 0.5) * size; + let n = l.ceil().clamp(1.0, 32.0); + let m = if (hh(3) as f32) < density.min(1.0) { 1.0 } else { 0.0 }; + data.extend([dx as f32, dy as f32, l as f32, jit as f32, n as f32, m]); + } + } + let mut p = Params::default(); + p.u[0] = [w, ncx, ctx.params.e("paintSurface"), h]; + p.f[0][0] = blend; + let buf = e.data(&data); + run(e, "fxs_brush", &p, b, Some(&buf)) +} + +fn roughen_edges(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let kind = ctx.params.e("edgeType"); + let ec = ctx.params.color("edgeColor"); + let border = ctx.params.f("border") * b.scale; + let sharp = ctx.params.f("edgeSharpness").max(0.01) as f32; + let infl = ctx.params.f("fractalInfluence") as f32; + let scale = (ctx.params.f("scale") * b.scale * 0.1).max(0.5) as f32; + let (ox, oy) = b.to_px(ctx.params.v2("offset")); + let oct = ctx.params.f("complexity") as f32; + let evo = ctx.params.f("evolution") as f32 / 360.0; + let st = ctx.params.f("stretch") as f32; + let (sx, sy) = (1.0 + st.max(0.0), 1.0 + (-st).max(0.0)); + let cycle = ctx.params.b("evolutionOptions/cycleEvolution").then(|| ctx.params.f("evolutionOptions/cycle").round().max(1.0) as f32); + let (evo, evo_w, c) = match cycle { + Some(c) => { + let ev = evo.rem_euclid(c); + (ev, ev / c, c) + } + None => (evo, 0.0, 0.0), + }; + let (fm, im, sm, colored) = match kind { + 1 => (1.0, 1.0, 1.0, true), + 2 => (1.0, 1.0, 3.0, false), + 3 => (3.0, 1.0, 1.5, false), + 4 => (1.5, 1.5, 1.0, false), + 5 => (1.5, 1.5, 1.0, true), + 6 => (0.6, 1.2, 2.5, false), + 7 => (0.6, 1.2, 2.5, true), + _ => (1.0, 1.0, 1.0, false), + }; + let extra = if kind == 4 || kind == 5 { 1.0 } else { 0.0 }; + let seed = ctx.seed ^ 0x40f1 ^ (ctx.params.f("evolutionOptions/randomSeed").round() as i64 as u32).wrapping_mul(0x9e37_79b9); + // util::gauss_plane of the alpha (edges repeated); the kernel reads .w. + let d = if border > 0.05 { gaussian_blur(e, &b.img, border * 0.5, border * 0.5, true) } else { b.img.clone() }; + let mut p = Params::default(); + p.u[0] = [seed, cycle.is_some() as u32, colored as u32, 0]; + p.f[0] = [ox as f32, oy as f32, scale * sx, scale * sy]; + p.f[1] = [fm, evo, c, evo_w]; + p.f[2] = [oct + extra, infl, im, sharp]; + p.f[3] = [ec[0], ec[1], ec[2], sm]; + let out = e.image(b.img.width, b.img.height); + e.pixels("fxs_roughen", &p, &b.img, Some(&d), &out, None); + b.img = out; + Some(b) +} + +fn texturize(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let ang = ctx.params.f("lightDirection").to_radians(); + let k = ctx.params.f("textureContrast") as f32; + if k == 0.0 { + return Some(b); + } + let Some(tex) = ctx.layer_param("textureLayer", true) else { return Some(b) }; + if tex.buf.img.is_empty() { + return None; + } + // transition::place_layer, per column / row in the texture buffer's pixels. + let (ls, os) = (ctx.layer_size, tex.size); + let inv = 1.0 / b.scale.max(1e-9); + let mode = ctx.params.e("texturePlacement"); + let place = |v: f64, k: usize| { + let p = (v - b.offset[k]) * inv; + let q = match mode { + 0 if os[0] >= 1.0 && os[1] >= 1.0 => p.rem_euclid(os[k]), + 1 => p + (os[k] - ls[k]) * 0.5, + _ if ls[0] > 0.0 && ls[1] > 0.0 => p * os[k] / ls[k], + _ => p, + }; + [q * tex.buf.scale + tex.buf.offset[k]] + }; + let data = axis_table(b.img.width, b.img.height, |x| place(x, 0), |y| place(y, 1)); + let timg = e.g.upload_image(&tex.buf.img)?; + let mut p = Params::default(); + p.u[0][0] = b.img.width; + let buf = e.data(&data); + let luma = e.image(b.img.width, b.img.height); + e.pixels("fxs_tex_luma", &p, &b.img, Some(&timg), &luma, Some(&buf)); + let s = 0.7 * b.scale; + let hgt = gaussian_blur(e, &luma, s, s, true); + let mut p = Params::default(); + p.f[0] = [ang.cos() as f32, -ang.sin() as f32, k, 0.0]; + let out = e.image(b.img.width, b.img.height); + e.pixels("fxs_texturize", &p, &b.img, Some(&hgt), &out, None); + b.img = out; + Some(b) +} + +// ---------------------------------------------------------------- distort.rs + +fn corner_pin(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let (w, h) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)); + let q = ["ul", "ur", "lr", "ll"].map(|k| { + let p = b.to_px(ctx.params.v2(k)); + vec2(p.0, p.1) + }); + let quad = Mat3::square_to_quad(q); + let o = b.to_px([0.0, 0.0]); + let m = quad * Mat3::scale(vec2(1.0 / (w * b.scale), 1.0 / (h * b.scale))) * Mat3::translate(vec2(-o.0, -o.1)); + let empty = e.image(b.img.width, b.img.height); + b.img = ops::warp(e, &empty, &b.img, &m, Sampling::Bilinear, BlendMode::Normal, 1.0, 0, None); + Some(b) +} + +// ---------------------------------------------------------------- Glow / Transform extras + +/// Glow settings the base kernel in `effects.rs` leaves to the CPU (another Glow Operation or +/// the Arbitrary Map) and [`glow`] implements. +pub(crate) fn glow_extra(ctx: &EffectCtx) -> bool { + ctx.params.e("colors") == 2 || effectcraft_effects::glow_operation(ctx) != BlendMode::Add +} + +fn dims_xy(dim: u32) -> (f64, f64) { + match dim { + 1 => (1.0, 0.0), + 2 => (0.0, 1.0), + _ => (1.0, 1.0), + } +} + +/// misc::glow with any Glow Operation and the Arbitrary Map. +pub(crate) fn glow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let thr = ctx.params.f("threshold") as f32 / 100.0; + let radius = ctx.params.f("radius") * b.scale; + let intensity = ctx.params.f("intensity") as f32; + let colors = ctx.params.e("colors"); + let (kx, ky) = dims_xy(ctx.params.e("glowDimensions")); + if !ctx.adjustment { + b.pad(e, (radius * 1.5).ceil() as u32 + 2)?; + } + let mut p = Params::default(); + p.u[0] = [(colors == 1) as u32, (ctx.params.e("based") == 0) as u32, ctx.params.e("colorLooping"), 0]; + p.f[0] = [thr, ctx.params.f("colorLoops") as f32, (ctx.params.f("colorPhase") / 360.0) as f32, ctx.params.f("abMidpoint") as f32 / 100.0]; + let bright = e.image(b.img.width, b.img.height); + if colors == 2 { + // Arbitrary Map: Curves tables per channel (see effects::curves). + let mut parts = ctx.params.s("arbitraryMap").split('|'); + let curves: Vec> = (0..3).map(|_| parts.next().and_then(effectcraft_effects::Curve::parse)).collect(); + let mut data = vec![0.0f32]; + let mut offs = [-1.0f32; 3]; + for (i, c) in curves.iter().enumerate() { + let Some(c) = c else { continue }; + let (xs, ys, ms, lut) = c.tables(); + offs[i] = data.len() as f32; + data.push(xs.len() as f32); + data.extend_from_slice(xs); + data.extend_from_slice(ys); + data.extend_from_slice(ms); + data.extend_from_slice(lut); + } + p.f[1] = [offs[0], offs[1], offs[2], 0.0]; + let buf = e.data(&data); + e.pixels("fxs_glow_map", &p, &b.img, None, &bright, Some(&buf)); + } else { + p.f[1] = ctx.params.color("colorA"); + p.f[2] = ctx.params.color("colorB"); + e.pixels("glow_bright", &p, &b.img, None, &bright, None); + } + let s = (radius / 2.0).max(0.5); + let blurred = gaussian_blur(e, &bright, s * kx, s * ky, false); + let operation = ctx.params.e("operation"); + let glow_op = effectcraft_effects::glow_operation(ctx); + let mut p = Params::default(); + p.f[0][0] = intensity; + let out = e.image(b.img.width, b.img.height); + if operation == 0 && glow_op != BlendMode::Add { + p.u[0][0] = ops::mode_id(glow_op); + e.pixels("fxs_glow_op", &p, &b.img, Some(&blurred), &out, None); + } else { + p.u[0][0] = operation.min(2); + e.pixels("glow_combine", &p, &b.img, Some(&blurred), &out, None); + } + b.img = out; + Some(b) +} + +/// Transform renders with motion blur (a shutter and a host to read the parameters at other +/// times): [`transform`] implements it, the base kernel in `effects.rs` the sharp case. +pub(crate) fn transform_blur(ctx: &EffectCtx) -> bool { + ctx.env.host.is_some() && effectcraft_effects::transform_shutter(ctx).is_some() +} + +/// distort::transform_matrix. +fn transform_matrix(pr: &effectcraft_effects::Params, b: &GBuf) -> Mat3 { + let anchor = b.to_px(pr.v2("anchor")); + let pos = b.to_px(pr.v2("position")); + let sh = pr.f("scaleHeight"); + let sw = if pr.b("uniform") { sh } else { pr.f("scaleWidth") }; + Mat3::translate(vec2(pos.0, pos.1)) + * Mat3::rotate_deg(pr.f("rotation")) + * Mat3::skew_deg(pr.f("skew"), pr.f("skewAxis")) + * Mat3::scale(vec2(sw / 100.0, sh / 100.0)) + * Mat3::translate(vec2(-anchor.0, -anchor.1)) +} + +/// distort::transform's motion blur: the transform at Samples instants across the shutter, +/// averaged. +pub(crate) fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let (angle, phase, n) = effectcraft_effects::transform_shutter(ctx)?; + let host = ctx.env.host?; + let sampling = if ctx.params.e("sampling") == 1 { Sampling::Bicubic } else { Sampling::Bilinear }; + let fd = 1.0 / ctx.fps(); + let (w, h) = (b.img.width, b.img.height); + let mut acc = e.image(w, h); + let mut count = 0u32; + for k in 0..n { + let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd; + let Some(pr) = host.params_at(t) else { continue }; + let op = pr.f("opacity") as f32 / 100.0; + let empty = e.image(w, h); + let one = ops::warp(e, &empty, &b.img, &transform_matrix(&pr, &b), sampling, BlendMode::Normal, op, 0, None); + let sum = e.image(w, h); + e.pixels("fxs_add", &Params::default(), &acc, Some(&one), &sum, None); + acc = sum; + count += 1; + } + if count == 0 { + // The CPU renders the frame sharp. + return None; + } + let mut p = Params::default(); + p.f[0][0] = count as f32; + let out = e.image(w, h); + e.pixels("fxs_div", &p, &acc, None, &out, None); + b.img = out; + Some(b) +} diff --git a/crates/gpu/src/fx_tone.rs b/crates/gpu/src/fx_tone.rs new file mode 100644 index 00000000..8025ffdd --- /dev/null +++ b/crates/gpu/src/fx_tone.rs @@ -0,0 +1,381 @@ +//! GPU effects, tonal and colour correction family (kernels in `shaders/fx_tone.wgsl`, every +//! entry point prefixed `fxt_`): one per-pixel pass each (`fxt_point`) with the CPU effect's +//! exact maths. +//! +//! Levels (Individual Controls), Gamma/Pedestal/Gain, Photo Filter, Change Color, Change to +//! Color, Leave Color, Broadcast Colors, Color Balance (HLS), Video Limiter, PS Arbitrary Map, +//! CC Toner, CC Color Offset and CC Kernel, plus Hue/Saturation's colour ranges and Levels' +//! per-channel controls (the parts the base kernels in `kernels.wgsl` leave to the CPU). Auto +//! Levels / Contrast / Color and Equalize measure the frame's histograms on the CPU (the image +//! is read back for that) and apply the correction on the GPU. + +use effectcraft_color::rgb_to_hsl; +use effectcraft_effects::EffectCtx; + +use crate::context::{Enc, Params}; +use crate::effects::{GBuf, gaussian_blur}; + +/// Compute entry points in `fx_tone.wgsl`. +pub(crate) const KERNELS: &[&str] = &["fxt_point"]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[ + "ec.color.levelsic", + "ec.color.gammapedestalgain", + "ec.color.photofilter", + "ec.color.changecolor", + "ec.color.changetocolor", + "ec.color.leavecolor", + "ec.color.broadcast", + "ec.color.colorbalancehls", + "ec.color.videolimiter", + "ec.color.psarbitrarymap", + "ec.color.cctoner", + "ec.color.cccoloroffset", + "ec.color.cckernel", + "ec.color.autolevels", + "ec.color.autocontrast", + "ec.color.autocolor", + "ec.color.equalize", + "ec.color.shadowhighlight", + "ec.color.cccolorneutralizer", + "ec.color.colorstabilizer", +]; + +/// 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 { + match id { + "ec.color.shadowhighlight" => shadow_highlight(e, ctx, b), + _ => point(e, id, ctx, b), + } +} + +/// Invert in the HLS and YIQ channels (the base `pointwise` kernel does RGB and Alpha). +pub(crate) fn invert(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let mut p = op(18); + p.u[0][1] = ctx.params.e("channel"); + p.f[0][0] = 1.0 - ctx.params.f("blend") as f32 / 100.0; + run(e, &p, b, None) +} + +/// Shadow/Highlight: the amounts (Auto Amounts) and the Black / White Clip ranges are measured +/// on the CPU; the luminance bases are blurred and the image adjusted on the GPU. +fn shadow_highlight(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let src = e.download(&b.img)?; + let (s_amt, h_amt) = effectcraft_effects::shadow_highlight_amounts(ctx, &src); + let (bc, wc) = (pr.f("moreOptions/blackClip").clamp(0.0, 49.0) / 100.0, pr.f("moreOptions/whiteClip").clamp(0.0, 49.0) / 100.0); + let clip0 = (bc > 0.0 || wc > 0.0).then(|| effectcraft_effects::luma_clip_points(&src, bc, wc)); + let s_r = pr.f("moreOptions/shadowRadius").max(0.0) * b.scale / 2.0; + let h_r = pr.f("moreOptions/highlightRadius").max(0.0) * b.scale / 2.0; + let (w, h) = (b.img.width, b.img.height); + let luma = e.image(w, h); + e.pixels("fxt_point", &op(19), &b.img, None, &luma, None); + let base_s = gaussian_blur(e, &luma, s_r, s_r, true); + let base_h = if (h_r - s_r).abs() < 1e-9 { base_s.clone() } else { gaussian_blur(e, &luma, h_r, h_r, true) }; + let bases = e.image(w, h); + e.pixels("fxt_point", &op(21), &base_s, Some(&base_h), &bases, None); + let mut p = op(20); + p.f[0] = + [s_amt, h_amt, (pr.f("moreOptions/shadowTonalWidth") as f32 / 100.0).max(0.01), (pr.f("moreOptions/highlightTonalWidth") as f32 / 100.0).max(0.01)]; + p.f[1] = [ + pr.f("moreOptions/colorCorrection") as f32 / 100.0, + pr.f("moreOptions/midtoneContrast") as f32 / 100.0, + (pr.f("blend") / 100.0).clamp(0.0, 1.0) as f32, + 0.0, + ]; + let out = e.image(w, h); + e.pixels("fxt_point", &p, &b.img, Some(&bases), &out, None); + let Some((lo0, hi0)) = clip0 else { return Some(GBuf { img: out, ..b }) }; + let adjusted = e.download(&out)?; + let (lo1, hi1) = effectcraft_effects::luma_clip_points(&adjusted, bc, wc); + if (lo0, hi0) == (lo1, hi1) || hi1 - lo1 <= 1e-3 { + return Some(GBuf { img: out, ..b }); + } + let mut p = op(22); + p.f[0] = [lo0, lo1, (hi0 - lo0) / (hi1 - lo1), 0.0]; + run(e, &p, GBuf { img: out, ..b }, None) +} + +fn rgb(c: [f32; 4]) -> [f32; 4] { + [c[0], c[1], c[2], 0.0] +} + +/// Hue/Saturation with colour ranges (Channel Control); Master and Colorize alone run in the +/// base `pointwise` kernel. +pub(crate) fn hue_saturation(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let f = |id: String, d: f64| pr.get(&id).map(|v| v.as_f64()).unwrap_or(d) as f32; + let mut data = vec![]; + for (r, _, c) in effectcraft_effects::HUESAT_RANGES { + let range = [ + f(format!("{r}RangeStart"), c - 15.0), + f(format!("{r}RangeEnd"), c + 15.0), + f(format!("{r}StartFalloff"), 30.0).max(0.0), + f(format!("{r}EndFalloff"), 30.0).max(0.0), + f(format!("{r}Hue"), 0.0) / 360.0, + f(format!("{r}Saturation"), 0.0) / 100.0, + f(format!("{r}Lightness"), 0.0) / 100.0, + ]; + if range[4] != 0.0 || range[5] != 0.0 || range[6] != 0.0 { + data.extend(range); + } + } + let g = |k: &str| pr.f(k) as f32; + let mut p = Params::default(); + p.u[0] = [14, pr.b("colorize") as u32, (data.len() / 7) as u32, 0]; + p.f[0] = [g("hue") / 360.0, g("saturation") / 100.0, g("lightness") / 100.0, 0.0]; + p.f[1] = [g("colorizeHue") / 360.0, g("colorizeSaturation") / 100.0, g("colorizeLightness") / 100.0, 0.0]; + run(e, &p, b, Some(data)) +} + +/// Levels with Red / Green / Blue / Alpha controls (the RGB controls alone run in the base +/// `pointwise` kernel). +pub(crate) fn levels(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let (cb, cw) = effectcraft_effects::levels_clip(ctx); + let mut data = vec![ + pr.f("inBlack") as f32, + pr.f("inWhite") as f32, + pr.f("gamma").max(0.01) as f32, + pr.f("outBlack") as f32, + pr.f("outWhite") as f32, + cb as u32 as f32, + cw as u32 as f32, + ]; + for (v, (cb, cw)) in effectcraft_effects::levels_channel_settings(ctx) { + data.extend(v); + data.extend([cb as u32 as f32, cw as u32 as f32]); + } + run(e, &op(15), b, Some(data)) +} + +fn op(code: u32) -> Params { + let mut p = Params::default(); + p.u[0][0] = code; + p +} + +fn run(e: &mut Enc, p: &Params, b: GBuf, data: Option>) -> Option { + let buf = data.map(|d| e.data(&d)); + let out = e.image(b.img.width, b.img.height); + e.pixels("fxt_point", p, &b.img, None, &out, buf.as_ref()); + Some(GBuf { img: out, ..b }) +} + +/// color3::parse_map. +fn parse_map(s: &str) -> Vec { + let vals: Vec = s.split(|ch: char| ch == ',' || ch.is_whitespace()).filter(|t| !t.is_empty()).filter_map(|t| t.parse::().ok()).collect(); + if vals.len() < 2 { + return (0..256).map(|i| i as f32 / 255.0).collect(); + } + let max = vals.iter().cloned().fold(0.0f32, f32::max); + let k = if max > 1.0 { 1.0 / 255.0 } else { 1.0 }; + vals.iter().map(|v| (v * k).clamp(0.0, 1.0)).collect() +} + +fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let f = |k: &str| pr.f(k) as f32; + let mut p = Params::default(); + let mut data = None; + match id { + "ec.color.levelsic" => { + const IDS: [[&str; 5]; 5] = [ + ["rgb/rgbInBlack", "rgb/rgbInWhite", "rgb/rgbGamma", "rgb/rgbOutBlack", "rgb/rgbOutWhite"], + ["red/redInBlack", "red/redInWhite", "red/redGamma", "red/redOutBlack", "red/redOutWhite"], + ["green/greenInBlack", "green/greenInWhite", "green/greenGamma", "green/greenOutBlack", "green/greenOutWhite"], + ["blue/blueInBlack", "blue/blueInWhite", "blue/blueGamma", "blue/blueOutBlack", "blue/blueOutWhite"], + ["alpha/alphaInBlack", "alpha/alphaInWhite", "alpha/alphaGamma", "alpha/alphaOutBlack", "alpha/alphaOutWhite"], + ]; + let lv: Vec<[f32; 5]> = IDS.iter().map(|ids| ids.map(f)).collect(); + let ident = |l: &[f32; 5]| *l == [0.0, 1.0, 1.0, 0.0, 1.0]; + if lv.iter().all(ident) { + return Some(b); + } + let (cb, cw) = effectcraft_effects::levels_clip(ctx); + let mut d = vec![]; + for l in &lv { + d.extend(l); + d.push(ident(l) as u32 as f32); + } + p.u[0] = [1, cb as u32, cw as u32, 0]; + data = Some(d); + } + "ec.color.gammapedestalgain" => { + let ch = |n: &str| [f(&format!("{n}Gamma")).max(0.01), f(&format!("{n}Pedestal")), f(&format!("{n}Gain")), 0.0]; + p.u[0][0] = 2; + p.f[0] = ch("red"); + p.f[1] = ch("green"); + p.f[2] = ch("blue"); + p.f[3][0] = f("blackStretch").max(0.0); + } + "ec.color.photofilter" => { + let fi = pr.e("filter"); + let c = match effectcraft_effects::PHOTO_FILTERS.get(fi as usize) { + Some((_, rgb)) if fi != effectcraft_effects::PHOTO_FILTER_CUSTOM => rgb.map(|v| v as f32 / 255.0), + _ => { + let c = pr.color("color"); + [c[0], c[1], c[2]] + } + }; + p.u[0] = [3, pr.b("preserveLuminosity") as u32, 0, 0]; + p.f[0] = [c[0], c[1], c[2], f("density") / 100.0]; + } + "ec.color.changetocolor" => { + let from = pr.color("from"); + let to = pr.color("to"); + let (fh, fs, fl) = rgb_to_hsl(from[0], from[1], from[2]); + let (th, ts, tl) = rgb_to_hsl(to[0], to[1], to[2]); + let change = pr.e("change"); + let flags = matches!(change, 1 | 3) as u32 + | (matches!(change, 2 | 3) as u32) << 1 + | ((pr.e("changeBy") == 1) as u32) << 2 + | (pr.b("viewCorrectionMatte") as u32) << 3; + p.u[0] = [4, flags, 0, 0]; + p.f[0] = [fh, fs, fl, f("softness") / 100.0]; + p.f[1] = [th, ts, tl, 0.0]; + p.f[2] = [f("toleranceGroup/hue") / 100.0, f("toleranceGroup/lightness") / 100.0, f("toleranceGroup/saturation") / 100.0, 0.0]; + } + "ec.color.leavecolor" => { + let amt = f("amount") / 100.0; + if amt <= 0.0 { + return Some(b); + } + let k = pr.color("color"); + p.u[0] = [5, (pr.e("matchColors") == 1) as u32, 0, 0]; + p.f[0] = [k[0], k[1], k[2], rgb_to_hsl(k[0], k[1], k[2]).0]; + p.f[1] = [amt, f("tolerance") / 100.0, f("edgeSoftness") / 100.0, 0.0]; + } + "ec.color.changecolor" => { + let view_mask = pr.e("view") == 1; + let (dh, dl, ds) = (f("hueTransform") / 360.0, f("lightnessTransform") / 100.0, f("saturationTransform") / 100.0); + if !view_mask && dh == 0.0 && dl == 0.0 && ds == 0.0 { + return Some(b); + } + let k = pr.color("colorToChange"); + p.u[0] = [6, pr.e("matchColors"), view_mask as u32 | (pr.b("invertColorCorrectionMask") as u32) << 1, 0]; + p.f[0] = [k[0], k[1], k[2], rgb_to_hsl(k[0], k[1], k[2]).0]; + p.f[1] = [dh, dl, ds, 0.0]; + p.f[2] = [f("matchingTolerance") / 100.0, f("matchingSoftness") / 100.0, 0.0, 0.0]; + } + "ec.color.broadcast" => { + let pal = pr.e("locale") == 1; + let (setup, span) = if pal { (0.0, 100.0) } else { (7.5, 92.5) }; + p.u[0] = [7, pal as u32, pr.e("howToMakeSafe"), 0]; + p.f[0][0] = (f("maxSignal") - setup) / span; + } + "ec.color.colorbalancehls" => { + let (dh, dl, ds) = (f("hue") / 360.0, f("lightness") / 100.0, f("saturation") / 100.0); + if dh == 0.0 && dl == 0.0 && ds == 0.0 { + return Some(b); + } + p.u[0][0] = 8; + p.f[0] = [dh, dl, ds, 0.0]; + } + "ec.color.cctoner" => { + let g = |id: &str| rgb(pr.color(id)); + let stops = match pr.e("tones") { + 0 => vec![g("shadows"), g("highlights")], + 2 => vec![g("shadows"), g("darktones"), g("midtones"), g("brights"), g("highlights")], + _ => vec![g("shadows"), g("midtones"), g("highlights")], + }; + p.u[0] = [9, stops.len() as u32 - 1, 0, 0]; + p.f[0][0] = (f("blend") / 100.0).clamp(0.0, 1.0); + for (i, s) in stops.iter().enumerate() { + p.f[1 + i] = *s; + } + } + "ec.color.cccoloroffset" => { + let offs = ["redPhase", "greenPhase", "bluePhase"].map(|id| (pr.f(id) / 360.0).rem_euclid(1.0) as f32); + if offs.iter().all(|o| *o == 0.0) { + return Some(b); + } + p.u[0] = [10, pr.e("overflow"), 0, 0]; + p.f[0] = [offs[0], offs[1], offs[2], 0.0]; + } + "ec.color.cckernel" => { + let k = ["k1", "k2", "k3", "k4", "k5", "k6", "k7", "k8", "k9"].map(f); + let (scale, offset) = (f("scale"), f("offset")); + let identity = k.iter().enumerate().all(|(i, v)| if i == 4 { *v == 1.0 } else { *v == 0.0 }); + if identity && scale == 1.0 && offset == 0.0 { + return Some(b); + } + p.u[0] = [11, pr.b("absolute") as u32, 0, 0]; + p.f[0] = [k[0], k[1], k[2], k[3]]; + p.f[1] = [k[4], k[5], k[6], k[7]]; + p.f[2] = [k[8], scale, offset, 0.0]; + } + "ec.color.psarbitrarymap" => { + let map = parse_map(pr.s("map")); + p.u[0] = [12, pr.b("applyPhaseMapToAlpha") as u32, map.len() as u32, 0]; + p.f[0][0] = (pr.f("phase") * (map.len() - 1) as f64 / 255.0) as f32; + data = Some(map); + } + "ec.color.videolimiter" => { + const CLIP_LEVELS: [f32; 5] = [0.9, 0.95, 1.0, 1.05, 1.09]; + let level = CLIP_LEVELS[(pr.e("clipLevel") as usize).min(4)]; + let comp = [0.0, 0.03, 0.05, 0.1, 0.2][(pr.e("compressionBeforeClipping") as usize).min(4)]; + p.u[0] = [13, pr.e("clipMethod"), pr.b("gamutWarning") as u32, 0]; + p.f[0] = [level, level * (1.0 - comp), 0.0, 0.0]; + p.f[1] = rgb(pr.color("gamutWarningColor")); + } + "ec.color.colorstabilizer" => { + // The sample means are measured on the CPU (the frame is read back). + if ctx.env.host.is_none() { + return Some(b); + } + let img = e.download(&b.img)?; + let cpu = effectcraft_effects::Buf { img, offset: b.offset, scale: b.scale }; + let Some(maps) = effectcraft_effects::color_stabilizer_maps(ctx, &cpu) else { return Some(b) }; + let mut d = vec![]; + for m in &maps { + d.push(m.len() as f32); + for i in 0..5 { + let (x, y) = m.get(i).copied().unwrap_or((0.0, 0.0)); + d.extend([x as f32, y as f32]); + } + } + p.u[0][0] = 24; + data = Some(d); + } + "ec.color.cccolorneutralizer" => { + let pairs = [("shadowsUnbalance", "shadowsBalance"), ("midtonesUnbalance", "midtonesBalance"), ("highlightsUnbalance", "highlightsBalance")]; + for (i, (u, bal)) in pairs.iter().enumerate() { + let (u, v) = (pr.color(u), pr.color(bal)); + p.f[i] = [v[0] - u[0], v[1] - u[1], v[2] - u[2], 0.0]; + } + p.u[0][0] = 23; + p.f[3] = [f("pinning") / 100.0, f("blendWOriginal") / 100.0, f("contrast") / 100.0, 0.0]; + p.f[4] = [f("darks") / 100.0, f("brights") / 100.0, 0.0, 0.0]; + } + "ec.color.autolevels" | "ec.color.autocontrast" | "ec.color.autocolor" | "ec.color.equalize" => { + // The histograms are measured on the CPU. + let img = e.download(&b.img)?; + if id == "ec.color.equalize" { + let amt = (pr.f("amount") / 100.0).clamp(0.0, 1.0) as f32; + if amt <= 0.0 { + return Some(b); + } + let style = pr.e("style"); + let tables = effectcraft_effects::equalize_tables(&img, style); + p.u[0] = [17, style, tables.first().map_or(0, |t| t.len()) as u32, 0]; + p.f[0][0] = amt; + data = Some(tables.concat()); + } else { + let kind = match id { + "ec.color.autolevels" => 0, + "ec.color.autocontrast" => 1, + _ => 2, + }; + let (ranges, gam) = effectcraft_effects::auto_correct_settings(ctx, &img, kind); + p.u[0][0] = 16; + p.f[0] = [ranges[0].0, ranges[1].0, ranges[2].0, 0.0]; + p.f[1] = [ranges[0].1, ranges[1].1, ranges[2].1, 0.0]; + p.f[2] = [gam[0], gam[1], gam[2], (pr.f("blend") / 100.0).clamp(0.0, 1.0) as f32]; + } + } + _ => return None, + } + run(e, &p, b, data) +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 0e3eb0e0..559393bc 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -16,7 +16,10 @@ //! //! GPU effects ([`effectcraft_effects::GPU_EFFECTS`]) run as compute kernels with the CPU //! effect's exact steps (padding, box-blur radii, parameter conversions); chains of them are -//! uploaded and read back once. +//! uploaded and read back once. Each family lives in its own module with its own WGSL file +//! (`fx_color`, `fx_distort`, `fx_generate`, `fx_key`, `fx_noise`, `fx_stylize`, `fx_tone`); +//! settings a kernel cannot match fall back to the CPU (`catalog::gpu_supported`, or `None` +//! from the family's `apply`). //! //! GPU particles (`particles`): the stepped particle effects hand their simulation to //! [`effectcraft_effects::psim::ParticleSim`], implemented here with one invocation per particle @@ -35,6 +38,10 @@ mod effects; mod fx_color; mod fx_distort; mod fx_generate; +mod fx_key; +mod fx_noise; +mod fx_stylize; +mod fx_tone; mod ops; mod particles; mod walk; @@ -174,4 +181,12 @@ mod tests_fx_distort; #[cfg(test)] mod tests_fx_generate; #[cfg(test)] +mod tests_fx_key; +#[cfg(test)] +mod tests_fx_noise; +#[cfg(test)] +mod tests_fx_stylize; +#[cfg(test)] +mod tests_fx_tone; +#[cfg(test)] mod tests_particles; diff --git a/crates/gpu/src/shaders/fx_key.wgsl b/crates/gpu/src/shaders/fx_key.wgsl new file mode 100644 index 00000000..261e8aca --- /dev/null +++ b/crates/gpu/src/shaders/fx_key.wgsl @@ -0,0 +1,867 @@ +// GPU effects (keying, matte and channel family): see src/fx_key.rs. Each entry point mirrors +// the CPU effect in effectcraft-effects operation for operation. Every name here is prefixed +// `fxk_` (the family files share one module). + +// util::unpremul: (straight colour, alpha); colour 0 when alpha ≤ 1e-6. +fn fxk_straight(px: vec4) -> vec3 { + if (px.w > 1e-6) { + return px.xyz / px.w; + } + return vec3(0.0); +} + +fn fxk_premul(c: vec3, a: f32) -> vec4 { + return vec4(c * a, a); +} + +// Pixel `p` of the third image, copied into `data` as RGBA f32 rows of P.u[3].x pixels. +fn fxk_rows(p: vec2) -> vec4 { + let i = (u32(p.y) * P.u[3].x + u32(p.x)) * 4u; + return vec4(data[i], data[i + 1u], data[i + 2u], data[i + 3u]); +} + +// keying::to_lab. +fn fxk_cbrt(t: f32) -> f32 { + return sign(t) * pow(abs(t), 1.0 / 3.0); +} + +fn fxk_lab_f(t: f32) -> f32 { + if (t > 0.008856) { + return fxk_cbrt(t); + } + return 7.787 * t + 16.0 / 116.0; +} + +// keying::range_components. +fn fxk_range_components(space: u32, c: vec3) -> vec3 { + if (space == 1u) { + let y = 0.299 * c.x + 0.587 * c.y + 0.114 * c.z; + let u = -0.14713 * c.x - 0.28886 * c.y + 0.436 * c.z; + let v = 0.615 * c.x - 0.51499 * c.y - 0.10001 * c.z; + return vec3(y * 255.0, u * 255.0 + 128.0, v * 255.0 + 128.0); + } + if (space == 2u) { + return c * 255.0; + } + let cl = clamp(c, vec3(0.0), vec3(1.0)); + let l = vec3(srgb_to_linear(cl.x), srgb_to_linear(cl.y), srgb_to_linear(cl.z)); + let x = (0.4124 * l.x + 0.3576 * l.y + 0.1805 * l.z) / 0.95047; + let yy = 0.2126 * l.x + 0.7152 * l.y + 0.0722 * l.z; + let z = (0.0193 * l.x + 0.1192 * l.y + 0.9505 * l.z) / 1.08883; + let fx = fxk_lab_f(x); + let fy = fxk_lab_f(yy); + let fz = fxk_lab_f(z); + return vec3((116.0 * fy - 16.0) * 2.55, 500.0 * (fx - fy) + 128.0, 200.0 * (fy - fz) + 128.0); +} + +// keying::matte_levels. +fn fxk_matte_levels(v: f32, in_b: f32, in_w: f32, gamma: f32, out_b: f32, out_w: f32) -> f32 { + var t = clamp((v - in_b / 255.0) / max((in_w - in_b) / 255.0, 1e-4), 0.0, 1.0); + t = powz(t, 1.0 / max(gamma, 0.01)); + return clamp(out_b / 255.0 + t * (out_w - out_b) / 255.0, 0.0, 1.0); +} + +// keying::secondary. +fn fxk_secondary(c: vec3, o1: u32, o2: u32, bal: f32) -> f32 { + var hi = c[o2]; + var lo = c[o1]; + if (c[o1] > c[o2]) { + hi = c[o1]; + lo = c[o2]; + } + return bal * hi + (1.0 - bal) * lo; +} + +// channel::arithmetic's 8-bit quantisation. +fn fxk_q(v: f32) -> u32 { + return u32(round_away(clamp(v, 0.0, 1.0) * 255.0)); +} + +// channel2::extract. +fn fxk_extract(px: vec4, ch: u32, inv: bool) -> vec4 { + let c = fxk_straight(px); + let a = max(px.w, 0.0); + var g: f32; + switch ch { + case 0u: { + if (inv) { + return fxk_premul(1.0 - c, a); + } + return px; + } + case 1u: { g = luminance(c) * a; } + case 2u: { g = c.x * a; } + case 3u: { g = c.y * a; } + case 4u: { g = c.z * a; } + default: { g = a; } + } + if (inv) { + g = 1.0 - g; + } + return vec4(g, g, g, 1.0); +} + +// channel2::arith. +fn fxk_arith(op: u32, a: f32, b: f32) -> f32 { + switch op { + case 0u: { return b; } + case 1u: { return a + b; } + case 2u: { return a - b; } + case 3u: { return a * b; } + case 4u: { return abs(a - b); } + case 5u: { return f32(fxk_q(a) & fxk_q(b)) / 255.0; } + case 6u: { return f32(fxk_q(a) | fxk_q(b)) / 255.0; } + case 7u: { return f32(fxk_q(a) ^ fxk_q(b)) / 255.0; } + case 8u, 11u: { return max(a, b); } + case 9u, 10u: { return min(a, b); } + case 12u: { return a + b - a * b; } + case 13u: { + if (a < 0.5) { + return 2.0 * a * b; + } + return 1.0 - 2.0 * (1.0 - a) * (1.0 - b); + } + default: { + if (b < 0.5) { + return 2.0 * a * b; + } + return 1.0 - 2.0 * (1.0 - a) * (1.0 - b); + } + } +} + +// channel2::overflow. +fn fxk_overflow(op: u32, v: f32, how: u32) -> f32 { + if (how == 0u) { + return clamp(v, 0.0, 1.0); + } + if (how == 1u) { + let m = 1.0001; + let r = v - m * floor(v / m); + return min(select(r, 0.0, r >= m || r < 0.0), 1.0); + } + if (op == 1u) { + return v * 0.5; + } + if (op == 2u) { + return (v + 1.0) * 0.5; + } + return clamp(v, 0.0, 1.0); +} + +// Per-pixel keying / matte / channel operations. u[0].x = op (see src/fx_key.rs for each +// effect's parameter layout): +// 1 Color Key matte 2 Luma Key matte 3 Color Range matte 4 Extract matte +// 5 apply a matte (aux.x; u[0].y = 0 final, 2 matte view, 3 Difference Matte's matte view, +// 4 Difference Matte's output) +// 6 Difference Matte matte (src and aux blurred) +// 7 Spill Suppressor 8 Advanced Spill Suppressor (aux = 1×1 excess sums when u[1].w) +// 9 Color Difference Key 10 Unmult 11 route channels (u[1] = sources) +// 12 Remove Color Matting 13 Arithmetic 14 Solid Composite 15 Set Matte +// 16 Channel Combiner 17 Blend 18 Calculations 19 Compound Arithmetic +// 20 Set Channels: channel u[0].y of the value image from aux; 21 Set Channels: premultiply +// 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(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let px = textureLoad(src, p, 0); + let c = fxk_straight(px); + let a = max(px.w, 0.0); + let u0 = P.u[0]; + let u1 = P.u[1]; + let f0 = P.f[0]; + var o = px; + switch u0.x { + case 1u: { + let k = f0.xyz; + let d = max(max(abs(c.x - k.x), abs(c.y - k.y)), abs(c.z - k.z)); + o = vec4(select(1.0, 0.0, d <= f0.w)); + } + case 2u: { + let l = luminance(c); + let thr = f0.x; + let tol = f0.y; + var m: f32; + switch u0.y { + case 0u: { + if (tol > 0.0) { + m = clamp((thr + tol - l) / tol, 0.0, 1.0); + } else { + m = select(1.0, 0.0, l > thr); + } + } + case 2u: { m = select(1.0, 0.0, abs(l - thr) <= tol); } + case 3u: { m = select(0.0, 1.0, abs(l - thr) <= tol); } + default: { + if (tol > 0.0) { + m = clamp((l - (thr - tol)) / tol, 0.0, 1.0); + } else { + m = select(1.0, 0.0, l < thr); + } + } + } + o = vec4(m); + } + case 3u: { + let v = fxk_range_components(u0.y, c); + var out_d = 0.0; + for (var i = 0u; i < 3u; i++) { + out_d = max(max(out_d, P.f[0][i] - v[i]), v[i] - P.f[1][i]); + } + let fuzz = P.f[2].x; + var m = 1.0; + if (out_d <= 0.0) { + m = 0.0; + } else if (fuzz > 0.0) { + m = clamp(out_d / fuzz, 0.0, 1.0); + } + o = vec4(m); + } + case 4u: { + var v: f32; + switch u0.y { + case 1u: { v = c.x; } + case 2u: { v = c.y; } + case 3u: { v = c.z; } + case 4u: { v = a; } + default: { v = luminance(c); } + } + v = 255.0 * v; + let bp = f0.x; + let wp = f0.y; + let bs = f0.z; + let ws = f0.w; + var k = 1.0; + if (v < bp) { + k = select(0.0, clamp(1.0 - (bp - v) / bs, 0.0, 1.0), bs > 0.0); + } else if (v > wp) { + k = select(0.0, clamp(1.0 - (v - wp) / ws, 0.0, 1.0), ws > 0.0); + } + if (u0.z != 0u) { + k = 1.0 - k; + } + o = vec4(k); + } + case 5u: { + let m = textureLoad(aux, p, 0).x; + switch u0.y { + case 2u: { + let k = clamp(m, 0.0, 1.0); + let v = px.w * k; + o = vec4(v, v, v, 1.0); + } + case 3u: { + let v = m * px.w; + o = vec4(v, v, v, px.w); + } + case 4u: { o = px * m; } + default: { o = px * clamp(m, 0.0, 1.0); } + } + } + case 6u: { + let q = textureLoad(aux, p, 0); + let oc = fxk_straight(q); + let oa = max(q.w, 0.0); + let d = max(max(max(abs(c.x - oc.x), abs(c.y - oc.y)), abs(c.z - oc.z)), abs(a - oa)); + var m: f32; + if (d <= f0.x) { + m = 0.0; + } else if (f0.y <= 1e-6) { + m = 1.0; + } else { + m = clamp((d - f0.x) / f0.y, 0.0, 1.0); + } + o = vec4(m); + } + case 7u: { + if (px.w <= 0.0) { + textureStore(out, p, px); + return; + } + var s = px.xyz / px.w; + if (u0.y == 1u) { + let g = (s.x + s.y + s.z) / 3.0; + let dir = f0.xyz; + let proj = (s.x - g) * dir.x + (s.y - g) * dir.y + (s.z - g) * dir.z; + if (proj > 0.0) { + s = s - proj * dir * f0.w; + } + } else { + let spill = max(s[u1.x] - max(s[u1.y], s[u1.z]), 0.0); + s[u1.x] = s[u1.x] - spill * f0.w; + } + o = vec4(s * px.w, px.w); + } + case 8u: { + if (px.w <= 0.0) { + textureStore(out, p, px); + return; + } + var pi = u1.x; + var o1 = u1.y; + var o2 = u1.z; + if (u1.w != 0u) { + let ex = textureLoad(aux, vec2(0, 0), 0); + if (ex.y >= ex.x && ex.y >= ex.z) { + pi = 1u; + o1 = 0u; + o2 = 2u; + } else if (ex.z >= ex.x) { + pi = 2u; + o1 = 0u; + o2 = 1u; + } else { + pi = 0u; + o1 = 1u; + o2 = 2u; + } + } + var s = px.xyz / px.w; + let amt = f0.x; + let spill_range = f0.y; + let luma = f0.z; + let tol = f0.w; + let desat = P.f[1].x; + let neutral = P.f[1].y; + let key_hue = P.f[1].z; + var w = 1.0; + if (tol < 1.0) { + let h = rgb_to_hsl(s).x; + let d0 = abs(h - key_hue); + let d = min(d0, 1.0 - d0); + w = clamp(1.0 - d / max(tol * 0.5, 1e-4), 0.0, 1.0); + } + let mx = max(s[o1], s[o2]); + let avg = 0.5 * (s[o1] + s[o2]); + let lim = mx + (avg - mx) * spill_range; + let spill = max(s[pi] - lim, 0.0) * amt * w; + s[pi] = s[pi] - spill; + let lw = vec3(0.2126, 0.7152, 0.0722); + let back = spill * lw[pi] * luma + spill * neutral / 3.0; + s = s + back; + if (desat > 0.0 && spill > 0.0) { + let l = luminance(s); + let k = min(desat * min(spill * 4.0, 1.0), 1.0); + s = s + (vec3(l) - s) * k; + } + o = vec4(s * px.w, px.w); + } + case 9u: { + var cc = c; + if (u0.z != 0u) { + let cl = clamp(cc, vec3(0.0), vec3(1.0)); + cc = vec3(srgb_to_linear(cl.x), srgb_to_linear(cl.y), srgb_to_linear(cl.z)); + } + let pa = P.f[1]; + let pb = P.f[3]; + let ua = 1.0 - clamp((cc[u1.x] - cc[u1.y]) / f0.x, 0.0, 1.0); + let ub = 1.0 - clamp((cc[u1.x] - cc[u1.z]) / f0.y, 0.0, 1.0); + let ca = fxk_matte_levels(ua, pa.x, pa.y, pa.z, pa.w, P.f[2].x); + let cb = fxk_matte_levels(ub, pb.x, pb.y, pb.z, pb.w, P.f[2].y); + let um = max(ua, ub); + let cm = fxk_matte_levels(max(ca, cb), f0.z, f0.w, P.f[2].z, 0.0, 255.0); + var view = u0.y; + if (view == 8u) { + let right = p.x * 2 >= dims.x; + let bottom = p.y * 2 >= dims.y; + if (!right && !bottom) { + view = 2u; + } else if (right && !bottom) { + view = 4u; + } else if (!right && bottom) { + view = 6u; + } else { + view = 7u; + } + } + var g = -1.0; + switch view { + case 1u: { g = ua; } + case 2u: { g = ca; } + case 3u: { g = ub; } + case 4u: { g = cb; } + case 5u: { g = um; } + case 6u: { g = cm; } + default: {} + } + if (g >= 0.0) { + let m = g * a; + o = vec4(m, m, m, 1.0); + } else { + o = px * cm; + } + } + case 10u: { + let white = u0.y != 0u; + var d: f32; + var start: f32; + if (white) { + d = 1.0 - min(min(c.x, c.y), c.z); + start = 1.0 - f0.x; + } else { + d = max(max(c.x, c.y), c.z); + start = f0.x; + } + d = clamp(d, 0.0, 1.0); + let width = f0.y * (1.0 - start); + var m: f32; + if (width > 1e-6) { + m = clamp((d - start) / width, 0.0, 1.0); + } else { + m = select(0.0, 1.0, d > start); + } + if (m <= 1e-6) { + o = vec4(0.0); + } else { + let bg = select(0.0, 1.0, white); + var oc = c; + if (u0.z != 0u) { + oc = (c - bg * (1.0 - m)) / m; + } + if (u0.w != 0u) { + oc = clamp(oc, vec3(0.0), vec3(1.0)); + } + o = fxk_premul(oc, a * m); + } + } + case 11u: { + let v = vec4(dst_pick(u1.x, c, a), dst_pick(u1.y, c, a), dst_pick(u1.z, c, a), dst_pick(u1.w, c, a)); + o = fxk_premul(v.xyz, clamp(v.w, 0.0, 1.0)); + } + case 12u: { + if (px.w <= 1e-6) { + o = vec4(0.0); + } else { + for (var i = 0; i < 3; i++) { + let s = px[i] / px.w; + var v = max(s - f0[i] * (1.0 - px.w), 0.0); + if (u0.y != 0u) { + v = min(v, px.w); + } + o[i] = v; + } + } + } + case 13u: { + if (px.w <= 0.0) { + textureStore(out, p, px); + return; + } + let s = px.xyz / px.w; + var r = vec3(0.0); + for (var i = 0u; i < 3u; i++) { + let v = s[i]; + let kv = f0[i]; + let iv = fxk_q(v); + let ik = u32(round_away(kv * 255.0)); + var x: f32; + switch u0.y { + case 0u: { x = f32(iv & ik) / 255.0; } + case 1u: { x = f32(iv | ik) / 255.0; } + case 2u: { x = f32(iv ^ ik) / 255.0; } + case 3u: { x = v + kv; } + case 4u: { x = v - kv; } + case 5u: { x = abs(v - kv); } + case 6u: { x = min(v, kv); } + case 7u: { x = max(v, kv); } + case 8u: { x = select(v, 0.0, v > kv); } + case 9u: { x = select(v, 0.0, v < kv); } + case 10u: { x = select(0.0, 1.0, v >= kv); } + case 11u: { x = v * kv; } + default: { x = 1.0 - (1.0 - v) * (1.0 - kv); } + } + if (u0.z != 0u) { + r[i] = clamp(x, 0.0, 1.0); + } else { + r[i] = max(x, 0.0); + } + } + o = vec4(r * px.w, px.w); + } + case 14u: { + var b = blend_pixel(u0.y, f0, px * P.f[1].x, 0.5); + b.w = clamp(b.w, 0.0, 1.0); + o = vec4(max(b.xyz, vec3(0.0)), b.w); + } + case 15u: { + var mc = c; + var ma = a; + if ((u0.z & 8u) == 0u) { + let q = textureLoad(aux, p, 0); + mc = fxk_straight(q); + ma = max(q.w, 0.0); + } + var m = dst_pick(u0.y, mc, ma); + if ((u0.z & 4u) != 0u) { + m = m * ma; + } + m = clamp(m, 0.0, 1.0); + let inv = (u0.z & 1u) != 0u; + if (inv) { + m = 1.0 - m; + } + let own_m = (u0.z & 8u) != 0u; + let is_alpha = (u0.z & 16u) != 0u; + var na = m; + if ((u0.z & 2u) != 0u && (!own_m || !is_alpha || inv)) { + na = a * m; + } + o = fxk_premul(c, clamp(na, 0.0, 1.0)); + } + case 16u: { + let ch_from = u0.y; + let ch_to = u0.z; + let inv = (u0.w & 1u) != 0u; + let solid = (u0.w & 2u) != 0u; + let second = (u0.w & 4u) != 0u; + var sc = c; + var sa = a; + if (second) { + let q = textureLoad(aux, p, 0); + sc = fxk_straight(q); + sa = max(q.w, 0.0); + } + if (sa <= 0.0 && ch_from != 7u && !second) { + if (solid) { + o = fxk_premul(c, 1.0); + } + textureStore(out, p, o); + return; + } + var na = a; + var r: vec3; + if (ch_from <= 3u) { + switch ch_from { + case 0u: { + let hsl = rgb_to_hsl(sc); + r = vec3(hsl.x, hsl.z, hsl.y); + } + case 1u: { r = hsl_to_rgb(sc.x, sc.z, sc.y); } + case 2u: { + let y = 0.299 * sc.x + 0.587 * sc.y + 0.114 * sc.z; + r = vec3(y, 0.492 * (sc.z - y) + 0.5, 0.877 * (sc.x - y) + 0.5); + } + default: { + let y = sc.x; + let uu = sc.y - 0.5; + let vv = sc.z - 0.5; + let rr = y + vv / 0.877; + let bb = y + uu / 0.492; + let gg = (y - 0.299 * rr - 0.114 * bb) / 0.587; + r = vec3(rr, gg, bb); + } + } + if (inv) { + r = 1.0 - r; + } + } else { + var v: f32; + switch ch_from { + case 4u: { v = sc.x; } + case 5u: { v = sc.y; } + case 6u: { v = sc.z; } + case 7u: { v = sa; } + case 8u: { v = rgb_to_hsl(sc).z; } + case 9u: { v = rgb_to_hsl(sc).x; } + case 10u: { v = rgb_to_hsl(sc).y; } + case 11u: { v = luminance(sc); } + case 12u: { v = max(max(sc.x, sc.y), sc.z); } + default: { v = min(min(sc.x, sc.y), sc.z); } + } + if (inv) { + v = 1.0 - v; + } + switch ch_to { + case 0u: { r = vec3(v, 0.0, 0.0); } + case 1u: { r = vec3(0.0, v, 0.0); } + case 2u: { r = vec3(0.0, 0.0, v); } + case 3u: { + na = clamp(v, 0.0, 1.0); + r = vec3(1.0); + } + case 5u: { r = hsl_to_rgb(v, 1.0, 0.5); } + case 6u: { r = hsl_to_rgb(0.0, clamp(v, 0.0, 1.0), 0.5); } + case 8u: { + let hsl = rgb_to_hsl(c); + r = hsl_to_rgb(hsl.x, clamp(hsl.y * v, 0.0, 1.0), hsl.z); + } + default: { r = vec3(v); } + } + } + if (solid) { + na = 1.0; + } + o = fxk_premul(max(r, vec3(0.0)), na); + } + case 17u: { + let q = textureLoad(aux, p, 0); + var bl: vec4; + switch u0.y { + case 0u: { bl = q; } + case 1u, 2u: { + let oa = q.w; + if (px.w <= 0.0 || oa <= 0.0) { + bl = px; + } else { + let hsl = rgb_to_hsl(c); + let ohsl = rgb_to_hsl(fxk_straight(q)); + var h2 = ohsl.x; + var s2 = ohsl.y; + if (u0.y == 2u) { + if (hsl.y > 1e-4) { + s2 = hsl.y; + } else { + h2 = 0.0; + s2 = 0.0; + } + } + let rgb = hsl_to_rgb(h2, s2, hsl.z); + bl = px + (fxk_premul(rgb, a) - px) * oa; + } + } + case 3u: { bl = vec4(min(px.xyz, q.xyz), px.w); } + default: { bl = max(px, q); } + } + o = bl + (px - bl) * f0.x; + } + case 18u: { + let flags = u1.x; + var r = fxk_extract(px, u0.y, (flags & 1u) != 0u); + if ((flags & 8u) != 0u) { + let s = fxk_extract(textureLoad(aux, p, 0), u0.z, (flags & 2u) != 0u) * f0.x; + r = blend_pixel(u0.w, r, s, 0.5); + } + if ((flags & 4u) != 0u && r.w > 1e-6) { + r = fxk_premul(fxk_straight(r), px.w); + } + r.w = clamp(r.w, 0.0, 1.0); + o = r; + } + case 19u: { + let q = textureLoad(aux, p, 0); + let oc = fxk_straight(q); + let oa = max(q.w, 0.0); + var rc = c; + var ra = a; + if (u0.z != 2u) { + for (var i = 0u; i < 3u; i++) { + rc[i] = fxk_overflow(u0.y, fxk_arith(u0.y, c[i], oc[i]), u0.w); + } + } + if (u0.z != 0u) { + ra = fxk_overflow(u0.y, fxk_arith(u0.y, a, oa), u0.w); + } + let res = fxk_premul(rc, ra); + o = res + (px - res) * f0.x; + } + case 20u: { + let q = textureLoad(aux, p, 0); + o = px; + o[u0.y] = dst_pick(u0.z, fxk_straight(q), max(q.w, 0.0)); + } + case 21u: { + o = fxk_premul(px.xyz, clamp(px.w, 0.0, 1.0)); + } + case 22u: { + let d = c[u1.x] - fxk_secondary(c, u1.y, u1.z, f0.z); + let t = clamp(d / f0.x * f0.y, 0.0, 1.0); + o = vec4(clamp((1.0 - t - f0.w) / max(P.f[1].x - f0.w, 1e-3), 0.0, 1.0)); + } + case 23u: { + let k = clamp(textureLoad(aux, p, 0).x, 0.0, 1.0); + let alpha = a * k; + if (u0.y == 1u) { + o = vec4(alpha, alpha, alpha, 1.0); + } else if (u0.y == 2u) { + var v = 0.5; + if (alpha <= 1e-3) { + v = 0.0; + } else if (alpha >= 0.999) { + v = 1.0; + } + o = vec4(v, v, v, 1.0); + } else { + let screen = f0.xyz; + var fg = c; + if (!(u0.z != 0u || k <= 1e-4)) { + let s = 1.0 - k; + for (var i = 0; i < 3; i++) { + fg[i] = min(max(c[i] - s * screen[i], 0.0) / k, max(c[i], 1.0)); + } + } + let spill = max(fg[u1.x] - fxk_secondary(fg, u1.y, u1.z, f0.w), 0.0); + fg[u1.x] = fg[u1.x] - spill * P.f[1].x; + o = fxk_premul(fg, alpha); + } + } + case 24u: { + o = vec4(px.w); + } + case 25u: { + var na = textureLoad(aux, p, 0).x; + if (u0.y != 0u) { + na = clamp((na - 0.5) * 1.5 + 0.5, 0.0, 1.0); + } + var cc = c; + let a0 = a; + if (P.u[3].y != 0u && a0 > 1e-4 && a0 < 0.999) { + let q = fxk_rows(p); + if (q.w > 1e-6) { + let ea = q.w; + if (ea > 1e-4) { + let t = f0.y * (1.0 - a0); + cc = cc + (q.xyz / ea - cc) * t; + } + } + } + na = clamp((na - 0.5) * f0.x + 0.5, 0.0, 1.0); + if (a0 <= 1e-6) { + na = 0.0; + } + o = fxk_premul(cc, na); + } + case 26u: { + o = vec4(clamp((px.x - f0.x) / f0.y + 0.5, 0.0, 1.0)); + } + case 27u: { + let na = clamp(textureLoad(aux, p, 0).x, 0.0, 1.0); + var cc = c; + if (px.w <= 1e-4) { + cc = fxk_straight(fxk_rows(p)); + } + 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); + } + default: {} + } + textureStore(out, p, o); +} + +// util::fit_layer: bilinear sample of the other layer (src) at the buffer coordinates in data +// (one per column, then one per row). +@compute @workgroup_size(16, 16) +fn fxk_fit(@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 x = data[u32(p.x)]; + let y = data[u32(dims.x) + u32(p.y)]; + textureStore(out, p, sample_bilinear(src, x, y)); +} + +// Sum of 16×16 blocks; u[0].x = first pass: sum Advanced Spill Suppressor's per-channel excess +// of each primary over the other two (straight colour × alpha), else sum the values. +@compute @workgroup_size(16, 16) +fn fxk_reduce(@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 sd = vec2(textureDimensions(src)); + var acc = vec4(0.0); + for (var j = 0; j < 16; j++) { + for (var i = 0; i < 16; i++) { + let q = vec2(p.x * 16 + i, p.y * 16 + j); + if (q.x >= sd.x || q.y >= sd.y) { + continue; + } + let v = textureLoad(src, q, 0); + if (P.u[0].x != 0u) { + let c = fxk_straight(v); + let a = max(v.w, 0.0); + acc += vec4( + max(c.x - max(c.y, c.z), 0.0) * a, + max(c.y - max(c.x, c.z), 0.0) * a, + max(c.z - max(c.x, c.y), 0.0) * a, + 0.0, + ); + } else { + acc += v; + } + } + } + textureStore(out, p, acc); +} diff --git a/crates/gpu/src/shaders/fx_noise.wgsl b/crates/gpu/src/shaders/fx_noise.wgsl new file mode 100644 index 00000000..56281158 --- /dev/null +++ b/crates/gpu/src/shaders/fx_noise.wgsl @@ -0,0 +1,825 @@ +// GPU effects (noise, blur and time family): see src/fx_noise.rs. Each entry point mirrors the CPU effect in +// effectcraft-effects operation for operation. Every name here is prefixed `fxn_` (the +// family files share one module). + +const FXN_MAX: f32 = 3.40282347e38; + +fn fxn_pixel(gid: vec3) -> vec2 { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return vec2(-1); + } + return p; +} + +// a / b rounded like the CPU's IEEE division (GPU division may be approximate): one +// residual correction with fma. Used where a threshold follows the quotient. +fn fxn_div4(a: vec4, b: vec4) -> vec4 { + let q = a / b; + let r = fma(-q, b, a); + return fma(r, 1.0 / b, q); +} + +// util::unpremul: (straight colour, alpha). +fn fxn_unpremul(p: vec4) -> vec4 { + if (p.w > 1e-6) { + return vec4(p.xyz / p.w, p.w); + } + return vec4(0.0, 0.0, 0.0, max(p.w, 0.0)); +} + +// ---------------------------------------------------------------- Median family (noise::median_image) + +// Quantise to the median's 512 levels (0..511). u[0].x = 1: straight colour with alpha 1 +// (Median (Legacy) colour only). +@compute @workgroup_size(16, 16) +fn fxn_quant(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + var v = textureLoad(src, p, 0); + if (P.u[0].x == 1u) { + v = vec4(fxn_unpremul(v).xyz, 1.0); + } + let q = clamp(v, vec4(0.0), vec4(1.0)) * 511.0; + textureStore(out, p, vec4(round_away(q.x), round_away(q.y), round_away(q.z), round_away(q.w))); +} + +// Per-channel median of the quantised levels over a (2r+1)² window (edges repeated): the +// smallest level m with more than half the window at or below m. u[0].x = r. +@compute @workgroup_size(16, 16) +fn fxn_median(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let r = i32(P.u[0].x); + let n = (2 * r + 1) * (2 * r + 1); + let half = f32(n / 2); + var lo = vec4(0.0); + var hi = vec4(511.0); + for (var it = 0; it < 9; it++) { + let mid = floor((lo + hi) * 0.5); + var cnt = vec4(0.0); + for (var dy = -r; dy <= r; dy++) { + for (var dx = -r; dx <= r; dx++) { + let q = tex_get_clamped(src, p.x + dx, p.y + dy); + cnt += select(vec4(0.0), vec4(1.0), q <= mid); + } + } + let above = cnt > vec4(half); + hi = select(hi, mid, above); + lo = select(mid + 1.0, lo, above); + } + textureStore(out, p, fxn_div4(lo, vec4(511.0))); +} + +// noise::median_px. +fn fxn_median_px(o: vec4, m: vec4, on_alpha: bool) -> vec4 { + if (on_alpha) { + let a = clamp(m.w, 0.0, 1.0); + return vec4(min(m.xyz, vec3(a)), a); + } + if (m.w <= 1e-4) { + return o; + } + return vec4(min(fxn_div4(m, vec4(m.w)).xyz, vec3(1.0)) * o.w, o.w); +} + +// src = the layer, aux = the median. u[0] = (mode, operate on alpha): 0 Median, 1 Dust & +// Scratches (f[0].x = threshold), 2 Median (Legacy) with alpha, 3 Median (Legacy) colour only. +@compute @workgroup_size(16, 16) +fn fxn_median_out(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let o = textureLoad(src, p, 0); + let m = textureLoad(aux, p, 0); + let on_alpha = P.u[0].y != 0u; + var r = o; + switch P.u[0].x { + case 0u: { + r = fxn_median_px(o, m, on_alpha); + } + case 1u: { + let cand = fxn_median_px(o, m, on_alpha); + let d = abs(cand - o); + if (max(max(d.x, d.y), max(d.z, d.w)) > P.f[0].x) { + r = cand; + } + } + case 2u: { + r = m; + } + default: { + r = vec4(m.xyz * o.w, o.w); + } + } + textureStore(out, p, r); +} + +// ---------------------------------------------------------------- Bilateral / Smart Blur (blur2.rs) + +// blur2::edge_preserving. u[0] = (ceil r, stride, hard, largest d²); u[1].x = grey (Bilateral +// with Colorize off); f[0] = (2σs², 2σr², range). +@compute @workgroup_size(16, 16) +fn fxn_edge(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let ri = i32(P.u[0].x); + let stride = i32(P.u[0].y); + let hard = P.u[0].z != 0u; + let d2max = i32(P.u[0].w); + let sig_s2 = P.f[0].x; + let sig_r2 = P.f[0].y; + let range = P.f[0].z; + let c = textureLoad(src, p, 0); + let cl = luminance(c.xyz); + var acc = vec4(0.0); + var wsum = 0.0; + for (var dy = -ri; dy <= ri; dy += stride) { + for (var dx = -ri; dx <= ri; dx += stride) { + let d2 = dx * dx + dy * dy; + if (d2 > d2max) { + continue; + } + let q = tex_get_clamped(src, p.x + dx, p.y + dy); + var w = 0.0; + if (hard) { + w = select(0.0, 1.0, abs(luminance(q.xyz) - cl) <= range); + } else { + let d = q - c; + let dc = d.x * d.x + d.y * d.y + d.z * d.z + d.w * d.w; + w = exp(-f32(d2) / sig_s2 - dc / sig_r2); + } + if (w > 0.0) { + acc += q * w; + wsum += w; + } + } + } + var o = c; + if (wsum > 0.0) { + o = acc / wsum; + } + if (P.u[1].x != 0u && o.w > 0.0) { + let l = luminance(o.xyz / o.w); + o = vec4(vec3(l * o.w), o.w); + } + textureStore(out, p, o); +} + +// Smart Blur's Edge Only / Overlay Edge (blur2::sobel on the blurred layer's premultiplied +// luminance). u[0].x = mode; f[0].x = edge threshold. +@compute @workgroup_size(16, 16) +fn fxn_smart_edges(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + var l: array; + for (var j = 0; j < 3; j++) { + for (var i = 0; i < 3; i++) { + l[j * 3 + i] = luminance(tex_get_clamped(src, p.x + i - 1, p.y + j - 1).xyz); + } + } + let gx = l[2] + 2.0 * l[5] + l[8] - l[0] - 2.0 * l[3] - l[6]; + let gy = l[6] + 2.0 * l[7] + l[8] - l[0] - 2.0 * l[1] - l[2]; + let e = sqrt(gx * gx + gy * gy) * 0.25; + let px = textureLoad(src, p, 0); + let on = e > P.f[0].x; + var o = px; + if (P.u[0].x == 1u) { + o = vec4(vec3(select(0.0, 1.0, on) * px.w), px.w); + } else if (on) { + o = vec4(vec3(px.w), px.w); + } + textureStore(out, p, o); +} + +// ---------------------------------------------------------------- Sharpen / Unsharp Mask (misc::unsharp_core) + +// src = the layer, aux = its blur. f[0] = (amount, threshold in 0..255). +@compute @workgroup_size(16, 16) +fn fxn_unsharp(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + var o = textureLoad(src, p, 0); + let bl = textureLoad(aux, p, 0); + for (var c = 0; c < 3; c++) { + let d = o[c] - bl[c]; + if (abs(d) * 255.0 >= P.f[0].y) { + o[c] = max(o[c] + d * P.f[0].x, 0.0); + } + } + textureStore(out, p, o); +} + +// ---------------------------------------------------------------- planes + +// Channel select: channel c of `src` becomes aux[c] (u[0].x = 0) or aux's alpha (1) where +// u[1][c] != 0. +@compute @workgroup_size(16, 16) +fn fxn_pick(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + var o = textureLoad(src, p, 0); + let a = textureLoad(aux, p, 0); + for (var c = 0; c < 4; c++) { + if (P.u[1][c] != 0u) { + o[c] = select(a[c], a.w, P.u[0].x == 1u); + } + } + textureStore(out, p, o); +} + +// Channel Blur's result: src = blurred premultiplied channels, aux = the alpha blurred like +// each channel (alpha = the Alpha Blurriness result). +@compute @workgroup_size(16, 16) +fn fxn_cblur_out(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let v = textureLoad(src, p, 0); + let ba = textureLoad(aux, p, 0); + var s = vec3(0.0); + for (var c = 0; c < 3; c++) { + if (ba[c] > 1e-6) { + s[c] = v[c] / ba[c]; + } + } + let a = clamp(ba.w, 0.0, 1.0); + textureStore(out, p, vec4(s * a, a)); +} + +// Straight colour planes with the raw alpha (Minimax). +@compute @workgroup_size(16, 16) +fn fxn_straight(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let v = textureLoad(src, p, 0); + textureStore(out, p, vec4(fxn_unpremul(v).xyz, v.w)); +} + +// Premultiply straight planes by the clamped alpha. +@compute @workgroup_size(16, 16) +fn fxn_premul(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let v = textureLoad(src, p, 0); + let a = clamp(v.w, 0.0, 1.0); + textureStore(out, p, vec4(v.xyz * a, a)); +} + +// util::morph_plane along one axis (edges repeated). u[0] = (r, vertical); u[1] = per-channel +// operation (0 min, 1 max, 2 keep). +@compute @workgroup_size(16, 16) +fn fxn_morph(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let r = i32(P.u[0].x); + let vertical = P.u[0].y != 0u; + let centre = textureLoad(src, p, 0); + var lo = centre; + var hi = centre; + for (var k = -r; k <= r; k++) { + var v: vec4; + if (vertical) { + v = tex_get_clamped(src, p.x, p.y + k); + } else { + v = tex_get_clamped(src, p.x + k, p.y); + } + lo = min(lo, v); + hi = max(hi, v); + } + var o = centre; + for (var c = 0; c < 4; c++) { + if (P.u[1][c] == 0u) { + o[c] = lo[c]; + } else if (P.u[1][c] == 1u) { + o[c] = hi[c]; + } + } + textureStore(out, p, o); +} + +// Crop: out(p) = src(p + u[0].xy). +@compute @workgroup_size(16, 16) +fn fxn_crop(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + textureStore(out, p, textureLoad(src, p + vec2(P.u[0].xy), 0)); +} + +// ---------------------------------------------------------------- Compound Blur (blur2::compound_blur) + +// The blur map (straight luminance × alpha, inverted with u[0].y) in every channel. u[0].x = 1: +// `src` is the Blur Layer sampled at the per-column / per-row coordinates in `data` (u[1].x = +// width); 0: `src` is the layer itself. +@compute @workgroup_size(16, 16) +fn fxn_cb_map(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + var v: vec4; + if (P.u[0].x == 1u) { + let w = P.u[1].x; + v = sample_bilinear(src, data[u32(p.x)], data[w + u32(p.y)]); + } else { + v = textureLoad(src, p, 0); + } + let s = fxn_unpremul(v); + var l = luminance(s.xyz) * s.w; + if (P.u[0].y != 0u) { + l = 1.0 - l; + } + textureStore(out, p, vec4(l)); +} + +// Writes the pixels whose blur level falls between levels k (src) and k + 1 (aux). The map is +// `data` (RGBA f32 rows of u[0].y pixels); u[0].x = k. +@compute @workgroup_size(16, 16) +fn fxn_cb_level(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let m = data[(u32(p.y) * P.u[0].y + u32(p.x)) * 4u]; + let t = clamp(m, 0.0, 1.0) * 4.0; + let k = min(u32(floor(t)), 3u); + if (k != P.u[0].x) { + return; + } + let f = t - f32(k); + let a = textureLoad(src, p, 0); + let c = textureLoad(aux, p, 0); + textureStore(out, p, a + (c - a) * f); +} + +// ---------------------------------------------------------------- Echo (time_fx::echo) + +// acc (src) ⊕ frame (aux) × f[0].x. u[0] = (operator, first frame). +@compute @workgroup_size(16, 16) +fn fxn_echo(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let op = P.u[0].x; + var acc = textureLoad(src, p, 0); + if (P.u[0].y != 0u) { + acc = select(vec4(0.0), vec4(FXN_MAX), op == 2u); + } + let k = P.f[0].x; + let q = textureLoad(aux, p, 0) * k; + switch op { + case 1u: { + acc = max(acc, q); + } + case 2u: { + acc = min(acc, q); + } + case 3u: { + acc = acc + q - acc * q; + } + case 4u: { + acc += q * (1.0 - acc.w); + } + case 5u: { + acc = q + acc * (1.0 - q.w); + } + default: { + acc += q; + } + } + textureStore(out, p, acc); +} + +// Echo's finish. u[0].x = operator; f[0].x = number of frames. +@compute @workgroup_size(16, 16) +fn fxn_echo_fin(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let op = P.u[0].x; + var acc = textureLoad(src, p, 0); + if (op == 6u) { + acc = acc / P.f[0].x; + } + if (op == 2u && acc.x == FXN_MAX) { + acc = vec4(0.0); + } + textureStore(out, p, vec4(max(acc.xyz, vec3(0.0)), clamp(acc.w, 0.0, 1.0))); +} + +// ---------------------------------------------------------------- Fractal / Turbulent Noise (noise3.rs) + +fn fxn_catmull(p0: f32, p1: f32, p2: f32, p3: f32, t: f32) -> f32 { + return 0.5 * (2.0 * p1 + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t * t + (3.0 * p1 - p0 - 3.0 * p2 + p3) * t * t * t); +} + +fn fxn_slice(ix: i32, iy: i32, iz: i32, tx: f32, ty: f32, seed: u32, nt: u32) -> f32 { + if (nt == 0u) { + return lattice(ix, iy, iz, seed); + } + if (nt == 1u) { + let a = lattice(ix, iy, iz, seed); + let b = lattice(ix + 1, iy, iz, seed); + let c = lattice(ix, iy + 1, iz, seed); + let d = lattice(ix + 1, iy + 1, iz, seed); + let top = a + (b - a) * tx; + let bot = c + (d - c) * tx; + return top + (bot - top) * ty; + } + var rows: array; + for (var j = 0; j < 4; j++) { + let y = iy + j - 1; + rows[j] = fxn_catmull(lattice(ix - 1, y, iz, seed), lattice(ix, y, iz, seed), lattice(ix + 1, y, iz, seed), lattice(ix + 2, y, iz, seed), tx); + } + return fxn_catmull(rows[0], rows[1], rows[2], rows[3], ty); +} + +// noise3::typed_noise. +fn fxn_typed_noise(x: f32, y: f32, z: f32, seed: u32, nt: u32) -> f32 { + if (nt == 2u) { + return value_noise(x, y, z, seed); + } + let x0 = floor(x); + let y0 = floor(y); + let z0 = floor(z); + let tx = x - x0; + let ty = y - y0; + let fz = fade(z - z0); + let a = fxn_slice(i32(x0), i32(y0), i32(z0), tx, ty, seed, nt); + let b = fxn_slice(i32(x0), i32(y0), i32(z0) + 1, tx, ty, seed, nt); + return a + (b - a) * fz; +} + +// noise3::shape. +fn fxn_shape(kind: u32, n: f32) -> f32 { + let s = n * 2.0 - 1.0; + switch kind { + case 1u: { + return 1.0 - s * s; + } + case 2u: { + return 1.0 - abs(s); + } + case 3u: { + let t = 1.0 - abs(s); + return t * t; + } + case 8u: { + let t = clamp(abs(s) / 0.12, 0.0, 1.0); + return 1.0 - t * t; + } + default: { + return n; + } + } +} + +// Fractal::layer. `o` = octave data base index. +fn fxn_layer(u: f32, v: f32, o: u32, z: f32, seed: u32) -> f32 { + let nt = P.u[0].y; + if (P.u[1].y != 0u) { + let t = P.f[2].x; + let c = P.f[2].y; + let w = P.f[2].z; + let a = fxn_typed_noise(u, v, t + z, seed, nt); + let b = fxn_typed_noise(u, v, t - c + z, seed, nt); + return a * (1.0 - w) + b * w; + } + return fxn_typed_noise(u, v, data[o + 7u] + z, seed, nt); +} + +// u[0] = (kind, noise type, octaves, seed); u[1] = (sub rotation, cycle, invert, overflow); +// u[2].x = blend mode (BlendMode::ALL index, 255 = None); f[0] = (sin, cos, size x, size y); +// f[1] = (contrast, brightness, opacity, blend with original); f[2] = (cycle t, cycle, t / cycle, +// norm); data = 10 floats per octave (noise3::FractalGpu). +@compute @workgroup_size(16, 16) +fn fxn_fractal(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let kind = P.u[0].x; + let n_oct = P.u[0].z; + let sr = P.f[0].x; + let cr = P.f[0].y; + let x = f32(p.x) + 0.5; + let y = f32(p.y) + 0.5; + var sum = 0.0; + var best = 0.0; + for (var oi = 0u; oi < n_oct; oi++) { + let o = oi * 10u; + let dx = x - data[o]; + let dy = y - data[o + 1u]; + var u = (dx * cr + dy * sr) / P.f[0].z; + var v = (-dx * sr + dy * cr) / P.f[0].w; + if (P.u[1].x != 0u) { + let s = data[o + 2u]; + let c = data[o + 3u]; + let u2 = u * c + v * s; + v = -u * s + v * c; + u = u2; + } + let f = data[o + 4u]; + u = u * f; + v = v * f; + let z = data[o + 8u]; + let seed = P.u[0].w + oi; + if (kind >= 4u && kind <= 6u) { + let k = data[o + 9u]; + let a = fxn_layer(u * 0.5, v * 0.5, o, z + 3.1, seed ^ 0x55u) - 0.5; + let bb = fxn_layer(u * 0.5, v * 0.5, o, z + 5.7, seed ^ 0xaau) - 0.5; + if (kind == 5u) { + let s = sin(a * 3.0); + let c = cos(a * 3.0); + let u2 = u * c - v * s; + v = u * s + v * c; + u = u2; + } else { + u += a * 1.5 * k; + v += bb * 1.5 * k; + } + } + let n = fxn_shape(kind, fxn_layer(u, v, o, z, seed)); + let amp = data[o + 5u]; + if (kind == 7u) { + best = max(best, n * amp); + } + sum += n * amp * data[o + 6u]; + } + var raw = sum / max(P.f[2].w, 1e-6); + if (kind == 7u) { + raw = best; + } + var val = (raw - 0.5) * P.f[1].x + 0.5 + P.f[1].y; + let ov = P.u[1].w; + if (ov == 0u) { + val = clamp(val, 0.0, 1.0); + } else if (ov == 1u) { + val = clamp(0.5 + 0.5 * tanh(2.0 * (val - 0.5)) / tanh(1.0), 0.0, 1.0); + } else if (ov == 2u) { + let t = val - 2.0 * floor(val / 2.0); + val = select(t, 2.0 - t, t > 1.0); + } + if (P.u[1].z != 0u) { + val = 1.0 - val; + } + let op = P.f[1].z; + let s = vec4(vec3(val * op), op); + let orig = textureLoad(src, p, 0); + var r = s; + if (P.u[2].x != 255u) { + r = blend_pixel(P.u[2].x, orig, s, 0.5); + } + let bo = P.f[1].w; + textureStore(out, p, orig * bo + r * (1.0 - bo)); +} + +// ---------------------------------------------------------------- Noise Alpha / Noise HLS (noise.rs) + +// noise::phased_cycle with the phase's two seeds and smoothed fraction resolved on the CPU. +fn fxn_phased(x: u32, y: u32, sa: u32, sb: u32, t: f32) -> f32 { + let a = hash_noise(x, y, sa); + let b = hash_noise(x, y, sb); + return a + (b - a) * t; +} + +// u[0] = (squared, original alpha mode, overflow, seed a); u[1].x = seed b; f[0] = (amount, t). +@compute @workgroup_size(16, 16) +fn fxn_noise_alpha(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let k = P.f[0].x; + var n = fxn_phased(u32(p.x), u32(p.y), P.u[0].w, P.u[1].x, P.f[0].y); + if (P.u[0].x != 0u) { + n = n * n; + } + let s = fxn_unpremul(textureLoad(src, p, 0)); + let a = s.w; + var na: f32; + switch P.u[0].y { + case 0u: { + na = a + n * k; + } + case 1u: { + na = select(0.0, a + n * k, a > 0.0); + } + case 2u: { + na = a * (1.0 + (2.0 * n - 1.0) * k); + } + default: { + na = a + (2.0 * n - 1.0) * k * 4.0 * a * (1.0 - a); + } + } + if (P.u[0].z == 1u) { + let t = na - 2.0 * floor(na / 2.0); + na = select(t, 2.0 - t, t > 1.0); + } else if (P.u[0].z == 2u && !(na >= 0.0 && na <= 1.0)) { + na = na - floor(na); + } + na = clamp(na, 0.0, 1.0); + textureStore(out, p, vec4(s.xyz * na, na)); +} + +// Noise HLS: u[0] = (noise kind, seed); u[1], u[2].xy = per channel (hue, lightness, +// saturation) the phase's two seeds; f[0] = (hue, lightness, saturation, grain size); f[1].x = +// phase (Grain), f[1].y = smoothed fraction. +fn fxn_hls_seed(i: u32) -> u32 { + if (i < 4u) { + return P.u[1][i]; + } + return P.u[2][i - 4u]; +} + +fn fxn_hls_n(x: u32, y: u32, k: u32) -> f32 { + let s = P.u[0].y + k * 7919u; + switch P.u[0].x { + case 2u: { + let gs = P.f[0].w; + return clamp((value_noise(f32(x) / gs, f32(y) / gs, P.f[1].x, s) - 0.5) * 3.0, -1.0, 1.0); + } + case 1u: { + let v = fxn_phased(x, y, fxn_hls_seed(2u * k), fxn_hls_seed(2u * k + 1u), P.f[1].y) * 2.0 - 1.0; + return v * abs(v); + } + default: { + return fxn_phased(x, y, fxn_hls_seed(2u * k), fxn_hls_seed(2u * k + 1u), P.f[1].y) * 2.0 - 1.0; + } + } +} + +@compute @workgroup_size(16, 16) +fn fxn_noise_hls(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let px = textureLoad(src, p, 0); + let s0 = fxn_unpremul(px); + let a = s0.w; + if (a <= 0.0) { + textureStore(out, p, px); + return; + } + let x = u32(p.x); + let y = u32(p.y); + var hsl = rgb_to_hsl(s0.xyz); + if (P.f[0].x != 0.0) { + hsl.x = fract_euclid(hsl.x + fxn_hls_n(x, y, 0u) * P.f[0].x * 0.5); + } + if (P.f[0].y != 0.0) { + hsl.z = clamp(hsl.z + fxn_hls_n(x, y, 1u) * P.f[0].y, 0.0, 1.0); + } + if (P.f[0].z != 0.0) { + hsl.y = clamp(hsl.y + fxn_hls_n(x, y, 2u) * P.f[0].z, 0.0, 1.0); + } + let c = hsl_to_rgb(hsl.x, hsl.y, hsl.z); + textureStore(out, p, vec4(c * a, a)); +} + +// ---------------------------------------------------------------- Remove Grain (noise::remove_grain) + +// The guide (util::guided_filter's g): the luminance of the premultiplied channels in every +// channel (Multichannel), or the channels themselves (u[0].x, Single Channel). +@compute @workgroup_size(16, 16) +fn fxn_gf_guide(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let v = textureLoad(src, p, 0); + if (P.u[0].x != 0u) { + textureStore(out, p, v); + } else { + textureStore(out, p, vec4(luminance(v.xyz))); + } +} + +// Guided filter arithmetic, S = src, A = aux, D = `data` (RGBA f32 rows of u[0].y pixels): +// u[0].x = 0: S − A·D, 1: S − A·A, 2: S / (A + f[0].x), 3: A − S·D, 4: S·D + A, 5: S·A, 6: S·S. +@compute @workgroup_size(16, 16) +fn fxn_gf(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let s = textureLoad(src, p, 0); + let a = textureLoad(aux, p, 0); + var d = vec4(0.0); + let mode = P.u[0].x; + if (mode == 0u || mode == 3u || mode == 4u) { + let i = (u32(p.y) * P.u[0].y + u32(p.x)) * 4u; + d = vec4(data[i], data[i + 1u], data[i + 2u], data[i + 3u]); + } + var o: vec4; + switch mode { + case 0u: { + o = s - a * d; + } + case 1u: { + o = s - a * a; + } + case 2u: { + o = s / (a + P.f[0].x); + } + case 3u: { + o = a - s * d; + } + case 4u: { + o = s * d + a; + } + case 5u: { + o = s * a; + } + default: { + o = s * s; + } + } + textureStore(out, p, o); +} + +// Texture: f + (o − f) × f[0].x on colour (src = filtered, aux = original). +@compute @workgroup_size(16, 16) +fn fxn_rg_texture(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let f = textureLoad(src, p, 0); + let o = textureLoad(aux, p, 0); + textureStore(out, p, vec4(f.xyz + (o.xyz - f.xyz) * P.f[0].x, f.w)); +} + +// Unsharp Mask on the planes: src = channels, aux = their blur; f[0] = (amount, threshold). +@compute @workgroup_size(16, 16) +fn fxn_rg_unsharp(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + var v = textureLoad(src, p, 0); + let l = textureLoad(aux, p, 0); + for (var c = 0; c < 3; c++) { + if (abs(v[c] - l[c]) > P.f[0].y) { + v[c] = v[c] + (v[c] - l[c]) * P.f[0].x; + } + } + textureStore(out, p, v); +} + +// Remove Grain's result: colour clamped to 0..alpha (alpha from aux = the original); with +// u[0].x the Preview viewing mode (noise::preview_compose): the original (aux) outside the +// region f[0] = (x0, x1, y0, y1), the box outline in f[1] where u[0].y. +@compute @workgroup_size(16, 16) +fn fxn_rg_out(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let o = textureLoad(aux, p, 0); + let a = o.w; + var v = vec4(clamp(textureLoad(src, p, 0).xyz, vec3(0.0), vec3(max(a, 0.0))), a); + if (P.u[0].x != 0u) { + let x = f32(p.x) + 0.5; + let y = f32(p.y) + 0.5; + let r = P.f[0]; + let inside = x >= r.x && x < r.y && y >= r.z && y < r.w; + if (!inside) { + v = o; + } + if (P.u[0].y != 0u) { + let nx = abs(x - r.x) < 0.75 || abs(x - r.y) < 0.75; + let ny = abs(y - r.z) < 0.75 || abs(y - r.w) < 0.75; + if ((nx && y >= r.z - 0.75 && y < r.w + 0.75) || (ny && x >= r.x - 0.75 && x < r.y + 0.75)) { + v = vec4(P.f[1].xyz, 1.0); + } + } + } + textureStore(out, p, v); +} diff --git a/crates/gpu/src/shaders/fx_stylize.wgsl b/crates/gpu/src/shaders/fx_stylize.wgsl new file mode 100644 index 00000000..bc119e8c --- /dev/null +++ b/crates/gpu/src/shaders/fx_stylize.wgsl @@ -0,0 +1,925 @@ +// GPU effects (stylize and distort family): see src/fx_stylize.rs. Each entry point mirrors the CPU effect in +// effectcraft-effects operation for operation. Every name here is prefixed `fxs_` (the +// family files share one module). Coordinates are pixel centres (x + 0.5, y + 0.5) in buffer +// pixels, as in util::remap. Per-column / per-row tables in `data` hold coordinates the CPU +// computes in f64 (anything with a floor or a wrap), so tile and wrap boundaries match exactly. + +const FXS_TAU: f32 = 6.2831855; +const FXS_PI: f32 = 3.1415927; +const FXS_HALF_PI: f32 = 1.5707964; + +fn fxs_inside(p: vec2) -> bool { + let d = out_dims(); + return p.x < d.x && p.y < d.y; +} + +fn fxs_centre(p: vec2) -> vec2 { + return vec2(f32(p.x) + 0.5, f32(p.y) + 0.5); +} + +// util::unpremul: straight colour (zero below alpha 1e-6) and alpha (≥ 0). +fn fxs_unpremul(px: vec4) -> vec4 { + let a = px.w; + if (a > 1e-6) { + return vec4(fxs_qdiv(px.xyz, a), a); + } + return vec4(0.0, 0.0, 0.0, max(a, 0.0)); +} + +// util::smoothstep. +fn fxs_smoothstep(e0: f32, e1: f32, x: f32) -> f32 { + if (abs(e1 - e0) < 1e-9) { + return select(1.0, 0.0, x < e0); + } + let t = clamp((x - e0) / (e1 - e0), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} + +// x / a rounded like the CPU's IEEE division (GPU division may be off by an ulp, which flips +// thresholds on 8 / 16 bpc colours that sit exactly on them): one residual correction. +fn fxs_qdiv(x: vec3, a: f32) -> vec3 { + let q = x / a; + let r = fma(-q, vec3(a), x); + return q + r / a; +} + +fn fxs_rem2(i: i32) -> i32 { + return ((i % 2) + 2) % 2; +} + +// ---------------------------------------------------------------- per-pixel colour effects +// u[0].x = mode: +// 1 Posterize (misc::posterize): f[0].x = levels − 1 +// 2 Threshold (misc::threshold): f[0].x = level +// 3 CC Threshold: u[0].yz = (channel, invert); f[0].xy = (threshold, blend) +// 4 CC Threshold RGB: u[0].yzw = invert R, G, B; f[0] = (thresholds, blend) +// 5 Strobe Light on colour: u[0].y = operator; f[0] = (strobe colour, blend) +// 6 Strobe Light transparent (Image::scale_alpha): f[0].x = factor +// 7 CC Vignette: f[0] = (cx, cy, distance, amount); f[1].x = pin highlights + +fn fxs_bin(v: f32, t: f32, inv: bool) -> f32 { + return select(0.0, 1.0, (v >= t) != inv); +} + +fn fxs_strobe_op(op: u32, v: f32, k: f32) -> f32 { + switch op { + case 1u: { return v + k; } + case 2u: { return max(v - k, 0.0); } + case 3u: { return v * k; } + case 4u: { return abs(v - k); } + case 5u: { return 1.0 - (1.0 - v) * (1.0 - k); } + case 6u: { return max(v, k); } + case 7u: { return min(v, k); } + default: { return k; } + } +} + +@compute @workgroup_size(16, 16) +fn fxs_point(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let px = textureLoad(src, p, 0); + let mode = P.u[0].x; + let a = px.w; + if (mode == 6u) { + textureStore(out, p, px * P.f[0].x); + return; + } + if (a <= 0.0) { + textureStore(out, p, px); + return; + } + switch mode { + case 1u: { + let lv = P.f[0].x; + let c = clamp(fxs_qdiv(px.xyz, a), vec3(0.0), vec3(1.0)) * lv; + let o = vec3(round_away(c.x), round_away(c.y), round_away(c.z)) / lv; + textureStore(out, p, vec4(o * a, a)); + } + case 2u: { + let v = select(0.0, 1.0, luminance(fxs_qdiv(px.xyz, a)) >= P.f[0].x); + textureStore(out, p, vec4(vec3(v) * a, a)); + } + case 3u: { + let u = fxs_unpremul(px); + let c = u.xyz; + let t = P.f[0].x; + let blend = P.f[0].y; + let inv = P.u[0].z != 0u; + var o = c; + var na = u.w; + switch P.u[0].y { + case 1u: { + o = vec3(fxs_bin(c.x, t, inv), fxs_bin(c.y, t, inv), fxs_bin(c.z, t, inv)); + } + case 2u: { + o = vec3(fxs_bin(rgb_to_hsl(c).y, t, inv)); + } + case 3u: { + na = fxs_bin(u.w, t, inv); + } + default: { + o = vec3(fxs_bin(luminance(c), t, inv)); + } + } + o = o + (c - o) * blend; + na = na + (u.w - na) * blend; + textureStore(out, p, vec4(o * na, na)); + } + case 4u: { + let c = fxs_qdiv(px.xyz, a); + let t = P.f[0].xyz; + let o = vec3(fxs_bin(c.x, t.x, P.u[0].y != 0u), fxs_bin(c.y, t.y, P.u[0].z != 0u), fxs_bin(c.z, t.z, P.u[0].w != 0u)); + let r = o + (c - o) * P.f[0].w; + textureStore(out, p, vec4(r * a, a)); + } + case 5u: { + let c = px.xyz / a; + let k = P.f[0].xyz; + let op = P.u[0].y; + let o = vec3(fxs_strobe_op(op, c.x, k.x), fxs_strobe_op(op, c.y, k.y), fxs_strobe_op(op, c.z, k.z)); + let r = o + (c - o) * P.f[0].w; + textureStore(out, p, vec4(r * a, a)); + } + case 7u: { + let u = fxs_unpremul(px); + let q = fxs_centre(p); + let dx = q.x - P.f[0].x; + let dy = q.y - P.f[0].y; + let r = sqrt(dx * dx + dy * dy) / P.f[0].z; + let f0 = 1.0 / (1.0 + r * r); + let f = f0 * f0; + var k = 1.0 - P.f[0].w * (1.0 - f); + let pin = P.f[1].x; + if (pin > 0.0) { + let l = clamp(luminance(u.xyz), 0.0, 1.0); + k += (1.0 - k) * pin * l; + } + textureStore(out, p, vec4(max(u.xyz * k, vec3(0.0)) * u.w, u.w)); + } + default: { + textureStore(out, p, px); + } + } +} + +// ---------------------------------------------------------------- inverse-mapped warps +// u[0].x = mode (all sample bilinear, transparent outside, unless noted): +// 1 Mirror (distort::mirror): f[0] = (cx, cy, nx, ny) +// 2 Spherize (distort::spherize): f[0] = (cx, cy, radius) +// 3 Polar Coordinates (distort::polar, edges repeated): u[0].y = to polar; +// f[0] = (w, h, cx, cy); f[1] = (rmax, interpolation) +// 4 CC Slant (distort3::cc_slant): f[0] = (floor x, floor y, 1 / height, slant); f[1].x = x scale +// 5 CC Smear (distort3::cc_smear): f[0] = (from x, from y, dx, dy); f[1] = (len², reach, radius) +// 6 CC Split (distort3::split_impl): f[0] = (ax, ay, ux, uy); f[1] = (len, split 1, split 2, reach) +// 7 Optics Compensation (distort2::optics_compensation): u[0].y = reverse; +// f[0] = (cx, cy, fit, f); f[1] = (reference radius, half FOV) +// 8 CC Kaleida (stylize2::kaleida, edges repeated): u[0].y = mirror; +// f[0] = (cx, cy, size, rotation); f[1].x = segment angle +// 9 Liquify (distort4::liquify): u[0].xy = (_, mesh nx, mesh ny); f[0] = (cell, mesh offset x, y, +// percentage); f[1] = (scale, buffer offset x, y); data = mesh offsets (x, y) per node +// 10 Twirl (Legacy) (distort3::twirl_legacy): f[0] = (cx, cy, radius, angle) +// 11 CC Ripple Pulse (distort3::cc_ripple_pulse): u[0].y = render bump; f[0] = (cx, cy, front, +// speed); f[1] = (time span, wavelength, height) +// 12 CC Power Pin, full perspective (distort3::cc_power_pin): f[2..5] = inverse of the +// square → quad matrix (rows); f[5] = source rect in layer pixels (x0, y0, w, h); +// f[6] = (scale, buffer offset x, y) + +fn fxs_mesh_d(i: u32, j: u32) -> vec2 { + let k = (j * P.u[0].y + i) * 2u; + return vec2(data[k], data[k + 1u]); +} + +// distort3::cc_ripple_pulse's wave. +fn fxs_pulse(r: f32) -> f32 { + let front = P.f[0].z; + let speed = P.f[0].w; + let span = P.f[1].x; + let age = (front - r) / max(speed, 1e-9); + if (age < 0.0 || age > span) { + return 0.0; + } + let fade = 1.0 - age / span; + return sin(FXS_TAU * (r - front) / P.f[1].y) * fade; +} + +// distort4::Mesh::at. +fn fxs_mesh_at(x: f32, y: f32) -> vec2 { + let cell = P.f[0].x; + let nx = P.u[0].y; + let ny = P.u[0].z; + let fx = x / cell; + let fy = y / cell; + if (fx < 0.0 || fy < 0.0 || fx > f32(nx - 1u) || fy > f32(ny - 1u)) { + return vec2(0.0); + } + let x0 = min(u32(floor(fx)), nx - 2u); + let y0 = min(u32(floor(fy)), ny - 2u); + let x1 = min(x0 + 1u, nx - 1u); + let y1 = min(y0 + 1u, ny - 1u); + let tx = fx - f32(x0); + let ty = fy - f32(y0); + let a = fxs_mesh_d(x0, y0); + let b = fxs_mesh_d(x1, y0); + let c = fxs_mesh_d(x0, y1); + let d = fxs_mesh_d(x1, y1); + let top = a + (b - a) * tx; + let bot = c + (d - c) * tx; + return top + (bot - top) * ty; +} + +@compute @workgroup_size(16, 16) +fn fxs_warp(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let q = fxs_centre(p); + let x = q.x; + let y = q.y; + var s = q; + var clamped = false; + switch P.u[0].x { + case 1u: { + let n = P.f[0].zw; + let d = (x - P.f[0].x) * n.x + (y - P.f[0].y) * n.y; + if (d > 0.0) { + s = vec2(x - 2.0 * d * n.x, y - 2.0 * d * n.y); + } + } + case 2u: { + let c = P.f[0].xy; + let r = P.f[0].z; + let dx = x - c.x; + let dy = y - c.y; + let d = sqrt(dx * dx + dy * dy); + if (d < r && d != 0.0) { + let nd = d / r; + let k = (asin(nd) / FXS_HALF_PI) / nd; + s = vec2(c.x + dx * k, c.y + dy * k); + } + } + case 3u: { + clamped = true; + let w = P.f[0].x; + let h = P.f[0].y; + let cx = P.f[0].z; + let cy = P.f[0].w; + let rmax = P.f[1].x; + var pp: vec2; + if (P.u[0].y != 0u) { + let dx = x - cx; + let dy = y - cy; + let a0 = atan2(dx, -dy); + let a = a0 - floor(a0 / FXS_TAU) * FXS_TAU; + let r = sqrt(dx * dx + dy * dy) / rmax; + pp = vec2(a / FXS_TAU * w, h - r * h); + } else { + let a = x / w * FXS_TAU; + let r = (h - y) / h * rmax; + pp = vec2(cx + r * sin(a), cy - r * cos(a)); + } + let amt = P.f[1].y; + s = vec2(x + (pp.x - x) * amt, y + (pp.y - y) * amt); + } + case 4u: { + let fl = P.f[0].xy; + s = vec2(fl.x + (x - fl.x - (fl.y - y) * P.f[0].w) / P.f[1].x, fl.y + (y - fl.y) * P.f[0].z); + } + case 5u: { + let fr = P.f[0].xy; + let dx = P.f[0].z; + let dy = P.f[0].w; + let t = clamp(((x - fr.x) * dx + (y - fr.y) * dy) / P.f[1].x, 0.0, 1.0); + let px = fr.x + dx * t; + let py = fr.y + dy * t; + let d = sqrt((x - px) * (x - px) + (y - py) * (y - py)); + let w0 = max(1.0 - d / P.f[1].z, 0.0); + let w = w0 * w0 * (3.0 - 2.0 * w0); + let k = P.f[1].y * w * t; + s = vec2(x - dx * k, y - dy * k); + } + case 6u: { + let a = P.f[0].xy; + let ux = P.f[0].z; + let uy = P.f[0].w; + let nx = -uy; + let ny = ux; + let len = P.f[1].x; + let reach = P.f[1].w; + let rx = x - a.x; + let ry = y - a.y; + let t = (rx * ux + ry * uy) / len; + let sd = rx * nx + ry * ny; + if (t >= 0.0 && t <= 1.0) { + let amt = select(P.f[1].z, P.f[1].y, sd >= 0.0); + let g = amt * reach * sin(FXS_PI * t); + let ad = abs(sd); + if (ad < g) { + textureStore(out, p, vec4(0.0)); + return; + } + let push = g * max(1.0 - (ad - g) / reach, 0.0); + let ns = select(-1.0, 1.0, sd >= 0.0) * (ad - push); + s = vec2(x + nx * (ns - sd), y + ny * (ns - sd)); + } + } + case 7u: { + let c = P.f[0].xy; + let fit = P.f[0].z; + let f = P.f[0].w; + let r_ref = P.f[1].x; + let half = P.f[1].y; + let dx = x - c.x; + let dy = y - c.y; + let r = sqrt(dx * dx + dy * dy) * fit; + if (r >= 1e-9) { + var rs: f32; + if (P.u[0].y != 0u) { + rs = atan(r / f) / half * r_ref; + } else { + let th = r / r_ref * half; + if (th >= FXS_HALF_PI - 1e-4) { + textureStore(out, p, vec4(0.0)); + return; + } + rs = f * tan(th); + } + let k = rs / r * fit; + s = vec2(c.x + dx * k, c.y + dy * k); + } + } + case 8u: { + clamped = true; + let c = P.f[0].xy; + let rot = P.f[0].w; + let seg = P.f[1].x; + let dx = x - c.x; + let dy = y - c.y; + let r = sqrt(dx * dx + dy * dy) / P.f[0].z; + let a0 = atan2(dy, dx) - rot; + var a = a0 - floor(a0 / seg) * seg; + if (P.u[0].y != 0u && a > seg * 0.5) { + a = seg - a; + } + s = vec2(c.x + r * cos(a + rot), c.y + r * sin(a + rot)); + } + case 9u: { + let scale = P.f[1].x; + let o = P.f[1].yz; + let lx = (x - o.x) / scale; + let ly = (y - o.y) / scale; + let d = fxs_mesh_at(lx - P.f[0].y, ly - P.f[0].z); + if (d.x == 0.0 && d.y == 0.0) { + textureStore(out, p, textureLoad(src, p, 0)); + return; + } + let pct = P.f[0].w; + s = vec2((lx + d.x * pct) * scale + o.x, (ly + d.y * pct) * scale + o.y); + } + case 10u: { + let c = P.f[0].xy; + let r = P.f[0].z; + let dx = x - c.x; + let dy = y - c.y; + let d = sqrt(dx * dx + dy * dy); + if (d < r) { + let a = -P.f[0].w * (1.0 - d / r); + let sn = sin(a); + let co = cos(a); + s = vec2(c.x + dx * co - dy * sn, c.y + dx * sn + dy * co); + } + } + case 11u: { + let c = P.f[0].xy; + let dx = x - c.x; + let dy = y - c.y; + let r = sqrt(dx * dx + dy * dy); + let height = P.f[1].z; + let d = fxs_pulse(r) * height; + var u = vec2(0.0); + if (r > 1e-9) { + u = vec2(dx / r, dy / r); + } + var o = sample_bilinear(src, x - u.x * d, y - u.y * d); + if (P.u[0].y != 0u && d != 0.0) { + let slope = (fxs_pulse(r + 0.5) - fxs_pulse(r - 0.5)) * height; + let k = clamp(1.0 - slope * 0.5, 0.0, 2.0); + o = vec4(o.xyz * k, o.w); + } + textureStore(out, p, o); + return; + } + case 12u: { + let r0 = P.f[2]; + let r1 = P.f[3]; + let r2 = P.f[4]; + var u = r0.x * x + r0.y * y + r0.z; + var v = r1.x * x + r1.y * y + r1.z; + let w = r2.x * x + r2.y * y + r2.z; + if (w != 1.0 && w != 0.0) { + u = u / w; + v = v / w; + } + if (u < -1e-9 || u > 1.0 + 1e-9 || v < -1e-9 || v > 1.0 + 1e-9) { + textureStore(out, p, vec4(0.0)); + return; + } + let rc = P.f[5]; + let lx = rc.x + u * rc.z; + let ly = rc.y + v * rc.w; + let o = P.f[6]; + s = vec2(lx * o.x + o.y, ly * o.x + o.z); + } + default: {} + } + if (clamped) { + textureStore(out, p, sample_bilinear_clamped(src, s.x, s.y)); + } else { + textureStore(out, p, sample_bilinear(src, s.x, s.y)); + } +} + +// ---------------------------------------------------------------- CC Flo Motion (distort4::flo_motion) + +// One knot's pull at layer point q: f[k] = (knot x, knot y, amount, _). +fn fxs_flo_knot(q: vec2, k: vec4) -> vec2 { + if (k.z == 0.0) { + return q; + } + let d = q - k.xy; + let r = sqrt(d.x * d.x + d.y * d.y) / P.f[2].y; + let w = powz(1.0 - min(r, 1.0), P.f[2].x); + let s = max(1.0 - clamp(k.z, -4.0, 0.95) * w, 0.02); + return k.xy + d * s; +} + +// u[0] = (supersampling, tile edges); f[0], f[1] = knots; f[2] = (falloff, radius, layer w, +// layer h); f[3] = (scale, buffer offset x, y) +@compute @workgroup_size(16, 16) +fn fxs_flomotion(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let ss = P.u[0].x; + let scale = P.f[3].x; + let off = P.f[3].yz; + let lw = P.f[2].z; + let lh = P.f[2].w; + var acc = vec4(0.0); + for (var sy = 0u; sy < ss; sy++) { + for (var sx = 0u; sx < ss; sx++) { + let px = f32(p.x) + (f32(sx) + 0.5) / f32(ss); + let py = f32(p.y) + (f32(sy) + 0.5) / f32(ss); + var q = vec2((px - off.x) / scale, (py - off.y) / scale); + q = fxs_flo_knot(q, P.f[0]); + q = fxs_flo_knot(q, P.f[1]); + if (P.u[0].y != 0u) { + q = vec2(q.x - floor(q.x / lw) * lw, q.y - floor(q.y / lh) * lh); + } + acc += sample_bilinear(src, q.x * scale + off.x, q.y * scale + off.y); + } + } + textureStore(out, p, acc / f32(ss * ss)); +} + +// ---------------------------------------------------------------- separable table warps +// The source point is (data[x], data[w + y]) (computed in f64 per column and per row), then +// mixed with the original: out = sample + (orig − sample) · blend (Offset, CC Tiler). +// u[0] = (buffer width, repeat edges); f[0].x = blend +@compute @workgroup_size(16, 16) +fn fxs_table(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let w = P.u[0].x; + let sx = data[u32(p.x)]; + let sy = data[w + u32(p.y)]; + var s: vec4; + if (P.u[0].y != 0u) { + s = sample_bilinear_clamped(src, sx, sy); + } else { + s = sample_bilinear(src, sx, sy); + } + let o = textureLoad(src, p, 0); + textureStore(out, p, s + (o - s) * P.f[0].x); +} + +// CC Griddler (distort2::cc_griddler). data: per column (tile centre x, local x), per row +// (tile centre y, local y). u[0] = (buffer width, cut tiles); f[0] = (cos, sin, h scale, v scale); +// f[1].x = cut half size (0.45 · tile) +@compute @workgroup_size(16, 16) +fn fxs_griddler(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let w = P.u[0].x; + let x = u32(p.x); + let y = u32(p.y); + let tcx = data[2u * x]; + let lx = data[2u * x + 1u]; + let tcy = data[2u * w + 2u * y]; + let ly = data[2u * w + 2u * y + 1u]; + if (P.u[0].y != 0u && max(abs(lx), abs(ly)) > P.f[1].x) { + textureStore(out, p, vec4(0.0)); + return; + } + let cr = P.f[0].x; + let sr = P.f[0].y; + let rx = lx * cr + ly * sr; + let ry = -lx * sr + ly * cr; + textureStore(out, p, sample_bilinear(src, tcx + rx / P.f[0].z, tcy + ry / P.f[0].w)); +} + +// Motion Tile (stylize2::motion_tile) into a new output size. data: per output column (tile +// index, fraction) of tx, then per row of ty. u[0] = (output width, horizontal phase shift, +// mirror edges); f[0] = (phase, layer w · scale, layer h · scale); f[1].xy = source buffer offset +@compute @workgroup_size(16, 16) +fn fxs_motiontile(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let w = P.u[0].x; + let x = u32(p.x); + let y = u32(p.y); + let ixc = data[2u * x]; + let fxc = data[2u * x + 1u]; + let iyr = data[2u * w + 2u * y]; + let fyr = data[2u * w + 2u * y + 1u]; + let phase = P.f[0].x; + var i = ixc; + var fx = fxc; + var j = iyr; + var fy = fyr; + if (P.u[0].y != 0u) { + let t = fxc + phase * iyr; + i = ixc + floor(t); + fx = t - floor(t); + } else { + let t = fyr + phase * ixc; + j = iyr + floor(t); + fy = t - floor(t); + } + if (P.u[0].z != 0u) { + if (fxs_rem2(i32(i)) == 1) { + fx = 1.0 - fx; + } + if (fxs_rem2(i32(j)) == 1) { + fy = 1.0 - fy; + } + } + textureStore(out, p, sample_bilinear(src, fx * P.f[0].y + P.f[1].x, fy * P.f[0].z + P.f[1].y)); +} + +// CC RepeTile (stylize2::repetile) into a new output size. data: per output column (tile index, +// position in the tile), then per row. u[0] = (output width, tiling); f[0] = layer rect (x0, y0, w, h) +@compute @workgroup_size(16, 16) +fn fxs_repetile(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let w = P.u[0].x; + let x = u32(p.x); + let y = u32(p.y); + let ix = i32(data[2u * x]); + var fx = data[2u * x + 1u]; + let iy = i32(data[2u * w + 2u * y]); + var fy = data[2u * w + 2u * y + 1u]; + let odd = fxs_rem2(ix + iy) == 1; + var flip_x = false; + var flip_y = false; + switch P.u[0].y { + case 1u: { + flip_x = fxs_rem2(ix) == 1; + flip_y = fxs_rem2(iy) == 1; + } + case 2u: { + flip_x = odd; + } + case 3u: { + flip_y = odd; + } + case 4u: { + flip_x = odd; + flip_y = odd; + } + default: {} + } + let r = P.f[0]; + if (flip_x) { + fx = r.z - fx; + } + if (flip_y) { + fy = r.w - fy; + } + textureStore(out, p, sample_bilinear(src, r.x + fx, r.y + fy)); +} + +// ---------------------------------------------------------------- Magnify (distort2::magnify) +// data: per column (dx, sx, floor(sx) + 0.5), then per row (dy, sy, floor(sy) + 0.5). +// u[0] = (buffer width, square, scaling, blend: 0 none, 1 normal, 2 blend mode); +// u[1] = (blend mode id, seed); f[0] = (size, feather, opacity, magnification); f[1].x = size − feather +@compute @workgroup_size(16, 16) +fn fxs_magnify(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let bw = P.u[0].x; + let x = u32(p.x); + let y = u32(p.y); + let dx = data[3u * x]; + let sx = data[3u * x + 1u]; + let nx = data[3u * x + 2u]; + let dy = data[3u * bw + 3u * y]; + let sy = data[3u * bw + 3u * y + 1u]; + let ny = data[3u * bw + 3u * y + 2u]; + var d: f32; + if (P.u[0].y != 0u) { + d = max(abs(dx), abs(dy)); + } else { + d = sqrt(dx * dx + dy * dy); + } + let size = P.f[0].x; + let feather = P.f[0].y; + var w: f32; + if (feather > 0.01) { + w = 1.0 - fxs_smoothstep(P.f[1].x, size, d); + } else { + w = clamp(size - d + 0.5, 0.0, 1.0); + } + w = w * P.f[0].z; + let px = textureLoad(src, p, 0); + let blend = P.u[0].w; + if (w <= 0.0) { + if (blend == 0u) { + textureStore(out, p, vec4(0.0)); + } else { + textureStore(out, p, px); + } + return; + } + var m: vec4; + switch P.u[0].z { + case 1u: { + m = sample_bicubic(src, sx, sy); + } + case 2u: { + let seed = P.u[1].y; + let k = max(1.0 - 1.0 / P.f[0].w, 0.0); + let j1 = (hash_noise(x, y ^ (1u << 20u), seed) - 0.5) * k; + let j2 = (hash_noise(x, y ^ (2u << 20u), seed) - 0.5) * k; + m = sample_bilinear(src, nx + j1, ny + j2); + } + default: { + m = sample_bilinear(src, nx, ny); + } + } + if (blend == 0u) { + textureStore(out, p, m * w); + } else if (blend == 1u) { + textureStore(out, p, px + (m - px) * w); + } else { + textureStore(out, p, blend_pixel(P.u[1].x, px, m * w, 0.5)); + } +} + +// ---------------------------------------------------------------- Scatter (stylize2::scatter) +// u[0] = (grain, seed); f[0].x = amount +@compute @workgroup_size(16, 16) +fn fxs_scatter(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let grain = P.u[0].x; + let seed = P.u[0].y; + let amt = P.f[0].x; + let x = u32(p.x); + let y = u32(p.y); + var dx = 0.0; + var dy = 0.0; + if (grain != 2u) { + dx = (hash_noise(x, y, seed) - 0.5) * 2.0 * amt; + } + if (grain != 1u) { + dy = (hash_noise(x, y, seed ^ 0x5bd1u) - 0.5) * 2.0 * amt; + } + textureStore(out, p, tex_get(src, i32(round_away(f32(p.x) + dx)), i32(round_away(f32(p.y) + dy)))); +} + +// ---------------------------------------------------------------- Brush Strokes (stylize2::brush_strokes) +// data: cell column per x (w), cell row per y (h), then per cell (dx, dy, length, jitter, samples, +// painted). u[0] = (buffer width, cell columns, paint surface, buffer height); f[0].x = blend +@compute @workgroup_size(16, 16) +fn fxs_brush(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let w = P.u[0].x; + let h = P.u[0].w; + let cx = u32(data[u32(p.x)]); + let cy = u32(data[w + u32(p.y)]); + let base = w + h + (cy * P.u[0].y + cx) * 6u; + let dx = data[base]; + let dy = data[base + 1u]; + let l = data[base + 2u]; + let jit = data[base + 3u]; + let n = u32(data[base + 4u]); + let m = data[base + 5u]; + let q = fxs_centre(p); + let bx = q.x - dy * jit; + let by = q.y + dx * jit; + var acc = vec4(0.0); + for (var i = 0u; i < n; i++) { + var t = 0.0; + if (n != 1u) { + t = (f32(i) / f32(n - 1u) - 0.5) * l; + } + acc += sample_bilinear_clamped(src, bx + dx * t, by + dy * t); + } + let stroke = acc / f32(n); + let orig = textureLoad(src, p, 0); + let surface = P.u[0].z; + var base_px = orig; + if (surface == 1u) { + base_px = vec4(0.0); + } else if (surface == 2u) { + base_px = vec4(1.0); + } else if (surface == 3u) { + base_px = vec4(0.0, 0.0, 0.0, 1.0); + } + var painted: vec4; + if (surface == 2u || surface == 3u) { + let k = 1.0 - stroke.w * m; + painted = stroke * m + base_px * k; + } else { + painted = base_px + (stroke - base_px) * m; + } + textureStore(out, p, painted + (orig - painted) * P.f[0].x); +} + +// ---------------------------------------------------------------- Roughen Edges (stylize2::roughen_edges) + +// noise::fbm. +fn fxs_fbm(u: f32, v: f32, z: f32, seed: u32, octaves0: f32) -> f32 { + let octaves = clamp(octaves0, 1.0, 20.0); + let n = u32(ceil(octaves)); + let frac = octaves - floor(octaves); + var sum = 0.0; + var norm = 0.0; + var amp = 1.0; + var f = 1.0; + for (var o = 0u; o < n; o++) { + var w = 1.0; + if (o + 1u == n && frac > 0.0) { + w = frac; + } + sum += value_noise(u * f, v * f, z + f32(o) * 7.31, seed + o) * amp * w; + norm += amp * w; + amp *= 0.5; + f *= 2.0; + } + return sum / max(norm, 1e-6); +} + +// aux = the (blurred) alpha in .w. u[0] = (seed, cycle evolution, coloured); +// f[0] = (offset x, offset y, scale · stretch x, scale · stretch y); f[1] = (frequency, evolution, +// cycle, cycle weight); f[2] = (octaves, influence, influence multiplier, sharpness); +// f[3] = (edge colour, sharpness multiplier) +@compute @workgroup_size(16, 16) +fn fxs_roughen(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let px = textureLoad(src, p, 0); + if (px.w <= 0.0) { + textureStore(out, p, px); + return; + } + let dv = textureLoad(aux, p, 0).w; + let fm = P.f[1].x; + let nx = (f32(p.x) + 0.5 - P.f[0].x) / P.f[0].z * fm; + let ny = (f32(p.y) + 0.5 - P.f[0].y) / P.f[0].w * fm; + let seed = P.u[0].x; + let evo = P.f[1].y; + let oct = P.f[2].x; + var n = fxs_fbm(nx, ny, evo, seed, oct); + if (P.u[0].y != 0u) { + n += (fxs_fbm(nx, ny, evo - P.f[1].z, seed, oct) - n) * P.f[1].w; + } + let infl = P.f[2].y; + let s = dv - 0.5 + (n - 0.5) * infl * P.f[2].z * 0.5; + let m = clamp(s * P.f[2].w * P.f[3].w * 4.0 + 0.5, 0.0, 1.0); + let u = fxs_unpremul(px); + var c = u.xyz; + if (P.u[0].z != 0u) { + let band = 1.0 - fxs_smoothstep(0.5, 0.9, dv + (n - 0.5) * infl * 0.5); + c = c + (P.f[3].xyz - c) * band; + } + let a = u.w * m; + textureStore(out, p, vec4(c * a, a)); +} + +// ---------------------------------------------------------------- Texturize (stylize2::texturize) + +// The texture layer placed into the buffer (transition::place_layer) as luminance +// (Plane::luma). aux = the texture layer's buffer; data: per column x, then per row y in its +// pixels. u[0].x = buffer width +@compute @workgroup_size(16, 16) +fn fxs_tex_luma(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let w = P.u[0].x; + let t = sample_bilinear(aux, data[u32(p.x)], data[w + u32(p.y)]); + let l = luminance(fxs_unpremul(t).xyz); + textureStore(out, p, vec4(l)); +} + +// Relief shading. aux = the blurred texture luminance; f[0] = (light x, light y, contrast) +@compute @workgroup_size(16, 16) +fn fxs_texturize(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let gx = (tex_get_clamped(aux, p.x + 1, p.y).x - tex_get_clamped(aux, p.x - 1, p.y).x) * 0.5; + let gy = (tex_get_clamped(aux, p.x, p.y + 1).x - tex_get_clamped(aux, p.x, p.y - 1).x) * 0.5; + let shade = max(1.0 - P.f[0].z * 4.0 * (gx * P.f[0].x + gy * P.f[0].y), 0.0); + let px = textureLoad(src, p, 0); + textureStore(out, p, vec4(px.xyz * shade, px.w)); +} + +// ---------------------------------------------------------------- Glow (misc::glow) extras + +// Bright pass with the Arbitrary Map: as `glow_bright` (same f[0] / u[0] layout, see +// kernels.wgsl) with the red, green and blue curves at data offsets f[1].xyz (−1 = identity). +@compute @workgroup_size(16, 16) +fn fxs_glow_map(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let px = textureLoad(src, p, 0); + let a = px.w; + if (a <= 0.0) { + textureStore(out, p, vec4(0.0)); + return; + } + let thr = P.f[0].x; + var l = luminance(px.xyz / a); + if (P.u[0].y != 0u) { + l = a; + } + let k = clamp((l - thr) / max(1.0 - thr, 1e-3), 0.0, 1.0); + let t = glow_ab_t(l); + let c = vec3(curve_eval(P.f[1].x, t), curve_eval(P.f[1].y, t), curve_eval(P.f[1].z, t)); + textureStore(out, p, vec4(c * k * a, k * a)); +} + +// On Top with another Glow Operation: blend_pixel(mode, original, glow · intensity, 0). +// aux = the blurred glow; u[0].x = blend mode id; f[0].x = intensity +@compute @workgroup_size(16, 16) +fn fxs_glow_op(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let g = textureLoad(aux, p, 0) * P.f[0].x; + var o = blend_pixel(P.u[0].x, textureLoad(src, p, 0), g, 0.0); + o.w = clamp(o.w, 0.0, 1.0); + textureStore(out, p, o); +} + +// ---------------------------------------------------------------- accumulation (Transform motion blur) + +// out = src + aux +@compute @workgroup_size(16, 16) +fn fxs_add(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + textureStore(out, p, textureLoad(src, p, 0) + textureLoad(aux, p, 0)); +} + +// out = src / f[0].x +@compute @workgroup_size(16, 16) +fn fxs_div(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + textureStore(out, p, textureLoad(src, p, 0) / P.f[0].x); +} diff --git a/crates/gpu/src/shaders/fx_tone.wgsl b/crates/gpu/src/shaders/fx_tone.wgsl new file mode 100644 index 00000000..39db3458 --- /dev/null +++ b/crates/gpu/src/shaders/fx_tone.wgsl @@ -0,0 +1,729 @@ +// GPU effects (tonal and colour correction family): see src/fx_tone.rs. Each operation mirrors +// the CPU effect in effectcraft-effects operation for operation. Every name here is prefixed +// `fxt_` (the family files share one module). + +fn fxt_straight(px: vec4) -> vec3 { + if (px.w > 1e-6) { + return px.xyz / px.w; + } + return vec3(0.0); +} + +fn fxt_mix(a: vec3, b: vec3, t: f32) -> vec3 { + return a + (b - a) * t; +} + +// f32::rem_euclid(360.0). +fn fxt_rem360(x: f32) -> f32 { + let r = x - 360.0 * floor(x / 360.0); + return select(r, 0.0, r >= 360.0); +} + +// Levels (Individual Controls) channel `i` (data: 5 × (ib, iw, g, ob, ow, identity)). +fn fxt_lv(i: u32, v: f32, clip_b: bool, clip_w: bool) -> f32 { + let o = i * 6u; + if (data[o + 5u] != 0.0) { + return v; + } + var t = (v - data[o]) / max(data[o + 1u] - data[o], 1e-6); + if (clip_b) { + t = max(t, 0.0); + } + if (clip_w) { + t = min(t, 1.0); + } + return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * powz(max(t, 0.0), 1.0 / max(data[o + 2u], 0.01)); +} + +// color_fx::level (data offset o: ib, iw, g, ob, ow, clip black, clip white). +fn fxt_level(o: u32, v: f32) -> f32 { + var t = (v - data[o]) / max(data[o + 1u] - data[o], 1e-6); + if (data[o + 5u] != 0.0) { + t = max(t, 0.0); + } + if (data[o + 6u] != 0.0) { + t = min(t, 1.0); + } + t = powz(max(t, 0.0), 1.0 / data[o + 2u]); + return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * t; +} + +fn fxt_level_ident(o: u32) -> bool { + return data[o] == 0.0 && data[o + 1u] == 1.0 && data[o + 2u] == 1.0 && data[o + 3u] == 0.0 && data[o + 4u] == 1.0; +} + +fn fxt_adjust_sat(s: f32, sat: f32) -> f32 { + if (sat >= 0.0) { + return s + (1.0 - s) * sat * min(s, 1.0); + } + return s * (1.0 + sat); +} + +fn fxt_adjust_light(l: f32, light: f32) -> f32 { + if (light >= 0.0) { + return l + (1.0 - l) * light; + } + return l * (1.0 + light); +} + +// HueRange::weight for range `r` (data: start, end, fall start, fall end, hue, sat, light). +fn fxt_range_weight(r: u32, h: f32) -> f32 { + let o = r * 7u; + let width = fxt_rem360(data[o + 1u] - data[o]); + let d = fxt_rem360(h - data[o]); + if (d <= width) { + return 1.0; + } + let after = d - width; + let before = 360.0 - d; + var wa = 0.0; + if (data[o + 3u] > 0.0) { + wa = 1.0 - after / data[o + 3u]; + } + var wb = 0.0; + if (data[o + 2u] > 0.0) { + wb = 1.0 - before / data[o + 2u]; + } + return clamp(max(wa, wb), 0.0, 1.0); +} + +// color3::colour_match. +fn fxt_colour_match(c: vec3, key: vec3, key_hue: f32, mode: u32, tol: f32, soft: f32) -> f32 { + var d: f32; + if (mode == 0u) { + d = sqrt(dot(c - key, c - key)) / sqrt(3.0); + } else if (mode == 1u) { + let hs = rgb_to_hsl(c); + let dh0 = abs(hs.x - key_hue); + let dh = min(dh0, 1.0 - dh0) * 2.0; + d = select(dh, 1.0, hs.y < 0.02); + } else { + let sa = max(c.x + c.y + c.z, 1e-6); + let sk = max(key.x + key.y + key.z, 1e-6); + let a = vec2(c.x / sa, c.y / sa); + let k = vec2(key.x / sk, key.y / sk); + d = sqrt(dot(a - k, a - k)) * 1.5; + } + if (d <= tol) { + return 1.0; + } + if (soft <= 1e-6) { + return 0.0; + } + return clamp(1.0 - (d - tol) / soft, 0.0, 1.0); +} + +// color3::knee. +fn fxt_knee(v: f32, start: f32, limit: f32) -> f32 { + if (v <= start || limit <= start) { + return min(v, limit); + } + let r = limit - start; + return start + r * (1.0 - exp(-(v - start) / r)); +} + +// PS Arbitrary Map lookup (map in data, n entries; f[0].x = phase offset in entries). +fn fxt_lookup(v: f32, n: u32) -> f32 { + let nf = f32(n); + var x = clamp(v, 0.0, 1.0) * (nf - 1.0) + P.f[0].x; + x = x - nf * floor(x / nf); + let fl = floor(x); + let i0 = u32(fl) % n; + let i1 = select(i0, i0 + 1u, i0 + 1u < n); + let t = x - fl; + return data[i0] + (data[i1] - data[i0]) * t; +} + +fn fxt_eq_look(table: u32, n: u32, v: f32) -> f32 { + let i = min(u32(round_away(clamp(v, 0.0, 1.0) * f32(n - 1u))), n - 1u); + return data[table * n + i]; +} + +// Per-pixel colour corrections. u[0].x = op (layouts in src/fx_tone.rs): +// 1 Levels (Individual Controls) 2 Gamma/Pedestal/Gain 3 Photo Filter 4 Change to Color +// 5 Leave Color 6 Change Color 7 Broadcast Colors 8 Color Balance (HLS) 9 CC Toner +// 10 CC Color Offset 11 CC Kernel 12 PS Arbitrary Map 13 Video Limiter +// 14 Hue/Saturation with colour ranges 15 Levels with channel controls +// 16 Auto Levels / Contrast / Color apply 17 Equalize apply 18 Invert (HLS / YIQ channels) +// 19 luminance plane 20 Shadow/Highlight (aux = (shadow base, highlight base)) +// 21 pack src.x, aux.x 22 Shadow/Highlight clip remap 23 CC Color Neutralizer +// 24 Color Stabilizer (data: per channel n, then 5 × (x, y)) +@compute @workgroup_size(16, 16) +fn fxt_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; + let u0 = P.u[0]; + let f0 = P.f[0]; + // Image::map_straight: transparent pixels stay as they are. + let straight_skip = a <= 0.0; + let c = fxt_straight(px); + var o = px; + switch u0.x { + case 1u: { + let cb = u0.y != 0u; + let cw = u0.z != 0u; + // color2::map_ca. + if (a <= 0.0 && px.x == 0.0 && px.y == 0.0 && px.z == 0.0) { + let na = clamp(fxt_lv(4u, 0.0, cb, cw), 0.0, 1.0); + if (na > 0.0) { + o = vec4(0.0, 0.0, 0.0, clamp(na, 0.0, 1.0)); + } + } else { + let aa = max(a, 0.0); + var nc: vec3; + for (var i = 0u; i < 3u; i++) { + nc[i] = fxt_lv(i + 1u, fxt_lv(0u, c[i], cb, cw), cb, cw); + } + let na = clamp(clamp(fxt_lv(4u, aa, cb, cw), 0.0, 1.0), 0.0, 1.0); + o = vec4(nc * na, na); + } + } + case 2u: { + if (straight_skip) { + break; + } + let stretch = P.f[3].x; + var r: vec3; + for (var i = 0; i < 3; i++) { + var v = c[i]; + if (stretch > 0.0 && v > 0.0 && v < 1.0) { + let q = 1.0 - v; + v = v + stretch * 0.25 * v * (q * q * q * q); + } + let g = P.f[i]; + r[i] = max(g.y + (g.z - g.y) * powz(max(v, 0.0), g.x), 0.0); + } + o = vec4(r * a, a); + } + case 3u: { + if (straight_skip) { + break; + } + var r = fxt_mix(c, c * f0.xyz, f0.w); + if (u0.y != 0u) { + let l0 = luminance(c); + let l1 = max(luminance(r), 1e-6); + r = r * l0 / l1; + } + o = vec4(r * a, a); + } + case 4u: { + if (straight_skip) { + break; + } + let fr = f0.xyz; + let tc = P.f[1].xyz; + let tol = P.f[2].xyz; + let soft = f0.w; + let hsl = rgb_to_hsl(c); + let d = vec3(abs(fract_euclid(hsl.x - fr.x + 0.5) - 0.5) * 2.0, abs(hsl.z - fr.z), abs(hsl.y - fr.y)); + var k = 1.0; + for (var i = 0; i < 3; i++) { + let over = d[i] - tol[i]; + if (over > 0.0) { + k = min(k, select(clamp(1.0 - over / soft, 0.0, 1.0), 0.0, soft <= 1e-6)); + } + } + let ch_l = (u0.y & 1u) != 0u; + let ch_s = (u0.y & 2u) != 0u; + var r = c; + if ((u0.y & 8u) != 0u) { + r = vec3(k); + } else if (k > 0.0) { + var nh: f32; + var ns = hsl.y; + var nl = hsl.z; + if ((u0.y & 4u) != 0u) { + let dh = (fract_euclid(tc.x - fr.x + 0.5) - 0.5) * k; + nh = fract_euclid(hsl.x + dh); + if (ch_s) { + ns = hsl.y + (tc.y - fr.y) * k; + } + if (ch_l) { + nl = hsl.z + (tc.z - fr.z) * k; + } + } else { + let dh = (fract_euclid(tc.x - hsl.x + 0.5) - 0.5) * k; + nh = fract_euclid(hsl.x + dh); + if (ch_s) { + ns = hsl.y + (tc.y - hsl.y) * k; + } + if (ch_l) { + nl = hsl.z + (tc.z - hsl.z) * k; + } + } + r = hsl_to_rgb(nh, clamp(ns, 0.0, 1.0), clamp(nl, 0.0, 1.0)); + } + o = vec4(r * a, a); + } + case 5u: { + if (straight_skip) { + break; + } + let keep = f0.xyz; + let amt = P.f[1].x; + let tol = P.f[1].y; + let soft = P.f[1].z; + var d: f32; + if (u0.y != 0u) { + let hsl = rgb_to_hsl(c); + d = select(abs(fract_euclid(hsl.x - f0.w + 0.5) - 0.5) * 2.0, 1.0, hsl.y < 0.02); + } else { + d = sqrt(dot(c - keep, c - keep)) / sqrt(3.0); + } + var keepk: f32; + if (tol >= 1.0 || d <= tol) { + keepk = 1.0; + } else if (soft <= 1e-6) { + keepk = 0.0; + } else { + keepk = clamp(1.0 - (d - tol) / soft, 0.0, 1.0); + } + let g = luminance(c); + let r = fxt_mix(c, vec3(g), amt * (1.0 - keepk)); + o = vec4(r * a, a); + } + case 6u: { + if (a <= 0.0) { + break; + } + var m = fxt_colour_match(c, f0.xyz, f0.w, u0.y, P.f[2].x, P.f[2].y); + if ((u0.z & 2u) != 0u) { + m = 1.0 - m; + } + var r = c; + if ((u0.z & 1u) != 0u) { + r = vec3(m); + } else if (m > 0.0) { + let hsl = rgb_to_hsl(c); + let dh = P.f[1].x; + let dl = P.f[1].y; + let ds = P.f[1].z; + let h2 = fract_euclid(hsl.x + dh); + let s2 = clamp(hsl.y + ds * select(hsl.y, 1.0 - hsl.y, ds > 0.0), 0.0, 1.0); + let l2 = clamp(hsl.z + dl * select(hsl.z, 1.0 - hsl.z, dl > 0.0), 0.0, 1.0); + r = fxt_mix(c, hsl_to_rgb(h2, s2, l2), m); + } + o = vec4(r * a, a); + } + case 7u: { + if (a <= 0.0) { + break; + } + let pal = u0.y != 0u; + let y = 0.299 * c.x + 0.587 * c.y + 0.114 * c.z; + var ch: f32; + if (pal) { + let uu = 0.492 * (c.z - y); + let vv = 0.877 * (c.x - y); + ch = sqrt(uu * uu + vv * vv); + } else { + let ii = 0.596 * c.x - 0.274 * c.y - 0.322 * c.z; + let qq = 0.211 * c.x - 0.523 * c.y + 0.312 * c.z; + ch = sqrt(ii * ii + qq * qq); + } + let max_norm = f0.x; + let unsafe_px = y + ch > max_norm + 1e-5; + switch u0.z { + case 2u: { + if (unsafe_px) { + o = vec4(0.0); + } + } + case 3u: { + if (!unsafe_px) { + o = vec4(0.0); + } + } + case 1u: { + if (unsafe_px) { + let target_v = max(max_norm - y, 0.0); + let s = select(1.0, target_v / ch, ch > 1e-6); + var r = y + (c - y) * s; + if (y > max_norm) { + r = r * max_norm / y; + } + o = vec4(r * a, a); + } + } + default: { + if (unsafe_px) { + let k = max_norm / (y + ch); + o = vec4(c * k * a, a); + } + } + } + } + case 8u: { + if (straight_skip) { + break; + } + let hsl = rgb_to_hsl(c); + let dh = f0.x; + let dl = f0.y; + let ds = f0.z; + var l = hsl.z; + if (dl > 0.0) { + l = l + (1.0 - l) * dl; + } else { + l = l + l * dl; + } + var s = hsl.y; + if (ds > 0.0) { + s = s + (1.0 - s) * ds; + } else { + s = s + s * ds; + } + let r = hsl_to_rgb(hsl.x + dh, clamp(s, 0.0, 1.0), clamp(l, 0.0, 1.0)); + o = vec4(r * a, a); + } + case 9u: { + if (straight_skip) { + break; + } + let n = u0.y; + let t = clamp(luminance(c), 0.0, 1.0) * f32(n); + let k = min(u32(floor(t)), n - 1u); + let tone = fxt_mix(P.f[1u + k].xyz, P.f[2u + k].xyz, t - f32(k)); + let r = fxt_mix(tone, c, f0.x); + o = vec4(r * a, a); + } + case 10u: { + if (straight_skip) { + break; + } + var r = c; + for (var k = 0; k < 3; k++) { + if (f0[k] == 0.0) { + continue; + } + let x = c[k] + f0[k]; + if (x <= 1.0) { + r[k] = x; + } else if (u0.y == 1u) { + r[k] = max(2.0 - x, 0.0); + } else if (u0.y == 2u) { + r[k] = select(1.0, 0.0, x - 1.0 < 0.5); + } else { + r[k] = fract_euclid(x); + } + } + o = vec4(r * a, a); + } + case 11u: { + if (a <= 0.0) { + break; + } + var acc = vec3(0.0); + for (var j = 0; j < 3; j++) { + for (var i = 0; i < 3; i++) { + let idx = j * 3 + i; + let w = P.f[idx / 4][idx % 4]; + if (w == 0.0) { + continue; + } + let q = tex_get_clamped(src, p.x + i - 1, p.y + j - 1); + acc += fxt_straight(q) * w; + } + } + var v = acc * P.f[2].y + P.f[2].z; + if (u0.y != 0u) { + v = abs(v); + } + o = vec4(max(v, vec3(0.0)) * a, a); + } + case 12u: { + let n = u0.z; + let aa = max(a, 0.0); + let r = vec3(fxt_lookup(c.x, n), fxt_lookup(c.y, n), fxt_lookup(c.z, n)); + var na = aa; + if (u0.y != 0u) { + na = fxt_lookup(aa, n); + } + o = vec4(r * na, na); + } + case 13u: { + if (a <= 0.0) { + break; + } + let level = f0.x; + let start = f0.y; + let y = luminance(c); + var r = c; + if (u0.y != 1u) { + let y2 = fxt_knee(max(y, 0.0), start, level); + r = r - y + y2; + } + if (u0.y != 0u) { + let yl = luminance(r); + var t = 1.0; + for (var i = 0; i < 3; i++) { + let v = r[i]; + if (v > level && v - yl > 1e-6) { + t = min(t, (level - yl) / (v - yl)); + } + if (v < 0.0 && yl - v > 1e-6) { + t = min(t, yl / (yl - v)); + } + } + t = clamp(t, 0.0, 1.0); + r = yl + (r - yl) * t; + } + r = clamp(r, vec3(0.0), vec3(level)); + if (u0.z != 0u && any(abs(c - r) > vec3(1e-4))) { + r = P.f[1].xyz; + } + o = vec4(r * a, a); + } + case 14u: { + if (straight_skip) { + break; + } + let hsl = rgb_to_hsl(c); + var h = hsl.x; + var s = hsl.y; + var l = hsl.z; + if (u0.y != 0u) { + let cl = P.f[1].z; + h = P.f[1].x; + s = P.f[1].y; + l = clamp(l + cl * select(l, 1.0 - l, cl > 0.0), 0.0, 1.0); + } else { + var s2 = fxt_adjust_sat(s, f0.y); + var l2 = fxt_adjust_light(l, f0.z); + var h2 = h + f0.x; + for (var r = 0u; r < u0.z; r++) { + let w = fxt_range_weight(r, hsl.x * 360.0) * min(hsl.y * 20.0, 1.0); + if (w > 0.0) { + let ro = r * 7u; + h2 += data[ro + 4u] * w; + s2 = fxt_adjust_sat(clamp(s2, 0.0, 1.0), data[ro + 5u] * w); + l2 = fxt_adjust_light(l2, data[ro + 6u] * w); + } + } + h = fract_euclid(h2); + s = clamp(s2, 0.0, 1.0); + l = l2; + } + o = vec4(hsl_to_rgb(h, s, l) * a, a); + } + case 15u: { + var r = c; + if (!straight_skip) { + for (var i = 0u; i < 3u; i++) { + r[i] = fxt_level(0u, c[i]); + } + } + // The master pass writes premultiplied values; the channel pass reads them back. + var q = px; + if (!straight_skip) { + q = vec4(r * a, a); + } + let qc = fxt_straight(q); + let qa = max(q.w, 0.0); + var any_ch = false; + for (var k = 0u; k < 4u; k++) { + if (!fxt_level_ident(7u + 7u * k)) { + any_ch = true; + } + } + if (!any_ch) { + o = q; + break; + } + var oc = qc; + for (var i = 0u; i < 3u; i++) { + let off = 7u + 7u * i; + if (!fxt_level_ident(off)) { + oc[i] = fxt_level(off, qc[i]); + } + } + var na = qa; + if (!fxt_level_ident(28u)) { + na = clamp(fxt_level(28u, qa), 0.0, 1.0); + } + o = vec4(oc * na, na); + } + case 16u: { + if (a <= 0.0 && px.x == 0.0 && px.y == 0.0 && px.z == 0.0) { + break; + } + let lo = f0.xyz; + let hi = P.f[1].xyz; + let g = P.f[2]; + var r: vec3; + for (var k = 0; k < 3; k++) { + let st = clamp((c[k] - lo[k]) / (hi[k] - lo[k]), 0.0, 1.0); + r[k] = powz(st, g[k]); + } + r = fxt_mix(r, c, g.w); + let na = clamp(max(a, 0.0), 0.0, 1.0); + o = vec4(r * na, na); + } + case 17u: { + if (a <= 0.0 && px.x == 0.0 && px.y == 0.0 && px.z == 0.0) { + break; + } + let n = u0.z; + let amt = f0.x; + var r: vec3; + if (u0.y == 1u) { + let l = luminance(c); + let nl = fxt_eq_look(0u, n, l); + if (l > 1e-5) { + r = c * nl / l; + } else { + r = vec3(nl); + } + } else if (u0.y == 2u) { + r = vec3(fxt_eq_look(0u, n, c.x), fxt_eq_look(0u, n, c.y), fxt_eq_look(0u, n, c.z)); + } else { + r = vec3(fxt_eq_look(0u, n, c.x), fxt_eq_look(1u, n, c.y), fxt_eq_look(2u, n, c.z)); + } + r = fxt_mix(c, r, amt); + let na = clamp(max(a, 0.0), 0.0, 1.0); + o = vec4(r * na, na); + } + case 18u: { + if (a <= 0.0) { + break; + } + let ch = u0.y; + var r: vec3; + if (ch <= 7u) { + var hsl = rgb_to_hsl(c); + if (ch == 4u || ch == 5u) { + hsl.x = 1.0 - hsl.x; + } + if (ch == 4u || ch == 6u) { + hsl.z = 1.0 - hsl.z; + } + if (ch == 4u || ch == 7u) { + hsl.y = 1.0 - hsl.y; + } + r = hsl_to_rgb(hsl.x, hsl.y, hsl.z); + } else { + var q = vec3( + 0.299 * c.x + 0.587 * c.y + 0.114 * c.z, + 0.596 * c.x - 0.274 * c.y - 0.322 * c.z, + 0.211 * c.x - 0.523 * c.y + 0.312 * c.z, + ); + if (ch == 8u || ch == 9u) { + q.x = 1.0 - q.x; + } + if (ch == 8u || ch == 10u) { + q.y = -q.y; + } + if (ch == 8u || ch == 11u) { + q.z = -q.z; + } + r = max( + vec3(q.x + 0.956 * q.y + 0.621 * q.z, q.x - 0.272 * q.y - 0.647 * q.z, q.x - 1.106 * q.y + 1.703 * q.z), + vec3(0.0), + ); + } + o = vec4((c + (r - c) * f0.x) * a, a); + } + case 19u: { + o = vec4(luminance(c)); + } + case 20u: { + if (a <= 0.0) { + break; + } + let bases = textureLoad(aux, p, 0); + let s_amt = f0.x; + let h_amt = f0.y; + let ws0 = clamp(1.0 - bases.x / f0.z, 0.0, 1.0); + let wh0 = clamp((bases.y - (1.0 - f0.w)) / f0.w, 0.0, 1.0); + let ws = ws0 * ws0; + let wh = wh0 * wh0; + let gain = (1.0 + s_amt * ws * 2.0) * (1.0 - h_amt * wh * 0.5); + let l = luminance(c); + let nl = l * gain; + let sat = pow(max(gain, 1e-3), P.f[1].x); + var r = nl + (c - l) * sat; + let mc = P.f[1].y; + if (mc != 0.0) { + let shift = (0.5 + (nl - 0.5) * (1.0 + mc)) - nl; + r = r + shift; + } + r = fxt_mix(max(r, vec3(0.0)), c, P.f[1].z); + o = vec4(r * a, a); + } + case 21u: { + o = vec4(px.x, textureLoad(aux, p, 0).x, 0.0, 0.0); + } + case 22u: { + if (a <= 0.0 && px.x == 0.0 && px.y == 0.0 && px.z == 0.0) { + break; + } + let r = max(f0.x + (c - f0.y) * f0.z, vec3(0.0)); + let na = clamp(max(a, 0.0), 0.0, 1.0); + o = vec4(r * na, na); + } + case 23u: { + if (a <= 0.0) { + break; + } + let l = clamp(luminance(c), 0.0, 1.0); + let ws = 1.0 - fxt_smoothstep(0.0, 0.5, l); + let wh = fxt_smoothstep(0.5, 1.0, l); + let wm = 1.0 - ws - wh; + let pin = P.f[3].x; + let keep = 1.0 - pin * clamp(1.0 - 4.0 * l * (1.0 - l), 0.0, 1.0); + let contrast = P.f[3].z; + let darks = P.f[4].x; + let brights = P.f[4].y; + var r: vec3; + for (var k = 0; k < 3; k++) { + let d = P.f[0][k] * ws + P.f[1][k] * wm + P.f[2][k] * wh; + var v = c[k] + d * keep; + v = 0.5 + (v - 0.5) * (1.0 + contrast); + v += darks * 0.25 * (1.0 - l) * (1.0 - l) + brights * 0.25 * l * l; + r[k] = max(v, 0.0); + } + r = fxt_mix(r, c, P.f[3].y); + o = vec4(r * a, a); + } + case 24u: { + if (a <= 0.0) { + break; + } + var r: vec3; + for (var k = 0u; k < 3u; k++) { + let base = k * 11u; + let n = u32(data[base]); + let v = c[k]; + // slice::partition_point(x < v), clamped to 1..n-1. + var i = 0u; + while (i < n && data[base + 1u + 2u * i] < v) { + i++; + } + i = clamp(i, 1u, n - 1u); + let ax = data[base + 1u + 2u * (i - 1u)]; + let ay = data[base + 2u + 2u * (i - 1u)]; + let cx = data[base + 1u + 2u * i]; + let cy = data[base + 2u + 2u * i]; + r[k] = ay + (cy - ay) * (v - ax) / max(cx - ax, 1e-9); + } + o = vec4(r * a, a); + } + default: {} + } + textureStore(out, p, o); +} + +// util::smoothstep. +fn fxt_smoothstep(e0: f32, e1: f32, x: f32) -> f32 { + if (abs(e1 - e0) < 1e-9) { + return select(1.0, 0.0, x < e0); + } + let t = clamp((x - e0) / (e1 - e0), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index f795b58d..7a3a1712 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -511,7 +511,7 @@ fn effect_chains_and_mixed_stacks() { let mut l = s.footage(80, 50); s.effect(&mut l, "ec.color.exposure", &[("master/exposure", n(0.7))]); s.effect(&mut l, "ec.blur.gaussian", &[("blurriness", n(5.0))]); - s.effect(&mut l, "ec.stylize.posterize", &[]); + s.effect(&mut l, "ec.stylize.coloremboss", &[]); s.effect(&mut l, "ec.stylize.glow", &[]); s.effect(&mut l, "ec.channel.invert", &[("blend", n(50.0))]); s.push(l); diff --git a/crates/gpu/src/tests_adjust.rs b/crates/gpu/src/tests_adjust.rs index ae7f88da..bc6be0cf 100644 --- a/crates/gpu/src/tests_adjust.rs +++ b/crates/gpu/src/tests_adjust.rs @@ -57,7 +57,7 @@ fn gpu_adjustment_chains_match_the_cpu() { #[test] fn mixed_adjustment_chains_fall_back_per_effect() { let mut stack = gpu_stack(); - stack.insert(1, ("ec.stylize.posterize", vec![])); + stack.insert(1, ("ec.stylize.coloremboss", vec![])); stack.push(("ec.stylize.glow", vec![("threshold", n(30.0))])); for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { let s = adjustment_scene(depth, &stack, true); @@ -93,7 +93,7 @@ impl FxTarget for Probe { fn adjustment_stacks_split_into_gpu_runs_and_cpu_steps() { let Some(g) = gpu() else { return }; let mut stack = gpu_stack(); - stack.insert(1, ("ec.stylize.posterize", vec![])); + stack.insert(1, ("ec.stylize.coloremboss", vec![])); let s = adjustment_scene(BitDepth::Bpc32, &stack, false); let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); r.accel = Some(g); diff --git a/crates/gpu/src/tests_fx_key.rs b/crates/gpu/src/tests_fx_key.rs new file mode 100644 index 00000000..76c7e831 --- /dev/null +++ b/crates/gpu/src/tests_fx_key.rs @@ -0,0 +1,230 @@ +//! GPU keying, matte and channel family effects vs the CPU effects (the oracle): direct on a +//! buffer at 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, c, check, compare_at, effect_case, n, opts, set}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn on() -> Value { + Value::Bool(true) +} + +fn off() -> Value { + Value::Bool(false) +} + +/// [`effect_case`] for hard thresholds (8-bit quantisation, bitwise operators): the composited +/// renders may flip a few pixels sitting on the threshold after the layer transform's float +/// rounding, so up to 0.1 % of them may differ by any amount. +fn discontinuous_case(id: &str, vals: &[(&str, Value)]) { + crate::tests::effect_direct(id, vals, false); + crate::tests::effect_direct(id, vals, true); + 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} {vals:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.001); + check(&format!("{id} {vals:?} {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.001); + } +} + +#[test] +fn colour_luma_range_extract_keys() { + effect_case("ec.key.colorkey", &[("keyColor", c(0.7, 0.6, 0.5)), ("colorTolerance", n(60.0))]); + effect_case("ec.key.colorkey", &[("keyColor", c(0.5, 0.6, 0.5)), ("colorTolerance", n(70.0)), ("edgeThin", n(1.5)), ("edgeFeather", n(4.0))]); + effect_case("ec.key.colorkey", &[("keyColor", c(0.5, 0.6, 0.5)), ("colorTolerance", n(70.0)), ("edgeThin", n(-2.0))]); + effect_case("ec.key.luma", &[("threshold", n(120.0)), ("tolerance", n(40.0))]); + effect_case("ec.key.luma", &[("keyType", e(0)), ("threshold", n(140.0)), ("tolerance", n(30.0)), ("edgeFeather", n(3.0))]); + effect_case("ec.key.luma", &[("keyType", e(2)), ("threshold", n(128.0)), ("tolerance", n(25.0)), ("edgeThin", n(1.0))]); + effect_case("ec.key.luma", &[("keyType", e(3)), ("threshold", n(100.0)), ("tolerance", n(50.0))]); + effect_case("ec.key.colorrange", &[("fuzziness", n(20.0))]); + effect_case("ec.key.colorrange", &[("colorSpace", e(1)), ("minL", n(60.0)), ("maxA", n(150.0)), ("minB", n(90.0)), ("fuzziness", n(15.0))]); + effect_case("ec.key.colorrange", &[("colorSpace", e(2)), ("minL", n(80.0)), ("maxA", n(180.0)), ("minB", n(40.0)), ("fuzziness", n(30.0))]); + effect_case("ec.key.extract", &[("blackPoint", n(60.0)), ("whitePoint", n(200.0)), ("blackSoftness", n(30.0)), ("whiteSoftness", n(20.0))]); + discontinuous_case("ec.key.extract", &[("channel", e(1)), ("blackPoint", n(90.0)), ("invert", on())]); + effect_case("ec.key.extract", &[("channel", e(4)), ("whitePoint", n(200.0)), ("whiteSoftness", n(40.0))]); +} + +#[test] +fn difference_colour_difference_and_screen_keys() { + effect_case("ec.key.differencematte", &[]); + effect_case("ec.key.differencematte", &[("view", e(2)), ("matchingTolerance", n(0.0)), ("blurBeforeDifference", n(3.0))]); + effect_case("ec.key.colordifference", &[("keyColor", c(0.2, 0.3, 0.8))]); + effect_case( + "ec.key.colordifference", + &[ + ("keyColor", c(0.3, 0.8, 0.2)), + ("colorMatchingAccuracy", e(1)), + ("partialAInBlack", n(20.0)), + ("partialAGamma", n(1.4)), + ("partialBOutWhite", n(200.0)), + ("blackLevel", n(10.0)), + ("gamma", n(0.8)), + ], + ); + for view in [1, 2, 3, 4, 5, 6, 8] { + effect_case("ec.key.colordifference", &[("keyColor", c(0.2, 0.3, 0.8)), ("view", e(view)), ("partialBInWhite", n(180.0))]); + } + effect_case("ec.key.screen", &[("screenColor", c(0.4, 0.75, 0.3))]); + effect_case( + "ec.key.screen", + &[ + ("screenColor", c(0.3, 0.4, 0.8)), + ("screenGain", n(130.0)), + ("screenBalance", n(30.0)), + ("despill", n(60.0)), + ("clipBlack", n(10.0)), + ("clipWhite", n(85.0)), + ("screenPreblur", n(2.0)), + ("screenShrinkGrow", n(-1.5)), + ("screenSoftness", n(3.0)), + ], + ); + effect_case("ec.key.screen", &[("screenColor", c(0.8, 0.4, 0.3)), ("screenShrinkGrow", n(2.0)), ("edgeColor", e(1))]); + for view in [1, 2] { + effect_case("ec.key.screen", &[("screenColor", c(0.4, 0.75, 0.3)), ("view", e(view))]); + } +} + +#[test] +fn spill_unmult_and_key_cleaner() { + effect_case("ec.key.spill", &[("colorToSuppress", c(0.2, 0.9, 0.1))]); + effect_case("ec.key.spill", &[("colorToSuppress", c(0.9, 0.8, 0.1)), ("colorAccuracy", e(1)), ("suppression", n(150.0))]); + effect_case("ec.key.advancedspill", &[]); + effect_case("ec.key.advancedspill", &[("suppression", n(80.0)), ("ultraSettings/spillRange", n(20.0)), ("ultraSettings/lumaCorrection", n(50.0))]); + effect_case( + "ec.key.advancedspill", + &[ + ("method", e(1)), + ("ultraSettings/keyColor", c(0.9, 0.2, 0.3)), + ("ultraSettings/tolerance", n(40.0)), + ("ultraSettings/desaturate", n(50.0)), + ("ultraSettings/spillColorCorrection", n(30.0)), + ], + ); + effect_case("ec.key.unmult", &[]); + effect_case("ec.key.unmult", &[("backgroundColor", e(1)), ("whiteLevel", n(90.0)), ("softness", n(40.0)), ("clipHdrResults", off())]); + effect_case("ec.key.unmult", &[("blackLevel", n(30.0)), ("softness", n(0.0)), ("removeColorMatting", off())]); + effect_case("ec.key.keycleaner", &[]); + effect_case("ec.key.keycleaner", &[("additionalEdgeRadius", n(6.0)), ("reduceChatter", on()), ("alphaContrast", n(250.0)), ("strength", n(60.0))]); +} + +#[test] +fn chokers() { + effect_case("ec.matte.simplechoker", &[("chokeMatte", n(1.5))]); + effect_case("ec.matte.simplechoker", &[("chokeMatte", n(-2.5))]); + effect_case("ec.matte.simplechoker", &[("chokeMatte", n(-1.0)), ("view", e(1))]); + effect_case("ec.matte.mattechoker", &[]); + effect_case( + "ec.matte.mattechoker", + &[ + ("geometricSoftness1", n(2.0)), + ("choke1", n(-40.0)), + ("geometricSoftness2", n(3.0)), + ("choke2", n(30.0)), + ("grayLevelSoftness2", n(40.0)), + ("iterations", n(2.0)), + ], + ); +} + +#[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))]); + effect_case("ec.channel.shiftchannels", &[("takeRedFrom", e(3)), ("takeGreenFrom", e(7)), ("takeAlphaFrom", e(4))]); + effect_case("ec.channel.removecolormatting", &[("backgroundColor", c(0.3, 0.5, 0.7))]); + effect_case("ec.channel.removecolormatting", &[("backgroundColor", c(0.8, 0.2, 0.1)), ("clipHdr", off())]); + for op in [0, 2, 3, 4, 8, 10, 12] { + discontinuous_case("ec.channel.arithmetic", &[("operator", e(op)), ("redValue", n(120.0)), ("greenValue", n(60.0)), ("blueValue", n(200.0))]); + } + effect_case("ec.channel.arithmetic", &[("operator", e(3)), ("redValue", n(200.0)), ("clip", off())]); + for mode in [0, 2, 4, 9, 11] { + effect_case("ec.channel.solidcomposite", &[("blendingMode", e(mode)), ("color", c(0.8, 0.3, 0.2)), ("opacity", n(70.0)), ("sourceOpacity", n(80.0))]); + } + effect_case("ec.channel.setmatte", &[("takeMatteFrom", e(4))]); + effect_case("ec.channel.setmatte", &[("takeMatteFrom", e(3)), ("invertMatte", on())]); + effect_case("ec.channel.setmatte", &[("takeMatteFrom", e(1)), ("compositeMatteWithOriginal", off()), ("premultiplyMatteLayer", off())]); + for (from, to) in [(0, 0), (1, 0), (2, 0), (3, 0), (8, 4), (9, 5), (10, 6), (11, 3), (12, 8), (13, 7), (7, 1)] { + effect_case("ec.channel.combiner", &[("from", e(from)), ("to", e(to))]); + } + effect_case("ec.channel.combiner", &[("from", e(4)), ("to", e(2)), ("invert", on()), ("solidAlpha", on())]); + effect_case("ec.channel.blend", &[("mode", e(1)), ("blendWithOriginal", n(30.0))]); + effect_case("ec.channel.calculations", &[("inputChannel", e(1))]); + effect_case("ec.channel.calculations", &[("inputChannel", e(0)), ("invertInput", on())]); + effect_case("ec.channel.compoundarithmetic", &[("operator", e(3)), ("operateOnChannels", e(1))]); +} + +/// The channel and keying effects that read another layer, composited. +#[test] +fn layer_inputs() { + let cases: Vec<(&str, Vec<(&str, Value)>)> = vec![ + ("ec.key.differencematte", vec![("matchingTolerance", n(10.0)), ("matchingSoftness", n(20.0))]), + ("ec.key.differencematte", vec![("ifLayerSizesDiffer", e(1)), ("blurBeforeDifference", n(2.0)), ("view", e(2))]), + ("ec.channel.setmatte", vec![("takeMatteFrom", e(4)), ("stretchMatteToFit", off())]), + ("ec.channel.setmatte", vec![("takeMatteFrom", e(3))]), + ("ec.channel.setchannels", vec![("setGreenTo", e(0)), ("setAlphaTo", e(4))]), + ("ec.channel.combiner", vec![("useSecondLayer", on()), ("from", e(11)), ("to", e(7))]), + ("ec.channel.blend", vec![("mode", e(0)), ("blendWithOriginal", n(40.0))]), + ("ec.channel.blend", vec![("mode", e(2))]), + ("ec.channel.blend", vec![("mode", e(3)), ("ifLayerSizesDiffer", e(1))]), + ("ec.channel.blend", vec![("mode", e(4))]), + ("ec.channel.calculations", vec![("secondLayerChannel", e(2)), ("blendingMode", e(2)), ("secondLayerOpacity", n(70.0))]), + ("ec.channel.calculations", vec![("inputChannel", e(4)), ("blendingMode", e(10)), ("preserveTransparency", on()), ("invertSecondLayer", on())]), + ("ec.channel.compoundarithmetic", vec![("operator", e(1)), ("overflowBehavior", e(2))]), + ("ec.channel.compoundarithmetic", vec![("operator", e(4)), ("operateOnChannels", e(1)), ("blendWithOriginal", n(25.0))]), + ("ec.channel.compoundarithmetic", vec![("operator", e(13)), ("operateOnChannels", e(2))]), + ]; + let layer_param = |id: &str| match id { + "ec.key.differencematte" => vec!["differenceLayer"], + "ec.channel.setmatte" => vec!["takeMatteFromLayer"], + "ec.channel.setchannels" => vec!["sourceLayer2", "sourceLayer4"], + "ec.channel.combiner" => vec!["sourceLayer"], + "ec.channel.blend" => vec!["blendWithLayer"], + "ec.channel.calculations" => vec!["secondLayer"], + _ => vec!["secondSource"], + }; + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for (id, vals) in &cases { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let mut other = s.footage(50, 36); + other.switches.video = false; + let oid = s.push(other); + let mut l = s.footage(70, 44); + let mut vals = vals.clone(); + for p in layer_param(id) { + vals.push((p, Value::Layer(Some(oid.0)))); + } + s.effect(&mut l, id, &vals); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let label = format!("{id} {vals:?} {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} diff --git a/crates/gpu/src/tests_fx_noise.rs b/crates/gpu/src/tests_fx_noise.rs new file mode 100644 index 00000000..70f893e8 --- /dev/null +++ b/crates/gpu/src/tests_fx_noise.rs @@ -0,0 +1,193 @@ +//! GPU noise, blur and time family effects vs the CPU effects (the oracle): direct on a buffer at full and half +//! resolution, as adjustment, and composited at 8 and 32 bpc. + +use effectcraft_color::Label; +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_project::build; +use effectcraft_project::{BitDepth, Comp, ItemKind, LayerSource}; +use effectcraft_render::RenderOpts; +use effectcraft_time::{FrameRate, Tick}; + +use crate::tests::{Scene, c, check, compare_at, effect_case, n, opts, set}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn on() -> Value { + Value::Bool(true) +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +#[test] +fn medians() { + effect_case("ec.noise.median", &[("radius", n(2.0))]); + effect_case("ec.noise.median", &[("radius", n(5.0)), ("operateOnAlpha", on())]); + effect_case("ec.noise.medianlegacy", &[("radius", n(3.0))]); + effect_case("ec.noise.medianlegacy", &[("radius", n(2.0)), ("operateOnAlphaChannel", on())]); + effect_case("ec.noise.dustscratches", &[("radius", n(3.0)), ("threshold", n(10.0))]); + effect_case("ec.noise.dustscratches", &[("radius", n(2.0)), ("threshold", n(0.0)), ("operateOnAlpha", on())]); +} + +#[test] +fn edge_preserving_blurs() { + effect_case("ec.blur.bilateral", &[]); + effect_case("ec.blur.bilateral", &[("radius", n(9.0)), ("threshold", n(30.0)), ("colorize", Value::Bool(false))]); + effect_case("ec.blur.bilateral", &[("radius", n(14.0)), ("threshold", n(5.0))]); + effect_case("ec.blur.smart", &[]); + effect_case("ec.blur.smart", &[("radius", n(6.0)), ("threshold", n(15.0)), ("mode", e(1))]); + effect_case("ec.blur.smart", &[("radius", n(4.0)), ("threshold", n(40.0)), ("mode", e(2))]); +} + +#[test] +fn sharpen_and_unsharp_mask() { + effect_case("ec.blur.sharpen", &[("amount", n(60.0))]); + effect_case("ec.blur.sharpen", &[("amount", n(250.0))]); + effect_case("ec.blur.unsharp", &[("amount", n(120.0)), ("radius", n(4.0))]); + effect_case("ec.blur.unsharp", &[("amount", n(80.0)), ("radius", n(2.5)), ("threshold", n(12.0))]); +} + +#[test] +fn channel_and_compound_blur() { + effect_case("ec.blur.channel", &[("redBlurriness", n(8.0)), ("blueBlurriness", n(3.0))]); + effect_case("ec.blur.channel", &[("greenBlurriness", n(6.0)), ("alphaBlurriness", n(6.0)), ("repeatEdge", on())]); + effect_case("ec.blur.channel", &[("redBlurriness", n(5.0)), ("alphaBlurriness", n(10.0)), ("dimensions", e(1))]); + effect_case("ec.blur.compound", &[]); + effect_case("ec.blur.compound", &[("maximumBlur", n(12.0)), ("invertBlur", on())]); +} + +/// Compound Blur reading another layer (stretched and centred), composited. +#[test] +fn compound_blur_layer() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for stretch in [true, false] { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let mut map = s.footage(50, 36); + map.switches.video = false; + let mid = s.push(map); + let mut l = s.footage(70, 44); + s.effect( + &mut l, + "ec.blur.compound", + &[("blurLayer", Value::Layer(Some(mid.0))), ("maximumBlur", n(10.0)), ("stretchMapToFit", Value::Bool(stretch))], + ); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let label = format!("compound blur layer {stretch} {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} + +#[test] +fn minimax() { + effect_case("ec.channel.minimax", &[("radius", n(3.0))]); + effect_case("ec.channel.minimax", &[("radius", n(2.0)), ("operation", e(0)), ("channel", e(2))]); + effect_case("ec.channel.minimax", &[("radius", n(4.0)), ("operation", e(2)), ("channel", e(1)), ("direction", e(1))]); + effect_case("ec.channel.minimax", &[("radius", n(3.0)), ("operation", e(3)), ("channel", e(4)), ("dontShrinkEdges", on()), ("direction", e(2))]); +} + +#[test] +fn turbulent_noise() { + effect_case("ec.noise.turbulent", &[]); + effect_case( + "ec.noise.turbulent", + &[("fractalType", e(3)), ("noiseType", e(3)), ("evolution", n(120.0)), ("evolutionOptions/turbulenceFactor", n(1.5)), ("blendingMode", e(5))], + ); + effect_case( + "ec.noise.turbulent", + &[("fractalType", e(5)), ("noiseType", e(1)), ("complexity", n(3.5)), ("transform/scale", n(40.0)), ("overflow", e(1)), ("blendingMode", e(0))], + ); + effect_case("ec.noise.turbulent", &[("fractalType", e(7)), ("noiseType", e(0)), ("transform/perspectiveOffset", on()), ("opacity", n(70.0))]); +} + +#[test] +fn fractal_noise_other_modes() { + effect_case("ec.noise.fractal", &[("noiseType", e(3)), ("fractalType", e(4)), ("blendingMode", e(3))]); + effect_case( + "ec.noise.fractal", + &[ + ("fractalType", e(6)), + ("evolutionOptions/cycleEvolution", on()), + ("evolutionOptions/cycle", n(2.0)), + ("evolution", n(500.0)), + ("subSettings/subRotation", n(25.0)), + ("subSettings/subOffset", pt(4.0, -3.0)), + ], + ); + effect_case("ec.noise.fractal", &[("fractalType", e(8)), ("noiseType", e(1)), ("blendingMode", e(12)), ("contrast", n(160.0)), ("invert", on())]); +} + +#[test] +fn noise_alpha_and_hls() { + effect_case("ec.noise.noisealpha", &[("amount", n(40.0))]); + effect_case("ec.noise.noisealpha", &[("amount", n(70.0)), ("noise", e(3)), ("originalAlpha", e(2)), ("overflow", e(1)), ("noisePhase", n(100.0))]); + effect_case( + "ec.noise.noisealpha", + &[("amount", n(60.0)), ("noise", e(2)), ("originalAlpha", e(3)), ("overflow", e(2)), ("noiseOptions/cycleNoise", on()), ("randomSeed", n(9.0))], + ); + effect_case("ec.noise.noisehls", &[("hue", n(30.0)), ("lightness", n(20.0)), ("saturation", n(40.0))]); + effect_case("ec.noise.noisehls", &[("noise", e(1)), ("lightness", n(50.0)), ("noisePhase", n(200.0))]); + effect_case("ec.noise.noisehls", &[("noise", e(2)), ("hue", n(60.0)), ("saturation", n(25.0)), ("grainSize", n(3.0))]); + effect_case("ec.noise.noisehlsauto", &[("noise", e(0)), ("hue", n(20.0)), ("lightness", n(30.0)), ("noiseAnimationSpeed", n(7.0))]); +} + +#[test] +fn remove_grain() { + effect_case("ec.noise.removegrain", &[]); + effect_case( + "ec.noise.removegrain", + &[ + ("noiseReductionSettings/noiseReduction", n(2.5)), + ("noiseReductionSettings/passes", n(2.0)), + ("noiseReductionSettings/mode", e(1)), + ("fineTuning/texture", n(0.3)), + ], + ); + effect_case("ec.noise.removegrain", &[("unsharpMask/amount", n(80.0)), ("unsharpMask/radius", n(2.0)), ("unsharpMask/threshold", n(4.0))]); + effect_case("ec.noise.removegrain", &[("viewingMode", e(0)), ("previewRegion/showBox", on()), ("previewRegion/boxColor", c(1.0, 0.2, 0.1))]); +} + +/// Echo and Posterize Time fetch other frames through the effect host: a layer whose rotation +/// is keyframed, at a nonzero time (the direct cases have no host and pass the layer through). +#[test] +fn echo_and_posterize_time() { + effect_case("ec.time.echo", &[]); + effect_case("ec.time.posterizetime", &[]); + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for (id, vals) in [ + ("ec.time.echo", vec![("numberOfEchoes", n(3.0)), ("decay", n(0.7)), ("echoTime", n(-0.1))]), + ("ec.time.echo", vec![("numberOfEchoes", n(4.0)), ("echoOperator", e(2)), ("echoTime", n(0.05))]), + ("ec.time.echo", vec![("numberOfEchoes", n(2.0)), ("echoOperator", e(4)), ("startingIntensity", n(0.8))]), + ("ec.time.echo", vec![("numberOfEchoes", n(5.0)), ("echoOperator", e(6))]), + ("ec.time.posterizetime", vec![("frameRate", n(4.0))]), + ] { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + // A precomp whose content moves, so the frames differ. + let inner = Comp::new(60, 40, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let inner_id = s.p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let mut a = s.footage(30, 24); + let pos = a.props.prop_mut("transform/position").unwrap(); + pos.keys = vec![Keyframe::new(Tick::ZERO, crate::tests::v3(10.0, 12.0)), Keyframe::new(Tick::from_seconds_f64(1.0), crate::tests::v3(50.0, 30.0))]; + let rot = a.props.prop_mut("transform/rotation").unwrap(); + rot.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(90.0))]; + s.p.comp_mut(inner_id).unwrap().layers = vec![a]; + let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); + s.effect(&mut l, id, &vals); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let t = Tick::from_seconds_f64(0.4); + let label = format!("{id} {vals:?} {depth:?}"); + check(&label, compare_at(&s, opts(), t), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, t), 0.0); + } + } +} diff --git a/crates/gpu/src/tests_fx_stylize.rs b/crates/gpu/src/tests_fx_stylize.rs new file mode 100644 index 00000000..35e3e99b --- /dev/null +++ b/crates/gpu/src/tests_fx_stylize.rs @@ -0,0 +1,223 @@ +//! GPU stylize and distort family effects vs the CPU effects (the oracle): direct on a buffer at full and half +//! resolution, as adjustment, and composited at 8 and 32 bpc. + +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_project::BitDepth; +use effectcraft_render::RenderOpts; +use effectcraft_time::Tick; + +use crate::tests::{Scene, c, check, compare_at, effect_case, n, opts, set}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn on() -> Value { + Value::Bool(true) +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +#[test] +fn posterize_thresholds_strobe_vignette() { + effect_case("ec.stylize.posterize", &[("level", n(4.0))]); + effect_case("ec.stylize.posterize", &[("level", n(9.0))]); + effect_case("ec.stylize.threshold", &[("level", n(100.0))]); + effect_case("ec.stylize.threshold", &[("level", n(181.0))]); + effect_case("ec.stylize.ccthreshold", &[]); + effect_case("ec.stylize.ccthreshold", &[("channel", e(1)), ("invert", on()), ("threshold", n(90.0))]); + effect_case("ec.stylize.ccthreshold", &[("channel", e(2)), ("blend", n(30.0))]); + effect_case("ec.stylize.ccthreshold", &[("channel", e(3)), ("threshold", n(60.0))]); + effect_case( + "ec.stylize.ccthresholdrgb", + &[("redThreshold", n(70.0)), ("greenThreshold", n(150.0)), ("blueThreshold", n(110.0)), ("invertGreen", on()), ("blend", n(25.0))], + ); + effect_case( + "ec.stylize.strobe", + &[("strobeDuration", n(0.5)), ("strobeOperator", e(1)), ("strobeColor", c(0.2, 0.5, 0.9)), ("blendWithOriginal", n(30.0))], + ); + effect_case("ec.stylize.strobe", &[("strobeDuration", n(0.5)), ("strobeOperator", e(4)), ("strobeColor", c(0.6, 0.1, 0.3))]); + effect_case("ec.stylize.strobe", &[("strobeDuration", n(0.5)), ("strobe", e(1)), ("blendWithOriginal", n(40.0))]); + effect_case("ec.stylize.strobe", &[("strobeDuration", n(0.1)), ("strobePeriod", n(0.2)), ("randomStrobeProbability", n(50.0))]); + effect_case("ec.stylize.ccvignette", &[("amount", n(80.0))]); + effect_case("ec.stylize.ccvignette", &[("amount", n(-60.0)), ("pinHighlights", n(50.0)), ("angleOfView", n(120.0)), ("center", pt(20.0, 15.0))]); +} + +#[test] +fn scatter_brush_strokes() { + effect_case("ec.stylize.scatter", &[("amount", n(5.0))]); + effect_case("ec.stylize.scatter", &[("amount", n(3.0)), ("grain", e(1))]); + effect_case("ec.stylize.scatter", &[("amount", n(7.0)), ("grain", e(2)), ("randomizeEveryFrame", on())]); + effect_case("ec.stylize.brushstrokes", &[]); + effect_case( + "ec.stylize.brushstrokes", + &[("strokeAngle", n(40.0)), ("brushSize", n(3.0)), ("strokeLength", n(10.0)), ("paintSurface", e(2)), ("blendWithOriginal", n(20.0))], + ); + effect_case("ec.stylize.brushstrokes", &[("paintSurface", e(1)), ("strokeDensity", n(0.6)), ("strokeRandomness", n(2.0)), ("brushSize", n(1.3))]); + effect_case("ec.stylize.brushstrokes", &[("paintSurface", e(3)), ("strokeLength", n(25.0))]); +} + +#[test] +fn roughen_edges() { + effect_case("ec.stylize.roughenedges", &[]); + effect_case("ec.stylize.roughenedges", &[("edgeType", e(1)), ("border", n(4.0)), ("edgeColor", c(0.1, 0.6, 0.9))]); + effect_case( + "ec.stylize.roughenedges", + &[ + ("edgeType", e(5)), + ("evolutionOptions/cycleEvolution", on()), + ("evolutionOptions/cycle", n(2.0)), + ("evolution", n(500.0)), + ("stretch", n(2.0)), + ("complexity", n(3.0)), + ], + ); + effect_case("ec.stylize.roughenedges", &[("edgeType", e(7)), ("border", n(0.0)), ("scale", n(60.0)), ("evolutionOptions/randomSeed", n(9.0))]); +} + +#[test] +fn tiles_kaleida_repetile() { + effect_case("ec.stylize.motiontile", &[("tileWidth", n(50.0)), ("tileHeight", n(40.0))]); + effect_case( + "ec.stylize.motiontile", + &[("outputWidth", n(150.0)), ("outputHeight", n(130.0)), ("mirrorEdges", on()), ("phase", n(70.0)), ("tileWidth", n(45.0))], + ); + effect_case( + "ec.stylize.motiontile", + &[("horizontalPhaseShift", on()), ("phase", n(33.0)), ("tileCenter", pt(30.0, 17.0)), ("tileHeight", n(55.0)), ("outputWidth", n(80.0))], + ); + effect_case("ec.stylize.cckaleida", &[]); + effect_case("ec.stylize.cckaleida", &[("mirroring", e(2)), ("rotation", n(20.0)), ("size", n(150.0))]); + effect_case("ec.stylize.ccrepetile", &[("expandRight", n(10.0)), ("expandUp", n(4.0))]); + effect_case("ec.stylize.ccrepetile", &[("expandLeft", n(12.0)), ("expandDown", n(9.0)), ("tiling", e(1))]); + effect_case("ec.stylize.ccrepetile", &[("expandLeft", n(30.0)), ("expandRight", n(30.0)), ("expandUp", n(20.0)), ("tiling", e(4))]); + effect_case("ec.distort.cctiler", &[]); + effect_case("ec.distort.cctiler", &[("scale", n(70.0)), ("blendWithOriginal", n(30.0)), ("center", pt(30.0, 18.0))]); + effect_case("ec.distort.ccgriddler", &[("rotation", n(20.0))]); + effect_case("ec.distort.ccgriddler", &[("cutTiles", on()), ("horizontalScale", n(120.0)), ("verticalScale", n(80.0)), ("tileSize", n(17.0))]); +} + +#[test] +fn mirror_offset_polar_spherize_corner_pin() { + effect_case("ec.distort.mirror", &[("angle", n(30.0))]); + effect_case("ec.distort.mirror", &[("angle", n(200.0)), ("center", pt(27.0, 19.0))]); + effect_case("ec.distort.offset", &[("shift", pt(10.0, 5.0))]); + effect_case("ec.distort.offset", &[("shift", pt(50.3, 20.7)), ("blend", n(40.0))]); + effect_case("ec.distort.polar", &[("interpolation", n(100.0))]); + effect_case("ec.distort.polar", &[("interpolation", n(60.0)), ("conversion", e(1))]); + effect_case("ec.distort.spherize", &[("radius", n(20.0))]); + effect_case("ec.distort.spherize", &[("radius", n(35.0)), ("center", pt(30.0, 25.0))]); + effect_case("ec.distort.cornerpin", &[("ul", pt(5.0, 3.0)), ("ur", pt(60.0, 8.0)), ("lr", pt(66.0, 40.0)), ("ll", pt(2.0, 44.0))]); + effect_case("ec.distort.cornerpin", &[("ul", pt(-8.0, 6.0)), ("ur", pt(75.0, -4.0)), ("lr", pt(50.0, 30.0)), ("ll", pt(12.0, 50.0))]); +} + +#[test] +fn optics_magnify() { + effect_case("ec.distort.opticscompensation", &[("fieldOfView", n(60.0))]); + effect_case("ec.distort.opticscompensation", &[("fieldOfView", n(90.0)), ("reverseLensDistortion", on()), ("fovOrientation", e(2))]); + effect_case( + "ec.distort.opticscompensation", + &[("fieldOfView", n(110.0)), ("reverseLensDistortion", on()), ("resize", e(1)), ("viewCenter", pt(30.0, 20.0))], + ); + effect_case("ec.distort.opticscompensation", &[("fieldOfView", n(120.0)), ("optimalPixels", on()), ("fovOrientation", e(1))]); + effect_case("ec.distort.magnify", &[("size", n(15.0))]); + effect_case( + "ec.distort.magnify", + &[("shape", e(1)), ("feather", n(6.0)), ("size", n(18.0)), ("scaling", e(1)), ("blendingMode", e(3)), ("magnification", n(270.0))], + ); + effect_case("ec.distort.magnify", &[("scaling", e(2)), ("blendingMode", e(0)), ("size", n(20.0)), ("opacity", n(70.0))]); + effect_case("ec.distort.magnify", &[("size", n(25.0)), ("center", pt(60.0, 10.0)), ("resizeLayer", on()), ("blendingMode", e(5))]); +} + +#[test] +fn cc_slant_smear_split() { + effect_case("ec.distort.ccslant", &[("slant", n(30.0))]); + effect_case("ec.distort.ccslant", &[("slant", n(-20.0)), ("stretching", on()), ("height", n(120.0))]); + effect_case("ec.distort.ccsmear", &[]); + effect_case("ec.distort.ccsmear", &[("reach", n(-40.0)), ("radius", n(20.0)), ("to", pt(40.0, 10.0))]); + effect_case("ec.distort.ccsplit", &[]); + effect_case("ec.distort.ccsplit2", &[("split1", n(30.0)), ("split2", n(80.0)), ("pointB", pt(60.0, 30.0))]); +} + +#[test] +fn twirl_legacy_ripple_pulse_power_pin() { + effect_case("ec.distort.twirllegacy", &[("angle", n(90.0))]); + effect_case("ec.distort.twirllegacy", &[("angle", n(-250.0)), ("radius", n(80.0)), ("center", pt(30.0, 18.0))]); + effect_case("ec.distort.ccripplepulse", &[("pulseLevel", n(80.0)), ("timeSpan", n(0.5))]); + effect_case("ec.distort.ccripplepulse", &[("pulseLevel", n(-60.0)), ("amplitude", n(150.0)), ("renderBump", on()), ("center", pt(25.0, 20.0))]); + effect_case("ec.distort.ccpowerpin", &[("topLeft", pt(5.0, 3.0)), ("bottomRight", pt(62.0, 38.0))]); + effect_case("ec.distort.ccpowerpin", &[("topRight", pt(80.0, -6.0)), ("bottomLeft", pt(-4.0, 50.0)), ("expandLeft", n(20.0)), ("expandBottom", n(-10.0))]); +} + +#[test] +fn flo_motion() { + effect_case("ec.distort.ccflomotion", &[]); + effect_case("ec.distort.ccflomotion", &[("antialiasing", e(2)), ("tileEdges", on()), ("amount1", n(-150.0)), ("falloff", n(0.7))]); + effect_case("ec.distort.ccflomotion", &[("antialiasing", e(0)), ("amount2", n(0.0)), ("knot1", pt(20.0, 30.0))]); +} + +#[test] +fn liquify_render() { + let mesh = "warp 20 50 0 0 0 10,10 20,15 30,20\ntwirlClockwise 24 80 0 0 0 40,25 42,27"; + effect_case("ec.distort.liquify", &[("distortionMesh", Value::Str(mesh.into()))]); + effect_case( + "ec.distort.liquify", + &[("distortionMesh", Value::Str(mesh.into())), ("distortionPercentage", n(60.0)), ("distortionMeshOffset", pt(3.0, -2.0))], + ); +} + +#[test] +fn glow_operations_and_arbitrary_map() { + effect_case("ec.stylize.glow", &[("glowOperation", e(2)), ("threshold", n(30.0)), ("radius", n(8.0))]); + effect_case("ec.stylize.glow", &[("glowOperation", e(3)), ("colors", e(1)), ("intensity", n(2.0))]); + effect_case("ec.stylize.glow", &[("colors", e(2)), ("arbitraryMap", Value::Str("0,0 0.5,0.8 1,1|0,0.2 1,0.6|".into())), ("threshold", n(20.0))]); + effect_case("ec.stylize.glow", &[("colors", e(2)), ("arbitraryMap", Value::Str("0,1 1,0||0,0 1,1".into())), ("operation", e(1)), ("glowOperation", e(4))]); +} + +/// Texturize with a texture layer (Tile / Center / Stretch), composited. +#[test] +fn texturize_layer() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for placement in 0..3 { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let mut tex = s.footage(50, 36); + tex.switches.video = false; + let tid = s.push(tex); + let mut l = s.footage(70, 44); + s.effect( + &mut l, + "ec.stylize.texturize", + &[("textureLayer", Value::Layer(Some(tid.0))), ("texturePlacement", e(placement)), ("textureContrast", n(1.5)), ("lightDirection", n(60.0))], + ); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let label = format!("texturize {placement} {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} + +/// Transform's own shutter angle: the effect's rotation and position animate. +#[test] +fn transform_motion_blur() { + 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, "ec.distort.transform", &[("useCompositionShutterAngle", Value::Bool(false)), ("shutterAngle", n(180.0))]); + let rot = l.props.prop_mut("effects/#1/rotation").expect("transform rotation"); + rot.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(120.0))]; + let pos = l.props.prop_mut("effects/#1/position").expect("transform position"); + pos.keys = vec![Keyframe::new(Tick::ZERO, pt(35.0, 22.0)), Keyframe::new(Tick::from_seconds_f64(1.0), pt(95.0, 40.0))]; + s.push(l); + let label = format!("transform motion blur {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::from_seconds_f64(0.3)), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::from_seconds_f64(0.3)), 0.0); + } +} diff --git a/crates/gpu/src/tests_fx_tone.rs b/crates/gpu/src/tests_fx_tone.rs new file mode 100644 index 00000000..61c977d1 --- /dev/null +++ b/crates/gpu/src/tests_fx_tone.rs @@ -0,0 +1,185 @@ +//! GPU colour-correction family effects vs the CPU effects (the oracle): direct on a buffer at +//! full and half resolution, as adjustment, and composited at 8 and 32 bpc. + +use effectcraft_color::Label; +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_project::{BitDepth, Comp, ItemKind, LayerSource, build}; +use effectcraft_render::RenderOpts; +use effectcraft_time::{FrameRate, Tick}; + +use crate::tests::{Scene, c, check, compare_at, effect_case, n, opts, set}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn on() -> Value { + Value::Bool(true) +} + +fn off() -> Value { + Value::Bool(false) +} + +#[test] +fn levels_and_gamma() { + effect_case( + "ec.color.levelsic", + &[("rgb/rgbInBlack", n(0.1)), ("rgb/rgbGamma", n(1.3)), ("red/redOutWhite", n(0.8)), ("blue/blueInWhite", n(0.7)), ("alpha/alphaGamma", n(0.7))], + ); + effect_case("ec.color.levelsic", &[("green/greenInBlack", n(0.2)), ("green/greenInWhite", n(0.6)), ("clipToOutputWhite", e(0))]); + effect_case("ec.color.levelsic", &[("alpha/alphaOutBlack", n(0.3))]); + effect_case("ec.color.levels", &[("redInBlack", n(0.1)), ("greenGamma", n(1.5)), ("blueOutWhite", n(0.7)), ("inWhite", n(0.9))]); + effect_case("ec.color.levels", &[("alphaInWhite", n(0.6)), ("redInWhite", n(0.5)), ("redClipToOutputWhite", e(0))]); + effect_case("ec.color.gammapedestalgain", &[("redGamma", n(1.4)), ("greenPedestal", n(0.1)), ("blueGain", n(0.8))]); + effect_case("ec.color.gammapedestalgain", &[("blackStretch", n(2.0)), ("redGain", n(1.2))]); +} + +#[test] +fn hue_saturation_ranges() { + effect_case("ec.color.huesaturation", &[("redsHue", n(40.0)), ("bluesSaturation", n(-60.0)), ("greensLightness", n(30.0))]); + effect_case( + "ec.color.huesaturation", + &[ + ("hue", n(20.0)), + ("saturation", n(15.0)), + ("yellowsSaturation", n(50.0)), + ("yellowsRangeStart", n(30.0)), + ("yellowsStartFalloff", n(8.0)), + ("cyansLightness", n(-40.0)), + ], + ); +} + +#[test] +fn colour_replacement() { + effect_case("ec.color.photofilter", &[]); + effect_case( + "ec.color.photofilter", + &[("filter", e(effectcraft_effects::PHOTO_FILTER_CUSTOM)), ("color", c(0.2, 0.7, 0.4)), ("density", n(60.0)), ("preserveLuminosity", off())], + ); + effect_case("ec.color.changetocolor", &[("from", c(0.8, 0.4, 0.3)), ("to", c(0.2, 0.5, 0.9)), ("toleranceGroup/hue", n(15.0))]); + effect_case( + "ec.color.changetocolor", + &[("from", c(0.6, 0.6, 0.3)), ("to", c(0.9, 0.2, 0.6)), ("change", e(3)), ("changeBy", e(1)), ("toleranceGroup/hue", n(25.0)), ("softness", n(30.0))], + ); + effect_case("ec.color.changetocolor", &[("from", c(0.6, 0.6, 0.3)), ("change", e(2)), ("toleranceGroup/hue", n(25.0)), ("viewCorrectionMatte", on())]); + effect_case("ec.color.leavecolor", &[("amount", n(80.0)), ("color", c(0.7, 0.5, 0.4)), ("tolerance", n(10.0)), ("edgeSoftness", n(20.0))]); + effect_case( + "ec.color.leavecolor", + &[("amount", n(100.0)), ("color", c(0.8, 0.4, 0.3)), ("matchColors", e(1)), ("tolerance", n(5.0)), ("edgeSoftness", n(30.0))], + ); + effect_case("ec.color.changecolor", &[("hueTransform", n(90.0)), ("colorToChange", c(0.7, 0.5, 0.4)), ("matchingSoftness", n(20.0))]); + effect_case( + "ec.color.changecolor", + &[ + ("lightnessTransform", n(-30.0)), + ("saturationTransform", n(40.0)), + ("matchColors", e(0)), + ("matchingTolerance", n(20.0)), + ("matchingSoftness", n(15.0)), + ], + ); + effect_case("ec.color.changecolor", &[("view", e(1)), ("matchColors", e(2)), ("matchingSoftness", n(25.0)), ("invertColorCorrectionMask", on())]); +} + +#[test] +fn broadcast_balance_limiter() { + effect_case("ec.color.broadcast", &[("maxSignal", n(95.0))]); + effect_case("ec.color.broadcast", &[("locale", e(1)), ("howToMakeSafe", e(1)), ("maxSignal", n(90.0))]); + effect_case("ec.color.colorbalancehls", &[("hue", n(40.0)), ("lightness", n(-20.0)), ("saturation", n(30.0))]); + effect_case("ec.color.colorbalancehls", &[("lightness", n(25.0)), ("saturation", n(-50.0))]); + effect_case("ec.color.videolimiter", &[("clipLevel", e(0))]); + effect_case("ec.color.videolimiter", &[("clipLevel", e(1)), ("clipMethod", e(1)), ("compressionBeforeClipping", e(4))]); + effect_case("ec.color.videolimiter", &[("clipLevel", e(0)), ("clipMethod", e(0)), ("gamutWarning", on())]); +} + +#[test] +fn cc_colour_and_arbitrary_map() { + effect_case("ec.color.cctoner", &[]); + effect_case("ec.color.cctoner", &[("tones", e(2)), ("blend", n(30.0))]); + effect_case("ec.color.cctoner", &[("tones", e(0)), ("shadows", c(0.1, 0.0, 0.3)), ("highlights", c(1.0, 0.9, 0.6))]); + effect_case("ec.color.cccoloroffset", &[("redPhase", n(90.0)), ("bluePhase", n(200.0))]); + effect_case("ec.color.cccoloroffset", &[("greenPhase", n(120.0)), ("overflow", e(1))]); + effect_case("ec.color.cckernel", &[("k1", n(-1.0)), ("k5", n(5.0)), ("k9", n(-1.0)), ("scale", n(0.5)), ("offset", n(0.05))]); + effect_case("ec.color.cckernel", &[("k2", n(1.0)), ("k5", n(0.0)), ("k8", n(-1.0)), ("absolute", on())]); + let map: String = (0..256).map(|i| format!("{}", ((i as f64 / 255.0).powf(0.6) * 255.0).round())).collect::>().join(","); + effect_case("ec.color.psarbitrarymap", &[("map", Value::Str(map.clone()))]); + effect_case("ec.color.psarbitrarymap", &[("map", Value::Str(map)), ("phase", n(40.0)), ("applyPhaseMapToAlpha", on())]); +} + +#[test] +fn automatic_corrections() { + effect_case("ec.color.autolevels", &[]); + effect_case("ec.color.autolevels", &[("blackClip", n(2.0)), ("whiteClip", n(3.0)), ("blend", n(20.0))]); + effect_case("ec.color.autocontrast", &[("blackClip", n(1.0))]); + effect_case("ec.color.autocolor", &[]); + effect_case("ec.color.autocolor", &[("snapNeutralMidtones", on()), ("whiteClip", n(2.0))]); + effect_case("ec.color.equalize", &[]); + effect_case("ec.color.equalize", &[("style", e(1)), ("amount", n(70.0))]); + effect_case("ec.color.equalize", &[("style", e(2))]); +} + +#[test] +fn shadow_highlight_neutralizer_invert() { + effect_case("ec.color.shadowhighlight", &[]); + effect_case( + "ec.color.shadowhighlight", + &[ + ("autoAmounts", off()), + ("shadowAmount", n(70.0)), + ("highlightAmount", n(40.0)), + ("moreOptions/shadowRadius", n(8.0)), + ("moreOptions/highlightRadius", n(15.0)), + ("moreOptions/midtoneContrast", n(30.0)), + ("moreOptions/blackClip", n(2.0)), + ("blend", n(20.0)), + ], + ); + effect_case("ec.color.shadowhighlight", &[("moreOptions/blackClip", n(0.0)), ("moreOptions/whiteClip", n(0.0)), ("moreOptions/colorCorrection", n(-40.0))]); + effect_case( + "ec.color.cccolorneutralizer", + &[("shadowsBalance", c(0.1, 0.0, 0.05)), ("midtonesUnbalance", c(0.6, 0.5, 0.4)), ("pinning", n(40.0)), ("contrast", n(20.0)), ("darks", n(-30.0))], + ); + effect_case("ec.color.cccolorneutralizer", &[("highlightsBalance", c(0.9, 1.0, 0.8)), ("brights", n(40.0)), ("blendWOriginal", n(25.0))]); + for ch in 4..=11 { + effect_case("ec.channel.invert", &[("channel", e(ch)), ("blend", n(20.0))]); + } +} + +/// Color Stabilizer reads its reference frame through the effect host: a precomp whose content +/// moves, at a nonzero time (the direct cases have no host and pass the layer through). +#[test] +fn color_stabilizer() { + effect_case("ec.color.colorstabilizer", &[]); + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for mode in [0, 1, 2] { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let inner = Comp::new(60, 40, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let inner_id = s.p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let mut a = s.footage(50, 34); + let pos = a.props.prop_mut("transform/position").unwrap(); + pos.keys = vec![Keyframe::new(Tick::ZERO, crate::tests::v3(22.0, 18.0)), Keyframe::new(Tick::from_seconds_f64(1.0), crate::tests::v3(38.0, 24.0))]; + let op = a.props.prop_mut("transform/opacity").unwrap(); + op.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(100.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(40.0))]; + s.p.comp_mut(inner_id).unwrap().layers = vec![a]; + let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); + let vals = [ + ("stabilize", e(mode)), + ("blackPoint", Value::Vec2([18.0, 20.0])), + ("midPoint", Value::Vec2([30.0, 18.0])), + ("whitePoint", Value::Vec2([42.0, 22.0])), + ("sampleSize", n(4.0)), + ]; + s.effect(&mut l, "ec.color.colorstabilizer", &vals); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let t = Tick::from_seconds_f64(0.5); + let label = format!("color stabilizer {mode} {depth:?}"); + check(&label, compare_at(&s, opts(), t), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, t), 0.0); + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index c15a6cda..d0c9267e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -174,10 +174,15 @@ against the caster planes (Shadow Diffusion, Light Transmission) and blending. A run their effect stacks on the GPU-resident comp (`Renderer::run_effects_on` with an `FxTarget`): runs of GPU effects stay on the device and only non-GPU effects read back and upload. Advanced 3D compositing and wireframes still run on the CPU between GPU steps (read back, draw, upload). GPU -effects (`effects::GPU_EFFECTS`, 58 of them: blurs, colour, keying incl. Key Light, distortion, -transitions, generators, noise and grain; see [effects.md](effects.md)) repeat the CPU effect's -steps (padding, box radii, parameters, hashes) as kernels; consecutive GPU effects run as one -chain with one upload and one readback. Tests render scenes on both paths and compare them +effects (`effects::GPU_EFFECTS`, 152 of them: blurs, colour correction, keying incl. Key Light, +mattes, channel, stylize, distortion, transitions, generators, noise, grain and time; see +[effects.md](effects.md)) repeat the CPU effect's steps (padding, box radii, parameters, hashes) +as kernels, in one module per family (`gpu::fx_*` with `shaders/fx_*.wgsl`); consecutive GPU +effects run as one chain with one upload and one readback. Statistics that need the whole frame +(Auto Levels / Contrast / Color, Equalize, Shadow/Highlight, Color Stabilizer, Remove Grain's +noise level) are measured on the CPU from one readback and applied on the GPU; effects reading +other frames (Echo, Posterize Time) upload the frames the host renders. Settings a kernel cannot +match render on the CPU (`effects::catalog::gpu_supported`). Tests render scenes on both paths and compare them (≤ 1/255 at 8 bpc, ≤ 1e-3 at 32 bpc); they skip without an adapter. The desktop viewer builds the `Gpu` on egui-wgpu's device and shows frames from GPU textures without reading them back (`ui-egui::frames`); headless renders, the CLI (unless `--gpu`) and CI use the CPU. On the web diff --git a/docs/effects.md b/docs/effects.md index e35ae5f6..32eabfa1 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**: 59 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). @@ -46,85 +46,85 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Bilateral Blur | `ec.blur.bilateral` | 3 | | 32 | Implemented | +| Bilateral Blur | `ec.blur.bilateral` | 3 | GPU | 32 | Implemented | | CC Cross Blur | `ec.blur.cccross` | 3 | | 32 | Implemented | | CC Radial Blur | `ec.blur.ccradial` | 4 | | 32 | Implemented | | CC Radial Fast Blur | `ec.blur.ccradialfast` | 3 | GPU | 32 | Implemented | | CC Vector Blur | `ec.blur.ccvector` | 4 | | 32 | Implemented | | Camera Lens Blur | `ec.blur.cameralens` | 16 | GPU | 32 | Implemented | | Camera-Shake Deblur | `ec.blur.camerashakedeblur` | 8 | | 32 | Implemented | -| Channel Blur | `ec.blur.channel` | 6 | | 32 | Implemented | -| Compound Blur | `ec.blur.compound` | 4 | | 32 | Implemented | +| Channel Blur | `ec.blur.channel` | 6 | GPU | 32 | Implemented | +| Compound Blur | `ec.blur.compound` | 4 | GPU | 32 | Implemented | | Directional Blur | `ec.blur.directional` | 2 | GPU | 32 | Implemented | | Fast Box Blur | `ec.blur.fastbox` | 4 | GPU | 32 | Implemented | | Gaussian Blur | `ec.blur.gaussian` | 3 | GPU | 32 | Implemented | | Radial Blur | `ec.blur.radial` | 4 | GPU | 32 | Implemented | -| Sharpen | `ec.blur.sharpen` | 1 | | 32 | Implemented | -| Smart Blur | `ec.blur.smart` | 3 | | 32 | Implemented | -| Unsharp Mask | `ec.blur.unsharp` | 3 | | 32 | Implemented | +| Sharpen | `ec.blur.sharpen` | 1 | GPU | 32 | Implemented | +| Smart Blur | `ec.blur.smart` | 3 | GPU | 32 | Implemented | +| Unsharp Mask | `ec.blur.unsharp` | 3 | GPU | 32 | Implemented | ## Channel | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Arithmetic | `ec.channel.arithmetic` | 5 | | 32 | Implemented | -| Blend | `ec.channel.blend` | 4 | | 32 | Implemented | +| Arithmetic | `ec.channel.arithmetic` | 5 | GPU | 32 | Implemented | +| Blend | `ec.channel.blend` | 4 | GPU | 32 | Implemented | | CC Composite | `ec.channel.cccomposite` | 3 | | 32 | Implemented | -| Calculations | `ec.channel.calculations` | 9 | | 32 | Implemented | -| Channel Combiner | `ec.channel.combiner` | 6 | | 32 | Implemented | -| Compound Arithmetic | `ec.channel.compoundarithmetic` | 6 | | 32 | Implemented | +| Calculations | `ec.channel.calculations` | 9 | GPU | 32 | Implemented | +| Channel Combiner | `ec.channel.combiner` | 6 | GPU | 32 | Implemented | +| Compound Arithmetic | `ec.channel.compoundarithmetic` | 6 | GPU | 32 | Implemented | | Invert | `ec.channel.invert` | 2 | GPU | 32 | Implemented | -| Minimax | `ec.channel.minimax` | 5 | | 32 | Implemented | -| Remove Color Matting | `ec.channel.removecolormatting` | 2 | | 32 | Implemented | -| Set Channels | `ec.channel.setchannels` | 9 | | 32 | Implemented | -| Set Matte | `ec.channel.setmatte` | 6 | | 32 | Implemented | -| Shift Channels | `ec.channel.shiftchannels` | 4 | | 32 | Implemented | -| Solid Composite | `ec.channel.solidcomposite` | 4 | | 32 | Implemented | +| Minimax | `ec.channel.minimax` | 5 | GPU | 32 | Implemented | +| Remove Color Matting | `ec.channel.removecolormatting` | 2 | GPU | 32 | Implemented | +| Set Channels | `ec.channel.setchannels` | 9 | GPU | 32 | Implemented | +| Set Matte | `ec.channel.setmatte` | 6 | GPU | 32 | Implemented | +| Shift Channels | `ec.channel.shiftchannels` | 4 | GPU | 32 | Implemented | +| Solid Composite | `ec.channel.solidcomposite` | 4 | GPU | 32 | Implemented | ## Color Correction | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Auto Color | `ec.color.autocolor` | 6 | | 32 | Implemented | -| Auto Contrast | `ec.color.autocontrast` | 5 | | 32 | Implemented | -| Auto Levels | `ec.color.autolevels` | 5 | | 32 | Implemented | +| Auto Color | `ec.color.autocolor` | 6 | GPU | 32 | Implemented | +| Auto Contrast | `ec.color.autocontrast` | 5 | GPU | 32 | Implemented | +| Auto Levels | `ec.color.autolevels` | 5 | GPU | 32 | Implemented | | Black & White | `ec.color.blackwhite` | 8 | GPU | 32 | Implemented | | Brightness & Contrast | `ec.color.brightnesscontrast` | 3 | GPU | 32 | Implemented | -| Broadcast Colors | `ec.color.broadcast` | 3 | | 32 | Implemented | -| CC Color Neutralizer | `ec.color.cccolorneutralizer` | 11 | | 32 | Implemented | -| CC Color Offset | `ec.color.cccoloroffset` | 4 | | 32 | Implemented | -| CC Kernel | `ec.color.cckernel` | 12 | | 32 | Implemented | -| CC Toner | `ec.color.cctoner` | 7 | | 32 | Implemented | -| Change Color | `ec.color.changecolor` | 9 | | 32 | Implemented | -| Change to Color | `ec.color.changetocolor` | 9 | | 32 | Implemented | +| Broadcast Colors | `ec.color.broadcast` | 3 | GPU | 32 | Implemented | +| CC Color Neutralizer | `ec.color.cccolorneutralizer` | 11 | GPU | 32 | Implemented | +| CC Color Offset | `ec.color.cccoloroffset` | 4 | GPU | 32 | Implemented | +| CC Kernel | `ec.color.cckernel` | 12 | GPU | 32 | Implemented | +| CC Toner | `ec.color.cctoner` | 7 | GPU | 32 | Implemented | +| Change Color | `ec.color.changecolor` | 9 | GPU | 32 | Implemented | +| Change to Color | `ec.color.changetocolor` | 9 | GPU | 32 | Implemented | | Channel Mixer | `ec.color.channelmixer` | 13 | GPU | 32 | Implemented | | Color Balance | `ec.color.colorbalance` | 10 | GPU | 32 | Implemented | -| Color Balance (HLS) | `ec.color.colorbalancehls` | 3 | | 32 | Implemented | +| Color Balance (HLS) | `ec.color.colorbalancehls` | 3 | GPU | 32 | Implemented | | Color Link | `ec.color.colorlink` | 6 | | 32 | Implemented | -| Color Stabilizer | `ec.color.colorstabilizer` | 6 | | 32 | Implemented | +| Color Stabilizer | `ec.color.colorstabilizer` | 6 | GPU | 32 | Implemented | | Colorama | `ec.color.colorama` | 20 | GPU | 32 | Implemented | | Curves | `ec.color.curves` | 0 | GPU | 32 | Implemented | -| Equalize | `ec.color.equalize` | 2 | | 32 | Implemented | +| Equalize | `ec.color.equalize` | 2 | GPU | 32 | Implemented | | Exposure | `ec.color.exposure` | 14 | GPU | 32 | Implemented | -| Gamma/Pedestal/Gain | `ec.color.gammapedestalgain` | 10 | | 32 | Implemented | +| Gamma/Pedestal/Gain | `ec.color.gammapedestalgain` | 10 | GPU | 32 | Implemented | | Hue/Saturation | `ec.color.huesaturation` | 50 | GPU | 32 | Implemented | -| Leave Color | `ec.color.leavecolor` | 5 | | 32 | Implemented | +| Leave Color | `ec.color.leavecolor` | 5 | GPU | 32 | Implemented | | Levels | `ec.color.levels` | 36 | GPU | 32 | Implemented | -| Levels (Individual Controls) | `ec.color.levelsic` | 27 | | 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 | -| PS Arbitrary Map | `ec.color.psarbitrarymap` | 2 | | 32 | Implemented | -| Photo Filter | `ec.color.photofilter` | 4 | | 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 | -| Shadow/Highlight | `ec.color.shadowhighlight` | 14 | | 32 | Implemented | +| Shadow/Highlight | `ec.color.shadowhighlight` | 14 | GPU | 32 | Implemented | | Tint | `ec.color.tint` | 3 | GPU | 32 | Implemented | | Tritone | `ec.color.tritone` | 4 | GPU | 32 | Implemented | | Vibrance | `ec.color.vibrance` | 2 | GPU | 32 | Implemented | -| Video Limiter | `ec.color.videolimiter` | 5 | | 32 | Implemented | +| Video Limiter | `ec.color.videolimiter` | 5 | GPU | 32 | Implemented | ## Distort @@ -135,37 +135,37 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Bend It | `ec.distort.ccbendit` | 4 | | 32 | Implemented | | CC Bender | `ec.distort.ccbender` | 5 | | 32 | Implemented | | CC Blobbylize | `ec.distort.ccblobbylize` | 15 | | 32 | Implemented | -| CC Flo Motion | `ec.distort.ccflomotion` | 7 | | 32 | Implemented | -| CC Griddler | `ec.distort.ccgriddler` | 5 | | 32 | Implemented | +| CC Flo Motion | `ec.distort.ccflomotion` | 7 | GPU | 32 | Implemented | +| CC Griddler | `ec.distort.ccgriddler` | 5 | GPU | 32 | Implemented | | CC Lens | `ec.distort.cclens` | 3 | GPU | 32 | Implemented | | CC Page Turn | `ec.distort.ccpageturn` | 7 | | 32 | Implemented | -| CC Power Pin | `ec.distort.ccpowerpin` | 10 | | 32 | Implemented | -| CC Ripple Pulse | `ec.distort.ccripplepulse` | 5 | | 32 | Implemented | -| CC Slant | `ec.distort.ccslant` | 4 | | 32 | Implemented | -| CC Smear | `ec.distort.ccsmear` | 4 | | 32 | Implemented | -| CC Split | `ec.distort.ccsplit` | 3 | | 32 | Implemented | -| CC Split 2 | `ec.distort.ccsplit2` | 4 | | 32 | Implemented | -| CC Tiler | `ec.distort.cctiler` | 3 | | 32 | Implemented | -| Corner Pin | `ec.distort.cornerpin` | 4 | | 32 | Implemented | +| CC Power Pin | `ec.distort.ccpowerpin` | 10 | GPU | 32 | Implemented | +| CC Ripple Pulse | `ec.distort.ccripplepulse` | 5 | GPU | 32 | Implemented | +| CC Slant | `ec.distort.ccslant` | 4 | GPU | 32 | Implemented | +| CC Smear | `ec.distort.ccsmear` | 4 | GPU | 32 | Implemented | +| CC Split | `ec.distort.ccsplit` | 3 | GPU | 32 | Implemented | +| CC Split 2 | `ec.distort.ccsplit2` | 4 | GPU | 32 | Implemented | +| CC Tiler | `ec.distort.cctiler` | 3 | GPU | 32 | Implemented | +| Corner Pin | `ec.distort.cornerpin` | 4 | GPU | 32 | Implemented | | Detail-preserving Upscale | `ec.distort.upscale` | 4 | | 32 | Implemented | | Displacement Map | `ec.distort.displacementmap` | 8 | GPU | 32 | Implemented | -| Liquify | `ec.distort.liquify` | 12 | | 32 | Implemented | -| Magnify | `ec.distort.magnify` | 10 | | 32 | Implemented | +| Liquify | `ec.distort.liquify` | 12 | GPU | 32 | Implemented | +| Magnify | `ec.distort.magnify` | 10 | GPU | 32 | Implemented | | Mesh Warp | `ec.distort.meshwarp` | 3 | GPU | 32 | Implemented | -| Mirror | `ec.distort.mirror` | 2 | | 32 | Implemented | -| Offset | `ec.distort.offset` | 2 | | 32 | Implemented | -| Optics Compensation | `ec.distort.opticscompensation` | 6 | | 32 | Implemented | -| Polar Coordinates | `ec.distort.polar` | 2 | | 32 | Implemented | +| Mirror | `ec.distort.mirror` | 2 | GPU | 32 | Implemented | +| Offset | `ec.distort.offset` | 2 | GPU | 32 | Implemented | +| Optics Compensation | `ec.distort.opticscompensation` | 6 | GPU | 32 | Implemented | +| Polar Coordinates | `ec.distort.polar` | 2 | GPU | 32 | Implemented | | Puppet | `ec.distort.puppet` | 1 | | 32 | Implemented | | Reshape | `ec.distort.reshape` | 6 | | 32 | Implemented | | Ripple | `ec.distort.ripple` | 7 | GPU | 32 | Implemented | | Rolling Shutter Repair | `ec.distort.rollingshutterrepair` | 5 | | 32 | Implemented | | Smear | `ec.distort.smear` | 8 | | 32 | Implemented | -| Spherize | `ec.distort.spherize` | 2 | | 32 | Implemented | +| Spherize | `ec.distort.spherize` | 2 | GPU | 32 | Implemented | | Transform | `ec.distort.transform` | 12 | GPU | 32 | Implemented | | Turbulent Displace | `ec.distort.turbulentdisplace` | 11 | GPU | 32 | Implemented | | Twirl | `ec.distort.twirl` | 3 | GPU | 32 | Implemented | -| Twirl (Legacy) | `ec.distort.twirllegacy` | 3 | | 32 | Implemented | +| Twirl (Legacy) | `ec.distort.twirllegacy` | 3 | GPU | 32 | Implemented | | Warp | `ec.distort.warp` | 5 | | 32 | Implemented | | Warp Stabilizer | `ec.distort.warpstabilizer` | 20 | | 32 | Implemented | | Wave Warp | `ec.distort.wavewarp` | 7 | GPU | 32 | Implemented | @@ -236,29 +236,29 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Advanced Spill Suppressor | `ec.key.advancedspill` | 8 | | 32 | Implemented | +| Advanced Spill Suppressor | `ec.key.advancedspill` | 8 | GPU | 32 | Implemented | | CC Simple Wire Removal | `ec.key.ccsimplewireremoval` | 7 | | 32 | Implemented | -| Color Difference Key | `ec.key.colordifference` | 16 | | 32 | Implemented | -| Color Range | `ec.key.colorrange` | 8 | | 32 | Implemented | -| Difference Matte | `ec.key.differencematte` | 6 | | 32 | Implemented | -| Extract | `ec.key.extract` | 6 | | 32 | Implemented | +| Color Difference Key | `ec.key.colordifference` | 16 | GPU | 32 | Implemented | +| Color Range | `ec.key.colorrange` | 8 | GPU | 32 | Implemented | +| Difference Matte | `ec.key.differencematte` | 6 | GPU | 32 | Implemented | +| Extract | `ec.key.extract` | 6 | GPU | 32 | Implemented | | Inner/Outer Key | `ec.key.innerouter` | 88 | | 32 | Implemented | -| Key Cleaner | `ec.key.keycleaner` | 4 | | 32 | Implemented | +| Key Cleaner | `ec.key.keycleaner` | 4 | GPU | 32 | Implemented | | Key Light | `ec.keying.keylight` | 56 | GPU | 32 | Implemented | | Linear Color Key | `ec.key.linearcolor` | 6 | GPU | 32 | Implemented | -| Screen Key | `ec.key.screen` | 11 | | 32 | Implemented | -| Unmult | `ec.key.unmult` | 6 | | 32 | Implemented | +| Screen Key | `ec.key.screen` | 11 | GPU | 32 | Implemented | +| Unmult | `ec.key.unmult` | 6 | GPU | 32 | Implemented | ## Matte | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Matte Choker | `ec.matte.mattechoker` | 7 | | 32 | Implemented | +| 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 | | 32 | Implemented | +| Simple Choker | `ec.matte.simplechoker` | 2 | GPU | 32 | Implemented | ## Noise & Grain @@ -266,17 +266,17 @@ Effects are addressed by id (`ec..`) or display name in commands |---|---|---:|:-:|:-:|---| | Add Grain | `ec.noise.addgrain` | 32 | GPU | 32 | Implemented | | Curl Noise | `ec.noise.curlnoise` | 10 | | 32 | Implemented | -| Dust & Scratches | `ec.noise.dustscratches` | 3 | | 32 | Implemented | +| Dust & Scratches | `ec.noise.dustscratches` | 3 | GPU | 32 | Implemented | | Fractal Noise | `ec.noise.fractal` | 26 | GPU | 32 | Implemented | | Match Grain | `ec.noise.matchgrain` | 37 | | 32 | Implemented | -| Median | `ec.noise.median` | 2 | | 32 | Implemented | -| Median (Legacy) | `ec.noise.medianlegacy` | 2 | | 32 | Implemented | +| Median | `ec.noise.median` | 2 | GPU | 32 | Implemented | +| Median (Legacy) | `ec.noise.medianlegacy` | 2 | GPU | 32 | Implemented | | Noise | `ec.noise.noise` | 3 | GPU | 32 | Implemented | -| Noise Alpha | `ec.noise.noisealpha` | 8 | | 32 | Implemented | -| Noise HLS | `ec.noise.noisehls` | 6 | | 32 | Implemented | -| Noise HLS Auto | `ec.noise.noisehlsauto` | 6 | | 32 | Implemented | -| Remove Grain | `ec.noise.removegrain` | 20 | | 32 | Implemented | -| Turbulent Noise | `ec.noise.turbulent` | 22 | | 32 | Implemented | +| Noise Alpha | `ec.noise.noisealpha` | 8 | GPU | 32 | Implemented | +| Noise HLS | `ec.noise.noisehls` | 6 | GPU | 32 | Implemented | +| Noise HLS Auto | `ec.noise.noisehlsauto` | 6 | GPU | 32 | Implemented | +| Remove Grain | `ec.noise.removegrain` | 20 | GPU | 32 | Implemented | +| Turbulent Noise | `ec.noise.turbulent` | 22 | GPU | 32 | Implemented | ## Obsolete @@ -284,13 +284,13 @@ Effects are addressed by id (`ec..`) or display name in commands |---|---|---:|:-:|:-:|---| | Basic 3D | `ec.obsolete.basic3d` | 5 | | 32 | Implemented | | Basic Text | `ec.obsolete.basictext` | 11 | | 32 | Implemented | -| Color Key | `ec.key.colorkey` | 4 | | 32 | Implemented | +| Color Key | `ec.key.colorkey` | 4 | GPU | 32 | Implemented | | Gaussian Blur (Legacy) | `ec.obsolete.gaussianlegacy` | 2 | | 32 | Implemented | | Lightning | `ec.obsolete.lightning` | 25 | | 32 | Implemented | -| Luma Key | `ec.key.luma` | 5 | | 32 | Implemented | +| Luma Key | `ec.key.luma` | 5 | GPU | 32 | Implemented | | Path Text | `ec.obsolete.pathtext` | 32 | | 32 | Implemented | | Reduce Interlace Flicker | `ec.blur.reduceflicker` | 1 | | 32 | Implemented | -| Spill Suppressor | `ec.key.spill` | 3 | | 32 | Implemented | +| Spill Suppressor | `ec.key.spill` | 3 | GPU | 32 | Implemented | ## Paint @@ -340,31 +340,31 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Brush Strokes | `ec.stylize.brushstrokes` | 7 | | 32 | Implemented | +| Brush Strokes | `ec.stylize.brushstrokes` | 7 | GPU | 32 | Implemented | | CC Block Load | `ec.stylize.ccblockload` | 5 | | 32 | Implemented | | CC Burn Film | `ec.stylize.ccburnfilm` | 3 | | 32 | Implemented | | CC Glass | `ec.stylize.ccglass` | 17 | | 32 | Implemented | | CC HexTile | `ec.stylize.cchextile` | 5 | | 32 | Implemented | -| CC Kaleida | `ec.stylize.cckaleida` | 4 | | 32 | Implemented | +| CC Kaleida | `ec.stylize.cckaleida` | 4 | GPU | 32 | Implemented | | CC Mr. Smoothie | `ec.stylize.ccmrsmoothie` | 7 | | 32 | Implemented | | CC Plastic | `ec.stylize.ccplastic` | 18 | | 32 | Implemented | -| CC RepeTile | `ec.stylize.ccrepetile` | 5 | | 32 | Implemented | -| CC Threshold | `ec.stylize.ccthreshold` | 4 | | 32 | Implemented | -| CC Threshold RGB | `ec.stylize.ccthresholdrgb` | 7 | | 32 | Implemented | -| CC Vignette | `ec.stylize.ccvignette` | 4 | | 32 | Implemented | +| CC RepeTile | `ec.stylize.ccrepetile` | 5 | GPU | 32 | Implemented | +| CC Threshold | `ec.stylize.ccthreshold` | 4 | GPU | 32 | Implemented | +| CC Threshold RGB | `ec.stylize.ccthresholdrgb` | 7 | GPU | 32 | Implemented | +| CC Vignette | `ec.stylize.ccvignette` | 4 | GPU | 32 | Implemented | | Cartoon | `ec.stylize.cartoon` | 12 | | 32 | Implemented | | Color Emboss | `ec.stylize.coloremboss` | 4 | | 32 | Implemented | | Emboss | `ec.stylize.emboss` | 4 | GPU | 32 | Implemented | | Find Edges | `ec.stylize.findedges` | 2 | GPU | 32 | Implemented | | Glow | `ec.stylize.glow` | 14 | GPU | 32 | Implemented | | Mosaic | `ec.stylize.mosaic` | 3 | GPU | 32 | Implemented | -| Motion Tile | `ec.stylize.motiontile` | 8 | | 32 | Implemented | -| Posterize | `ec.stylize.posterize` | 1 | | 32 | Implemented | -| Roughen Edges | `ec.stylize.roughenedges` | 13 | | 32 | Implemented | -| Scatter | `ec.stylize.scatter` | 3 | | 32 | Implemented | -| Strobe Light | `ec.stylize.strobe` | 7 | | 32 | Implemented | -| Texturize | `ec.stylize.texturize` | 4 | | 32 | Implemented | -| Threshold | `ec.stylize.threshold` | 1 | | 32 | Implemented | +| Motion Tile | `ec.stylize.motiontile` | 8 | GPU | 32 | Implemented | +| Posterize | `ec.stylize.posterize` | 1 | GPU | 32 | Implemented | +| Roughen Edges | `ec.stylize.roughenedges` | 13 | GPU | 32 | Implemented | +| Scatter | `ec.stylize.scatter` | 3 | GPU | 32 | Implemented | +| Strobe Light | `ec.stylize.strobe` | 7 | GPU | 32 | Implemented | +| Texturize | `ec.stylize.texturize` | 4 | GPU | 32 | Implemented | +| Threshold | `ec.stylize.threshold` | 1 | GPU | 32 | Implemented | ## Text @@ -379,9 +379,9 @@ Effects are addressed by id (`ec..`) or display name in commands |---|---|---:|:-:|:-:|---| | CC Force Motion Blur | `ec.time.ccforcemotionblur` | 4 | | 32 | Implemented | | CC Wide Time | `ec.time.ccwidetime` | 3 | | 32 | Implemented | -| Echo | `ec.time.echo` | 5 | | 32 | Implemented | +| Echo | `ec.time.echo` | 5 | GPU | 32 | Implemented | | Pixel Motion Blur | `ec.time.pixelmotionblur` | 4 | | 32 | Implemented | -| Posterize Time | `ec.time.posterizetime` | 1 | | 32 | Implemented | +| Posterize Time | `ec.time.posterizetime` | 1 | GPU | 32 | Implemented | | Time Difference | `ec.time.timedifference` | 5 | | 32 | Implemented | | Time Displacement | `ec.time.timedisplacement` | 4 | | 32 | Implemented | | Timewarp | `ec.time.timewarp` | 28 | | 32 | Implemented | diff --git a/docs/parity.md b/docs/parity.md index 089a72a1..fee439c4 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -10,7 +10,7 @@ How close EffectCraft is to After Effects 2026, feature by feature, and how much | Unweighted | ≈ 94% (half-credit) / 98% (fractions) | | P0 / P1 / P2 | ≈ 96% / 92% / 85% (half-credit); 99% / 97% / 96% (fractions) | | Features done / partial / missing | 80 / 12 / 0 of 92 | -| **Effects** | **298 of 298** After Effects 2026 effects exist; 42 still simplified ([effects.md](effects.md)); 58 run on the GPU | +| **Effects** | **298 of 298** After Effects 2026 effects exist; 42 still simplified ([effects.md](effects.md)); 152 run on the GPU | | Disabled menu entries left | 0 (the stub list is empty) | | Remaining work | ≈ 19–22 agent-hours at the pace measured so far (≈ 58 on the conservative audit scale) | | **Wall-clock estimate** | **≈ 4–5 hours** with five agents in parallel; ≈ 3 hours for 100% of P0 + P1 | @@ -136,7 +136,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Text | ≈ 95% | 0.4 | no extruded strokes, the Variable Font Axes animator changes outlines but not advances (M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) | | Web | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) | | 3D | 88% | 5.5 | multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 | -| Effects | ≈ 80% | 4.0 | GPU versions of the remaining effects (58 run on the GPU since M12.7) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | +| Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (152 run on the GPU since M13.9, EFF-5: keying, mattes, channel, colour correction, stylize, distort, noise, blurs and time effects joined the 58 of M12.7; Warp, Bezier Warp, Reshape, Smear, Cartoon, CC Page Turn and others still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | | Interface | 74% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | | Project | ≈ 68% | 5.5 | auto-save, folder moves, OCIO displays beyond the built-in tone map (Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) | | Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) | @@ -247,7 +247,7 @@ Browser in the web app. 2. Effect Controls widgets: angle dial, point crosshair, eyedropper, curves and levels editors. 3. ~~Viewer basics: snapping, rulers, channel view, snapshots, exposure, a drawable region of interest.~~ (M0.13) 4. ~~A GPU (wgpu) compositor, then GPU effects~~ (M12.2: 2D compositing and 16 GPU effects; M12.7: - Classic 3D runs, adjustment layers, 58 GPU effects and GPU particles). + Classic 3D runs, adjustment layers, 58 GPU effects and GPU particles; M13.9: 152 GPU effects). 5. ~~Pen tool for shape paths, shape vertex editing, free transform~~ (M6.5); the Layer viewer. 6. ~~Motion-path handles in the viewer; graph editor transform box and snapping.~~ (M5.8) 7. Point tracking and stabilization (in progress).