Shape layers: always rasterise at the on-screen scale

Shape layers are always continuously rasterised, as in After Effects and
as text layers already are here: the contents are drawn at the layer's
effective scale (own transform and parents) whether or not the
Continuously Rasterize switch is on, so scaled-up shapes stay sharp.

Fixes #751

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Fabio Lussi
2026-10-11 20:38:17 +02:00
co-authored by Claude Opus 5.5
parent 67434e9038
commit 183d052c6a
2 changed files with 83 additions and 32 deletions
+5 -6
View File
@@ -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))
}
+78 -26
View File
@@ -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}");
}