From 4afe56cbca6022712214854ec2d4d061f0363541 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 08:15:11 -0400 Subject: [PATCH] M13.9: gpu noise, blurs and time (median, dust & scratches, remove grain, turbulent / fractal noise modes, noise alpha / HLS, smart / bilateral / channel / compound blur, sharpen, unsharp mask, minimax, echo, posterize time) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/catalog.rs | 1 + crates/effects/src/lib.rs | 21 + crates/effects/src/noise.rs | 37 +- crates/effects/src/noise3.rs | 83 +++ crates/effects/src/time_fx.rs | 6 + crates/gpu/src/effects.rs | 1 + crates/gpu/src/fx_noise.rs | 578 ++++++++++++++++++- crates/gpu/src/shaders/fx_noise.wgsl | 822 +++++++++++++++++++++++++++ crates/gpu/src/tests_fx_noise.rs | 191 +++++++ docs/effects.md | 36 +- 10 files changed, 1736 insertions(+), 40 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index f41b4b2e..ed1d65d5 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -87,6 +87,7 @@ 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"]), ]; /// Whether the GPU kernel of effect `id` covers the instance's current settings (see diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index bc1cd520..c15fc4df 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -85,6 +85,10 @@ 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}; /// Effect categories in Effects & Presets order. pub const CATEGORIES: &[&str] = &[ @@ -811,6 +815,23 @@ pub const GPU_EFFECTS: &[&str] = &[ // effectcraft-gpu fx_stylize (stylize, distort) // // 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", // ]; 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/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/effects.rs b/crates/gpu/src/effects.rs index 8169164a..d07ca599 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -117,6 +117,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { "ec.perspective.dropshadow" => drop_shadow(e, ctx, b), "ec.distort.transform" => transform(e, ctx, b), "ec.color.curves" => curves(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), diff --git a/crates/gpu/src/fx_noise.rs b/crates/gpu/src/fx_noise.rs index 55838838..eaa7324a 100644 --- a/crates/gpu/src/fx_noise.rs +++ b/crates/gpu/src/fx_noise.rs @@ -1,19 +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_effects::EffectCtx; +use effectcraft_color::BlendMode; +use effectcraft_effects::{Buf, EffectCtx}; +use effectcraft_keyframe::Value; -use crate::context::Enc; -use crate::effects::GBuf; +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] = &[]; +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. -pub(crate) const IDS: &[&str] = &[]; +/// 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 { - let _ = (e, id, ctx, b); - None + 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/shaders/fx_noise.wgsl b/crates/gpu/src/shaders/fx_noise.wgsl index 4fd5ac81..56281158 100644 --- a/crates/gpu/src/shaders/fx_noise.wgsl +++ b/crates/gpu/src/shaders/fx_noise.wgsl @@ -1,3 +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/tests_fx_noise.rs b/crates/gpu/src/tests_fx_noise.rs index 3b2b2743..70f893e8 100644 --- a/crates/gpu/src/tests_fx_noise.rs +++ b/crates/gpu/src/tests_fx_noise.rs @@ -1,2 +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/docs/effects.md b/docs/effects.md index e35ae5f6..072e21b5 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**: 76 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,22 +46,22 @@ 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 @@ -74,7 +74,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Channel Combiner | `ec.channel.combiner` | 6 | | 32 | Implemented | | Compound Arithmetic | `ec.channel.compoundarithmetic` | 6 | | 32 | Implemented | | Invert | `ec.channel.invert` | 2 | GPU | 32 | Implemented | -| Minimax | `ec.channel.minimax` | 5 | | 32 | Implemented | +| Minimax | `ec.channel.minimax` | 5 | GPU | 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 | @@ -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 @@ -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 |