use super::bounds::{cull_bounds, effected_path_carrying}; use super::entries::{Content, Drawn, FX_RASTER_BUDGET, FxGeom, Inputs, ItemGeom, Kept, Made, Reads, Shadow, ShadowEntry}; use super::predicates::{evaluates_live, is_container, rasterize_bounds, reads_outside, shifted}; use super::raster::{ DRAG_PIXEL_BUDGET, MAX_RASTERIZE, PIXEL_BUDGET, ViewCrop, content_changing, doc_raster_ppi, image_size, linear_key, pan_margin, pcolor, shade_filter, shadow_filter, silhouette, }; use super::{ Affine, Arc, BezPath, FillRule, Frame, GeomContext, Ink, Node, NodeKind, RasterFx, Rect, RenderContext, Renderer, Shape, SymbolBoxes, Vec2, blend_mode, effects, fill_rule, peniko, }; #[cfg(test)] thread_local! { /// How many silhouettes of drop shadows and outer glows renderers on this thread have drawn: /// one per shadow or glow raster made ([`Renderer::shadow_raster`]) and per one drawn through a /// filter layer ([`Renderer::draw_shadow_filter`]). static SILHOUETTES: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// [`SILHOUETTES`] of this thread. #[cfg(test)] pub(super) fn silhouettes() -> u64 { SILHOUETTES.get() } /// Adds one to [`SILHOUETTES`]. #[cfg(test)] fn note_silhouette() { SILHOUETTES.set(SILHOUETTES.get().saturating_add(1)); } impl Renderer { /// Drop shadow / outer glow `fx` (shadow-cache slot `i` of `n`) as a cached raster: the /// silhouette `paint` draws is rendered once into a small crop, blurred and tinted on the CPU, /// and reused while the object, the zoom/rotation and what the silhouette reads ([`Inputs`]) are /// unchanged (pans only move it), and while interacting after the object only moved /// ([`Self::move_shadow`]). A raster of the whole reach too large for the budget is cropped to /// what can show in the `screen` (device pixels): the view, and around it the blur's spread and /// `around` (document units: how far the shadows and glows stacked over this one look), made /// coarser to fit the budget where the blur hides the coarser grid. Such a raster, and one /// made over it, is reused while a pan keeps the view inside the rect it was made for: the /// view, and when the raster kept for the object was made at the same zoom and rotation and no /// drag is on, the view with [`pan_margin`] around it, within `over` (the rect, in device /// pixels, that the cropped rasters of the shadows and glows it is made over were made for; /// `None` when it is made over none). `None` when the effect isn't a shadow/glow or the raster /// would still be too large (caller falls back to [`Self::draw_shadow_filter`]). /// While dragging on a multithreaded context, a raster that has to be made again counts as too /// large when it would hold more than [`DRAG_PIXEL_BUDGET`] pixels. #[allow(clippy::too_many_arguments)] fn shadow_raster( &mut self, f: &Frame, n: &Node, i: usize, fx: &RasterFx, reach: Rect, owner: Option<&Arc>, (screen, around, over): (Rect, f64, Option), paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), ) -> Option { let (mode, opacity, dx, dy, blur, color, darkness) = match fx { RasterFx::DropShadow { mode, opacity, dx, dy, blur, color, darkness } => (*mode, *opacity, *dx, *dy, *blur, *color, *darkness), RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color, None), _ => return None, }; let linear = linear_key(f.view); let key = (n as *const Node as usize, i, f.ink); let stamp = self.stamp; let interacting = f.opts.interacting; // What the silhouette reads: as the raster kept for `n` found it, else found now. let reads = self.shadows.get(&key).filter(|e| e.node.is(n)).map_or_else(|| Reads::of(n), |e| e.inputs.reads); let inputs = self.raster_inputs(f, n, reads); let exact = |e: &ShadowEntry| { e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved) && e.view.is_none_or(|v| v.serves(f.view, screen)) }; if interacting && !self.shadows.get(&key).is_some_and(exact) { self.move_shadow(f, n, key, fx, reach + Vec2::new(dx, dy), owner, &inputs, paint); } let hit = self.shadows.get_mut(&key).filter(|e| exact(e)); let (image, x, y, scale, moved, cropped) = match hit { Some(e) => { e.stamp = stamp; self.stats.moved += usize::from(e.moved); crate::paint::add_decode_failures(e.failed); if e.image_left_out { self.image_left_out = self.image_left_out.map(|_| true); } let p = f.view * e.anchor; (e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round(), e.scale, e.moved, e.view.map(|v| v.served(f.view))) } None => { // An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first. let grow = if matches!(fx, RasterFx::OuterGlow { .. }) { 18.0 / doc_raster_ppi(f.doc) } else { 0.0 }; let spread = (blur.max(0.0) * 1.5 + grow) / f.px + 2.0; let reach = reach + Vec2::new(dx, dy); let whole = f.view.transform_rect_bbox(reach).inflate(spread, spread); // The raster's region and scale within `budget` pixels: `None` when over the limits. let region = |r: Rect, coarser: bool, budget: f64| { let (x0, y0) = (r.x0.floor(), r.y0.floor()); let (w, h) = (r.x1.ceil() - x0, r.y1.ceil() - y0); let scale = if coarser { (w * h / budget).sqrt().ceil().max(1.0) } else { 1.0 }; if !scale.is_finite() || scale > 1.0 && blur / 2.0 / f.px / scale < 2.0 { return None; } let (w, h) = ((w / scale).ceil(), (h / scale).ceil()); ((1.0..=8192.0).contains(&w) && (1.0..=8192.0).contains(&h) && w * h <= budget).then_some((x0, y0, w, h, scale)) }; // A glow too large for the budget is made coarser, where its blur hides the coarser grid: the filter // layer the caller falls back to can't make its silhouette solid. let full = region(whole, matches!(fx, RasterFx::OuterGlow { .. }), 4.0e6).filter(|_| over.is_none()); // Else it is cropped to what can show in the view, within the budget of the filter // layer it would otherwise be drawn through, and made coarser in the same way. Made // again at this zoom and rotation (after a pan), it covers a margin around the view // too, so that later pans within the margin draw it. let ((x0, y0, w, h, scale), cropped) = match full { Some(r) => (r, None), None => { let m = spread + around / f.px; let again = self.shadows.get(&key).is_some_and(|e| e.linear == linear) && !f.opts.dragging; let e = if again { pan_margin((screen.width(), screen.height()), m, PIXEL_BUDGET, 8192.0) } else { 0.0 }; let room = over.map_or(screen.inflate(e, e), |o| screen.inflate(e, e).intersect(o)); let r = whole.intersect(room.inflate(m, m)); if r.width() <= 0.0 || r.height() <= 0.0 { return None; } (region(r, true, PIXEL_BUDGET)?, Some(room)) } }; // During a drag, a larger shadow on a multithreaded context is filtered within the // drag's budget (`with_filters`), and no raster is kept. if f.opts.dragging && f.mt && w * h > DRAG_PIXEL_BUDGET { return None; } let (w, h) = (w as u16, h as u16); // Silhouette (the object's painted alpha, and its colors under Darkness), offset by // the shadow distance. let mut off = f.offscreen_context(w, h); let view = Affine::scale(1.0 / scale) * Affine::translate((-x0, -y0)) * f.view; // Art is culled against the raster's own area: members of a group outside the view cast their shadows in it. let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); let ink = darkness.map_or(f.ink, |d| f.ink.darkened(d)); let crop = Frame { mt: false, view, visible, ink, ..*f }; let before = self.stats.moved; let decoded = crate::paint::decode_failures(); let outer_image_left_out = self.image_left_out.replace(false); let pattern_images_left_out = crate::pattern::images_left_out(); #[cfg(test)] note_silhouette(); self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy))); let failed = crate::paint::decode_failures() - decoded; let image_left_out = self.image_left_out.unwrap_or(false) || crate::pattern::images_left_out() > pattern_images_left_out; self.image_left_out = outer_image_left_out.map(|outer| outer || image_left_out); // A silhouette drawn over moved rasters makes a moved shadow. let moved = self.stats.moved > before; off.flush(); let mut pm = vello_cpu::Pixmap::new(w, h); off.render(&mut pm, &mut self.resources); // What the raster is made from, for its object once it moves (`move_shadow`). let made = Made { pixels: silhouette(&pm, darkness.is_some()), fx: vec![fx.clone()], size: reach.size(), channels: None, outline: None }; let (wu, hu) = (w as usize, h as usize); let sigma = blur / 2.0 / f.px / scale; if darkness.is_some() { // The object's own colors, blurred, with the black Darkness adds (premultiplied). let mut planes: [Vec; 4] = [ pm.data().iter().map(|p| p.r as f32).collect(), pm.data().iter().map(|p| p.g as f32).collect(), pm.data().iter().map(|p| p.b as f32).collect(), pm.data().iter().map(|p| p.a as f32).collect(), ]; for plane in &mut planes { effects::pixel::blur_plane(plane, wu, hu, sigma); } let [r, g, b, a] = &planes; for (k, px) in pm.data_mut().iter_mut().enumerate() { let al = (a[k] * opacity).clamp(0.0, 255.0).round(); let value = |plane: &Vec| (plane[k] * opacity).round().clamp(0.0, al) as u8; *px = vello_cpu::color::PremulRgba8 { r: value(r), g: value(g), b: value(b), a: al as u8 }; } } else { // An Outer Glow blurs a solid silhouette: wherever the art has any alpha, as AI files draw a glow round a glow. let solid = matches!(fx, RasterFx::OuterGlow { .. }); let mut a: Vec = pm.data().iter().map(|p| if solid && p.a > 0 { 255.0 } else { p.a as f32 }).collect(); effects::pixel::dilate_plane(&mut a, wu, hu, grow / f.px / scale); effects::pixel::blur_plane(&mut a, wu, hu, sigma); let [cr, cg, cb] = f.ink.rgb(&color); let tint = |v: f32, ch: f32| (v * ch).round().clamp(0.0, 255.0) as u8; for (px, av) in pm.data_mut().iter_mut().zip(&a) { let al = (av * opacity).clamp(0.0, 255.0); *px = vello_cpu::color::PremulRgba8 { r: tint(al, cr), g: tint(al, cg), b: tint(al, cb), a: al.round() as u8 }; } } let image = std::sync::Arc::new(pm); let anchor = reach.origin(); let p = f.view * anchor; let node = Drawn::of(n, owner); let rel = (x0 - p.x, y0 - p.y); let view = cropped.map(|room| ViewCrop::of(f.view, room)); self.shadows.insert( key, ShadowEntry { node, linear, anchor, rel, image: image.clone(), scale, view, made, inputs, moved, failed, image_left_out, stamp }, ); self.trim_fx_rasters(FX_RASTER_BUDGET); (image, x0, y0, scale, moved, cropped) } }; self.stats.view_sized += usize::from(cropped.is_some() || scale != 1.0); Some(Shadow { image, at: Affine::translate((x, y)) * Affine::scale(scale), mode, moved, cropped }) } /// Draw shadow raster `s` through `to`, from the view the raster was made for to the one `ctx` /// draws in. The blend mode applies per draw: a blend layer would be a full-viewport compositing /// pass per shadow. A full-size raster drawn as made sits on whole pixels, so the cheapest /// sampler is exact. fn draw_shadow(&mut self, ctx: &mut RenderContext, s: &Shadow, to: Affine) { let (w, h) = (s.image.width() as f64, s.image.height() as f64); let at = to * s.at; ctx.set_transform(at); ctx.set_blend_mode(blend_mode(s.mode)); let [a, b, c, d, _, _] = at.as_coeffs(); let exact = (a, b, c, d) == (1.0, 0.0, 0.0, 1.0) && at.translation().x.fract() == 0.0 && at.translation().y.fract() == 0.0; let quality = if exact { peniko::ImageQuality::Low } else { peniko::ImageQuality::Medium }; let sampler = peniko::ImageSampler { quality, ..Default::default() }; ctx.reset_paint_transform(); ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(s.image.clone()), sampler }); ctx.fill_rect(&Rect::new(0.0, 0.0, w, h)); ctx.set_blend_mode(blend_mode(vectorcraft_doc::color::BlendMode::Normal)); ctx.set_transform(Affine::IDENTITY); } /// Drop shadow / outer glow `fx` of what `paint` draws (bounded by `reach`) through a vello /// filter layer, when there is no raster for it ([`Self::shadow_raster`]). vello's shadow filter /// blurs the soft alpha, so an Outer Glow drawn here lacks the raster's solid silhouette. fn draw_shadow_filter( &mut self, ctx: &mut RenderContext, f: &Frame, fx: &RasterFx, reach: Rect, paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), ) { let (mode, opacity, dx, dy, blur, color, darkness) = match fx { RasterFx::DropShadow { mode, opacity, dx, dy, blur, color, darkness } => (*mode, *opacity, *dx, *dy, *blur, *color, *darkness), RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color, None), _ => return, }; let filter = match darkness { Some(_) => shade_filter(blur), None => shadow_filter(0.0, 0.0, blur, pcolor(f.ink, &color)), }; // The offset moves the content rather than the filter: vello_cpu drops layer content // that lies entirely outside the viewport before filtering. let reach = reach + Vec2::new(dx, dy); #[cfg(test)] note_silhouette(); self.with_filters(ctx, f, reach, blur, Some((mode, opacity)), |r, c, fr, comp| { c.set_transform(fr.view); c.push_layer(None, comp.map(|m| blend_mode(m.0)), comp.map(|m| m.1), None, Some(filter)); let ink = darkness.map_or(fr.ink, |d| fr.ink.darkened(d)); let source = Frame { ink, ..*fr }; paint(r, c, &shifted(&source, dx, dy)); c.pop_layer(); }); } /// While interacting, give `n` (shadow-cache key `key`) the shadow or glow raster an earlier /// frame made for its object (the same id, effect slot and ink, at this zoom and rotation, with /// the same `inputs`) when only the silhouette's place changed after that frame: made by the /// same effect `fx` from a silhouette whose bounds have the size of `reach` (the content's /// bounds moved by the shadow's offset), and whose alpha (and colors, under Darkness) are those /// of `n`'s silhouette drawn into a raster placed as that one was against its anchor /// ([`silhouette`]). The raster is kept as `n`'s, marked /// moved, and is drawn at the nearest whole pixel. #[allow(clippy::too_many_arguments)] fn move_shadow( &mut self, f: &Frame, n: &Node, key: (usize, usize, Ink), fx: &RasterFx, reach: Rect, owner: Option<&Arc>, inputs: &Inputs, paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), ) { let (dx, dy, colored) = match fx { RasterFx::DropShadow { dx, dy, darkness, .. } => (*dx, *dy, darkness.is_some()), _ => (0.0, 0.0, false), }; let (linear, stamp, size) = (linear_key(f.view), self.stamp, reach.size()); let fits = |k: &(usize, usize, Ink), e: &ShadowEntry| { (k.1, k.2) == (key.1, key.2) && e.stamp < stamp && e.linear == linear && e.view.is_none() && e.node.id() == n.id && e.inputs == *inputs && e.made.fits(&[fx], size, None, None) }; let Some((old, rel, w, h, scale)) = self.shadows.iter().find(|(k, e)| fits(k, e)).map(|(k, e)| (*k, e.rel, e.image.width(), e.image.height(), e.scale)) else { return; }; // The silhouette drawn where the old one lies in its raster. let anchor = reach.origin(); let p = f.view * anchor; let mut off = f.offscreen_context(w, h); let view = Affine::scale(1.0 / scale) * Affine::translate((-(p.x + rel.0), -(p.y + rel.1))) * f.view; // Culled against the raster's area, as `shadow_raster` draws the silhouette. let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); let ink = match fx { RasterFx::DropShadow { darkness: Some(d), .. } => f.ink.darkened(*d), _ => f.ink, }; let crop = Frame { mt: false, view, visible, ink, ..*f }; self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy))); off.flush(); let mut pm = vello_cpu::Pixmap::new(w, h); off.render(&mut pm, &mut self.resources); if self.shadows.get(&old).is_none_or(|e| silhouette(&pm, colored) != e.made.pixels) { return; } if let Some(mut e) = self.shadows.remove(&old) { (e.node, e.anchor, e.moved) = (Drawn::of(n, owner), anchor, true); self.shadows.insert(key, e); } } /// `draw_shape` for nodes with live effects. pub(crate) fn draw_shape_fx(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) { // The node `draw_arc` is drawing gets its geometry from the cache: `bp` is its own path, // made from the node alone, so the effects give the same result every frame while the // plug-ins installed stay the same (`check_plugins`). let owner = self.fx_owner.as_ref().filter(|o| std::ptr::eq(&***o, n)).cloned(); let mut cached = owner.map(|o| { let key = Arc::as_ptr(&o) as usize; let stamp = self.stamp; let e = match self.fx_geom.remove(&key) { Some(e) if Arc::ptr_eq(&e.node, &o) => e, _ => { let (g, carry) = effected_path_carrying(f.symbols.document(), n, bp); FxGeom { g: Arc::new(g), carried: carried(n, &carry), items: vec![None; n.appearance.items.len()], node: o, stamp } } }; (key, FxGeom { stamp, ..e }) }); let (g, carried) = match &cached { Some((_, e)) => (e.g.clone(), e.carried.clone()), None => { let (g, carry) = effected_path_carrying(f.symbols.document(), n, bp); (Arc::new(g), carried(n, &carry)) } }; let paints = carried.as_deref().unwrap_or(n); self.draw_shape_fx_geom(ctx, f, n, paints, &g, rule, cached.as_mut().map(|(_, e)| &mut e.items)); if let Some((key, e)) = cached { self.fx_geom.insert(key, e); } } /// [`Self::draw_shape_fx`] on the effected geometry `g`, with the items' cached geometry: /// `paints` is `n` with the fills and strokes it paints. #[allow(clippy::too_many_arguments)] fn draw_shape_fx_geom( &mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, paints: &Node, g: &BezPath, rule: FillRule, items: Option<&mut Vec>>, ) { if g.elements().is_empty() { return; } if f.opts.outline { self.hairline(ctx, f, g, [0, 0, 0, 255]); return; } let gctx = GeomContext { doc: Some(f.symbols.document()), ..GeomContext::of(paints) }; // `paints` hides the Transform effects that did not move `g`. let rfx = effects::raster_effects(&paints.appearance.effects); // Document-space reach of the painted geometry (strokes, arrowheads…). let outset = n.appearance.outset(); let reach = g.bounding_box().inflate(outset, outset); let bounds = rasterize_bounds(f.symbols, n, &rfx); // Under Rasterize the content reaches as far as its fills' and strokes' raster effects paint. let reach = bounds.map_or(reach, |b| reach.union(b)); let content = Content { node: n, owner: None, cache: Some(0), reach, outline: Some((g, rule)), bounds, kept: None }; let mut items = items; self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, paints, g, rule, &gctx, items.as_deref_mut())); ctx.set_transform(Affine::IDENTITY); } /// Draw type, an image, a symbol instance or a live object with object-level effects /// ([`has_object_fx`]) inside its transparency group: its art (reshaped by the geometry /// effects, see [`effects::reshape`]) with the raster effects painted around it as for paths, /// the art standing for the object's silhouette. pub(crate) fn draw_object_fx(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc, cache: bool) { let opacity = self.opacity_of(a); if !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_doc::color::BlendMode::Normal || a.isolate) { let blends = self.blends_through(a); let bounds = if blends { self.bounds_of(f.symbols, a) } else { None }; let comp = crate::group::Composite { blend: a.blend, opacity, isolated: a.isolate, blends, bounds, ..Default::default() }; self.group(ctx, f, comp, &mut |r, c, fr| r.draw_object_fx_art(c, fr, a, cache)); } else { self.draw_object_fx_art(ctx, f, a, cache); } self.stats.drawn += 1; } /// What [`Self::draw_object_fx`] draws inside the object's transparency group. fn draw_object_fx_art(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc, cache: bool) { if !self.charge_fx_instance(f.doc, a) { return; } let art = self.fx_art(f.doc, a, cache); // Art the cache did not keep is made again in the next frame. let kept = cache && self.fx_arts.get(&(Arc::as_ptr(a) as usize), self.stamp).is_some_and(|e| Arc::ptr_eq(&e.1, &art)); let outer = self.made_again; self.made_again = outer || !kept; // With Crop Marks, the object inside the art paints its own raster effects; Vector // Halftone samples them with the object. let rfx = if effects::has_crop_marks(a) || effects::addons::halftone::has_halftone(a) { vec![] } else { effects::raster_effects(&a.appearance.effects) }; match cull_bounds(f.symbols, &art) { Some(reach) if !f.opts.outline && !rfx.is_empty() => { // Feather, Inner Glow and Rasterize with Create Clipping Mask clip to the art's outline. let clipped = rfx.iter().any(|x| { matches!(x, RasterFx::Feather { .. } | RasterFx::InnerGlow { .. }) || matches!(x, RasterFx::Rasterize(r) if r.clipping_mask) }); let region = if clipped { self.clip_of(&art) } else { None }; // A symbol's art can change while the instance doesn't: no cached shadows. let cache = (cache && !matches!(a.kind, NodeKind::SymbolInstance { .. })).then_some(0); // A 3D effect's art is in hand: Rasterize takes its bounds, measured once while it is // kept, without building it again. let rasterizes = rfx.iter().any(|x| matches!(x, RasterFx::Rasterize(_))); let bounds = match rasterizes.then(|| self.three_d_art_bounds(f.symbols, &art)).flatten() { Some(b) => b, None => rasterize_bounds(f.symbols, a, &rfx), }; let reach = bounds.map_or(reach, |b| reach.union(b)); let content = Content { node: a, owner: Some(a), cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)), bounds, kept: None }; self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_fx_art(c, fr, a, &art)); ctx.set_transform(Affine::IDENTITY); } _ => self.draw_fx_art(ctx, f, a, &art), } self.made_again = outer; } /// Whether the frame has room for the art of `a` when it is a symbol instance, charged to the /// frame's symbol budget as an instance without effects is (`draw_node`): an instance whose /// effects reshape its art draws through here, and nested instances would otherwise expand /// without a limit. True for any other object. pub(crate) fn charge_fx_instance(&mut self, doc: &vectorcraft_doc::Document, a: &Node) -> bool { let NodeKind::SymbolInstance { symbol, .. } = &a.kind else { return true }; let count = doc.symbols.iter().find(|s| &s.name == symbol).map_or(0, |s| s.art.count() as u64); let mut used = self.symbol_nodes; let room = vectorcraft_doc::charge_symbol_art(&mut used, count, || ((), count)).is_some(); self.symbol_nodes = used; room } /// The art [`Self::draw_object_fx`] draws for `a` (cached by `Arc` identity when `cache`): /// reshaped by its geometry effects, else type, images and paths as themselves and the others /// as their evaluated art (groups and layers: [`effects::evaluate_container`], keeping their /// knockout setting), recoloured by the colour adjustments in it ([`Self::adjusted_art`]); /// without the object's transparency and effects (a path keeps its geometry effects). Art that /// is made from live objects it evaluated ([`evaluates_live`]), or from type, symbol art or /// colors shaded through the color settings ([`reads_outside`]), is kept only while what it /// reads from outside `a` stays the same ([`Self::live_found`]). pub(super) fn fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc, cache: bool) -> Arc { let key = Arc::as_ptr(a) as usize; let mut kept = None; if cache && let Some((node, art, found)) = self.fx_arts.get(&key, self.stamp) && Arc::ptr_eq(node, a) { match found { None => return art.clone(), Some(was) => kept = Some((art.clone(), was.0)), } } let found = (kept.is_some() || (cache && (evaluates_live(a) || reads_outside(a)))).then(|| self.live_found(doc, a)); if let Some((art, was)) = kept && found.as_ref().is_some_and(|f| f.0 == was) { return art; } // Made to measure it ([`Self::measured_fx_art`]): taken out, and kept in `fx_arts` from now // on if made from what it reads now, else made again. let measured = self.measured.remove(&key).filter(|m| Arc::ptr_eq(&m.0, a)).map(|m| { self.measured_bytes = self.measured_bytes.saturating_sub(crate::lru::node_bytes(&m.1)); (m.1, m.2) }); let art = match measured { Some((art, was)) => { let now = match &found { Some(f) => Some(f.0), None => self.outside_key(doc, a), }; if was == now { art } else { self.make_fx_art(doc, a) } } None => self.make_fx_art(doc, a), }; if cache { let bytes = crate::lru::node_bytes(&art); self.fx_arts.insert(key, (a.clone(), art.clone(), found), bytes, self.stamp); } art } /// The art [`Self::fx_art`] draws for `a`, to measure it: the art kept for it, else made now and /// held until the frame ends ([`Renderer::measured`]), for [`Self::fx_art`] to keep if `a` is /// drawn. What is measured but never drawn (culled, off the view) is let go, and isn't kept in /// place of what is drawn. pub(crate) fn measured_fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc) -> Arc { let key = Arc::as_ptr(a) as usize; if let Some((node, art, found)) = self.fx_arts.get(&key, self.stamp) && Arc::ptr_eq(node, a) { let (art, was) = (art.clone(), found.as_ref().map(|f| f.0)); if was.is_none_or(|was| self.live_found(doc, a).0 == was) { return art; } } let now = self.outside_key(doc, a); if let Some((node, art, was)) = self.measured.get(&key) && Arc::ptr_eq(node, a) && *was == now { return art.clone(); } let art = self.make_fx_art(doc, a); // Art held for an object before (made from what it read then) gives way. if let Some(old) = self.measured.remove(&key) { self.measured_bytes = self.measured_bytes.saturating_sub(crate::lru::node_bytes(&old.1)); } let bytes = crate::lru::node_bytes(&art); // Within a budget: past it, what is measured is made again to draw it. if self.measured_bytes + bytes <= self.measured_budget { self.measured_bytes += bytes; self.measured.insert(key, (a.clone(), art.clone(), now)); } art } /// The bounds of `art`, art [`Self::fx_art`] made for an object, when it is a 3D effect's art /// ([`super::three_d_art_bounds`]), measured once while it is kept; `None` for any other art. pub(crate) fn three_d_art_bounds(&mut self, symbols: &SymbolBoxes, art: &Arc) -> Option> { let key = Arc::as_ptr(art) as usize; // Raster effects in the art spread by the resolution; brushed strokes in it draw from the // brushes. let at = (super::raster::doc_raster_ppi(symbols.document()).to_bits(), self.brushes.library_key(symbols.document(), self.stamp)); let (kept, measured) = self.three_d_bounds.get(&key).filter(|e| std::ptr::eq(e.0.as_ptr(), Arc::as_ptr(art))).map(|e| (true, e.1.filter(|m| m.0 == at)))?; if let (true, Some((_, b))) = (kept, measured) { return Some(b); } let b = super::three_d_art_bounds(symbols, art); self.three_d_bounds.insert(key, (Arc::downgrade(art), Some((at, b)))); Some(b) } /// The key of what `a`'s art reads from outside it ([`Self::live_found`]), `None` when it reads /// nothing. fn outside_key(&mut self, doc: &vectorcraft_doc::Document, a: &Arc) -> Option { (evaluates_live(a) || reads_outside(a)).then(|| self.live_found(doc, a).0) } /// The art [`Self::fx_art`] draws for `a`, made now. fn make_fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc) -> Arc { // Whether the art is a 3D effect's ([`Self::three_d_art_bounds`]). let mut three_d = false; let symbol = match &a.kind { NodeKind::SymbolInstance { symbol, .. } => { doc.symbols.iter().find(|s| s.name == *symbol).map(|s| crate::brush_fx::instance_art(&s.art, a)) } _ => None, }; let container = is_container(a); let art = match effects::crop_marks_art(a) .or_else(|| crate::addons::halftone::art(doc, a)) .or_else(|| { let art = effects::revolve_art_in(Some(doc), a).or_else(|| effects::solid_art_in(Some(doc), a)); self.stats.three_d_built += usize::from(art.is_some()); three_d |= art.is_some(); art }) .or_else(|| effects::reshape(Some(doc), a, symbol.as_ref())) { Some(r) => r, None if container => effects::evaluate_container(Some(doc), a).unwrap_or_else(|| (**a).clone()), None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::Path { .. } | NodeKind::Compound { .. }) => (**a).clone(), None => effects::outline_art(Some(doc), a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])), }; let has_three_d = effects::has_revolve(a) || effects::has_solid(a); let mut art = if has_three_d { effects::evaluate_container(Some(doc), &art).unwrap_or(art) } else { art }; // A 3D effect on a fill or stroke that built nothing (Revolve after Roughen, a solid too // complex) draws the item as without it: left in the art, it would draw the art as an // object with effects again, and that art again, without end. if has_three_d && !three_d && !container { for item in &mut art.appearance.items { item.effects_mut().retain(|e| !effects::is_three_d(&e.id)); } } // Vector Halftone samples the object with its colour adjustments: its dots keep their inks. let outer = if effects::addons::halftone::has_halftone(a) { None } else { effects::color_map(&a.appearance.effects) }; let mut art = self.adjusted_art(doc, art, outer.as_ref()); // A path's geometry effects still apply when it is drawn. let path = matches!(art.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); art.appearance.effects.retain(|e| path && effects::is_geometry(&e.id)); let mut art = art.with_mask(None); art.opacity = 1.0; art.blend = Default::default(); art.isolate = false; // One element: its pieces never knock each other out (a group's members still do as its // knockout setting says). art.knockout = if container { a.knockout } else { vectorcraft_doc::Knockout::Off }; let art = Arc::new(art); if three_d { // A renderer that only measures (an export, Fit to Artwork Bounds) ends no frame: what // it no longer holds is let go here too. if self.three_d_bounds.len() >= (2 * self.three_d_kept).max(1024) { self.three_d_bounds.retain(|_, e| e.0.strong_count() > 0); self.three_d_kept = self.three_d_bounds.len(); #[cfg(test)] { self.three_d_sweeps += 1; } } self.three_d_bounds.insert(Arc::as_ptr(&art) as usize, (Arc::downgrade(&art), None)); } art } /// Draw `art`, the art [`Self::fx_art`] made for `a`. Type art that `fx_arts` keeps for `a` is /// drawn as `fx_owner`, so [`Self::draw_text`] takes its glyph geometry from `texts`. fn draw_fx_art(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc, art: &Arc) { let kept = matches!(art.kind, NodeKind::Text(_)) && self.fx_arts.get(&(Arc::as_ptr(a) as usize), self.stamp).is_some_and(|e| Arc::ptr_eq(&e.1, art)); if !kept { return self.draw_node(ctx, f, art, true); } let outer = self.fx_owner.replace(art.clone()); self.draw_node(ctx, f, art, true); self.fx_owner = outer; } /// `art` with the colour adjustments inside it applied, then `outer` (its object's own) on /// everything ([`effects::adjust`]), with `doc`'s swatches. Embedded images become recoloured /// copies, cached with the decoded images under a key naming the adjustment. fn adjusted_art(&mut self, doc: &vectorcraft_doc::Document, mut art: Node, outer: Option<&effects::ColorMap>) -> Node { art.appearance.effects.retain(|e| !effects::is_adjustment(&e.id)); let mut wanted: Vec<(String, String, u64, effects::ColorMap)> = vec![]; // shortcut: art kept by `fx_arts` isn't made again when only a swatch's kind (spot or // process) changes, which Duotone reads; editing the object makes it again. let swatches = |name: &str| effects::addons::duotone::swatch_kind(doc, name); let adjusted = effects::adjust( &art, outer, &swatches, &mut |key, map| { let mut h = std::hash::DefaultHasher::new(); std::hash::Hash::hash(&format!("{map:?}"), &mut h); let hash = std::hash::Hasher::finish(&h); let derived = format!("{key}\u{0}adjust\u{0}{hash:016x}"); wanted.push((key.to_string(), derived.clone(), hash, map.clone())); Some(derived) }, &|key| doc.images.contains_key(key), ); for (key, derived, hash, map) in wanted { self.adjusted_image(&key, derived, hash, &map); } adjusted.unwrap_or(art) } /// Paint `content` with raster effects `rfx` (see the module docs): shadows and outer glows /// below it, the content blurred or feathered, inner glows above it (clipped to its outline). /// `paint` draws the content in the frame it is given. pub(super) fn raster_fx( &mut self, ctx: &mut RenderContext, f: &Frame, content: &Content, rfx: &[RasterFx], paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), ) { // Only the last MAX_RASTERIZE Rasterize effects are drawn. let count = rfx.iter().filter(|fx| matches!(fx, RasterFx::Rasterize(_))).count(); let capped: Vec; let rfx = if count > MAX_RASTERIZE { let mut left_out = count - MAX_RASTERIZE; capped = rfx .iter() .filter(|fx| { let out = left_out > 0 && matches!(fx, RasterFx::Rasterize(_)); left_out -= usize::from(out); !out }) .cloned() .collect(); &capped[..] } else { rfx }; // Rasterize at the document's raster effects resolution takes it here. let resolved: Vec; let rfx = if rfx.iter().any(|fx| matches!(fx, RasterFx::Rasterize(r) if r.ppi.is_none())) { let ppi = doc_raster_ppi(f.doc); resolved = rfx.iter().map(|fx| fx.at_document_ppi(ppi)).collect(); &resolved[..] } else { rfx }; // Rasterize makes an image of everything the effects before it paint, shadows and glows // included, and the shadows and glows after it paint around that image. When some come // after the last Rasterize, the stack splits after it, else when any effect comes before // it, the stack splits before it; the effects after the split paint around the content // drawn with those before it. Rasterize then comes first in its part. if let Some(at) = rfx.iter().rposition(|fx| matches!(fx, RasterFx::Rasterize(_))) { let paints_around = |fx: &[RasterFx]| fx.iter().any(|fx| !content_changing(&fx)); let (_, rest) = rfx.split_at(at + 1); let split = if paints_around(rest) { Some(at + 1) } else { (at > 0).then_some(at) }; if let Some(split) = split { let (before, after) = rfx.split_at(split); let line = 72.0 / doc_raster_ppi(f.doc); let (mut reach, mut spread) = (content.reach, content.bounds); // After a Rasterize on white without a clipping mask, the silhouette is its image, // and it stays the silhouette through the effects after it (as in `pixel_content`). let mut image = None; // Whether the image of a Rasterize before the split is over `rasterize_image`'s // limits. Such an image is kept only as far as the view needs it, so the effects // after the split are not cached. let (mut over, mut last_over) = (false, false); for fx in before { if let RasterFx::Rasterize(r) = fx { last_over = image_size(r, r.image(spread.unwrap_or(reach), line), line).is_none(); over |= last_over; } let o = fx.outset(reach); reach = reach.inflate(o, o); spread = spread.map(|b| fx.spread(b, line)); if let RasterFx::Rasterize(r) = fx && r.white && !r.clipping_mask { image = Some(spread.unwrap_or(reach).to_path(0.1)); } } // When the split comes after the Rasterize, the shadows and glows after it and the // content each draw the content before the split. The first draw makes the image, // and the others draw the one it keeps. let window = after.iter().fold(f.visible, |w, fx| { let o = fx.outset(w); w.inflate(o, o) }); let frame = last_over.then_some((f.view, f.px)); let kept = (split > at).then(|| Kept { window, frame, image: std::cell::RefCell::new(None) }); let inner = Content { node: content.node, owner: content.owner, cache: None, reach: content.reach, outline: content.outline, bounds: content.bounds, kept: kept.as_ref(), }; let outline = image.as_ref().map(|g| (g, FillRule::NonZero)).or(content.outline); // When the split comes before the Rasterize, the part from it on draws the content // once, and its image is the one kept for a split above. let outer = Content { node: content.node, owner: content.owner, cache: content.cache.filter(|_| !over), reach, outline, bounds: spread, kept: content.kept.filter(|_| split == at), }; self.raster_fx(ctx, f, &outer, after, &mut |r, c, fr| r.raster_fx(c, fr, &inner, before, paint)); return; } } let reach = content.reach; // The `Arc` drawn: the caller's, or the node `draw_arc` is drawing. let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node))); // Below the content: shadows and outer glows, each of the content with the ones before it in the stack, as AI // files draw a glow round a glow and the SVG export nests them. Their rasters are made first to last, each once // and drawn into the later ones' silhouettes as an image (so n of them cost n rasters, not 2^n draws), then // drawn with the last lowest. One without a raster (an object drawn uncached, a glow past the budget) is drawn // through a filter layer from the content alone, and goes into the later silhouettes that way. let mut made: Vec<(&RasterFx, Option)> = vec![]; let mut stacked = reach; let screen = Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height())); // How far (document units) a shadow or glow looks from a pixel to the silhouette under it. let looks = |fx: &RasterFx| match fx { RasterFx::DropShadow { dx, dy, blur, .. } => blur.max(0.0) * 1.5 + dx.abs() + dy.abs(), RasterFx::OuterGlow { blur, .. } => blur.max(0.0) * 1.5, _ => 0.0, }; for (i, fx) in rfx.iter().enumerate().filter(|(_, x)| x.is_below()) { // A raster cropped to the view covers what the shadows and glows stacked over it read. let around: f64 = rfx.iter().skip(i + 1).filter(|x| x.is_below()).map(looks).sum(); // The rect that the cropped rasters of the earlier ones (drawn into this one's silhouette) were all made for. let over = made.iter().filter_map(|(_, s)| s.as_ref()?.cropped).reduce(|a, b| a.intersect(b)); let shadow = { let earlier = &made; let view = f.view; let mut art = |r: &mut Self, c: &mut RenderContext, fr: &Frame| { let to = fr.view * view.inverse(); for (fx, s) in earlier.iter().rev() { match s { Some(s) => { r.stats.moved += usize::from(s.moved); r.draw_shadow(c, s, to); } None => r.draw_shadow_filter(c, fr, fx, reach, paint), } } paint(r, c, fr); }; let view = (screen, around, over); content.cache.and_then(|slot| self.shadow_raster(f, content.node, slot + i, fx, stacked, owner.as_ref(), view, &mut art)) }; stacked = match fx { RasterFx::DropShadow { dx, dy, blur, .. } => stacked.union((stacked + Vec2::new(*dx, *dy)).inflate(blur * 1.5, blur * 1.5)), _ => stacked.inflate(fx.outset(stacked), fx.outset(stacked)), }; made.push((fx, shadow)); } for (fx, s) in made.iter().rev() { match s { Some(s) => self.draw_shadow(ctx, s, Affine::IDENTITY), None => self.draw_shadow_filter(ctx, f, fx, reach, paint), } } // The content itself (blurred / feathered / filtered), on its pixels: a filter layer's blur // works on a coarser grid tied to the canvas, so the blurred art shifted by up to a pixel // and looked blocky as the view moved by parts of a pixel (#755). let blurs = rfx.iter().any(|fx| matches!(fx, RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius, .. } if *radius > 0.0)); if blurs || rfx.iter().any(|fx| matches!(fx, RasterFx::Pixel(_) | RasterFx::Rasterize(_))) { self.pixel_content(ctx, f, content, rfx, paint); } else { paint(self, ctx, f); } // Above the content: inner glows, clipped to its outline. An image's glow follows its // visible pixels, not its frame (#5308). let Some((g, rule)) = content.outline else { return }; let pixels = matches!(content.node.kind, NodeKind::Image(_)); for fx in rfx { let RasterFx::InnerGlow { mode, opacity, blur, color, center } = fx else { continue }; let filter = shadow_filter(0.0, 0.0, *blur, pcolor(f.ink, color)); if pixels { self.with_filters(ctx, f, g.bounding_box(), *blur, Some((*mode, *opacity)), |r, c, fr, comp| { if let Some((m, o)) = comp { c.set_transform(Affine::IDENTITY); c.push_layer(None, Some(blend_mode(m)), Some(o), None, None); } c.set_transform(fr.view); c.set_fill_rule(fill_rule(rule)); c.push_clip_layer(g); c.push_layer(None, None, None, None, Some(filter)); if !*center { // Everything around the visible pixels, so the glow bleeds in from their edges. let pad = blur * 2.0 + 4.0 * fr.px; c.set_transform(fr.view); c.set_paint(peniko::Color::BLACK); c.fill_rect(&g.bounding_box().inflate(pad, pad)); c.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut)), None, None, None); } // The glow is cast by the pixels' alpha alone (the filter takes no color from them). paint(r, c, fr); if !*center { c.pop_layer(); } c.pop_layer(); // Only where the image shows, as much as it shows. c.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)), None, None, None); paint(r, c, fr); c.pop_layer(); c.pop_layer(); if comp.is_some() { c.pop_layer(); } }); continue; } self.with_filters(ctx, f, g.bounding_box(), *blur, Some((*mode, *opacity)), |_, c, fr, comp| { // The blend layer goes outside the clip: a clip layer is isolated, so a blend // inside it would mix with nothing instead of the content below. if let Some((m, o)) = comp { c.set_transform(Affine::IDENTITY); c.push_layer(None, Some(blend_mode(m)), Some(o), None, None); } c.set_transform(fr.view); c.set_fill_rule(fill_rule(rule)); c.push_clip_layer(g); c.push_layer(None, None, None, None, Some(filter)); c.set_paint(peniko::Color::BLACK); if *center { c.set_fill_rule(fill_rule(rule)); c.fill_path(g); } else { // Everything outside the shape, so the glow bleeds in from the edges. let pad = blur * 2.0 + 4.0 * fr.px; let mut inv = g.bounding_box().inflate(pad, pad).to_path(0.1); inv.extend(g.iter()); c.set_fill_rule(peniko::Fill::EvenOdd); c.fill_path(&inv); } c.pop_layer(); c.pop_layer(); if comp.is_some() { c.pop_layer(); } }); } } } /// `n` with the gradients of its fills and strokes moved along with its effects by `carry`, and /// the Transform effects the geometry pass left out hidden ([`effects::Carry::hide_skipped`]), so /// its raster effects and those of its fills and strokes stay with its art; `None` when it stays /// as it is. fn carried(n: &Node, carry: &effects::Carry) -> Option> { let moves = carry.moves(&n.appearance); (moves || carry.skipped()).then(|| { let mut m = n.clone(); if moves { carry.apply(&mut m.appearance); } carry.hide_skipped(&mut m.appearance.effects); Arc::new(m) }) } #[cfg(test)] mod tests { use super::*; use crate::RenderOptions; use vectorcraft_color::Color; use vectorcraft_doc::{Document, color::BlendMode}; /// The node a path is painted from hides a Transform effect left out past the effect budget, /// so the drop shadow before it is drawn with its own offset and blur, as the art stays. #[test] fn a_path_paints_from_the_transforms_that_moved_it() { use vectorcraft_geom::{Anchor, PathData, Point, SubPath}; let anchors = (0..=30_000) .map(|i| { let x = 2.0 * f64::from(i); Anchor::with_handles(Point::new(x, 0.0), Point::new(x - 0.5, -20.0), Point::new(x + 0.5, 20.0)) }) .collect(); let path = PathData::single(SubPath::new(anchors, true)); let mut n = Node::path( vectorcraft_doc::NodeId(1), path.clone(), vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::solid(Color::rgb(1.0, 0.0, 0.0)), vectorcraft_color::Paint::None, 0.0), ); n.appearance.effects = vec![ vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 10, "y": 5, "blur": 4}), visible: true }, vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"scaleH": 100000, "scaleV": 100000}), visible: true, }, ]; let doc = Document::new(100.0, 100.0); let bp = path.to_bezpath(); let (g, carry) = effected_path_carrying(&doc, &n, &bp); assert_eq!(g.bounding_box(), bp.bounding_box(), "the art stays where it is"); let paints = carried(&n, &carry).expect("the Transform effect is hidden"); match effects::raster_effects(&paints.appearance.effects).first() { Some(RasterFx::DropShadow { dx, dy, blur, .. }) => assert!((*dx, *dy, *blur) == (10.0, 5.0, 4.0), "{dx} {dy} {blur}"), other => panic!("expected a drop shadow, got {other:?}"), } } #[test] fn uncached_darkness_shadow_subtracts_before_separate_opacity() { let doc = Document::new(100.0, 100.0); let symbols = doc.symbol_boxes().quick(); let opts = RenderOptions::default(); let frame = Frame { mt: false, doc: &doc, symbols: &symbols, view: Affine::IDENTITY, visible: Rect::new(0.0, 0.0, 100.0, 100.0), px: 1.0, opts: &opts, ink: Ink::Display, output: Affine::IDENTITY, plates: None, }; let mut ctx = frame.offscreen_context(100, 100); let mut r = Renderer::new(); let fx = RasterFx::DropShadow { mode: BlendMode::Normal, opacity: 0.5, dx: 40.0, dy: 0.0, blur: 0.0, color: Color::BLACK, darkness: Some(0.25) }; let source = Rect::new(10.0, 10.0, 30.0, 30.0); r.draw_shadow_filter(&mut ctx, &frame, &fx, source, &mut |_, c, f| { c.set_transform(f.view); c.set_paint(f.ink.color(&Color::rgb(0.8, 0.4, 0.2), 1.0)); c.fill_rect(&source); }); ctx.flush(); let mut pm = vello_cpu::Pixmap::new(100, 100); ctx.render(&mut pm, &mut r.resources); let px = pm.data()[20 * 100 + 60]; assert!(px.r.abs_diff(70) <= 1 && px.g.abs_diff(19) <= 1 && px.b == 0 && px.a.abs_diff(128) <= 1, "{px:?}"); assert!(r.shadows.is_empty()); } }