From 7f6e9c5e41144de2b0c56c48f5542b5d752e56c2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 08:25:22 -0400 Subject: [PATCH] M13.9: gpu Twirl (Legacy), CC Ripple Pulse, CC Power Pin (full perspective), CC Flo Motion Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 4 + crates/gpu/src/fx_stylize.rs | 113 ++++++++++++++++++++++++ crates/gpu/src/shaders/fx_stylize.wgsl | 117 +++++++++++++++++++++++++ crates/gpu/src/tests_fx_stylize.rs | 17 ++++ docs/effects.md | 10 +-- 5 files changed, 256 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 972378e29..a848866d0 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -838,6 +838,10 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.distort.cctiler", "ec.distort.ccgriddler", "ec.distort.liquify", + "ec.distort.twirllegacy", + "ec.distort.ccripplepulse", + "ec.distort.ccpowerpin", + "ec.distort.ccflomotion", // effectcraft-gpu fx_noise (noise, blur, time) // ]; diff --git a/crates/gpu/src/fx_stylize.rs b/crates/gpu/src/fx_stylize.rs index 7b3d4891c..6e83c7c8e 100644 --- a/crates/gpu/src/fx_stylize.rs +++ b/crates/gpu/src/fx_stylize.rs @@ -36,6 +36,7 @@ pub(crate) const KERNELS: &[&str] = &[ "fxs_glow_op", "fxs_add", "fxs_div", + "fxs_flomotion", ]; /// Effect ids implemented here. @@ -67,6 +68,10 @@ pub(crate) const IDS: &[&str] = &[ "ec.distort.cctiler", "ec.distort.ccgriddler", "ec.distort.liquify", + "ec.distort.twirllegacy", + "ec.distort.ccripplepulse", + "ec.distort.ccpowerpin", + "ec.distort.ccflomotion", ]; /// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. @@ -102,6 +107,10 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option cc_tiler(e, ctx, b), "ec.distort.ccgriddler" => cc_griddler(e, ctx, b), "ec.distort.liquify" => liquify(e, ctx, b), + "ec.distort.twirllegacy" => twirl_legacy(e, ctx, b), + "ec.distort.ccripplepulse" => ripple_pulse(e, ctx, b), + "ec.distort.ccpowerpin" => power_pin(e, ctx, b), + "ec.distort.ccflomotion" => flo_motion(e, ctx, b), _ => None, } } @@ -438,6 +447,110 @@ fn liquify(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { ) } +fn twirl_legacy(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let ang = ctx.params.f("angle").to_radians(); + if ang == 0.0 { + return Some(b); + } + let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale); + let r = (ctx.params.f("radius") / 100.0 * lw.min(lh) * 0.5).max(1.0); + let c = b.to_px(ctx.params.v2("center")); + warp(e, 10, |p| p.f[0] = [c.0 as f32, c.1 as f32, r as f32, ang as f32], b, None) +} + +fn ripple_pulse(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let level = ctx.params.f("pulseLevel") / 100.0; + let amp = ctx.params.f("amplitude") / 100.0; + if level == 0.0 || amp == 0.0 { + return Some(b); + } + let c = b.to_px(ctx.params.v2("center")); + let span = ctx.params.f("timeSpan").max(0.01); + let size = ctx.layer_size[0].max(ctx.layer_size[1]) * b.scale; + let speed = size * 0.5 / span; + let lambda = (size * 0.06).max(2.0); + let height = level * amp * lambda * 0.5; + let front = speed * ctx.time.max(0.0); + let bump = ctx.params.b("renderBump"); + warp( + e, + 11, + |p| { + p.u[0][1] = bump as u32; + p.f[0] = [c.0 as f32, c.1 as f32, front as f32, speed as f32]; + p.f[1] = [span as f32, lambda as f32, height as f32, 0.0]; + }, + b, + None, + ) +} + +fn power_pin(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + // Perspective below 100 % (a bilinear inverse by Newton iterations with finite + // differences) renders on the CPU. + if ctx.params.f("perspective") / 100.0 < 1.0 { + return None; + } + let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)); + let ex = [ctx.params.f("expandTop"), ctx.params.f("expandLeft"), ctx.params.f("expandRight"), ctx.params.f("expandBottom")]; + let (x0, y0) = (-ex[1] / 100.0 * lw, -ex[0] / 100.0 * lh); + let (x1, y1) = (lw + ex[2] / 100.0 * lw, lh + ex[3] / 100.0 * lh); + let corners = [ctx.params.v2("topLeft"), ctx.params.v2("topRight"), ctx.params.v2("bottomRight"), ctx.params.v2("bottomLeft")]; + if !ctx.adjustment { + let mut need = 0.0f64; + for c in &corners { + let (px, py) = b.to_px(*c); + need = need.max(-px).max(-py).max(px - b.img.width as f64).max(py - b.img.height as f64); + } + if need > 0.0 { + b.pad(e, need.ceil().min(4096.0) as u32 + 1)?; + } + } + let q = corners.map(|c| { + let p = b.to_px(c); + vec2(p.0, p.1) + }); + let Some(inv) = Mat3::square_to_quad(q).inverse() else { return Some(b) }; + let r = inv.0.map(|r| [r[0] as f32, r[1] as f32, r[2] as f32, 0.0]); + let (sc, off) = (b.scale, b.offset); + warp( + e, + 12, + |p| { + p.f[2] = r[0]; + p.f[3] = r[1]; + p.f[4] = r[2]; + p.f[5] = [x0 as f32, y0 as f32, (x1 - x0) as f32, (y1 - y0) as f32]; + p.f[6] = [sc as f32, off[0] as f32, off[1] as f32, 0.0]; + }, + b, + None, + ) +} + +fn flo_motion(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let knots = [(ctx.params.v2("knot1"), ctx.params.f("amount1") / 100.0), (ctx.params.v2("knot2"), ctx.params.f("amount2") / 100.0)]; + if knots.iter().all(|k| k.1 == 0.0) { + return Some(b); + } + let ss = match ctx.params.e("antialiasing") { + 0 => 1, + 1 => 2, + _ => 3, + }; + let falloff = ctx.params.f("falloff").max(0.01); + let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)); + let radius = (lw * lw + lh * lh).sqrt() * 0.5; + let mut p = Params::default(); + p.u[0] = [ss, ctx.params.b("tileEdges") as u32, 0, 0]; + for (i, (k, a)) in knots.iter().enumerate() { + p.f[i] = [k[0] as f32, k[1] as f32, *a as f32, 0.0]; + } + p.f[2] = [falloff as f32, radius as f32, lw as f32, lh as f32]; + p.f[3] = [b.scale as f32, b.offset[0] as f32, b.offset[1] as f32, 0.0]; + run(e, "fxs_flomotion", &p, b, None) +} + // ---------------------------------------------------------------- separable tables /// Per-column `fx(x + 0.5)` then per-row `fy(y + 0.5)`, `k` values each. diff --git a/crates/gpu/src/shaders/fx_stylize.wgsl b/crates/gpu/src/shaders/fx_stylize.wgsl index 9c29925db..bc119e8cd 100644 --- a/crates/gpu/src/shaders/fx_stylize.wgsl +++ b/crates/gpu/src/shaders/fx_stylize.wgsl @@ -180,12 +180,31 @@ fn fxs_point(@builtin(global_invocation_id) gid: vec3) { // f[0] = (cx, cy, size, rotation); f[1].x = segment angle // 9 Liquify (distort4::liquify): u[0].xy = (_, mesh nx, mesh ny); f[0] = (cell, mesh offset x, y, // percentage); f[1] = (scale, buffer offset x, y); data = mesh offsets (x, y) per node +// 10 Twirl (Legacy) (distort3::twirl_legacy): f[0] = (cx, cy, radius, angle) +// 11 CC Ripple Pulse (distort3::cc_ripple_pulse): u[0].y = render bump; f[0] = (cx, cy, front, +// speed); f[1] = (time span, wavelength, height) +// 12 CC Power Pin, full perspective (distort3::cc_power_pin): f[2..5] = inverse of the +// square → quad matrix (rows); f[5] = source rect in layer pixels (x0, y0, w, h); +// f[6] = (scale, buffer offset x, y) fn fxs_mesh_d(i: u32, j: u32) -> vec2 { let k = (j * P.u[0].y + i) * 2u; return vec2(data[k], data[k + 1u]); } +// distort3::cc_ripple_pulse's wave. +fn fxs_pulse(r: f32) -> f32 { + let front = P.f[0].z; + let speed = P.f[0].w; + let span = P.f[1].x; + let age = (front - r) / max(speed, 1e-9); + if (age < 0.0 || age > span) { + return 0.0; + } + let fade = 1.0 - age / span; + return sin(FXS_TAU * (r - front) / P.f[1].y) * fade; +} + // distort4::Mesh::at. fn fxs_mesh_at(x: f32, y: f32) -> vec2 { let cell = P.f[0].x; @@ -360,6 +379,60 @@ fn fxs_warp(@builtin(global_invocation_id) gid: vec3) { let pct = P.f[0].w; s = vec2((lx + d.x * pct) * scale + o.x, (ly + d.y * pct) * scale + o.y); } + case 10u: { + let c = P.f[0].xy; + let r = P.f[0].z; + let dx = x - c.x; + let dy = y - c.y; + let d = sqrt(dx * dx + dy * dy); + if (d < r) { + let a = -P.f[0].w * (1.0 - d / r); + let sn = sin(a); + let co = cos(a); + s = vec2(c.x + dx * co - dy * sn, c.y + dx * sn + dy * co); + } + } + case 11u: { + let c = P.f[0].xy; + let dx = x - c.x; + let dy = y - c.y; + let r = sqrt(dx * dx + dy * dy); + let height = P.f[1].z; + let d = fxs_pulse(r) * height; + var u = vec2(0.0); + if (r > 1e-9) { + u = vec2(dx / r, dy / r); + } + var o = sample_bilinear(src, x - u.x * d, y - u.y * d); + if (P.u[0].y != 0u && d != 0.0) { + let slope = (fxs_pulse(r + 0.5) - fxs_pulse(r - 0.5)) * height; + let k = clamp(1.0 - slope * 0.5, 0.0, 2.0); + o = vec4(o.xyz * k, o.w); + } + textureStore(out, p, o); + return; + } + case 12u: { + let r0 = P.f[2]; + let r1 = P.f[3]; + let r2 = P.f[4]; + var u = r0.x * x + r0.y * y + r0.z; + var v = r1.x * x + r1.y * y + r1.z; + let w = r2.x * x + r2.y * y + r2.z; + if (w != 1.0 && w != 0.0) { + u = u / w; + v = v / w; + } + if (u < -1e-9 || u > 1.0 + 1e-9 || v < -1e-9 || v > 1.0 + 1e-9) { + textureStore(out, p, vec4(0.0)); + return; + } + let rc = P.f[5]; + let lx = rc.x + u * rc.z; + let ly = rc.y + v * rc.w; + let o = P.f[6]; + s = vec2(lx * o.x + o.y, ly * o.x + o.z); + } default: {} } if (clamped) { @@ -369,6 +442,50 @@ fn fxs_warp(@builtin(global_invocation_id) gid: vec3) { } } +// ---------------------------------------------------------------- CC Flo Motion (distort4::flo_motion) + +// One knot's pull at layer point q: f[k] = (knot x, knot y, amount, _). +fn fxs_flo_knot(q: vec2, k: vec4) -> vec2 { + if (k.z == 0.0) { + return q; + } + let d = q - k.xy; + let r = sqrt(d.x * d.x + d.y * d.y) / P.f[2].y; + let w = powz(1.0 - min(r, 1.0), P.f[2].x); + let s = max(1.0 - clamp(k.z, -4.0, 0.95) * w, 0.02); + return k.xy + d * s; +} + +// u[0] = (supersampling, tile edges); f[0], f[1] = knots; f[2] = (falloff, radius, layer w, +// layer h); f[3] = (scale, buffer offset x, y) +@compute @workgroup_size(16, 16) +fn fxs_flomotion(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let ss = P.u[0].x; + let scale = P.f[3].x; + let off = P.f[3].yz; + let lw = P.f[2].z; + let lh = P.f[2].w; + var acc = vec4(0.0); + for (var sy = 0u; sy < ss; sy++) { + for (var sx = 0u; sx < ss; sx++) { + let px = f32(p.x) + (f32(sx) + 0.5) / f32(ss); + let py = f32(p.y) + (f32(sy) + 0.5) / f32(ss); + var q = vec2((px - off.x) / scale, (py - off.y) / scale); + q = fxs_flo_knot(q, P.f[0]); + q = fxs_flo_knot(q, P.f[1]); + if (P.u[0].y != 0u) { + q = vec2(q.x - floor(q.x / lw) * lw, q.y - floor(q.y / lh) * lh); + } + acc += sample_bilinear(src, q.x * scale + off.x, q.y * scale + off.y); + } + } + textureStore(out, p, acc / f32(ss * ss)); +} + // ---------------------------------------------------------------- separable table warps // The source point is (data[x], data[w + y]) (computed in f64 per column and per row), then // mixed with the original: out = sample + (orig − sample) · blend (Offset, CC Tiler). diff --git a/crates/gpu/src/tests_fx_stylize.rs b/crates/gpu/src/tests_fx_stylize.rs index 1d11f801f..35e3e99ba 100644 --- a/crates/gpu/src/tests_fx_stylize.rs +++ b/crates/gpu/src/tests_fx_stylize.rs @@ -141,6 +141,23 @@ fn cc_slant_smear_split() { effect_case("ec.distort.ccsplit2", &[("split1", n(30.0)), ("split2", n(80.0)), ("pointB", pt(60.0, 30.0))]); } +#[test] +fn twirl_legacy_ripple_pulse_power_pin() { + effect_case("ec.distort.twirllegacy", &[("angle", n(90.0))]); + effect_case("ec.distort.twirllegacy", &[("angle", n(-250.0)), ("radius", n(80.0)), ("center", pt(30.0, 18.0))]); + effect_case("ec.distort.ccripplepulse", &[("pulseLevel", n(80.0)), ("timeSpan", n(0.5))]); + effect_case("ec.distort.ccripplepulse", &[("pulseLevel", n(-60.0)), ("amplitude", n(150.0)), ("renderBump", on()), ("center", pt(25.0, 20.0))]); + effect_case("ec.distort.ccpowerpin", &[("topLeft", pt(5.0, 3.0)), ("bottomRight", pt(62.0, 38.0))]); + effect_case("ec.distort.ccpowerpin", &[("topRight", pt(80.0, -6.0)), ("bottomLeft", pt(-4.0, 50.0)), ("expandLeft", n(20.0)), ("expandBottom", n(-10.0))]); +} + +#[test] +fn flo_motion() { + effect_case("ec.distort.ccflomotion", &[]); + effect_case("ec.distort.ccflomotion", &[("antialiasing", e(2)), ("tileEdges", on()), ("amount1", n(-150.0)), ("falloff", n(0.7))]); + effect_case("ec.distort.ccflomotion", &[("antialiasing", e(0)), ("amount2", n(0.0)), ("knot1", pt(20.0, 30.0))]); +} + #[test] fn liquify_render() { let mesh = "warp 20 50 0 0 0 10,10 20,15 30,20\ntwirlClockwise 24 80 0 0 0 40,25 42,27"; diff --git a/docs/effects.md b/docs/effects.md index 833a0d7b2..589c512ce 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**: 86 effects also run on the GPU compositor with identical results. +- **GPU**: 90 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). @@ -135,12 +135,12 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Bend It | `ec.distort.ccbendit` | 4 | | 32 | Implemented | | CC Bender | `ec.distort.ccbender` | 5 | | 32 | Implemented | | CC Blobbylize | `ec.distort.ccblobbylize` | 15 | | 32 | Implemented | -| CC Flo Motion | `ec.distort.ccflomotion` | 7 | | 32 | Implemented | +| CC Flo Motion | `ec.distort.ccflomotion` | 7 | GPU | 32 | Implemented | | CC Griddler | `ec.distort.ccgriddler` | 5 | GPU | 32 | Implemented | | CC Lens | `ec.distort.cclens` | 3 | GPU | 32 | Implemented | | CC Page Turn | `ec.distort.ccpageturn` | 7 | | 32 | Implemented | -| CC Power Pin | `ec.distort.ccpowerpin` | 10 | | 32 | Implemented | -| CC Ripple Pulse | `ec.distort.ccripplepulse` | 5 | | 32 | Implemented | +| CC Power Pin | `ec.distort.ccpowerpin` | 10 | GPU | 32 | Implemented | +| CC Ripple Pulse | `ec.distort.ccripplepulse` | 5 | GPU | 32 | Implemented | | CC Slant | `ec.distort.ccslant` | 4 | GPU | 32 | Implemented | | CC Smear | `ec.distort.ccsmear` | 4 | GPU | 32 | Implemented | | CC Split | `ec.distort.ccsplit` | 3 | GPU | 32 | Implemented | @@ -165,7 +165,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Transform | `ec.distort.transform` | 12 | GPU | 32 | Implemented | | Turbulent Displace | `ec.distort.turbulentdisplace` | 11 | GPU | 32 | Implemented | | Twirl | `ec.distort.twirl` | 3 | GPU | 32 | Implemented | -| Twirl (Legacy) | `ec.distort.twirllegacy` | 3 | | 32 | Implemented | +| Twirl (Legacy) | `ec.distort.twirllegacy` | 3 | GPU | 32 | Implemented | | Warp | `ec.distort.warp` | 5 | | 32 | Implemented | | Warp Stabilizer | `ec.distort.warpstabilizer` | 20 | | 32 | Implemented | | Wave Warp | `ec.distort.wavewarp` | 7 | GPU | 32 | Implemented |