From e339a5b3753432ab5004a268c595782c7b6e60cb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 15:53:34 -0500 Subject: [PATCH] Effects: cap Camera Lens Blur and median radii (#2951) * Effects: cap Camera Lens Blur and median radii Camera Lens Blur built its iris table (one row per pixel of radius) from an uncapped Blur Radius before any size check, and the median window is filled (2r + 1)^2 per row from an uncapped Radius, on the CPU and the GPU. The radius is now capped where the work is built: 2000 buffer pixels for the lens iris (UI max 500) and 512 for the median window (UI max 255), on both paths. * address review: cap camera lens reach with the spans --- crates/effects/src/blur2.rs | 33 ++++++++++++++++++++++++++++++--- crates/effects/src/lib.rs | 4 ++-- crates/effects/src/noise.rs | 18 ++++++++++++++++++ crates/gpu/src/fx_generate.rs | 2 +- crates/gpu/src/fx_noise.rs | 2 ++ 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/crates/effects/src/blur2.rs b/crates/effects/src/blur2.rs index 880ad16c5..31ef8be42 100644 --- a/crates/effects/src/blur2.rs +++ b/crates/effects/src/blur2.rs @@ -228,8 +228,21 @@ fn compound_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { // --------------------------------------------------------------------------------------------- // Camera Lens Blur -/// Half-open horizontal spans (dy, x_left, x_right) of a polygonal / round iris of radius `r`. +/// Largest Camera Lens Blur radius in buffer pixels. Blur Radius goes to 500 in the UI; a project +/// or expression value far beyond it would otherwise build an iris table of one row per pixel of +/// radius (and sum that many rows per output pixel) before any image size check. +pub const CAMERA_LENS_MAX_RADIUS: f64 = 2000.0; + +/// Camera Lens Blur's iris radius in buffer pixels: Blur Radius at the buffer's `scale`, capped +/// at [`CAMERA_LENS_MAX_RADIUS`]. Shared by the CPU and GPU paths. +pub fn camera_lens_radius(ctx: &EffectCtx, scale: f64) -> f64 { + (ctx.params.f("blurRadius").max(0.0) * scale).clamp(0.0, CAMERA_LENS_MAX_RADIUS) +} + +/// Half-open horizontal spans (dy, x_left, x_right) of a polygonal / round iris of radius `r` +/// (capped at [`CAMERA_LENS_MAX_RADIUS`]). fn iris_spans(r: f64, sides: u32, rotation_deg: f64, roundness: f64) -> Vec<(i64, i64, i64)> { + let r = if r.is_nan() { 0.0 } else { r.clamp(0.0, CAMERA_LENS_MAX_RADIUS) }; let ri = r.ceil() as i64; let rot = rotation_deg.to_radians(); let verts: Vec<(f64, f64)> = (0..sides) @@ -292,7 +305,8 @@ pub fn camera_lens_spans(ctx: &EffectCtx, r: f64) -> (Vec<(i64, i64, i64)>, f64) s.2 = (s.2 as f64 * aspect).round() as i64; } } - (spans, r * aspect.max(1.0)) + // Same cap as iris_spans, so the reach matches the spans for unbounded radii. + (spans, r.clamp(0.0, CAMERA_LENS_MAX_RADIUS) * aspect.max(1.0)) } /// Whether Camera Lens Blur needs more than plain iris spans (Diffraction Fringe or a Blur @@ -302,7 +316,7 @@ pub fn camera_lens_plain(ctx: &EffectCtx) -> bool { } fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { - let r = ctx.params.f("blurRadius").max(0.0) * b.scale; + let r = camera_lens_radius(ctx, b.scale); if r < 0.5 { return b; } @@ -910,6 +924,19 @@ mod tests { assert!(out.img.get(15 + o, 10 + d)[0] < 0.9, "bright map blurs"); } + #[test] + fn iris_spans_cap_a_huge_radius() { + // A project's Blur Radius far beyond the UI's 500 builds a bounded table. + let max_rows = 2 * CAMERA_LENS_MAX_RADIUS as usize + 1; + assert!(iris_spans(1e12, 6, 0.0, 0.0).len() <= max_rows); + assert!(iris_spans(f64::INFINITY, 0, 0.0, 0.0).len() <= max_rows); + assert_eq!(iris_spans(f64::NAN, 0, 0.0, 0.0), vec![(0, 0, 0)]); + let params = Params { values: [("blurRadius".into(), num(1e15))].into_iter().collect() }; + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [4.0, 4.0], seed: 1, adjustment: false, env: Default::default() }; + assert_eq!(camera_lens_radius(&ctx, 1.0), CAMERA_LENS_MAX_RADIUS); + assert!(camera_lens_spans(&ctx, camera_lens_radius(&ctx, 1.0)).0.len() <= max_rows); + } + #[test] fn iris_spans_hexagon_is_symmetric() { let s = iris_spans(10.0, 6, 0.0, 0.0); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 019596f80..532bb28c9 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -98,14 +98,14 @@ pub use sim::particle_state; pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state}; pub use stylize2::strobe_on; // CPU helpers the GPU kernels share (effectcraft-gpu). -pub use blur2::{camera_lens_plain, camera_lens_spans, radial_fast_pad}; +pub use blur2::{CAMERA_LENS_MAX_RADIUS, camera_lens_plain, camera_lens_radius, camera_lens_spans, radial_fast_pad}; pub use generate::{four_color_softening, gen_mode}; pub use generate2::pattern_kind as cell_pattern_kind; pub use generate2::{BoltSeg, Contour, Ghost, RadioPlan, Wave, flare_ghosts, lightning_segments, radio_plan}; pub use generate3::{ Coverage, MarksPlan, PaintPlan, Seg, eyedropper_color, marks_plan, paint_bucket_region, paint_plan, plan_reads_pixels, poly_segs, scribble_style, }; -pub use noise::GrainLook; +pub use noise::{GrainLook, MEDIAN_MAX_RADIUS}; pub use textfx::{BoltPlan, TextLook, TextPass, bolt_plan, text_passes}; pub use transition::{GridWipe, place_layer}; // effectcraft-gpu fx_noise. diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index b6f76a268..09217f831 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -370,9 +370,16 @@ fn quant(v: f32) -> u16 { (v.clamp(0.0, 1.0) * (NB - 1) as f32).round() as u16 } +/// Largest median window radius (buffer pixels) for Median, Median (Legacy), Dust & Scratches +/// and the other median users. Radius goes to 255 in the UI; filling one (2r + 1)² window per +/// row costs the square of the radius, so a project value far beyond it is held here (also +/// keeping the window's pixel count well inside `u32` / `i32` on the GPU). +pub const MEDIAN_MAX_RADIUS: usize = 512; + /// Per-channel median over a (2r+1)² window of the premultiplied channels (values quantised to /// 512 levels in 0..1, edges repeated). pub(crate) fn median_image(img: &Image, r: usize) -> Image { + let r = r.min(MEDIAN_MAX_RADIUS); let (w, h) = (img.width as usize, img.height as usize); if r == 0 || w == 0 || h == 0 { return img.clone(); @@ -871,6 +878,17 @@ mod tests { } } + #[test] + fn median_radius_is_capped() { + // A project's Radius far beyond the UI's 255 takes the capped window, not a + // (2r + 1)² fill per row (or an overflow) on a tiny image. + let mut img = Image::filled(4, 3, [0.2, 0.3, 0.4, 1.0]); + img.set(1, 1, [1.0, 1.0, 1.0, 1.0]); + let capped = median_image(&img, MEDIAN_MAX_RADIUS); + assert_eq!(median_image(&img, usize::MAX).data, capped.data); + assert_eq!(median_image(&img, MEDIAN_MAX_RADIUS + 1000).data, capped.data); + } + #[test] fn median_matches_brute_force() { let (w, h) = (9u32, 7u32); diff --git a/crates/gpu/src/fx_generate.rs b/crates/gpu/src/fx_generate.rs index c6f2d1def..893208a15 100644 --- a/crates/gpu/src/fx_generate.rs +++ b/crates/gpu/src/fx_generate.rs @@ -133,7 +133,7 @@ fn radial_fast(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { } fn camera_lens(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { - let r = ctx.params.f("blurRadius").max(0.0) * b.scale; + let r = effectcraft_effects::camera_lens_radius(ctx, b.scale); if r < 0.5 { return Some(b); } diff --git a/crates/gpu/src/fx_noise.rs b/crates/gpu/src/fx_noise.rs index 9ae503994..a7dd0a3f5 100644 --- a/crates/gpu/src/fx_noise.rs +++ b/crates/gpu/src/fx_noise.rs @@ -202,6 +202,8 @@ pub(crate) fn fractal(e: &mut Enc, ctx: &EffectCtx, b: GBuf, turbulent: bool) -> /// noise::median_image (of the straight colour with alpha 1 when `straight`). pub(crate) fn median_image(e: &mut Enc, img: &GpuImage, r: usize, straight: bool) -> GpuImage { + // As on the CPU: the window's radius is capped, so its first fill stays bounded. + let r = r.min(effectcraft_effects::MEDIAN_MAX_RADIUS); let q = run(e, "fxn_quant", &with_u0([straight as u32, 0, 0, 0]), img, None, None); // Preserve the CPU's rounded level / 511 values before the strict Dust & Scratches // threshold. GPU division (even residual-corrected) may differ by one ulp.