From b48d163f5f2ad89be7e4ceaae4fb1ed87738a355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Thu, 29 Oct 2026 18:27:19 +0100 Subject: [PATCH] :zap: Fix slow drop shadow spread in CPU exports (#12718) * :zap: Fix slow drop shadow spread in CPU exports * :bug: Fix text opacity in board drop shadows on CPU exports --- render-wasm/src/render/vector.rs | 216 ++++++++++++++++++++---------- render-wasm/src/shapes.rs | 14 -- render-wasm/src/shapes/shadows.rs | 21 +-- 3 files changed, 160 insertions(+), 91 deletions(-) diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index bfbeedf0d0..5bb8c4795f 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -1,9 +1,11 @@ +use std::borrow::Cow; + use skia_safe::{self as skia, Canvas, Paint, RRect}; use crate::error::Result; use crate::shapes::{ circle_segments_local, merge_fills, radius_to_sigma, rect_segments_local, stroke_to_path, - BlurType, Fill, Frame, Path, Rect, Shape, Stroke, StrokeKind, StrokeStyle, Type, + BlurType, Fill, Frame, Path, Rect, Shape, SolidColor, Stroke, StrokeKind, StrokeStyle, Type, }; use crate::state::ShapesPoolRef; use crate::uuid::Uuid; @@ -29,6 +31,10 @@ pub(super) struct VectorRenderer<'a> { /// techniques that `SkSVGDevice` drops: draw fills individually and emit /// solid Inner/Outer strokes as filled outlines. compose_fills: bool, + /// Set when an enclosing layer already applies the shape's layer blur. + blur_in_layer: bool, + /// Set inside a container's drop-shadow pass: text glyphs paint opaque. + silhouette: bool, } impl<'a> VectorRenderer<'a> { @@ -43,6 +49,8 @@ impl<'a> VectorRenderer<'a> { shared, scale, compose_fills, + blur_in_layer: false, + silhouette: false, } } @@ -50,9 +58,10 @@ impl<'a> VectorRenderer<'a> { /// /// SVG export (`compose_fills == false`) returns `None`: `SkSVGDevice` drops /// paint image-filters (the shape would vanish). Layer blur is re-emitted as - /// a native SVG `` wrapper instead. + /// a native SVG `` wrapper instead. Also `None` inside a layer + /// that already blurs, or the shape would blur twice. fn layer_blur_filter(&self, shape: &Shape) -> Option { - if !self.compose_fills { + if !self.compose_fills || self.blur_in_layer { return None; } shape.image_filter(1.) @@ -121,7 +130,7 @@ impl ShapeRenderer for VectorRenderer<'_> { fn draw_drop_shadows(&mut self, shape: &Shape) -> Result<()> { let layer_bounds = shape.layer_bounds(); for shadow in shape.drop_shadows_visible() { - if let Some(filter) = shadow.get_drop_shadow_filter() { + if let Some(filter) = shadow.get_drop_shadow_blur_filter() { let mut paint = Paint::default(); paint.set_image_filter(filter); let layer_rec = skia::canvas::SaveLayerRec::default() @@ -131,6 +140,12 @@ impl ShapeRenderer for VectorRenderer<'_> { let mut fill_paint = Paint::default(); fill_paint.set_anti_alias(true); fill_paint.set_color(skia::Color::BLACK); + // Spread as a mitered outline: Skia's dilate filter runs as + // SkSL per pixel on CPU canvases, which takes minutes. + if shadow.spread > 0.0 { + fill_paint.set_style(skia::PaintStyle::StrokeAndFill); + fill_paint.set_stroke_width(2.0 * shadow.spread); + } draw_shape_geometry(self.canvas, shape, &fill_paint); self.canvas.restore(); } @@ -182,13 +197,17 @@ impl ShapeRenderer for VectorRenderer<'_> { }; let text_content = text_content.new_bounds(shape.selrect()); - let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); + let mut paragraph_builders = + text_content.paragraph_builder_group_from_text(Some(self.silhouette)); let blur_filter = self.layer_blur_filter(shape); // Text drop shadows: one filter layer per shadow over fill + stroke - // silhouettes (mirrors GPU `render_text_shadows`). - let drop_shadows = shape.drop_shadow_paints(); - if !drop_shadows.is_empty() { + // silhouettes (mirrors GPU `render_text_shadows`). Spread grows the + // glyphs through strokes (`spread_shape`). + let layer_bounds = shape.layer_bounds(); + for shadow in shape.drop_shadows_visible() { + let spread = spread_shape(shape, shadow.spread); + let shape: &Shape = &spread; let shadow_stroke_outset = Stroke::max_bounds_width(shape.visible_strokes(), false); let mut shadow_paragraphs = text_content.paragraph_builder_group_from_text(Some(true)); let mut stroke_shadow_groups: Vec<(StrokeKind, _)> = shape @@ -208,67 +227,66 @@ impl ShapeRenderer for VectorRenderer<'_> { }) .collect(); - let layer_bounds = shape.layer_bounds(); - for shadow_paint in &drop_shadows { - self.canvas.save_layer( - &skia::canvas::SaveLayerRec::default() - .bounds(&layer_bounds) - .paint(shadow_paint), - ); + let mut shadow_paint = Paint::default(); + shadow_paint.set_image_filter(shadow.get_drop_shadow_blur_filter()); + self.canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&shadow_paint), + ); - text::render_overlay_emoji( - self.canvas, - shape, - &mut shadow_paragraphs, - None, - blur_filter.as_ref(), - None, - None, - )?; + text::render_overlay_emoji( + self.canvas, + shape, + &mut shadow_paragraphs, + None, + blur_filter.as_ref(), + None, + None, + )?; - for (kind, stroke_paragraphs) in &mut stroke_shadow_groups { - if *kind == StrokeKind::Inner { - // Inner stroke masked by the glyph fill (outset 0 here). - let mut fill_builders = - text_content.paragraph_builder_group_from_text(Some(true)); - text::render_inner_stroke( - None, - Some(self.canvas), - shape, - stroke_paragraphs, - &mut fill_builders, - None, - blur_filter.as_ref(), - 0.0, - None, - )?; - } else if *kind == StrokeKind::Outer { - text::render_outer_stroke( - None, - Some(self.canvas), - shape, - stroke_paragraphs, - None, - blur_filter.as_ref(), - 0.0, - None, - )?; - } else { - text::render_with_bounds_outset_overlay_emoji( - self.canvas, - shape, - stroke_paragraphs, - None, - blur_filter.as_ref(), - shadow_stroke_outset, - None, - None, - )?; - } + for (kind, stroke_paragraphs) in &mut stroke_shadow_groups { + if *kind == StrokeKind::Inner { + // Inner stroke masked by the glyph fill (outset 0 here). + let mut fill_builders = + text_content.paragraph_builder_group_from_text(Some(true)); + text::render_inner_stroke( + None, + Some(self.canvas), + shape, + stroke_paragraphs, + &mut fill_builders, + None, + blur_filter.as_ref(), + 0.0, + None, + )?; + } else if *kind == StrokeKind::Outer { + text::render_outer_stroke( + None, + Some(self.canvas), + shape, + stroke_paragraphs, + None, + blur_filter.as_ref(), + 0.0, + None, + )?; + } else { + text::render_with_bounds_outset_overlay_emoji( + self.canvas, + shape, + stroke_paragraphs, + None, + blur_filter.as_ref(), + shadow_stroke_outset, + None, + None, + )?; } - - self.canvas.restore(); } + + self.canvas.restore(); } text::render_overlay_emoji( @@ -427,6 +445,9 @@ struct TreeOpts<'a> { root_fills: &'a [Fill], /// Nearest group's fills, painted by fill-less leaves (GPU `nested_fills`). inherited_fills: &'a [Fill], + /// Set inside a container's drop-shadow pass: the spread to grow every + /// shape by. + silhouette: Option, } impl<'a> TreeOpts<'a> { @@ -447,6 +468,7 @@ impl<'a> TreeOpts<'a> { skip: self.skip, root_fills: self.root_fills, inherited_fills, + silhouette: self.silhouette, } } } @@ -500,6 +522,7 @@ fn render_tree_dispatch( skip: None, root_fills: &root_fills, inherited_fills: &root_fills, + silhouette: None, }; render_tree_inner(shared, canvas, id, tree, scale, &opts) } @@ -525,6 +548,10 @@ fn render_tree_inner( if element.hidden { return Ok(()); } + let silhouette = opts + .silhouette + .map(|spread| silhouette_shape(element, spread)); + let element: &Shape = silhouette.as_deref().unwrap_or(element); // Background blur samples already-drawn content behind the shape, so it must // run before the shape (and its subtree) paints. SVGRaw is excluded, @@ -561,7 +588,8 @@ fn render_tree_inner( } else { element.fills.as_slice() }; - render_leaf(shared, canvas, element, fills, scale)?; + let silhouette = opts.silhouette.is_some(); + render_leaf(shared, canvas, element, fills, scale, silhouette)?; } } @@ -696,6 +724,7 @@ fn render_background_blur_image( skip: Some(&shape.id), root_fills: opts.root_fills, inherited_fills: opts.root_fills, + silhouette: None, }; render_tree_inner(shared, oc, opts.root, tree, scale, &sub)?; } @@ -952,6 +981,8 @@ fn render_frame( canvas.save(); canvas.concat(&matrix); let mut renderer = VectorRenderer::new(canvas, shared, scale, true); + // The opacity/blend layer above carries the frame's layer blur. + renderer.blur_in_layer = needs_layer; renderer.draw_fills(element, &element.fills)?; renderer.draw_fill_inner_shadows(element)?; canvas.restore(); @@ -996,7 +1027,7 @@ fn render_container_drop_shadows( ) -> Result<()> { let subtree_bounds = element.extrect(tree, scale); for shadow in element.drop_shadows_visible() { - let Some(filter) = shadow.get_drop_shadow_filter() else { + let Some(filter) = shadow.get_drop_shadow_blur_filter() else { continue; }; let mut paint = Paint::default(); @@ -1007,13 +1038,22 @@ fn render_container_drop_shadows( .paint(&paint), ); + // Spread grows every silhouette shape instead of dilating the layer. + let spread = opts.silhouette.unwrap_or(0.0) + shadow.spread.max(0.0); if draw_fills && !element.fills.is_empty() { + let mut background = Cow::Borrowed(element); + if spread > 0.0 { + background.to_mut().selrect.outset((spread, spread)); + } let mut renderer = VectorRenderer::new(canvas, shared, scale, true); - renderer.draw_fills(element, &element.fills)?; + renderer.draw_fills(&background, &[Fill::Solid(SolidColor(skia::Color::BLACK))])?; } let children = children_paint_order(tree, element); - let child_opts = opts.for_children_of(element); + let child_opts = TreeOpts { + silhouette: Some(spread), + ..opts.for_children_of(element) + }; if element.is_masked_group() { render_masked_children(shared, canvas, element, &children, tree, scale, &child_opts)?; } else { @@ -1027,6 +1067,41 @@ fn render_container_drop_shadows( Ok(()) } +/// `shape` grown by a drop-shadow `spread` for its silhouette, through its own +/// strokes and joins (as the GPU does for paths). Empty frames and containers +/// have no outline of their own to grow. +fn spread_shape(shape: &Shape, spread: f32) -> Cow<'_, Shape> { + let outline = match shape.shape_type { + Type::Group(_) | Type::SVGRaw(_) => false, + Type::Frame(_) => !shape.fills.is_empty() || !shape.strokes.is_empty(), + _ => true, + }; + if spread <= 0.0 || !outline { + return Cow::Borrowed(shape); + } + let mut shape = shape.clone(); + shape.apply_shadow_spread(spread); + Cow::Owned(shape) +} + +/// `shape` as drawn in a container's drop-shadow pass: grown by `spread` and +/// solid black, since fill and stroke opacity don't affect the shadow (GPU). +fn silhouette_shape(shape: &Shape, spread: f32) -> Cow<'_, Shape> { + let mut shape = spread_shape(shape, spread); + if shape.has_fills() || !shape.strokes.is_empty() { + let black = Fill::Solid(SolidColor(skia::Color::BLACK)); + let shape = shape.to_mut(); + if shape.has_fills() { + shape.clear_fills(); + shape.add_fill(black.clone()); + } + for stroke in shape.strokes.iter_mut() { + stroke.fill = black.clone(); + } + } + shape +} + // --------------------------------------------------------------------------- // Leaf shapes (Rect, Circle, Path, Bool, Text, SVGRaw) // --------------------------------------------------------------------------- @@ -1037,6 +1112,7 @@ fn render_leaf( element: &Shape, fills: &[Fill], scale: f32, + silhouette: bool, ) -> Result<()> { let needs_layer = element.needs_layer(); @@ -1065,6 +1141,8 @@ fn render_leaf( } else { false }; + renderer.blur_in_layer = blur_layer; + renderer.silhouette = silhouette; renderer.draw_drop_shadows(element)?; render_leaf_content(&mut renderer, element, fills)?; diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 3baa6dd66c..6a157a1c13 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -2187,20 +2187,6 @@ impl Shape { true } - pub fn drop_shadow_paints(&self) -> Vec { - let drop_shadows: Vec<&Shadow> = self.drop_shadows_visible().collect(); - - drop_shadows - .into_iter() - .map(|shadow| { - let mut paint = skia_safe::Paint::default(); - let filter = shadow.get_drop_shadow_filter(); - paint.set_image_filter(filter); - paint - }) - .collect() - } - pub fn inner_shadow_paints(&self) -> Vec { let inner_shadows: Vec<&Shadow> = self.inner_shadows_visible().collect(); diff --git a/render-wasm/src/shapes/shadows.rs b/render-wasm/src/shapes/shadows.rs index b13c6d76bf..0a11dfdceb 100644 --- a/render-wasm/src/shapes/shadows.rs +++ b/render-wasm/src/shapes/shadows.rs @@ -87,25 +87,30 @@ impl Shadow { } pub fn get_drop_shadow_filter(&self) -> Option { + let filter = self.get_drop_shadow_blur_filter()?; + + if self.spread > 0. { + return image_filters::dilate((self.spread, self.spread), filter, None); + } + + Some(filter) + } + + /// [`Self::get_drop_shadow_filter`] without the spread dilate. + pub fn get_drop_shadow_blur_filter(&self) -> Option { if self.style == ShadowStyle::Inner { return None; } let sigma = radius_to_sigma(self.blur); - let mut filter = image_filters::drop_shadow_only( + image_filters::drop_shadow_only( (self.offset.0, self.offset.1), (sigma, sigma), self.color, None, None, None, - ); - - if self.spread > 0. { - filter = image_filters::dilate((self.spread, self.spread), filter, None); - } - - filter + ) } /// Same shadow as [`Self::get_drop_shadow_filter`], for a `merge` input.