diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 9800b188d..6265cb5f7 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -488,7 +488,7 @@ impl<'a> Renderer<'a> { || styles::active(ctx, layer) || ctx.comp.layers.iter().any(|l| matches!(l.source, LayerSource::Light { .. })) || self.is_adv3d(ctx) - || (matches!(layer.source, LayerSource::Text) && text::per_char_3d(ctx, layer)) + || (matches!(layer.source, LayerSource::Text) && (text::per_char_3d(ctx, layer) || text::animator_blur(ctx, layer))) { return None; } diff --git a/crates/render/src/tests/raster_clip.rs b/crates/render/src/tests/raster_clip.rs index f7227f634..c85176fca 100644 --- a/crates/render/src/tests/raster_clip.rs +++ b/crates/render/src/tests/raster_clip.rs @@ -332,3 +332,29 @@ fn fine_clip_cells_have_distinct_cache_keys() { assert_ne!(key(a), key(kurbo::Rect::ZERO)); assert_ne!(key(b), key(kurbo::Rect::ZERO)); } + +/// Text animator Blur reads beyond the fixed anti-aliasing padding of a clipped window. +#[test] +fn animated_text_blur_keeps_offscreen_glyph_pixels_while_panning() { + let (mut p, cid) = scene(|p, c| text3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() + 40.0], 0.0)); + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let props = build::text_anim_props(&mut ids, "blur", false); + let mut animator = build::text_animator(&mut ids, "Blur", props); + animator.prop_mut("properties/blur").unwrap().value = Value::Vec2([40.0, 40.0]); + p.next_id = next; + p.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators").unwrap().children.push(animator.into()); + let opts = RenderOpts { scale: 0.125, ..Default::default() }; + let cache = crate::LayerCache::default(); + for dx in [-10.0, 0.0, 10.0] { + set(&mut p.comp_mut(cid).unwrap().layers[0], "transform/position", Value::Vec3([W as f64 / 2.0 + dx, H as f64 / 2.0, -zoom() + 40.0])); + let mut r = Renderer::new(&p, &NoFootage, opts); + r.cache = Some(&cache); + let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap(); + let layer = &p.comp(cid).unwrap().layers[0]; + assert!(r.raster_clip(&ctx, layer).is_none(), "animator blur needs off-window pixels"); + let mut whole = Renderer::new(&p, &NoFootage, opts); + whole.whole_raster = true; + assert_eq!(r.comp_frame(cid, Tick::ZERO).data, whole.comp_frame(cid, Tick::ZERO).data); + } +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 3a2110e0d..1e95bbc11 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -509,6 +509,16 @@ pub fn per_char_3d(ctx: &EvalCtx, layer: &Layer) -> bool { layer.is_3d() && layer.props.sub("text").is_some_and(|t| ctx.b(layer, t, "perChar3d")) } +/// Animator blur can read glyph pixels beyond a viewport raster window. Conservatively +/// keep the whole raster even when the current selector happens to select no characters. +pub(crate) fn animator_blur(ctx: &EvalCtx, layer: &Layer) -> bool { + layer.props.group("text/animators").is_some_and(|anims| { + anims.groups().filter(|anim| anim.enabled).any(|anim| { + anim.sub("properties").and_then(|props| props.get("blur")).is_some_and(|blur| ctx.value(layer, blur).as_vec2().iter().any(|value| *value != 0.0)) + }) + }) +} + /// One drawn character. #[derive(Clone, Debug)] pub struct PlacedGlyph {