diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f4a5a98a3..d03fa580c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -732,15 +732,14 @@ impl<'a> Renderer<'a> { Region::Full } - /// Rasterisation scale of a layer's source: the output scale, or — for text layers, which are - /// always continuously rasterised, and Continuously Rasterize shape layers and vector footage — - /// the output scale times the layer's on-screen scale (quarter-octave steps, rounded up, so - /// vector content is drawn at its final size and stays sharp when scaled up). + /// Rasterisation scale of a layer's source: the output scale, or — for text and shape layers, + /// which are always continuously rasterised (#390, #751), and Continuously Rasterize vector + /// footage — the output scale times the layer's on-screen scale (quarter-octave steps, rounded + /// up, so vector content is drawn at its final size and stays sharp when scaled up). pub fn raster_scale(&self, ctx: &EvalCtx, layer: &Layer) -> f64 { let s = self.opts.scale; let continuous = match layer.source { - LayerSource::Text => true, - LayerSource::Shape => layer.switches.collapse, + LayerSource::Text | LayerSource::Shape => true, LayerSource::Footage { item } => { layer.switches.collapse && matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f)) } diff --git a/crates/render/src/tests_collapse.rs b/crates/render/src/tests_collapse.rs index f13dedd11..797960033 100644 --- a/crates/render/src/tests_collapse.rs +++ b/crates/render/src/tests_collapse.rs @@ -145,36 +145,53 @@ fn edge_width(img: &Image, y: i64, x0: i64, x1: i64) -> usize { (x0..x1).filter(|&x| (0.02..0.98).contains(&img.get(x, y)[3])).count() } -#[test] -fn continuously_rasterized_shape_stays_sharp_when_scaled_up() { - let run = |cr: bool| { - let mut p = project(); - let comp = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); - let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 200), None); - let mut next = p.next_id; - let mut ids = Ids(&mut next); - let rect = build::shape_rect(&mut ids, [10.0, 10.0], [0.0, 0.0], 0.0); - let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]); - let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]); - p.next_id = next; - l.props.sub_mut("contents").unwrap().children.push(g.into()); - set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0])); +/// A 10 px white square in a 200×200 comp, scaled 800% either by its own Scale or by a parent +/// null's, with the Continuously Rasterize switch `cr`. +fn scaled_square(cr: bool, via_parent: bool) -> Image { + let mut p = project(); + let comp = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 200), None); + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let rect = build::shape_rect(&mut ids, [10.0, 10.0], [0.0, 0.0], 0.0); + let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]); + let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]); + p.next_id = next; + l.props.sub_mut("contents").unwrap().children.push(g.into()); + set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0])); + l.switches.collapse = cr; + let mut c = comp.clone(); + if via_parent { + let mut null = build::layer(&mut p, &comp, "Null", LayerSource::Null, (100, 100), None); + set(&mut null, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0])); + set(&mut null, "transform/position", Value::Vec3([100.3, 100.3, 0.0])); + set(&mut null, "transform/scale", Value::Vec3([800.0, 800.0, 100.0])); + set(&mut l, "transform/position", Value::Vec3([0.0, 0.0, 0.0])); + l.parent = Some(null.id); + c.layers = vec![l, null]; + } else { set(&mut l, "transform/position", Value::Vec3([100.3, 100.3, 0.0])); set(&mut l, "transform/scale", Value::Vec3([800.0, 800.0, 100.0])); - l.switches.collapse = cr; - let mut c = comp; c.layers = vec![l]; - let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); - render_frame(&p, cid, Tick::ZERO, 1.0) - }; + } + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); + render_frame(&p, cid, Tick::ZERO, 1.0) +} + +/// Shape layers are always continuously rasterised, as in After Effects (#751): a shape scaled up +/// by its own transform or a parent's is drawn at its on-screen size, with a 1 px anti-aliased +/// edge, whether or not the Continuously Rasterize switch is on. +#[test] +fn shape_scaled_up_stays_sharp_without_the_switch() { // The 10 px square scaled 800% spans 60.3..140.3. - let soft = run(false); - let sharp = run(true); - let ws = edge_width(&soft, 100, 50, 75); - let wc = edge_width(&sharp, 100, 50, 75); - assert!(ws >= 4, "bitmap scaling blurs the edge: {ws}"); - assert!(wc <= 1, "continuously rasterized edge: {wc}"); - assert!(sharp.get(100, 100)[3] > 0.99 && sharp.get(55, 100)[3] < 0.01); + for cr in [false, true] { + for via_parent in [false, true] { + let img = scaled_square(cr, via_parent); + let w = edge_width(&img, 100, 50, 75); + assert!(w <= 1, "switch {cr}, scaled by parent {via_parent}: edge {w} px wide"); + assert!(img.get(100, 100)[3] > 0.99 && img.get(55, 100)[3] < 0.01); + } + } } #[test] @@ -285,3 +302,38 @@ fn unscaled_text_rasterises_at_the_output_scale() { assert_eq!(k, 1.0, "text at {pos:?}"); } } + +/// Effects on a shape layer run on its continuously rasterised pixels, still in layer space as +/// with the switch off in After Effects: a 10 px square scaled 800% with a 2 px blur looks like +/// an 80 px square with a 16 px blur, not a blurred bitmap enlarged afterwards (#751). +#[test] +fn effects_on_a_scaled_shape_follow_its_scale() { + let run = |size: f64, scale: f64, blur: f64| { + let mut p = project(); + let comp = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 200), None); + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let rect = build::shape_rect(&mut ids, [size, size], [0.0, 0.0], 0.0); + let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]); + let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]); + let spec = effectcraft_effects::find("ec.blur.gaussian").unwrap(); + let mut fx = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [200.0, 200.0]); + p.next_id = next; + fx.prop_mut("blurriness").unwrap().value = Value::Scalar(blur); + l.props.sub_mut("contents").unwrap().children.push(g.into()); + l.props.sub_mut("effects").unwrap().children.push(fx.into()); + set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0])); + set(&mut l, "transform/position", Value::Vec3([100.0, 100.0, 0.0])); + set(&mut l, "transform/scale", Value::Vec3([scale, scale, 100.0])); + let mut c = comp; + c.layers = vec![l]; + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); + render_frame(&p, cid, Tick::ZERO, 1.0) + }; + let scaled = run(10.0, 800.0, 2.0); + let native = run(80.0, 100.0, 16.0); + let d = max_diff(&scaled, &native); + assert!(scaled.get(100, 100)[3] > 0.99 && scaled.get(70, 100)[3] > 0.05, "the blur spreads the edge"); + assert!(d < 0.05, "scaled blurred shape differs from the native one by {d}"); +}