From 8d41ef72183fe4dad5d8c79453c5a55eba322d78 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 11:45:32 +0200 Subject: [PATCH] Radial Wipe: Feather in pixels on both edges of the wedge, as After Effects After Effects feathers both edges of the wiped wedge (the start ray too) with a Gaussian whose width is constant in pixels: Completion 40, Start Angle 30, Counterclockwise, Feather 20 (AE 2026, case 904 E12) fits sigma = 0.4 x Feather = 8.0 px at every radius (mean alpha error 0.0002 over the frame). EffectCraft ramped only the moving edge, linearly over Feather degrees, so the edge was 14 px wide near the centre and 140 px wide at 400 px out, and the start edge was hard: 378 of 5184 grid points off (max 111/255, 6.6 % of pixels > 8), now 1 (max 3), 0.009 % of pixels > 8 (near the centre). CPU and GPU (the kernel now measures distances to the two rays). 0 % and 100 % are unchanged, so the three Transitions presets keep their end frames; their Feather (6 / 10) now means pixels. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 2 +- crates/effects/src/misc.rs | 132 +++++++++++++++++++----- crates/gpu/src/fx_generate.rs | 38 +++---- crates/gpu/src/shaders/fx_generate.wgsl | 56 +++++----- docs/effects-parity.md | 3 +- 5 files changed, 148 insertions(+), 83 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index ff10c99c9..d942ff13d 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -85,7 +85,7 @@ use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; pub use effectcraft_raster::{AuxChannels, Image}; -pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation, glow_sigma, venetian_cover, venetian_origin}; +pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation, glow_sigma, radial_wipe_sigma, radial_wipe_wedge, venetian_cover, venetian_origin}; pub use sim::particle_state; pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state}; pub use stylize2::strobe_on; diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index 90306be08..fa92c998e 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -532,30 +532,6 @@ fn linear_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -fn radial_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { - 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); - b.img.rows_mut().for_each(|(y, row)| { - for (x, px) in row.iter_mut().enumerate() { - let a = ((x as f64 + 0.5 - c.0).atan2(-(y as f64 + 0.5 - c.1)).to_degrees() - start).rem_euclid(360.0); - // Both: the wipe opens in both directions from the start angle at once. - let (a, edge) = match dir { - 1 => (360.0 - a, done * 360.0), - 2 => (a.min(360.0 - a), done * 180.0), - _ => (a, done * 360.0), - }; - let k = ((a - edge) / feather + 0.5).clamp(0.0, 1.0) as f32; - for ch in px.iter_mut() { - *ch *= k; - } - } - }); - b -} - /// Standard normal CDF Φ (Abramowitz & Stegun 7.1.26 erf, |error| < 1.5e-7). fn norm_cdf(z: f64) -> f64 { let x = z.abs() / std::f64::consts::SQRT_2; @@ -565,6 +541,66 @@ fn norm_cdf(z: f64) -> f64 { if z >= 0.0 { 0.5 * (1.0 + erf) } else { 0.5 * (1.0 - erf) } } +/// Radial Wipe's wiped wedge relative to the Start Angle: (first ray, span), degrees clockwise +/// from up. Clockwise (0) wipes from the start angle on, Counterclockwise (1) back from it, +/// Both (2) half each way. +pub fn radial_wipe_wedge(wipe: u32, done: f64) -> (f64, f64) { + let span = done.clamp(0.0, 1.0) * 360.0; + match wipe { + 1 => (-span, span), + 2 => (-span / 2.0, span), + _ => (0.0, span), + } +} + +/// Radial Wipe's feather: a Gaussian of σ = 0.4 × Feather pixels across both edges of the wedge +/// (After Effects 2026, Feather 20: the edge profile is the same width in pixels at every +/// radius, σ = 8.0 px, mean error 0.0002 over the frame). +pub fn radial_wipe_sigma(feather: f64) -> f64 { + feather.max(0.0) * 0.4 +} + +/// Visible fraction of a pixel at `(ux, uy)` from the centre (y down) for a wiped wedge from +/// `ray0` (absolute degrees clockwise from up) over `span` degrees: the signed distance to the +/// wedge's nearer edge, through the feather's Gaussian. +pub fn radial_wipe_alpha(ux: f64, uy: f64, ray0: f64, span: f64, sigma: f64) -> f64 { + let a = (ux.atan2(-uy).to_degrees() - ray0).rem_euclid(360.0); + let wiped = a < span; + let ray_dist = |deg: f64| { + let (s, c) = deg.to_radians().sin_cos(); + // Ray direction (sin, −cos) in y-down pixels. + let along = ux * s - uy * c; + if along > 0.0 { (ux * c + uy * s).abs() } else { ux.hypot(uy) } + }; + let d = ray_dist(ray0).min(ray_dist(ray0 + span)); + let sd = if wiped { -d } else { d }; + if sigma < 1e-3 { + return if wiped { 0.0 } else { 1.0 }; + } + norm_cdf(sd / sigma) +} + +fn radial_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { + let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0); + if done <= 0.0 { + return b; + } + let start = ctx.params.f("startAngle"); + let c = b.to_px(ctx.params.v2("center")); + let (w0, span) = radial_wipe_wedge(ctx.params.e("wipe"), done); + let sigma = radial_wipe_sigma(ctx.params.f("feather")) * b.scale; + let full = done >= 1.0; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let k = if full { 0.0 } else { radial_wipe_alpha(x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1, start + w0, span, sigma) as f32 }; + for ch in px.iter_mut() { + *ch *= k; + } + } + }); + b +} + /// Venetian Blinds: how much of a pixel at phase `d` (0..`width`) a band of `done × width` /// covers, its two edges softened by a Gaussian of σ = Feather / 2 (σ in the units of `width`). /// Fitted to After Effects 2026 (Completion 40, Direction 30, Width 50, Feather 5 on 960 × 540 @@ -955,6 +991,54 @@ mod tests { assert_eq!(venetian_cover(3.0, 10.0, 0.5, 1e9), 0.5); } + /// After Effects 2026, Radial Wipe Completion 40, Start Angle 30, Wipe Counterclockwise, + /// Feather 20, centre (800, 500): visible fraction at pixel offsets from the centre on both + /// edges (8-bit captures of dark steps, so ±0.05). + #[test] + fn radial_wipe_feather_matches_after_effects() { + let img = effectcraft_raster::Image::filled(400, 300, [1.0, 1.0, 1.0, 1.0]); + let vals = [ + ("completion", Value::Scalar(40.0)), + ("startAngle", Value::Scalar(30.0)), + ("center", Value::Vec2([200.0, 150.0])), + ("wipe", Value::Enum(1)), + ("feather", Value::Scalar(20.0)), + ]; + let o = run_fx("ec.transition.radialwipe", &vals, img.clone(), 0.0, EffectEnv::default()); + let ae = [ + ((-50, 34), 0.922), + ((-52, 30), 0.797), + ((-54, 26), 0.609), + ((-55, 24), 0.5), + ((-56, 22), 0.406), + ((-57, 19), 0.266), + ((-58, 15), 0.125), + ((-104, 60), 0.951), + ((-108, 53), 0.732), + ((-111, 45), 0.341), + ((-114, 37), 0.062), + ((22, -56), 0.156), + ((26, -54), 0.312), + ((30, -52), 0.531), + ((34, -50), 0.729), + ((37, -47), 0.875), + ((53, -108), 0.177), + ((67, -99), 0.875), + ]; + for ((dx, dy), a) in ae { + let got = o.img.get(200 + dx, 150 + dy)[3]; + assert!((got - a).abs() < 0.05, "offset ({dx}, {dy}): {got} vs AE {a}"); + } + // 0 % leaves the layer, 100 % clears it, Feather 0 is a hard edge. + let at = |c: f64, f: f64| { + let v = [("completion", Value::Scalar(c)), ("feather", Value::Scalar(f))]; + run_fx("ec.transition.radialwipe", &v, img.clone(), 0.0, EffectEnv::default()).img + }; + assert_eq!(at(0.0, 20.0), img); + assert!(at(100.0, 20.0).data.iter().all(|p| p[3] == 0.0)); + assert!(at(50.0, 0.0).data.iter().all(|p| p[3] == 0.0 || p[3] == 1.0)); + } + #[test] fn glow_operation_and_arbitrary_map() { // Pixels addressed in layer space (Glow pads its buffer). diff --git a/crates/gpu/src/fx_generate.rs b/crates/gpu/src/fx_generate.rs index 7f2678a57..b32151e33 100644 --- a/crates/gpu/src/fx_generate.rs +++ b/crates/gpu/src/fx_generate.rs @@ -234,33 +234,23 @@ fn venetian(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { } fn radial_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { - let done = ctx.params.f("completion") / 100.0; + let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0); + if done <= 0.0 { + return Some(b); + } 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 (w0, span) = effectcraft_effects::radial_wipe_wedge(ctx.params.e("wipe"), done); + let sigma = effectcraft_effects::radial_wipe_sigma(ctx.params.f("feather")) * b.scale; + let ray0 = start + w0; + let (s0, c0) = ray0.to_radians().sin_cos(); + let (s1, c1) = (ray0 + span).to_radians().sin_cos(); 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)) + p.u[0] = [(done >= 1.0) as u32, (sigma < 1e-3) as u32, 0, 0]; + p.f[0] = [c.0 as f32, c.1 as f32, sigma.max(1e-3) as f32, ray0.rem_euclid(360.0) as f32]; + p.f[1] = [s0 as f32, c0 as f32, s1 as f32, c1 as f32]; + p.f[2][0] = span as f32; + run(e, "gen_radial_wipe", &p, b, None, None) } fn gradient_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { diff --git a/crates/gpu/src/shaders/fx_generate.wgsl b/crates/gpu/src/shaders/fx_generate.wgsl index aa132217a..c4dbb878e 100644 --- a/crates/gpu/src/shaders/fx_generate.wgsl +++ b/crates/gpu/src/shaders/fx_generate.wgsl @@ -281,20 +281,9 @@ fn gen_venetian(@builtin(global_invocation_id) gid: vec3) { textureStore(out, p, textureLoad(src, p, 0) * (1.0 - cover)); } -// 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). +// Radial Wipe (effects::misc::radial_wipe_alpha). u[0] = (fully wiped, hard edge); +// f[0] = (centre x, centre y, σ, first ray in degrees); f[1] = (sin, cos) of both rays; +// f[2].x = span in degrees. @compute @workgroup_size(16, 16) fn gen_radial_wipe(@builtin(global_invocation_id) gid: vec3) { let dims = out_dims(); @@ -302,30 +291,31 @@ fn gen_radial_wipe(@builtin(global_invocation_id) gid: vec3) { 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); + var k = 0.0; + if (P.u[0].x == 0u) { + let ux = f32(p.x) + 0.5 - P.f[0].x; + let uy = f32(p.y) + 0.5 - P.f[0].y; + var a = atan2(ux, -uy) * GEN_DEG - P.f[0].w; + a = a - 360.0 * floor(a / 360.0); + let wiped = a < P.f[2].x; + let d = min(gen_ray_dist(ux, uy, P.f[1].x, P.f[1].y), gen_ray_dist(ux, uy, P.f[1].z, P.f[1].w)); + if (P.u[0].y != 0u) { + k = select(1.0, 0.0, wiped); + } else { + k = gen_norm_cdf(select(d, -d, wiped) / P.f[0].z); } } - let k = clamp(base / P.f[0].y + 0.5, 0.0, 1.0); textureStore(out, p, textureLoad(src, p, 0) * k); } +// Distance from (ux, uy) to the ray from the centre in direction (s, −c) (y down). +fn gen_ray_dist(ux: f32, uy: f32, s: f32, c: f32) -> f32 { + if (ux * s - uy * c > 0.0) { + return abs(ux * c + uy * s); + } + return length(vec2(ux, uy)); +} + // 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) { diff --git a/docs/effects-parity.md b/docs/effects-parity.md index f4da80ba7..bd89044bb 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -45,7 +45,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Stylize | 25 | 25 | Glow's halo (coverage of a dim layer's edge) and blur width match After Effects 2026 renders; glows above the threshold match too (each channel 8 × (value − threshold + 0.061), measured at three thresholds and two radii); Composite Original's labels are swapped against After Effects (EffectCraft's On Top adds the glow over the original, which After Effects calls Behind); Motion Tile output size (#369) | | Text | 2 | 2 | — | | Time | 8 | 8 | #535 (Pixel Motion Blur, Vector Detail) is closed | -| Transition | 17 | 17 | Venetian Blinds matches After Effects 2026 renders (band phase from the layer centre, Gaussian feather on both edges) except within ~6 px of the layer edge (up to 48/255) | +| Transition | 17 | 17 | Venetian Blinds matches After Effects 2026 renders (band phase from the layer centre, Gaussian feather on both edges) except within ~6 px of the layer edge (up to 48/255); Radial Wipe's Feather matches After Effects 2026 (a Gaussian of σ = 0.4 × Feather pixels on both edges of the wedge) | | Utility | 9 | 7 | — | Third-party bundles in After Effects (Cineware, Mocha AE) are out of scope; Keylight is @@ -68,6 +68,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Glow (colour channels): the glow covers the layer's blurred alpha, blur σ = 0.384 × radius, measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-19 | minor | Radial Blur repeats the edge instead of sampling transparency outside the layer, measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-19 | minor | Venetian Blinds: stripes measured from the layer centre, both band edges Gaussian (σ = Feather / 2), measured against After Effects 2026 (CPU and GPU) | +| 2026-10-19 | minor | Radial Wipe: Feather is in pixels on both edges of the wiped wedge (was degrees on the moving edge only), measured against After Effects 2026 (CPU and GPU) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | | 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls | | 2026-10-12 | minor | Tint's Swap Colors button and agent-drivable command (#963); undo/redo, instance targeting, animated layer-time edits and invalid-input tests |