From 9b26c268b9bfe94d15ccee5828e5ea275f3e6084 Mon Sep 17 00:00:00 2001 From: EthanSuquet <292885762+EthanSuquet@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:06:42 -0400 Subject: [PATCH] Render: text layers are always continuously rasterised (#194) A text layer was drawn at its font size and the bitmap resampled by the layer's Scale unless its Continuously Rasterize switch was on, so text grown with Scale (the usual way to animate it) went soft. Text layers now always rasterise at their on-screen scale, as they would with the switch; shape layers and vector footage still follow the switch. Text at 10 px scaled 800% now renders identically to text set at 80 px (795 partially covered pixels each, maximum difference 0; it was 5854 against 795 before). Co-Authored-By: Claude Opus 5.5 --- crates/render/src/lib.rs | 19 ++++++++++-------- crates/render/src/tests_collapse.rs | 31 ++++++++++++++++++++++++++++- docs/architecture.md | 5 +++-- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5c1a7e74..59f0a5d6 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -667,18 +667,21 @@ impl<'a> Renderer<'a> { Region::Full } - /// Rasterisation scale of a layer's source: the output scale, or — for Continuously - /// Rasterize text and shape layers — 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 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). pub fn raster_scale(&self, ctx: &EvalCtx, layer: &Layer) -> f64 { let s = self.opts.scale; - let vector = match layer.source { - LayerSource::Text | LayerSource::Shape => true, - LayerSource::Footage { item } => matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f)), + let continuous = match layer.source { + LayerSource::Text => true, + LayerSource::Shape => layer.switches.collapse, + LayerSource::Footage { item } => { + layer.switches.collapse && matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f)) + } _ => false, }; - if !layer.switches.collapse || !vector { + if !continuous { return s; } let (l2c, _) = ctx.layer_to_comp(layer); diff --git a/crates/render/src/tests_collapse.rs b/crates/render/src/tests_collapse.rs index 7d7e28ae..0ab11b3f 100644 --- a/crates/render/src/tests_collapse.rs +++ b/crates/render/src/tests_collapse.rs @@ -1,7 +1,7 @@ //! Collapse Transformations, Continuously Rasterize and the Quality switch. use effectcraft_color::{BlendMode, Label}; -use effectcraft_keyframe::Value; +use effectcraft_keyframe::{TextDoc, Value}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Quality, Solid}; use effectcraft_time::{FrameRate, Tick}; @@ -234,3 +234,32 @@ fn collapsed_precomp_of_another_size_renders_through_the_parent_camera() { assert!(img.get(100, 100)[3] < 0.01, "only the 40 px solid is drawn"); } } + +/// Text layers are always continuously rasterised (#194): text scaled up with the layer's Scale is drawn +/// at its on-screen size, as sharp as text set at that font size, without the switch. +#[test] +fn text_scaled_up_stays_sharp_without_the_switch() { + let run = |size: f64, scale: f64| { + let mut p = project(); + let comp = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (240, 160), None); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HE".into(), size, ..Default::default() })); + set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0])); + set(&mut l, "transform/position", Value::Vec3([40.0, 120.0, 0.0])); + set(&mut l, "transform/scale", Value::Vec3([scale, scale, 100.0])); + assert!(!l.switches.collapse); + 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 partial = |img: &Image| img.data.iter().filter(|px| (0.02..0.98).contains(&px[3])).count(); + let scaled = run(10.0, 800.0); + let native = run(80.0, 100.0); + let (ps, pn) = (partial(&scaled), partial(&native)); + let d = max_diff(&scaled, &native); + eprintln!("partially covered pixels: scaled {ps}, native {pn}; max difference {d}"); + assert!(native.data.iter().filter(|px| px[3] > 0.98).count() > 1000, "the text renders"); + assert!(ps <= pn + pn / 2, "scaled text is blurred: {ps} soft pixels against {pn}"); + assert!(d < 0.05, "scaled text differs from text set at that size by {d}"); +} diff --git a/docs/architecture.md b/docs/architecture.md index 49f9da4a..5ea2b35a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -317,8 +317,9 @@ hierarchical block-matching optical flow and a bidirectional warp (Pixel Motion, its nested layers straight into the parent with concatenated transforms (one resample), so nested blend modes and adjustment layers act on the parent's layers; nested 3D layers use the parent's camera and lights and, when the precomp layer is 3D, are depth-sorted with the parent's -3D run. Masks, effects or styles force a flattened render. On text and shape layers the switch is -Continuously Rasterize: the source is rasterised at its on-screen scale. Quality: Draft samples +3D run. Masks, effects or styles force a flattened render. Text layers are always rasterised at +their on-screen scale; on shape layers and vector footage the switch is Continuously Rasterize and +does the same. Quality: Draft samples nearest-neighbour, Wireframe draws the layer bounds. Slip edit (`layer.slip`, Alt+PageUp/Down, dragging the source bar in the timeline) moves the source under fixed in/out points.