diff --git a/crates/effects/src/blur2.rs b/crates/effects/src/blur2.rs index 929f41553..8f5773aa2 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) @@ -302,7 +315,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 +923,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 c865d4f96..34017bcd4 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -95,14 +95,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::place_layer; // effectcraft-gpu fx_noise. diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index 9d21e77a7..26a9ae0a2 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -348,9 +348,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(); @@ -849,6 +856,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 d0c96a029..e317f6e0a 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 22f23fd86..30d94214c 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.