diff --git a/crates/effects/src/blur2.rs b/crates/effects/src/blur2.rs index 529a8dee..3b84d283 100644 --- a/crates/effects/src/blur2.rs +++ b/crates/effects/src/blur2.rs @@ -277,11 +277,9 @@ fn iris_spans(r: f64, sides: u32, rotation_deg: f64, roundness: f64) -> Vec<(i64 spans } -fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { - let r = ctx.params.f("blurRadius").max(0.0) * b.scale; - if r < 0.5 { - return b; - } +/// Camera Lens Blur's iris spans at blur radius `r` pixels (shape, rotation, roundness and +/// aspect ratio applied) and the iris' horizontal reach (shared with the GPU kernel). +pub fn camera_lens_spans(ctx: &EffectCtx, r: f64) -> (Vec<(i64, i64, i64)>, f64) { let shape = ctx.params.e("irisProperties/irisShape"); let sides = if shape >= 8 { 0 } else { shape + 3 }; let mut spans = iris_spans(r, sides, ctx.params.f("irisProperties/irisRotation"), ctx.params.f("irisProperties/irisRoundness").clamp(0.0, 100.0) / 100.0); @@ -293,7 +291,15 @@ fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { s.2 = (s.2 as f64 * aspect).round() as i64; } } - let reach = r * aspect.max(1.0); + (spans, r * aspect.max(1.0)) +} + +fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { + let r = ctx.params.f("blurRadius").max(0.0) * b.scale; + if r < 0.5 { + return b; + } + let (spans, reach) = camera_lens_spans(ctx, r); let repeat = ctx.params.b("repeatEdge") || ctx.adjustment; if !repeat { b.pad(reach.ceil() as u32 + 1); diff --git a/crates/effects/src/generate.rs b/crates/effects/src/generate.rs index 511a8fea..09b25a27 100644 --- a/crates/effects/src/generate.rs +++ b/crates/effects/src/generate.rs @@ -75,7 +75,7 @@ pub(crate) const GEN_BLEND_MODES: [&str; 20] = [ "Silhouette Alpha", ]; -fn gen_mode(i: u32) -> Option { +pub fn gen_mode(i: u32) -> Option { Some(match i { 0 => return None, 2 => BlendMode::Add, diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index 19a9b6a3..ddde36d7 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -61,7 +61,7 @@ const CELL_PATTERNS: [&str; 12] = [ ]; /// Shaping used for Cell Pattern option `i` (see [`cell_value`]). -fn pattern_kind(i: u32) -> u32 { +pub fn pattern_kind(i: u32) -> u32 { match i { 0..=5 => i, 6 => 1, diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index f6278cf9..1ebecb97 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -71,6 +71,12 @@ pub use effectcraft_raster::{AuxChannels, Image}; pub use misc::{INVERT_ALPHA, INVERT_CHANNELS}; pub use sim::particle_state; pub use sim3::playground_state; +// CPU helpers the GPU kernels share (effectcraft-gpu). +pub use blur2::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; /// Effect categories in Effects & Presets order. pub const CATEGORIES: &[&str] = &[ @@ -661,6 +667,20 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.stylize.mosaic", "ec.stylize.findedges", "ec.stylize.emboss", + // Blur, transition and generate family (effectcraft-gpu `fx_generate`). + "ec.blur.radial", + "ec.blur.cameralens", + "ec.blur.ccradialfast", + "ec.transition.venetian", + "ec.transition.linearwipe", + "ec.transition.radialwipe", + "ec.transition.gradientwipe", + "ec.generate.cellpattern", + "ec.generate.checkerboard", + "ec.generate.grid", + "ec.generate.fourcolor", + "ec.noise.noise", + "ec.noise.addgrain", ]; /// 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 4555d16a..4181c070 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -41,7 +41,7 @@ pub(crate) const GRAIN_BLEND_MODES: [&str; 5] = ["Film", "Multiply", "Add", "Scr /// The Tweaking / Color / Application / Animation controls Add Grain and Match Grain share /// (twirl-down groups with the same ids in both effects). -pub(crate) struct GrainLook { +pub struct GrainLook { pub intensity: f32, pub channel_intensity: [f32; 3], pub size: f32, @@ -62,7 +62,7 @@ pub(crate) struct GrainLook { } impl GrainLook { - pub(crate) fn from_params(ctx: &EffectCtx) -> GrainLook { + pub fn from_params(ctx: &EffectCtx) -> GrainLook { let pr = ctx.params; let ch = |pre: &str, a: &str, b: &str, c: &str| [a, b, c].map(|k| pr.get(&format!("{pre}{k}")).map(Value::as_f64).unwrap_or(1.0) as f32); let ft = ctx.time * 24.0 * pr.f("animation/animationSpeed"); diff --git a/crates/effects/src/transition.rs b/crates/effects/src/transition.rs index c41c6449..9701f0c4 100644 --- a/crates/effects/src/transition.rs +++ b/crates/effects/src/transition.rs @@ -30,7 +30,7 @@ fn scale_px(px: &mut Px, k: f32) { /// Resample `other` into `b`'s pixel grid with placement `mode` (Gradient Wipe's Gradient /// Placement, Texturize's Texture Placement): 0 tile from the top-left, 1 centre once, /// 2 stretch to fit. -pub(crate) fn place_layer(ctx: &EffectCtx, b: &Buf, other: &crate::LayerPixels, mode: u32) -> effectcraft_raster::Image { +pub fn place_layer(ctx: &EffectCtx, b: &Buf, other: &crate::LayerPixels, mode: u32) -> effectcraft_raster::Image { let (ls, os) = (ctx.layer_size, other.size); let inv = 1.0 / b.scale.max(1e-9); crate::util::gen_image(b.img.width, b.img.height, |x, y| { diff --git a/crates/gpu/src/fx_generate.rs b/crates/gpu/src/fx_generate.rs index 61c18def..976bfc8e 100644 --- a/crates/gpu/src/fx_generate.rs +++ b/crates/gpu/src/fx_generate.rs @@ -1,18 +1,404 @@ //! GPU effects, blur, transition and generate family (kernels in `shaders/fx_generate.wgsl`): the CPU effects' exact //! steps with the pixel loops as compute kernels. +//! +//! Every effect here covers all of its parameters. Where the CPU works in f64 around a hard +//! edge (the wipes), the per-column and per-row terms are computed in f64 here and uploaded as +//! (hi, lo) f32 pairs so the GPU puts the edge where the CPU does. -use effectcraft_effects::EffectCtx; +use effectcraft_color::BlendMode; +use effectcraft_effects::{EffectCtx, GrainLook}; -use crate::context::Enc; -use crate::effects::GBuf; +use crate::context::{Enc, Params}; +use crate::effects::{GBuf, gaussian_blur}; /// Compute entry points in `fx_generate.wgsl`. -pub(crate) const KERNELS: &[&str] = &[]; +pub(crate) const KERNELS: &[&str] = &[ + "gen_radial_blur", + "gen_ccradialfast", + "gen_lens_pre", + "gen_row_prefix", + "gen_lens_blur", + "gen_linear_wipe", + "gen_venetian", + "gen_radial_wipe", + "gen_gradient_wipe", + "gen_cell_pattern", + "gen_checker", + "gen_grid", + "gen_fourcolor", + "gen_noise", + "gen_grain_planes", + "gen_grain_apply", +]; /// Effect ids implemented here. -pub(crate) const IDS: &[&str] = &[]; +pub(crate) const IDS: &[&str] = &[ + "ec.blur.radial", + "ec.blur.cameralens", + "ec.blur.ccradialfast", + "ec.transition.venetian", + "ec.transition.linearwipe", + "ec.transition.radialwipe", + "ec.transition.gradientwipe", + "ec.generate.cellpattern", + "ec.generate.checkerboard", + "ec.generate.grid", + "ec.generate.fourcolor", + "ec.noise.noise", + "ec.noise.addgrain", +]; /// 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 { - None +pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + match id { + "ec.blur.radial" => radial_blur(e, ctx, b), + "ec.blur.cameralens" => camera_lens(e, ctx, b), + "ec.blur.ccradialfast" => radial_fast(e, ctx, b), + "ec.transition.venetian" => venetian(e, ctx, b), + "ec.transition.linearwipe" => linear_wipe(e, ctx, b), + "ec.transition.radialwipe" => radial_wipe(e, ctx, b), + "ec.transition.gradientwipe" => gradient_wipe(e, ctx, b), + "ec.generate.cellpattern" => cell_pattern(e, ctx, b), + "ec.generate.checkerboard" => checkerboard(e, ctx, b), + "ec.generate.grid" => grid(e, ctx, b), + "ec.generate.fourcolor" => four_color(e, ctx, b), + "ec.noise.noise" => noise(e, ctx, b), + "ec.noise.addgrain" => add_grain(e, ctx, b), + _ => None, + } +} + +/// Run a per-pixel kernel over the buffer. +fn run(e: &mut Enc, entry: &str, p: &Params, b: GBuf, aux: Option<&crate::context::GpuImage>, data: Option<&[f32]>) -> Option { + let buf = data.map(|d| e.data(d)); + let out = e.image(b.img.width, b.img.height); + e.pixels(entry, p, &b.img, aux, &out, buf.as_ref()); + Some(GBuf { img: out, ..b }) +} + +/// `v` as an f32 (hi, lo) pair. +fn hl(v: f64) -> [f32; 2] { + let h = v as f32; + [h, (v - h as f64) as f32] +} + +/// Generate blending mode index → `BlendMode::ALL` index (255 = None: replace). +fn gen_mode(i: u32) -> u32 { + match effectcraft_effects::gen_mode(i) { + None => 255, + Some(m) => BlendMode::ALL.iter().position(|&x| x == m).unwrap_or(0) as u32, + } +} + +// ---------------------------------------------------------------- blurs + +fn radial_blur(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let amt = ctx.params.f("amount"); + let zoom = ctx.params.e("type") == 1; + let c = b.to_px(ctx.params.v2("center")); + let amount = if zoom { amt / 100.0 } else { amt }; + if amount.abs() < 1e-6 { + return Some(b); + } + let n = 32usize; + let mut data = Vec::with_capacity(2 * n); + for i in 0..n { + let t = i as f64 / (n - 1) as f64 - 0.5; + if zoom { + data.push((1.0 + t * amount) as f32); + } else { + let (s, c) = (t * amount).to_radians().sin_cos(); + data.extend([s as f32, c as f32]); + } + } + let mut p = Params::default(); + p.u[0] = [zoom as u32, n as u32, 0, 0]; + p.f[0] = [c.0 as f32, c.1 as f32, 0.0, 0.0]; + run(e, "gen_radial_blur", &p, b, None, Some(&data)) +} + +fn radial_fast(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let s = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) * 0.5; + if s <= 0.0 { + return Some(b); + } + let c = b.to_px(ctx.params.v2("center")); + let mut p = Params::default(); + p.u[0][0] = ctx.params.e("zoom"); + p.f[0] = [c.0 as f32, c.1 as f32, s as f32, 0.0]; + run(e, "gen_ccradialfast", &p, b, None, None) +} + +fn camera_lens(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let r = ctx.params.f("blurRadius").max(0.0) * b.scale; + if r < 0.5 { + return Some(b); + } + let (spans, reach) = effectcraft_effects::camera_lens_spans(ctx, r); + let repeat = ctx.params.b("repeatEdge") || ctx.adjustment; + if !repeat { + b.pad(e, reach.ceil() as u32 + 1)?; + } + let linear = ctx.params.b("useLinear"); + let thr = ctx.params.f("highlight/specularThreshold") as f32 / 255.0; + let boost = 1.0 + ctx.params.f("highlight/specularBrightness").max(0.0) as f32 / 100.0 * 4.0; + let sat = (ctx.params.f("highlight/highlightSaturation") / 100.0).clamp(0.0, 1.0) as f32; + let (w, h) = (b.img.width, b.img.height); + let mut src = b.img.clone(); + if linear || boost > 1.0 { + let mut p = Params::default(); + p.u[0] = [linear as u32, (boost > 1.0) as u32, 0, 0]; + p.f[0] = [thr, boost, sat, 0.0]; + let out = e.image(w, h); + e.pixels("gen_lens_pre", &p, &src, None, &out, None); + src = out; + } + let count: i64 = spans.iter().map(|(_, l, r)| r - l + 1).sum(); + let norm = 1.0 / count as f64; + // Large irises sum each span from per-row prefix sums; small ones add the pixels directly. + let prefix = count > 256; + let pre = if prefix { + if !e.g.fits(w + 1, h) { + return None; + } + let pre = e.image(w + 1, h); + e.dispatch("gen_row_prefix", &Params::default(), &src, None, &pre, None, (h.div_ceil(64), 1)); + Some(pre) + } else { + None + }; + let data: Vec = spans.iter().flat_map(|&(dy, l, r)| [dy as f32, l as f32, r as f32]).collect(); + let mut p = Params::default(); + p.u[0] = [spans.len() as u32, repeat as u32, linear as u32, prefix as u32]; + p.f[0][0] = norm as f32; + let buf = e.data(&data); + let out = e.image(w, h); + e.pixels("gen_lens_blur", &p, &src, pre.as_ref(), &out, Some(&buf)); + b.img = out; + Some(b) +} + +// ---------------------------------------------------------------- transitions + +fn linear_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let done = ctx.params.f("completion") / 100.0; + let ang = ctx.params.f("angle").to_radians(); + let feather = (ctx.params.f("feather") * b.scale).max(0.001); + let (w, h) = (b.img.width as f64, b.img.height as f64); + let (dx, dy) = (ang.sin(), -ang.cos()); + let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)]; + let proj: Vec = corners.iter().map(|(x, y)| x * dx + y * dy).collect(); + let (lo, hi) = (proj.iter().cloned().fold(f64::INFINITY, f64::min), proj.iter().cloned().fold(f64::NEG_INFINITY, f64::max)); + let edge = lo - feather + (hi - lo + 2.0 * feather) * done; + let mut data = Vec::with_capacity(2 * (b.img.width + b.img.height) as usize); + for x in 0..b.img.width { + data.extend(hl((x as f64 + 0.5) * dx - edge)); + } + for y in 0..b.img.height { + data.extend(hl((y as f64 + 0.5) * dy)); + } + let mut p = Params::default(); + p.u[0][0] = 2 * b.img.width; + p.f[0][0] = feather as f32; + run(e, "gen_linear_wipe", &p, b, None, Some(&data)) +} + +fn venetian(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let done = ctx.params.f("completion") / 100.0; + let ang = ctx.params.f("direction").to_radians(); + let width = (ctx.params.f("width") * b.scale).max(1.0); + let feather = (ctx.params.f("feather") * b.scale).max(0.001); + let (dx, dy) = (ang.cos(), ang.sin()); + let mut data = Vec::with_capacity((6 * b.img.width + 2 * b.img.height) as usize); + for x in 0..b.img.width { + let cx = ((x as f64 + 0.5) * dx).rem_euclid(width); + data.extend(hl(cx - done * width)); + data.extend(hl(cx - width - done * width)); + data.extend(hl(cx - width)); + } + for y in 0..b.img.height { + data.extend(hl(((y as f64 + 0.5) * dy).rem_euclid(width))); + } + let mut p = Params::default(); + p.u[0][0] = 6 * b.img.width; + p.f[0][0] = feather as f32; + run(e, "gen_venetian", &p, b, None, Some(&data)) +} + +fn radial_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let done = ctx.params.f("completion") / 100.0; + let start = ctx.params.f("startAngle"); + let c = b.to_px(ctx.params.v2("center")); + let dir = ctx.params.e("wipe"); + let feather = ctx.params.f("feather").max(0.01); + let edge = if dir == 2 { done * 180.0 } else { done * 360.0 }; + // Rotations: the start angle, and the start angle ± the edge (where the wipe's edge lies). + let rots = [start, start + edge, start - edge].map(|r| r.to_radians().sin_cos()); + let mut data = Vec::with_capacity(9 * (b.img.width + b.img.height) as usize); + for x in 0..b.img.width { + let ux = x as f64 + 0.5 - c.0; + for (s, co) in rots { + data.extend(hl(ux * co)); + data.push((ux * s) as f32); + } + } + for y in 0..b.img.height { + let uy = -(y as f64 + 0.5 - c.1); + for (s, co) in rots { + data.extend(hl(uy * s)); + data.push((uy * co) as f32); + } + } + let mut p = Params::default(); + p.u[0] = [9 * b.img.width, dir.min(2), 0, 0]; + p.f[0] = [edge as f32, feather as f32, 0.0, 0.0]; + run(e, "gen_radial_wipe", &p, b, None, Some(&data)) +} + +fn gradient_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0) as f32; + if done <= 0.0 { + return Some(b); + } + let soft = (ctx.params.f("softness") / 100.0) as f32; + // The gradient layer is fetched and placed on the CPU (as the CPU effect does). + let grad = match ctx.layer_param("gradientLayer", false) { + Some(o) => { + // place_layer reads only the buffer's size, offset and scale. + let img = effectcraft_raster::Image { width: b.img.width, height: b.img.height, data: vec![] }; + let cpu = effectcraft_effects::Buf { img, offset: b.offset, scale: b.scale }; + let img = effectcraft_effects::place_layer(ctx, &cpu, &o, ctx.params.e("gradientPlacement")); + Some(e.g.upload_image(&img)?) + } + None => None, + }; + let mut p = Params::default(); + p.u[0][0] = ctx.params.b("invert") as u32; + p.f[0] = [done, soft, 0.0, 0.0]; + let out = e.image(b.img.width, b.img.height); + e.pixels("gen_gradient_wipe", &p, &b.img, Some(grad.as_ref().unwrap_or(&b.img)), &out, None); + Some(GBuf { img: out, ..b }) +} + +// ---------------------------------------------------------------- generate + +fn cell_pattern(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let size = (pr.f("size") * b.scale).max(1.0); + let off = b.to_px(pr.v2("offset")); + let mut evo = pr.f("evolution") / 360.0; + if pr.b("evolutionOptions/cycleEvolution") { + evo = evo.rem_euclid(pr.f("evolutionOptions/cycle").round().max(1.0)); + } + let seed = (pr.f("evolutionOptions/randomSeed") as i64 as u32).wrapping_mul(7919) ^ 0xce11; + let tile = pr.b("tilingOptions/enableTiling"); + let nx = pr.f("tilingOptions/cellsHorizontal").round().max(1.0) as i64; + let ny = pr.f("tilingOptions/cellsVertical").round().max(1.0) as i64; + if nx > i32::MAX as i64 || ny > i32::MAX as i64 { + return None; + } + let mut p = Params::default(); + p.u[0] = [effectcraft_effects::cell_pattern_kind(pr.e("cellPattern")), pr.e("overflow"), pr.b("invert") as u32, seed]; + p.u[1] = [tile as u32, nx as u32, ny as u32, 0]; + // cos / sin of 2π·(h + evo) only see evo modulo 1. + p.f[0] = [pr.f("contrast") as f32 / 100.0, pr.f("disperse").clamp(0.0, 1.5) as f32, size as f32, evo.rem_euclid(1.0) as f32]; + p.f[1] = [(0.5 - off.0) as f32, (0.5 - off.1) as f32, 0.0, 0.0]; + run(e, "gen_cell_pattern", &p, b, None, None) +} + +/// generate::cell_size. +fn cell_size(ctx: &EffectCtx, b: &GBuf, anchor: (f64, f64)) -> (f64, f64) { + let (w, h) = match ctx.params.e("sizeFrom") { + 0 => { + let c = b.to_px(ctx.params.v2("corner")); + ((c.0 - anchor.0).abs(), (c.1 - anchor.1).abs()) + } + 1 => (ctx.params.f("width") * b.scale, ctx.params.f("width") * b.scale), + _ => (ctx.params.f("width") * b.scale, ctx.params.f("height") * b.scale), + }; + (w.max(1.0), h.max(1.0)) +} + +fn checkerboard(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let anchor = b.to_px(ctx.params.v2("anchor")); + let (w, h) = cell_size(ctx, &b, anchor); + let fw = (ctx.params.f("feather/featherWidth") * b.scale).max(1.0); + let fh = (ctx.params.f("feather/featherHeight") * b.scale).max(1.0); + let mut p = Params::default(); + p.u[0][0] = gen_mode(ctx.params.e("blendingMode")); + p.f[0] = [(0.5 - anchor.0) as f32, (0.5 - anchor.1) as f32, w as f32, h as f32]; + p.f[1] = [fw as f32, fh as f32, ctx.params.f("opacity") as f32 / 100.0, 0.0]; + p.f[2] = ctx.params.color("color"); + run(e, "gen_checker", &p, b, None, None) +} + +fn grid(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let anchor = b.to_px(ctx.params.v2("anchor")); + let (w, h) = cell_size(ctx, &b, anchor); + let border = (ctx.params.f("border") * b.scale).max(0.0); + let fw = (ctx.params.f("feather/featherWidth") * b.scale).max(1.0); + let fh = (ctx.params.f("feather/featherHeight") * b.scale).max(1.0); + let mut p = Params::default(); + p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.params.b("invertGrid") as u32, 0, 0]; + p.f[0] = [(0.5 - anchor.0) as f32, (0.5 - anchor.1) as f32, w as f32, h as f32]; + p.f[1] = [fw as f32, fh as f32, ctx.params.f("opacity") as f32 / 100.0, border as f32]; + p.f[2] = ctx.params.color("color"); + run(e, "gen_grid", &p, b, None, None) +} + +fn four_color(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let mut p = Params::default(); + for i in 0..4 { + let q = b.to_px(ctx.params.v2(&format!("positionsColors/point{}", i + 1))); + p.f[i] = [q.0 as f32, q.1 as f32, 0.0, 0.0]; + p.f[4 + i] = ctx.params.color(&format!("positionsColors/color{}", i + 1)); + } + let blend = ctx.params.f("blend").max(1.0); + let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32; + p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.seed, 0, 0]; + p.f[8] = [(blend * 100.0) as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0]; + run(e, "gen_fourcolor", &p, b, None, None) +} + +// ---------------------------------------------------------------- noise + +fn noise(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let amt = ctx.params.f("amount") as f32 / 100.0; + let clip = ctx.params.get("clip").is_none_or(|v| v.as_bool()); + let frame_seed = (ctx.time * 1000.0) as u32 ^ ctx.seed; + let mut p = Params::default(); + p.u[0] = [frame_seed, ctx.params.b("color") as u32, clip as u32, b.img.width]; + p.f[0][0] = amt; + run(e, "gen_noise", &p, b, None, None) +} + +fn add_grain(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let look = GrainLook::from_params(ctx); + if look.intensity <= 0.0 { + return Some(b); + } + let (w, h) = (b.img.width, b.img.height); + // GrainLook::planes. + let size = look.size * b.scale as f32; + let s = |k: usize| (size * if look.mono { 1.0 } else { look.channel_size[k].max(0.05) }).max(0.05); + let mut p = Params::default(); + p.u[0] = [look.mono as u32, look.seed, 101, 0]; + p.f[0] = [s(0) * look.aspect, s(1) * look.aspect, s(2) * look.aspect, look.frame]; + p.f[1] = [s(0), s(1), s(2), 0.0]; + let mut planes = e.image(w, h); + e.pixels("gen_grain_planes", &p, &b.img, None, &planes, None); + let soft = look.softness * b.scale; + if soft > 0.05 { + planes = gaussian_blur(e, &planes, soft * look.aspect as f64, soft, true); + } + // GrainLook::color's tint factors. + let l = effectcraft_color::luminance(look.tint[0], look.tint[1], look.tint[2]).max(1e-3); + let tint = [0, 1, 2].map(|k| (1.0 - look.tint_amount) + look.tint_amount * look.tint[k] / l); + let mut p = Params::default(); + p.u[0] = [look.mono as u32, look.mode, (look.tint_amount > 0.0) as u32, 0]; + p.f[0] = [look.intensity * 0.25, look.saturation, look.midpoint, 0.0]; + p.f[1] = [tint[0], tint[1], tint[2], 0.0]; + p.f[2] = [look.channel_intensity[0], look.channel_intensity[1], look.channel_intensity[2], 0.0]; + p.f[3] = [look.tones[0], look.tones[1], look.tones[2], 0.0]; + run(e, "gen_grain_apply", &p, b, Some(&planes), None) } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 4038afe5..603877f0 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -164,3 +164,5 @@ mod tests_adv3d; mod tests_fx_distort; #[cfg(test)] mod tests_particles; +#[cfg(test)] +mod tests_fx_generate; diff --git a/crates/gpu/src/shaders/fx_generate.wgsl b/crates/gpu/src/shaders/fx_generate.wgsl index bc67b93a..e5e2f411 100644 --- a/crates/gpu/src/shaders/fx_generate.wgsl +++ b/crates/gpu/src/shaders/fx_generate.wgsl @@ -1,2 +1,631 @@ // GPU effects (generate family): see src/fx_generate.rs. Each entry point mirrors the CPU effect in // effectcraft-effects operation for operation; parameter layouts are documented per entry. +// +// Hard edges (wipes) take per-column and per-row terms computed in f64 on the CPU and uploaded +// as (hi, lo) f32 pairs in `data`: near an edge the hi parts cancel exactly, so the edge sits +// where the CPU's f64 arithmetic puts it. + +const GEN_DEG: f32 = 57.29577951308232; + +// util::unpremul: (straight colour, alpha). +fn gen_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)); +} + +// util::smoothstep. +fn gen_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); +} + +// generate::gen_blend: mode = index into BlendMode::ALL, 255 = None (replace). +fn gen_blend(o: vec4, g: vec4, mode: u32) -> vec4 { + if (mode == 255u) { + return g; + } + return blend_pixel(mode, o, g, 0.5); +} + +// A (hi, lo) pair from `data`. +fn gen_hl(i: u32) -> vec2 { + return vec2(data[i], data[i + 1u]); +} + +// (a + b) for (hi, lo) pairs whose hi parts cancel near zero. +fn gen_sum(a: vec2, b: vec2) -> f32 { + return (a.x + b.x) + (a.y + b.y); +} + +// ---------------------------------------------------------------- Radial Blur (raster::radial_blur) + +// u[0] = (zoom, samples); f[0] = (cx, cy); data = per sample (sin, cos) (spin) or k (zoom). +@compute @workgroup_size(16, 16) +fn gen_radial_blur(@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 zoom = P.u[0].x != 0u; + let n = P.u[0].y; + let c = P.f[0].xy; + let v = vec2(f32(p.x) + 0.5 - c.x, f32(p.y) + 0.5 - c.y); + var acc = vec4(0.0); + for (var i = 0u; i < n; i++) { + var s: vec2; + if (zoom) { + let k = data[i]; + s = vec2(c.x + v.x * k, c.y + v.y * k); + } else { + let sn = data[2u * i]; + let cs = data[2u * i + 1u]; + s = vec2(c.x + v.x * cs - v.y * sn, c.y + v.x * sn + v.y * cs); + } + acc += sample_bilinear(src, s.x, s.y); + } + textureStore(out, p, acc / f32(n)); +} + +// ---------------------------------------------------------------- CC Radial Fast Blur + +// u[0].x = zoom mode (0 standard, 1 brightest, 2 darkest); f[0] = (cx, cy, s). +@compute @workgroup_size(16, 16) +fn gen_ccradialfast(@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 mode = P.u[0].x; + let c = P.f[0].xy; + let s = P.f[0].z; + let d = vec2(f32(p.x) + 0.5 - c.x, f32(p.y) + 0.5 - c.y); + let len = sqrt(d.x * d.x + d.y * d.y) * s; + let n = clamp(u32(ceil(len)), 2u, 64u); + var acc = vec4(0.0); + if (mode == 1u) { + acc = vec4(-3.0e38); + } else if (mode == 2u) { + acc = vec4(3.0e38); + } + for (var i = 0u; i < n; i++) { + let k = 1.0 - s * f32(i) / f32(n - 1u); + let q = sample_bilinear(src, c.x + d.x * k, c.y + d.y * k); + if (mode == 1u) { + acc = max(acc, q); + } else if (mode == 2u) { + acc = min(acc, q); + } else { + acc += q / f32(n); + } + } + let a = acc.w; + textureStore(out, p, vec4(max(min(acc.xyz, vec3(a)), vec3(0.0)), clamp(a, 0.0, 1.0))); +} + +// ---------------------------------------------------------------- Camera Lens Blur + +// Linear working space and highlight boost before the blur. +// u[0] = (linear, boost); f[0] = (threshold, boost, saturation). +@compute @workgroup_size(16, 16) +fn gen_lens_pre(@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; + } + var px = textureLoad(src, p, 0); + if (P.u[0].x != 0u) { + let u = gen_unpremul(px); + px = vec4(srgb_to_linear(u.x) * u.w, srgb_to_linear(u.y) * u.w, srgb_to_linear(u.z) * u.w, u.w); + } + if (P.u[0].y != 0u) { + let u = gen_unpremul(px); + let c = u.xyz; + let a = u.w; + if (a > 0.0 && luminance(c) >= P.f[0].x) { + let boost = P.f[0].y; + let sat = P.f[0].z; + let m = max(max(c.x, c.y), c.z); + let tinted = c + (m - c) * (1.0 - sat); + px = vec4((c + tinted * (boost - 1.0)) * a, px.w); + } + } + textureStore(out, p, px); +} + +// Per-row prefix sums: out(x + 1, y) = sum of src(0..=x, y), out(0, y) = 0. One thread per row. +@compute @workgroup_size(64, 1) +fn gen_row_prefix(@builtin(global_invocation_id) gid: vec3) { + let dims = vec2(textureDimensions(src)); + let y = i32(gid.x); + if (y >= dims.y) { + return; + } + var acc = vec4(0.0); + textureStore(out, vec2(0, y), acc); + for (var x = 0; x < dims.x; x++) { + acc += textureLoad(src, vec2(x, y), 0); + textureStore(out, vec2(x + 1, y), acc); + } +} + +// The iris sum. data = spans (dy, left, right); aux = row prefix sums (when u[0].w). +// u[0] = (spans, repeat, linear, prefix); f[0].x = 1 / pixel count. +@compute @workgroup_size(16, 16) +fn gen_lens_blur(@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 n = P.u[0].x; + let repeat = P.u[0].y != 0u; + let prefix = P.u[0].w != 0u; + let w = dims.x; + let h = dims.y; + var acc = vec4(0.0); + for (var s = 0u; s < n; s++) { + var yy = p.y + i32(data[3u * s]); + if (yy < 0 || yy >= h) { + if (!repeat) { + continue; + } + yy = clamp(yy, 0, h - 1); + } + var a = p.x + i32(data[3u * s + 1u]); + var bnd = p.x + i32(data[3u * s + 2u]); + if (!prefix) { + for (var i = a; i <= bnd; i++) { + if (repeat) { + acc += textureLoad(src, vec2(clamp(i, 0, w - 1), yy), 0); + } else if (i >= 0 && i < w) { + acc += textureLoad(src, vec2(i, yy), 0); + } + } + continue; + } + if (repeat) { + if (a < 0) { + let k = min(-a, bnd - a + 1); + acc += textureLoad(src, vec2(0, yy), 0) * f32(k); + a = 0; + } + if (bnd >= w) { + let k = min(bnd - (w - 1), bnd - a + 1); + acc += textureLoad(src, vec2(w - 1, yy), 0) * f32(k); + bnd = w - 1; + } + } else { + a = max(a, 0); + bnd = min(bnd, w - 1); + } + if (a <= bnd) { + acc += textureLoad(aux, vec2(bnd + 1, yy), 0) - textureLoad(aux, vec2(a, yy), 0); + } + } + var px = acc * P.f[0].x; + px.w = clamp(px.w, 0.0, 1.0); + if (P.u[0].z != 0u) { + let u = gen_unpremul(px); + px = vec4(linear_to_srgb(max(u.x, 0.0)) * u.w, linear_to_srgb(max(u.y, 0.0)) * u.w, linear_to_srgb(max(u.z, 0.0)) * u.w, u.w); + } + textureStore(out, p, px); +} + +// ---------------------------------------------------------------- wipes + +// Linear Wipe. data = per column ((x + 0.5)·dx − edge) (hi, lo), then per row ((y + 0.5)·dy) +// (hi, lo); u[0].x = row table offset; f[0].x = feather. +@compute @workgroup_size(16, 16) +fn gen_linear_wipe(@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 d = gen_sum(gen_hl(2u * u32(p.x)), gen_hl(P.u[0].x + 2u * u32(p.y))); + let k = clamp(d / P.f[0].x + 0.5, 0.0, 1.0); + textureStore(out, p, textureLoad(src, p, 0) * k); +} + +// Venetian Blinds. Per column (6 floats): (cx − done·w), (cx − w − done·w), (cx − w) as +// (hi, lo), with cx = ((x + 0.5)·dx) mod w; per row (2 floats): ((y + 0.5)·dy) mod w. +// u[0].x = row table offset; f[0].x = feather. +@compute @workgroup_size(16, 16) +fn gen_venetian(@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 col = 6u * u32(p.x); + let row = gen_hl(P.u[0].x + 2u * u32(p.y)); + // (d mod w) wraps once the column and row terms reach w. + let wrapped = gen_sum(gen_hl(col + 4u), row) >= 0.0; + var t: f32; + if (wrapped) { + t = gen_sum(gen_hl(col + 2u), row); + } else { + t = gen_sum(gen_hl(col), row); + } + let k = clamp(t / P.f[0].x + 0.5, 0.0, 1.0); + textureStore(out, p, textureLoad(src, p, 0) * k); +} + +// Angle (degrees, clockwise from up) of the pixel vector rotated by rotation `r` (0 = start, +// 1 = start + edge, 2 = start − edge), from the per-column / per-row tables of Radial Wipe. +fn gen_psi(r: u32, x: u32, y: u32) -> f32 { + let c = 9u * x + 3u * r; + let w = P.u[0].x + 9u * y + 3u * r; + // x' = ux·cos R − uy·sin R (cancels at the edge), y' = uy·cos R + ux·sin R. + let xr = (data[c] - data[w]) + (data[c + 1u] - data[w + 1u]); + let yr = data[w + 2u] + data[c + 2u]; + return atan2(xr, yr) * GEN_DEG; +} + +// Radial Wipe. data: per column 9 floats (for each rotation: ux·cos R (hi, lo), ux·sin R), +// per row 9 floats (uy·sin R (hi, lo), uy·cos R); u[0] = (row offset, wipe); +// f[0] = (edge, feather). +@compute @workgroup_size(16, 16) +fn gen_radial_wipe(@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 = u32(p.x); + let y = u32(p.y); + let dir = P.u[0].y; + let edge = P.f[0].x; + var a = gen_psi(0u, x, y); + if (a < 0.0) { + a += 360.0; + } + var base: f32; + if (dir == 0u || (dir == 2u && a <= 180.0)) { + base = a - edge; + if (abs(base) < 90.0) { + base = gen_psi(1u, x, y); + } + } else { + base = (360.0 - a) - edge; + if (abs(base) < 90.0) { + base = -gen_psi(2u, x, y); + } + } + let k = clamp(base / P.f[0].y + 0.5, 0.0, 1.0); + textureStore(out, p, textureLoad(src, p, 0) * k); +} + +// Gradient Wipe: aux = the placed gradient layer. u[0].x = invert; f[0] = (done, softness). +@compute @workgroup_size(16, 16) +fn gen_gradient_wipe(@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 done = P.f[0].x; + let soft = P.f[0].y; + let g = gen_unpremul(textureLoad(aux, p, 0)); + var t = clamp(luminance(g.xyz), 0.0, 1.0); + if (P.u[0].x != 0u) { + t = 1.0 - t; + } + var k = 0.0; + if (done >= 1.0) { + k = 0.0; + } else if (soft > 0.0) { + k = clamp((t - (done * (1.0 + soft) - soft)) / soft, 0.0, 1.0); + } else if (t >= done) { + k = 1.0; + } + textureStore(out, p, textureLoad(src, p, 0) * k); +} + +// ---------------------------------------------------------------- Cell Pattern (generate2) + +// Worley distances for (u, v) in cell units: (F1, F2, hash of the nearest cell). +fn gen_worley(u: f32, v: f32, disperse: f32, evo: f32, seed: u32) -> vec3 { + let tile = P.u[1].x != 0u; + let nx = i32(P.u[1].y); + let ny = i32(P.u[1].z); + let cx = i32(floor(u)); + let cy = i32(floor(v)); + var f1 = 3.0e38; + var f2 = 3.0e38; + var id = 0.0; + for (var j = -1; j <= 1; j++) { + for (var i = -1; i <= 1; i++) { + let gx = cx + i; + let gy = cy + j; + var tx = gx; + var ty = gy; + if (tile) { + tx = ((gx % nx) + nx) % nx; + ty = ((gy % ny) + ny) % ny; + } + let hx = bitcast(tx); + let hy = bitcast(ty); + let h1 = hash_noise(hx, hy, seed); + let h2 = hash_noise(hx, hy, seed + 1u); + let h3 = hash_noise(hx, hy, seed + 2u); + let ang = 6.283185307179586 * (h3 + evo); + let px = f32(gx) + 0.5 + (h1 - 0.5) * disperse + 0.15 * disperse * cos(ang); + let py = f32(gy) + 0.5 + (h2 - 0.5) * disperse + 0.15 * disperse * sin(ang); + let d = sqrt((px - u) * (px - u) + (py - v) * (py - v)); + if (d < f1) { + f2 = f1; + f1 = d; + id = hash_noise(hx, hy, seed + 3u); + } else if (d < f2) { + f2 = d; + } + } + } + return vec3(f1, f2, id); +} + +fn gen_cell_value(pattern: u32, f1: f32, f2: f32, id: f32) -> f32 { + let e = f2 - f1; + switch pattern { + case 0u: { return clamp(1.0 - f1 * 1.3, 0.0, 1.0); } + case 1u: { return id * 0.8 + 0.2 * clamp(1.0 - f1, 0.0, 1.0); } + case 2u: { return clamp(e * 2.0, 0.0, 1.0); } + case 3u: { return id * gen_smoothstep(0.0, 0.06, e); } + case 4u: { return id; } + case 5u: { return clamp(1.0 - f1 * f1 * 2.0, 0.0, 1.0); } + case 6u: { return id * clamp(e * 3.0, 0.0, 1.0); } + default: { return 1.0 - clamp(abs(e - 0.15) * 5.0, 0.0, 1.0); } + } +} + +fn gen_overflow(v: f32, mode: u32) -> f32 { + if (mode == 1u) { + return 0.5 + 0.5 * tanh((v - 0.5) * 2.0); + } + if (mode == 2u) { + let t = v - 2.0 * floor(v * 0.5); + return select(t, 2.0 - t, t > 1.0); + } + return clamp(v, 0.0, 1.0); +} + +// u[0] = (pattern kind, overflow, invert, seed); u[1] = (tile, columns, rows); +// f[0] = (contrast, disperse, size, evolution mod 1); f[1] = (0.5 − offset x, 0.5 − offset y). +@compute @workgroup_size(16, 16) +fn gen_cell_pattern(@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 size = P.f[0].z; + let u = (f32(p.x) + P.f[1].x) / size; + let v = (f32(p.y) + P.f[1].y) / size; + let w = gen_worley(u, v, P.f[0].y, P.f[0].w, P.u[0].w); + var val = gen_cell_value(P.u[0].x, w.x, w.y, w.z); + val = gen_overflow((val - 0.5) * P.f[0].x + 0.5, P.u[0].y); + if (P.u[0].z != 0u) { + val = 1.0 - val; + } + textureStore(out, p, vec4(val, val, val, 1.0)); +} + +// ---------------------------------------------------------------- Checkerboard / Grid / 4-Color + +// Signed distance (in feather units) to the nearest cell edge, positive in even cells. +fn gen_axis(q: f32, size: f32, f: f32) -> f32 { + let u = q / size; + let fl = floor(u); + let fr = u - fl; + let d = min(fr, 1.0 - fr) * size; + let n = i32(fl); + let s = select(-1.0, 1.0, ((n % 2) + 2) % 2 == 0); + return s * min(d / (f * 0.5), 1.0); +} + +// u[0].x = mode; f[0] = (0.5 − anchor x, 0.5 − anchor y, cell w, cell h); +// f[1] = (feather w, feather h, opacity); f[2] = colour. +@compute @workgroup_size(16, 16) +fn gen_checker(@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 sx = gen_axis(f32(p.x) + P.f[0].x, P.f[0].z, P.f[1].x); + let sy = gen_axis(f32(p.y) + P.f[0].y, P.f[0].w, P.f[1].y); + let cov = clamp(0.5 + 0.5 * sx * sy, 0.0, 1.0) * P.f[1].z; + let c = P.f[2].xyz; + textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4(c * cov, cov), P.u[0].x)); +} + +// f32::rem_euclid. +fn gen_rem(x: f32, m: f32) -> f32 { + let r = x - m * trunc(x / m); + return select(r, r + m, r < 0.0); +} + +// u[0] = (mode, invert); f[0] = (0.5 − anchor x, 0.5 − anchor y, cell w, cell h); +// f[1] = (feather w, feather h, opacity, border); f[2] = colour. +@compute @workgroup_size(16, 16) +fn gen_grid(@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 w = P.f[0].z; + let h = P.f[0].w; + let border = P.f[1].w; + let gx = gen_rem(f32(p.x) + P.f[0].x, w); + let gy = gen_rem(f32(p.y) + P.f[0].y, h); + let dx = min(gx, w - gx); + let dy = min(gy, h - gy); + let cx = clamp((border / 2.0 - dx) / P.f[1].x + 0.5, 0.0, 1.0); + let cy = clamp((border / 2.0 - dy) / P.f[1].y + 0.5, 0.0, 1.0); + var cov = 0.0; + if (border > 0.0) { + cov = max(cx, cy); + } + if (P.u[0].y != 0u) { + cov = 1.0 - cov; + } + let a = cov * P.f[1].z; + textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4(P.f[2].xyz * a, a), P.u[0].x)); +} + +// u[0] = (mode, seed); f[0..4] = points (x, y); f[4..8] = colours; +// f[8] = (blend · 100, jitter, opacity). +@compute @workgroup_size(16, 16) +fn gen_fourcolor(@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 bl = P.f[8].x; + var acc = vec3(0.0); + var wsum = 0.0; + for (var i = 0; i < 4; i++) { + let q = P.f[i].xy; + let dx = f32(p.x) + 0.5 - q.x; + let dy = f32(p.y) + 0.5 - q.y; + let d2 = dx * dx + dy * dy; + let w = 1.0 / pow(d2 / bl + 1e-6, 1.5); + acc += P.f[4 + i].xyz * w; + wsum += w; + } + var c = acc / wsum; + let jitter = P.f[8].y; + if (jitter > 0.0) { + let n = (hash_noise(u32(p.x), u32(p.y), P.u[0].y) - 0.5) * jitter * (8.0 / 255.0); + c += vec3(n); + } + let op = P.f[8].z; + textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4(c * op, op), P.u[0].x)); +} + +// ---------------------------------------------------------------- Noise / Add Grain + +// Noise (misc::noise). u[0] = (frame seed, colour, clip, width); f[0].x = amount. +@compute @workgroup_size(16, 16) +fn gen_noise(@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; + } + var px = textureLoad(src, p, 0); + let a = px.w; + if (a <= 0.0) { + textureStore(out, p, px); + return; + } + let seed = P.u[0].x; + let w = P.u[0].w; + let amt = P.f[0].x; + let x = u32(p.x); + let y = u32(p.y); + var n = vec3((hash_noise(x, y, seed) - 0.5) * amt); + if (P.u[0].y != 0u) { + n.y = (hash_noise(x + w, y, seed) - 0.5) * amt; + n.z = (hash_noise(x + 2u * w, y, seed) - 0.5) * amt; + } + if (P.u[0].z != 0u) { + px = vec4(clamp(px.xyz + n * a, vec3(0.0), vec3(a)), a); + } else { + // Unclipped: values past black / white wrap around. + px = vec4(fract_euclid(px.x / a + n.x) * a, fract_euclid(px.y / a + n.y) * a, fract_euclid(px.z / a + n.z) * a, a); + } + textureStore(out, p, px); +} + +// Add Grain's noise planes (GrainLook::planes before Softness): rgb = per-channel zero-mean +// value noise. u[0] = (mono, seed, salt); f[0].xyz = x divisors (size · aspect), f[0].w = frame; +// f[1].xyz = y divisors (size). +@compute @workgroup_size(16, 16) +fn gen_grain_planes(@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 = f32(p.x); + let y = f32(p.y); + let frame = P.f[0].w; + let seed = P.u[0].y; + let salt = P.u[0].z; + let n0 = value_noise(x / P.f[0].x, y / P.f[1].x, frame, seed) - 0.5; + var g = vec3(n0); + if (P.u[0].x == 0u) { + g.y = value_noise(x / P.f[0].y, y / P.f[1].y, frame, seed + salt) - 0.5; + g.z = value_noise(x / P.f[0].z, y / P.f[1].z, frame, seed + 2u * salt) - 0.5; + } + textureStore(out, p, vec4(g, 0.0)); +} + +// GrainLook::blend. +fn gen_grain_blend(mode: u32, c: f32, n: f32) -> f32 { + var v: f32; + switch mode { + case 1u: { v = c * (1.0 + n); } + case 2u: { v = c + n; } + case 3u: { v = 1.0 - (1.0 - c) * (1.0 - abs(n)); } + case 4u: { v = c + n * 4.0 * c * (1.0 - c); } + default: { v = c + n * (1.0 - 0.5 * c); } + } + return max(v, 0.0); +} + +// Add Grain applied: aux = noise planes. u[0] = (mono, mode, tint); f[0] = (amp, saturation, +// midpoint); f[1].xyz = tint factors; f[2].xyz = channel intensities; f[3].xyz = tone weights. +@compute @workgroup_size(16, 16) +fn gen_grain_apply(@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 u = gen_unpremul(px); + let a = u.w; + if (a <= 0.0) { + textureStore(out, p, px); + return; + } + let c = u.xyz; + var g = textureLoad(aux, p, 0).xyz; + if (P.u[0].x != 0u) { + g = vec3(g.x); + } else { + let gm = (g.x + g.y + g.z) / 3.0; + g = gm + (g - gm) * P.f[0].y; + } + if (P.u[0].z != 0u) { + g = g * P.f[1].xyz; + } + let mid = P.f[0].z; + let l = clamp(luminance(c), 0.0, 1.0); + let s = 1.0 - gen_smoothstep(0.0, mid, l); + let hi = gen_smoothstep(mid, 1.0, l); + let tones = P.f[3].xyz; + let weight = tones.x * s + tones.y * (1.0 - s - hi) + tones.z * hi; + let amp = P.f[0].x; + let ci = P.f[2].xyz; + let mode = P.u[0].y; + let o = vec3( + gen_grain_blend(mode, c.x, g.x * amp * ci.x * weight), + gen_grain_blend(mode, c.y, g.y * amp * ci.y * weight), + gen_grain_blend(mode, c.z, g.z * amp * ci.z * weight), + ); + textureStore(out, p, vec4(o * a, a)); +} diff --git a/crates/gpu/src/tests_fx_generate.rs b/crates/gpu/src/tests_fx_generate.rs new file mode 100644 index 00000000..e15abb37 --- /dev/null +++ b/crates/gpu/src/tests_fx_generate.rs @@ -0,0 +1,200 @@ +//! GPU vs CPU for the blur, transition and generate family (`fx_generate`). + +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 b(v: bool) -> Value { + Value::Bool(v) +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +#[test] +fn radial_blur() { + effect_case("ec.blur.radial", &[("amount", n(12.0))]); + effect_case("ec.blur.radial", &[("amount", n(30.0)), ("type", e(1)), ("center", pt(20.3, 14.7))]); + effect_case("ec.blur.radial", &[("amount", n(-45.0)), ("center", pt(50.0, 30.0))]); +} + +#[test] +fn cc_radial_fast_blur() { + effect_case("ec.blur.ccradialfast", &[("amount", n(40.0))]); + effect_case("ec.blur.ccradialfast", &[("amount", n(70.0)), ("zoom", e(1)), ("center", pt(22.4, 16.1))]); + effect_case("ec.blur.ccradialfast", &[("amount", n(25.0)), ("zoom", e(2))]); +} + +#[test] +fn camera_lens_blur() { + effect_case("ec.blur.cameralens", &[("blurRadius", n(5.0))]); + effect_case( + "ec.blur.cameralens", + &[ + ("blurRadius", n(7.5)), + ("irisProperties/irisShape", e(2)), + ("irisProperties/irisRotation", n(20.0)), + ("irisProperties/irisAspectRatio", n(1.6)), + ("repeatEdge", b(true)), + ], + ); + effect_case( + "ec.blur.cameralens", + &[ + ("blurRadius", n(4.0)), + ("irisProperties/irisShape", e(8)), + ("highlight/specularBrightness", n(60.0)), + ("highlight/specularThreshold", n(150.0)), + ("highlight/highlightSaturation", n(40.0)), + ("useLinear", b(true)), + ], + ); + // Large iris: per-row prefix sums. + effect_case("ec.blur.cameralens", &[("blurRadius", n(14.0)), ("irisProperties/irisRoundness", n(60.0))]); + effect_case("ec.blur.cameralens", &[("blurRadius", n(12.0)), ("repeatEdge", b(true)), ("useLinear", b(true))]); +} + +#[test] +fn wipes() { + effect_case("ec.transition.linearwipe", &[("completion", n(40.0)), ("angle", n(30.0)), ("feather", n(12.0))]); + effect_case("ec.transition.linearwipe", &[("completion", n(55.0)), ("angle", n(-120.0))]); + effect_case("ec.transition.linearwipe", &[("completion", n(20.0)), ("angle", n(90.0)), ("feather", n(3.0))]); + effect_case("ec.transition.venetian", &[("completion", n(35.0)), ("direction", n(30.0)), ("width", n(11.0)), ("feather", n(2.0))]); + effect_case("ec.transition.venetian", &[("completion", n(60.0)), ("width", n(17.0))]); + effect_case("ec.transition.venetian", &[("completion", n(25.0)), ("direction", n(-70.0)), ("width", n(9.0)), ("feather", n(0.5))]); + effect_case("ec.transition.radialwipe", &[("completion", n(30.0)), ("startAngle", n(25.0)), ("feather", n(10.0))]); + effect_case("ec.transition.radialwipe", &[("completion", n(45.0)), ("wipe", e(1)), ("center", pt(30.2, 19.6))]); + effect_case("ec.transition.radialwipe", &[("completion", n(60.0)), ("wipe", e(2)), ("startAngle", n(-40.0)), ("feather", n(3.0))]); + effect_case("ec.transition.radialwipe", &[("completion", n(0.0)), ("feather", n(20.0))]); + effect_case("ec.transition.gradientwipe", &[("completion", n(40.0))]); + effect_case("ec.transition.gradientwipe", &[("completion", n(55.0)), ("softness", n(30.0)), ("invert", b(true))]); + effect_case("ec.transition.gradientwipe", &[("completion", n(100.0))]); +} + +/// Gradient Wipe driven by another layer (fetched through the effect host, placed on the CPU). +#[test] +fn gradient_wipe_with_a_gradient_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 grad = s.footage(40, 30); + let gid = s.push(grad); + let mut l = s.footage(70, 44); + s.effect( + &mut l, + "ec.transition.gradientwipe", + &[("completion", n(45.0)), ("softness", n(20.0)), ("gradientLayer", Value::Layer(Some(gid.0))), ("gradientPlacement", e(placement))], + ); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let label = format!("gradient wipe layer {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); + } + } +} + +#[test] +fn generators() { + effect_case("ec.generate.cellpattern", &[("size", n(14.0))]); + effect_case("ec.generate.cellpattern", &[("cellPattern", e(2)), ("size", n(11.0)), ("evolution", n(130.0)), ("overflow", e(1)), ("contrast", n(150.0))]); + effect_case( + "ec.generate.cellpattern", + &[ + ("cellPattern", e(11)), + ("size", n(9.0)), + ("tilingOptions/enableTiling", b(true)), + ("tilingOptions/cellsHorizontal", n(4.0)), + ("tilingOptions/cellsVertical", n(3.0)), + ("overflow", e(2)), + ("contrast", n(250.0)), + ("invert", b(true)), + ("evolutionOptions/randomSeed", n(17.0)), + ], + ); + effect_case("ec.generate.cellpattern", &[("cellPattern", e(5)), ("size", n(16.0)), ("disperse", n(0.6)), ("offset", pt(3.3, -7.1))]); + effect_case("ec.generate.checkerboard", &[("width", n(9.0))]); + effect_case( + "ec.generate.checkerboard", + &[ + ("sizeFrom", e(2)), + ("width", n(13.0)), + ("height", n(7.0)), + ("feather/featherWidth", n(4.0)), + ("anchor", pt(5.3, 2.2)), + ("blendingMode", e(5)), + ("opacity", n(70.0)), + ], + ); + effect_case("ec.generate.checkerboard", &[("sizeFrom", e(0)), ("corner", pt(16.0, 11.0)), ("color", c(0.9, 0.3, 0.1)), ("blendingMode", e(1))]); + effect_case("ec.generate.grid", &[("width", n(12.0)), ("border", n(3.0))]); + effect_case( + "ec.generate.grid", + &[ + ("sizeFrom", e(2)), + ("width", n(15.0)), + ("height", n(9.0)), + ("border", n(2.5)), + ("feather/featherWidth", n(3.0)), + ("feather/featherHeight", n(2.0)), + ("invertGrid", b(true)), + ("blendingMode", e(4)), + ], + ); + effect_case( + "ec.generate.grid", + &[("sizeFrom", e(0)), ("corner", pt(11.0, 8.0)), ("anchor", pt(2.5, 3.5)), ("blendingMode", e(14)), ("color", c(0.2, 0.8, 0.4))], + ); + effect_case("ec.generate.fourcolor", &[]); + effect_case("ec.generate.fourcolor", &[("blend", n(40.0)), ("jitter", n(50.0)), ("opacity", n(80.0)), ("blendingMode", e(3))]); + effect_case("ec.generate.fourcolor", &[("blend", n(600.0)), ("blendingMode", e(16)), ("positionsColors/point2", pt(30.0, 20.0))]); +} + +#[test] +fn noise_and_grain() { + effect_case("ec.noise.noise", &[("amount", n(30.0))]); + effect_case("ec.noise.noise", &[("amount", n(50.0)), ("color", b(false))]); + effect_case("ec.noise.noise", &[("amount", n(40.0)), ("clip", b(false))]); + effect_case("ec.noise.addgrain", &[("tweaking/intensity", n(2.0))]); + effect_case( + "ec.noise.addgrain", + &[ + ("tweaking/intensity", n(3.0)), + ("tweaking/size", n(2.5)), + ("tweaking/softness", n(1.5)), + ("tweaking/aspectRatio", n(1.4)), + ("tweaking/channelSize/redSize", n(0.6)), + ("color/saturation", n(0.5)), + ("color/tintAmount", n(0.4)), + ("color/tintColor", c(1.0, 0.6, 0.2)), + ("application/blendingMode", e(4)), + ], + ); + effect_case( + "ec.noise.addgrain", + &[ + ("tweaking/intensity", n(1.5)), + ("color/monochromatic", b(true)), + ("application/blendingMode", e(3)), + ("application/shadows", n(0.3)), + ("application/highlights", n(1.8)), + ("application/midpoint", n(0.35)), + ("animation/randomSeed", n(42.0)), + ("animation/animationSpeed", n(2.0)), + ], + ); + effect_case( + "ec.noise.addgrain", + &[("tweaking/intensity", n(2.0)), ("application/blendingMode", e(1)), ("tweaking/channelIntensities/blueIntensity", n(2.5))], + ); +} diff --git a/docs/effects.md b/docs/effects.md index 75d32478..a69f91b6 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -5,7 +5,7 @@ EffectCraft ships 303 effects with 2434 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**: 24 effects also run on the GPU compositor with identical results. +- **GPU**: 37 effects also run on the GPU compositor with identical results. - **32**: 303 effects process 32-bit float (HDR, overbright) pixels without clamping. - **Status**: 261 implemented in full, 42 partial (what is missing is listed). @@ -49,16 +49,16 @@ Effects are addressed by id (`ec..`) or display name in commands | Bilateral Blur | `ec.blur.bilateral` | 3 | | 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 | | 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` | 10 | | 32 | Partial: no Diffraction Fringe or Blur Map layer | +| Camera Lens Blur | `ec.blur.cameralens` | 10 | GPU | 32 | Partial: no Diffraction Fringe or Blur Map layer | | 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 | | 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 | | 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 | @@ -187,7 +187,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| 4-Color Gradient | `ec.generate.fourcolor` | 12 | | 32 | Implemented | +| 4-Color Gradient | `ec.generate.fourcolor` | 12 | GPU | 32 | Implemented | | Advanced Lightning | `ec.generate.advancedlightning` | 16 | | 32 | Partial: no Alpha Obstacle; most Expert Settings missing | | Audio Spectrum | `ec.generate.audiospectrum` | 23 | | 32 | Implemented | | Audio Waveform | `ec.generate.audiowaveform` | 17 | | 32 | Implemented | @@ -197,15 +197,15 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Light Rays | `ec.generate.cclightrays` | 8 | | 32 | Implemented | | CC Light Sweep | `ec.generate.cclightsweep` | 9 | | 32 | Implemented | | CC Threads | `ec.generate.ccthreads` | 8 | | 32 | Implemented | -| Cell Pattern | `ec.generate.cellpattern` | 14 | | 32 | Partial: HQ variants render like the standard ones | -| Checkerboard | `ec.generate.checkerboard` | 10 | | 32 | Implemented | +| Cell Pattern | `ec.generate.cellpattern` | 14 | GPU | 32 | Partial: HQ variants render like the standard ones | +| Checkerboard | `ec.generate.checkerboard` | 10 | GPU | 32 | Implemented | | Circle | `ec.generate.circle` | 11 | | 32 | Implemented | | Ellipse | `ec.generate.ellipse` | 8 | | 32 | Implemented | | Eyedropper Fill | `ec.generate.eyedropperfill` | 5 | | 32 | Implemented | | Fill | `ec.generate.fill` | 3 | GPU | 32 | Partial: no Fill Mask / All Masks / feather | | Fractal | `ec.generate.fractal` | 21 | | 32 | Implemented | | Gradient Ramp | `ec.generate.gradientramp` | 7 | GPU | 32 | Implemented | -| Grid | `ec.generate.grid` | 12 | | 32 | Implemented | +| Grid | `ec.generate.grid` | 12 | GPU | 32 | Implemented | | Lens Flare | `ec.generate.lensflare` | 4 | | 32 | Implemented | | Paint Bucket | `ec.generate.paintbucket` | 11 | | 32 | Implemented | | Radio Waves | `ec.generate.radiowaves` | 17 | | 32 | Partial: no Image Contours or Mask wave types, Parameters Are Set At, reflection or stroke profile | @@ -263,14 +263,14 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Add Grain | `ec.noise.addgrain` | 22 | | 32 | Partial: no preview region, presets, channel balance or temporal controls | +| Add Grain | `ec.noise.addgrain` | 22 | GPU | 32 | Partial: no preview region, presets, channel balance or temporal controls | | Curl Noise | `ec.noise.curlnoise` | 10 | | 32 | Implemented | | Dust & Scratches | `ec.noise.dustscratches` | 3 | | 32 | Implemented | | Fractal Noise | `ec.noise.fractal` | 26 | GPU | 32 | Implemented | | Match Grain | `ec.noise.matchgrain` | 25 | | 32 | Partial: no preview region, presets, sampling or temporal controls | | Median | `ec.noise.median` | 2 | | 32 | Implemented | | Median (Legacy) | `ec.noise.medianlegacy` | 2 | | 32 | Implemented | -| Noise | `ec.noise.noise` | 3 | | 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 | @@ -401,11 +401,11 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Twister | `ec.transition.cctwister` | 5 | | 32 | Implemented | | CC WarpoMatic | `ec.transition.ccwarpomatic` | 8 | | 32 | Implemented | | Card Wipe | `ec.transition.cardwipe` | 12 | | 32 | Partial: 2D flip only; no back layer picker, camera, lighting or jitter | -| Gradient Wipe | `ec.transition.gradientwipe` | 5 | | 32 | Implemented | +| Gradient Wipe | `ec.transition.gradientwipe` | 5 | GPU | 32 | Implemented | | Iris Wipe | `ec.transition.iriswipe` | 7 | | 32 | Implemented | -| Linear Wipe | `ec.transition.linearwipe` | 3 | | 32 | Implemented | -| Radial Wipe | `ec.transition.radialwipe` | 5 | | 32 | Implemented | -| Venetian Blinds | `ec.transition.venetian` | 4 | | 32 | Implemented | +| Linear Wipe | `ec.transition.linearwipe` | 3 | GPU | 32 | Implemented | +| Radial Wipe | `ec.transition.radialwipe` | 5 | GPU | 32 | Implemented | +| Venetian Blinds | `ec.transition.venetian` | 4 | GPU | 32 | Implemented | ## Utility