From 43e82587fdced6c9942a64eef03206ba3fb9b7f3 Mon Sep 17 00:00:00 2001 From: FredApps Date: Mon, 9 Nov 2026 18:10:06 +0100 Subject: [PATCH] #3357: avoid whole-layer cover for Drop Shadow --- crates/effects/src/lib.rs | 4 +- crates/render/src/tests.rs | 1 + crates/render/src/tests/drop_shadow_cover.rs | 142 +++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 crates/render/src/tests/drop_shadow_cover.rs diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 771d51068..019596f80 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -451,7 +451,9 @@ pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u3 /// Effects that grow their own buffer by what they need (blur and glow margins, Repeat Edge /// Pixels through [`Buf::cover`], displacement through [`cover_pad`]): a stack of only these /// skips the renderer's cover of the whole layer rectangle (#1205). -pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace"]; +/// Drop Shadow also pads by its distance and softness on both CPU and GPU: covering the +/// layer rectangle first only inflates working images on highly scaled text and shapes (#3357). +pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace", "ec.perspective.dropshadow"]; /// The grid phase [`Buf::align`] keeps and the step [`cover_pad`] grows in: covers the levels /// of the resampling pyramid used for downscaled layers. diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 59fa0fadc..e7dd02182 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -11,6 +11,7 @@ mod auto; mod aux_channels; mod collapse; mod color; +mod drop_shadow_cover; mod footage_size; mod frame_blend; mod guides; diff --git a/crates/render/src/tests/drop_shadow_cover.rs b/crates/render/src/tests/drop_shadow_cover.rs new file mode 100644 index 000000000..94d4ca67b --- /dev/null +++ b/crates/render/src/tests/drop_shadow_cover.rs @@ -0,0 +1,142 @@ +use super::*; +use crate::{EvalCtx, Image, NoFootage, RenderOpts, Renderer}; +use effectcraft_effects::Buf; + +// A synthetic painted source avoids installed-font dependencies and the multi-gigabyte +// allocation in #3357 while exercising the same text/shape effect-stack bounds. +fn source(scale: f64) -> Buf { + let mut img = Image::new(20, 12); + for y in 2..10 { + for x in 2..18 { + img.set(x, y, [0.8, 0.2, 0.1, 1.0]); + } + } + Buf { img, offset: [7.0, -3.0], scale } +} + +#[test] +fn drop_shadow_does_not_cover_the_zoomed_layer_rectangle() { + let (mut p, cid, comp) = setup(); + let mut layer = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (0, 0), None); + add_effect_200(&mut p, &mut layer, "ec.perspective.dropshadow", &[("distance", Value::Scalar(2.0)), ("softness", Value::Scalar(1.0))]); + let r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let ctx = EvalCtx::new(&p, cid, &comp, Tick::ZERO); + let out = r.apply_effects(&ctx, &layer, source(4.0), false); + // The old cover makes this at least 800 x 400. Alignment and the shadow's + // own padding need less than 128 pixels per side for this source. + assert!(out.img.width < 128 && out.img.height < 128, "{} x {}", out.img.width, out.img.height); +} + +#[test] +fn drop_shadow_matches_the_covered_reference() { + for scale in [0.5, 1.0, 4.0] { + for only in [false, true] { + for direction in [0.0, 135.0, 270.0] { + let (mut p, cid, comp) = setup(); + let mut layer = build::layer(&mut p, &comp, "Text", LayerSource::Text, (0, 0), None); + add_effect_200( + &mut p, + &mut layer, + "ec.perspective.dropshadow", + &[ + ("distance", Value::Scalar(3.5)), + ("softness", Value::Scalar(2.0)), + ("direction", Value::Scalar(direction)), + ("shadowOnly", Value::Bool(only)), + ], + ); + let r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let ctx = EvalCtx::new(&p, cid, &comp, Tick::ZERO); + let input = source(scale); + let mut covered = input.clone(); + let (size, origin) = ctx.effect_bounds(&layer); + covered.rebase(origin); + covered.cover(size); + covered.rebase([-origin[0], -origin[1]]); + let actual = r.apply_effects(&ctx, &layer, input, false); + let reference = r.apply_effects(&ctx, &layer, covered, false); + // Compare the union in layer-pixel coordinates, including the shadow outside + // the original content. Buffer origins differ by integral pixels. + let x0 = (-actual.offset[0]).min(-reference.offset[0]) as i32; + let y0 = (-actual.offset[1]).min(-reference.offset[1]) as i32; + let x1 = (f64::from(actual.img.width) - actual.offset[0]).max(f64::from(reference.img.width) - reference.offset[0]) as i32; + let y1 = (f64::from(actual.img.height) - actual.offset[1]).max(f64::from(reference.img.height) - reference.offset[1]) as i32; + for y in y0..y1 { + for x in x0..x1 { + let sample = |b: &Buf| b.img.sample_bilinear(f64::from(x) + b.offset[0] + 0.5, f64::from(y) + b.offset[1] + 0.5); + let (a, b) = (sample(&actual), sample(&reference)); + for c in 0..4 { + assert!( + (a[c] - b[c]).abs() < 1e-5, + "scale {scale}, only {only}, direction {direction}, ({x}, {y}), channel {c}: {} != {}", + a[c], + b[c] + ); + } + } + } + } + } + } +} +#[test] +fn drop_shadow_with_glow_stays_local_but_other_effects_keep_cover() { + for other in ["ec.stylize.glow", "ec.color.tint"] { + let (mut p, cid, comp) = setup(); + let mut layer = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (0, 0), None); + add_effect_200(&mut p, &mut layer, "ec.perspective.dropshadow", &[("distance", Value::Scalar(2.0)), ("softness", Value::Scalar(1.0))]); + let values = if other == "ec.stylize.glow" { vec![("radius", Value::Scalar(1.0))] } else { vec![] }; + add_effect_200(&mut p, &mut layer, other, &values); + let r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let ctx = EvalCtx::new(&p, cid, &comp, Tick::ZERO); + let out = r.apply_effects(&ctx, &layer, source(4.0), false); + if other == "ec.stylize.glow" { + assert!(out.img.width < 192 && out.img.height < 192, "{} x {}", out.img.width, out.img.height); + } else { + assert!(out.img.width >= 800 && out.img.height >= 400); + } + } +} +#[test] +fn parented_text_and_shape_keep_small_working_images_at_2200_percent() { + for text in [false, true] { + let mut p = Project::default(); + p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; + let comp = Comp::new(80, 40, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let mut parent = build::layer(&mut p, &comp, "Null", LayerSource::Null, (100, 100), None); + parent.props.prop_mut("transform/anchor").unwrap().value = Value::Vec3([0.0; 3]); + parent.props.prop_mut("transform/position").unwrap().value = Value::Vec3([40.0, 20.0, 0.0]); + parent.props.prop_mut("transform/scale").unwrap().value = Value::Vec3([2200.0, 2200.0, 100.0]); + let source = if text { LayerSource::Text } else { LayerSource::Shape }; + let mut layer = build::layer(&mut p, &comp, "Painted", source, (80, 40), None); + layer.parent = Some(parent.id); + layer.props.prop_mut("transform/anchor").unwrap().value = Value::Vec3([0.0; 3]); + layer.props.prop_mut("transform/position").unwrap().value = Value::Vec3([0.0; 3]); + if text { + layer.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "I".into(), size: 8.0, ..Default::default() })); + } else { + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let rect = build::shape_rect(&mut ids, [4.0, 4.0], [0.0, 0.0], 0.0); + let fill = build::shape_fill(&mut ids, [1.0; 4]); + let group = build::shape_group(&mut ids, "Rectangle", vec![rect, fill]); + p.next_id = next; + layer.props.sub_mut("contents").unwrap().children.push(group.into()); + } + add_effect(&mut p, &mut layer, "ec.stylize.glow", [80.0, 40.0], &[("radius", Value::Scalar(0.1))]); + add_effect(&mut p, &mut layer, "ec.perspective.dropshadow", [80.0, 40.0], &[("distance", Value::Scalar(0.1)), ("softness", Value::Scalar(0.1))]); + p.comp_mut(cid).unwrap().layers = vec![layer, parent].into(); + let comp = p.comp(cid).unwrap(); + let layer = &comp.layers[0]; + for scale in [1.0, 0.5] { + let r = Renderer::new(&p, &NoFootage, RenderOpts { scale, ..Default::default() }); + let ctx = EvalCtx::new(&p, cid, comp, Tick::ZERO); + let input = r.layer_input(&ctx, layer, usize::MAX).unwrap(); + assert!(input.img.width < 512 && input.img.height < 512, "text={text}, output scale={scale}: {} x {}", input.img.width, input.img.height); + assert!(input.img.data.iter().any(|pixel| pixel[3] > 0.5), "the painted source is not empty"); + let frame = r.comp_frame(cid, Tick::ZERO); + assert!(frame.data.iter().any(|pixel| pixel[3] > 0.5), "the layer still draws in the composition"); + } + } +}