diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 825bb171b..e2df6b6f6 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -644,7 +644,7 @@ impl PixelEntry { } /// The four linear coefficients of `view` (zoom and rotation, not the pan), as cache keys. -fn linear_key(view: Affine) -> [u64; 4] { +pub(crate) fn linear_key(view: Affine) -> [u64; 4] { let c = view.as_coeffs(); [c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()] } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f9d8eb2a8..4cfe8388e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -430,6 +430,26 @@ struct PlacedEntry { fonts: Option, } +/// Type drawn with an anti-aliasing method other than Sharp, as [`Renderer::draw_type_edges`] drew +/// it offscreen on the pixels of the output ([`Frame::output`]). +struct TypeEdgeEntry { + /// A copy of the type's object: the pixels are drawn again only for an object equal to it. + node: Node, + /// The output's zoom and rotation ([`fx::linear_key`]). + linear: [u64; 4], + /// The method, the edge threshold, the frame's edge smoothing, whether the frame composites + /// precisely, and the opacity folded into the paints. + settings: (TypeAntiAlias, Option, AntiAlias, bool, u32), + /// What the type read from the document and the color settings ([`Renderer::raster_inputs`]). + inputs: fx::Inputs, + /// The output's offset: a frame whose offset differs from it by whole pixels draws the pixels + /// moved by them. + shift: kurbo::Vec2, + /// The output pixel at the image's top-left corner. + origin: (f64, f64), + image: Arc, +} + /// Reusable renderer (keeps the render context, geometry and glyph caches between frames; decoded /// images are shared by all renderers, see [`set_image_cache_budget`]). pub struct Renderer { @@ -485,6 +505,9 @@ pub struct Renderer { /// Objects' content run through Photoshop-style effects, per object and content slot (see /// `fx`): kept only while drawn. pixel_fx: PtrMap<(usize, usize, Ink), fx::PixelEntry>, + /// Type drawn with an anti-aliasing method other than Sharp, per object and ink (see + /// [`Self::draw_type_edges`]). + type_edges: FrameCache<(usize, Ink), TypeEdgeEntry>, /// What the contents of those rasters read from the document, and the color settings, as last /// found for a frame (`stamp`) and document (address) (see [`Self::doc_inputs`]). doc_inputs: Option<((u64, usize), Arc)>, @@ -562,6 +585,10 @@ pub struct FrameStats { /// How many threads composed the frame from its two ink planes, in a CMYK document that shows /// transparency; 0 for other frames. pub ink_threads: usize, + /// The type objects the frame drew offscreen with an anti-aliasing method other than Sharp + /// ([`Renderer::draw_type_edges`]). Type drawn from the pixels an earlier frame kept is not + /// counted. + pub type_edges: usize, pub micros: u64, } @@ -632,6 +659,7 @@ impl Renderer { brushes: Default::default(), shadows: PtrMap::default(), pixel_fx: PtrMap::default(), + type_edges: FrameCache::new(lru::TYPE_EDGE_BUDGET, 0), doc_inputs: None, transparency: None, live: live::LiveCache::default(), @@ -754,6 +782,7 @@ impl Renderer { } // Filtered rasters are large: only those of the last frame stay. self.pixel_fx.retain(|_, e| e.stamp == g); + self.type_edges.sweep(g); self.texts.sweep(g); self.clips.sweep(g); self.clip_paints.sweep(g); @@ -1888,6 +1917,12 @@ impl Renderer { /// and more than [`fx::PIXEL_BUDGET`] (a frame drawn coarser than the output), the frame's own /// pixels are used. /// + /// The pixels are kept (`type_edges`). A later frame draws them again, without drawing the + /// type offscreen, while the object, the output's zoom and rotation, the method and edge + /// smoothing and what the type reads ([`Self::raster_inputs`]) are unchanged, the output has + /// moved by whole pixels, and the kept pixels hold every pixel the frame needs: a pan moves + /// them, and a pan that brings more of the type into view draws it again. + /// /// Returns false, having drawn nothing, for type that is drawn as Sharp draws it: type with /// inline graphics, type whose own fills or strokes include a brush stroke or a blend mode /// other than Normal, type that overprints while Overprint Preview is on, and type that shows a @@ -1947,6 +1982,53 @@ impl Renderer { _ if sx.abs() < 0.999 || sy.abs() < 0.999 => None, _ => f.opts.anti_alias.threshold(), }; + // Pixels kept from an earlier frame, for the type of the node itself (not a copy of its + // text): drawn on output pixels of the same size, rotation and phase, holding those needed. + let own = matches!(&n.kind, NodeKind::Text(o) if std::ptr::eq(&**o, t)); + let key = (std::ptr::from_ref(n) as usize, f.ink); + let linear = fx::linear_key(output); + let shift = output.translation(); + let settings = (t.anti_alias, threshold, f.opts.anti_alias, f.opts.precise, self.alpha.to_bits()); + let inputs = own.then(|| self.raster_inputs(f, n, fx::Reads::of(n))); + let whole = |v: f64| (v - v.round()).abs() < 1e-6; + let kept = inputs.as_ref().and_then(|inputs| { + let e = self.type_edges.get(&key, self.stamp)?; + let d = shift - e.shift; + let (ox, oy) = (e.origin.0 + d.x.round(), e.origin.1 + d.y.round()); + let (iw, ih) = (f64::from(e.image.width()), f64::from(e.image.height())); + let holds = ox <= x0 && oy <= y0 && ox + iw >= x0 + f64::from(w) && oy + ih >= y0 + f64::from(h); + let fits = e.linear == linear && e.settings == settings && whole(d.x) && whole(d.y) && holds && e.inputs == *inputs && e.node == *n; + fits.then(|| (e.image.clone(), ox, oy)) + }); + let (image, ox, oy) = match kept { + Some(kept) => kept, + None => { + let pm = self.draw_type_pixels(&frame, n, t, g, (w, h), threshold); + self.stats.type_edges += 1; + let image = Arc::new(pm); + if let Some(inputs) = inputs { + let bytes = usize::from(w) * usize::from(h) * 4 + lru::node_bytes(n); + let e = TypeEdgeEntry { node: n.clone(), linear, settings, inputs, shift, origin: (x0, y0), image: image.clone() }; + self.type_edges.insert(key, e, bytes, self.stamp); + } + (image, x0, y0) + } + }; + ctx.set_transform(grid.inverse() * Affine::translate((ox, oy))); + // The pixmap maps onto the rect pixel for pixel, each one on a pixel of the output. + ctx.reset_paint_transform(); + let sampler = peniko::ImageSampler { quality: peniko::ImageQuality::Low, ..Default::default() }; + let (iw, ih) = (f64::from(image.width()), f64::from(image.height())); + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler }); + ctx.fill_rect(&Rect::new(0.0, 0.0, iw, ih)); + ctx.set_transform(Affine::IDENTITY); + true + } + + /// The pixels of type `t` of node `n` (laid out in `g`) with its anti-aliasing method, drawn + /// offscreen in `frame` on `w` × `h` pixels with edge threshold `threshold` + /// ([`Self::draw_type_edges`]). + fn draw_type_pixels(&mut self, frame: &Frame, n: &Node, t: &TextObject, g: &TextGeom, (w, h): (u16, u16), threshold: Option) -> Pixmap { let draw = |r: &mut Self, frame: &Frame, n: &Node, t: &TextObject| { let mut off = single_threaded_context(w, h); off.set_aliasing_threshold(threshold); @@ -1957,7 +2039,7 @@ impl Renderer { pm }; let sharp = TextObject { anti_alias: TypeAntiAlias::Sharp, ..t.clone() }; - let mut pm = draw(self, &frame, n, &sharp); + let mut pm = draw(self, frame, n, &sharp); if t.anti_alias != TypeAntiAlias::None { let black = |p: &mut vectorcraft_color::Paint| { if !p.is_none() { @@ -1977,19 +2059,12 @@ impl Renderer { AppearanceItem::Stroke(st) => st.opacity = 1.0, } } - let covered = draw(self, &frame, &inked, &ink); + let covered = draw(self, frame, &inked, &ink); for (p, c) in pm.data_mut().iter_mut().zip(covered.data()) { *p = edge_pixel(*p, c.a, t.anti_alias); } } - ctx.set_transform(grid.inverse() * Affine::translate((x0, y0))); - // The pixmap maps onto the rect pixel for pixel, each one on a pixel of the output. - ctx.reset_paint_transform(); - let sampler = peniko::ImageSampler { quality: peniko::ImageQuality::Low, ..Default::default() }; - ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(Arc::new(pm)), sampler }); - ctx.fill_rect(&Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); - ctx.set_transform(Affine::IDENTITY); - true + pm } /// Paint some of type `n`'s own fills and strokes (`items`) on its glyph outlines `all` diff --git a/crates/render/src/lru.rs b/crates/render/src/lru.rs index d9fec8752..36846e8bb 100644 --- a/crates/render/src/lru.rs +++ b/crates/render/src/lru.rs @@ -43,6 +43,8 @@ pub(crate) const MESH_BUDGET: usize = 32 << 20; pub(crate) const PATTERN_BUDGET: usize = 128 << 20; /// Freeform gradient grids, per thread (`freeform::set_freeform_paint`). pub(crate) const FREEFORM_BUDGET: usize = 32 << 20; +/// Type drawn with an anti-aliasing method other than Sharp, as pixels (`Renderer::draw_type_edges`). +pub(crate) const TYPE_EDGE_BUDGET: usize = 128 << 20; /// Frames an entry may go unused before [`FrameCache::sweep`] drops it. const AGE: u64 = 3; diff --git a/crates/render/src/tests_typeedges.rs b/crates/render/src/tests_typeedges.rs index caf08aba1..0cd9cca51 100644 --- a/crates/render/src/tests_typeedges.rs +++ b/crates/render/src/tests_typeedges.rs @@ -314,3 +314,71 @@ fn type_outside_its_frame_is_drawn() { assert!(outside(&page(TypeAntiAlias::Sharp)) > 20, "the opening quotation mark hangs outside the frame"); assert!(outside(&page(TypeAntiAlias::None)) > 20, "None type draws it there too"); } + +/// A frame draws Crisp type from the pixels an earlier frame kept when the view only moved by +/// whole pixels, and they are the pixels a new renderer draws. Another zoom, another method, a pan +/// that brings more of the type into view, a pan by part of a pixel and other edge smoothing draw +/// the type again. +#[test] +fn a_pan_draws_the_type_from_the_kept_pixels() { + let opts = RenderOptions::default(); + let fonts = || vectorcraft_text::FontDb::global().generation(); + // How many type objects a renderer drew offscreen for `b` in `second` after drawing `a` in + // `first`. The second frame's pixels are a new renderer's, each channel within `near`. The + // frames are drawn again while the fonts change between them (other tests load fonts, and the + // type is then drawn again). + let drawn = |(a, first): (&Document, Affine), (b, second): (&Document, Affine), near: u8| { + for _ in 0..5 { + let before = fonts(); + let mut r = Renderer::new(); + r.render(a, W, H, first, &opts); + assert_eq!(r.stats.type_edges, 1, "{first:?}"); + let pixels = r.render(b, W, H, second, &opts).pixels; + if fonts() != before { + continue; + } + let want = Renderer::new().render(b, W, H, second, &opts).pixels; + let differ: Vec<_> = pixels + .chunks(4) + .zip(want.chunks(4)) + .enumerate() + .filter(|(_, (p, q))| p.iter().zip(*q).any(|(p, q)| p.abs_diff(*q) > near)) + .map(|(i, (p, q))| (i as u32 % W, i as u32 / W, p[3], q[3])) + .collect(); + assert!( + differ.is_empty(), + "{second:?}: {} pixels differ from a new renderer's, first (x, y, alpha, new alpha) {:?}", + differ.len(), + &differ[..differ.len().min(8)] + ); + return r.stats.type_edges; + } + panic!("the fonts changed in every try"); + }; + let (crisp, strong) = (page(TypeAntiAlias::Crisp, None), page(TypeAntiAlias::Strong, None)); + let at = |d, (x, y): (f64, f64)| (d, Affine::translate((x, y))); + for pan in [(7.0, -3.0), (2.0, 5.0), (-4.0, -1.0)] { + assert_eq!(drawn(at(&crisp, (0.0, 0.0)), at(&crisp, pan), 0), 0, "a pan by {pan:?}"); + } + assert_eq!(drawn(at(&crisp, (0.0, 0.0)), (&crisp, Affine::scale(1.5)), 0), 1, "another zoom"); + assert_eq!(drawn(at(&crisp, (0.0, 0.0)), at(&strong, (0.0, 0.0)), 0), 1, "another method"); + assert_eq!(drawn(at(&crisp, (0.0, 0.0)), at(&crisp, (0.5, 0.0)), 0), 1, "a pan by half a pixel"); + // At 400% the type fills the view, so the pixels kept hold every pixel of the next frame. + let zoomed = Affine::translate((-200.0, -150.0)) * Affine::scale(4.0); + let nudged = Affine::translate((0.3, 0.0)) * zoomed; + assert_eq!(drawn((&crisp, zoomed), (&crisp, nudged), 0), 1, "a pan by part of a pixel at 400%"); + assert_eq!(drawn(at(&strong, (-120.0, 0.0)), at(&strong, (0.0, 0.0)), 0), 1, "a pan that brings the rest into view"); + // Type the view cuts is drawn offscreen from the view's edge, and its coverage there can round + // a step the other way: Strong doubles the step, and the color takes a step of rounding. + assert_eq!(drawn(at(&strong, (0.0, 0.0)), at(&strong, (-120.0, 0.0)), 3), 0, "a pan that takes half of it out of view"); + // With hard edges (Anti-aliased Artwork off) and with type snapped to pixels (an export's Type + // Optimized), the frame draws the type again. + for smoothing in [AntiAlias::None, AntiAlias::Type] { + let other = RenderOptions { anti_alias: smoothing, ..Default::default() }; + let mut r = Renderer::new(); + r.render(&crisp, W, H, Affine::IDENTITY, &opts); + let pixels = r.render(&crisp, W, H, Affine::IDENTITY, &other).pixels; + assert_eq!(r.stats.type_edges, 1, "{smoothing:?}"); + assert!(pixels == Renderer::new().render(&crisp, W, H, Affine::IDENTITY, &other).pixels, "{smoothing:?}"); + } +}