⚡ Fix slow drop shadow spread in CPU exports (#12718)

* ⚡ Fix slow drop shadow spread in CPU exports

* 🐛 Fix text opacity in board drop shadows on CPU exports
This commit is contained in:
Elena Torró 2026-10-29 18:27:19 +01:00 committed by GitHub
parent cb8f563bb2
commit b48d163f5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 160 additions and 91 deletions

View File

@ -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 `<filter>` wrapper instead.
/// a native SVG `<filter>` wrapper instead. Also `None` inside a layer
/// that already blurs, or the shape would blur twice.
fn layer_blur_filter(&self, shape: &Shape) -> Option<skia::ImageFilter> {
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<f32>,
}
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)?;

View File

@ -2187,20 +2187,6 @@ impl Shape {
true
}
pub fn drop_shadow_paints(&self) -> Vec<skia_safe::Paint> {
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<skia_safe::Paint> {
let inner_shadows: Vec<&Shadow> = self.inner_shadows_visible().collect();

View File

@ -87,25 +87,30 @@ impl Shadow {
}
pub fn get_drop_shadow_filter(&self) -> Option<ImageFilter> {
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<ImageFilter> {
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.