🔧 Improve performance on shapes with blur

This commit is contained in:
Elena Torro
2026-02-17 10:50:47 +01:00
parent 7ef16a2b69
commit 337cfc2d3e
3 changed files with 108 additions and 19 deletions

View File

@@ -224,8 +224,9 @@
show-gradient-handlers? (= (count selected) 1)
show-grids? (contains? layout :display-guides)
show-frame-outline? (= transform :move)
show-frame-outline? (and (= transform :move) (not panning))
show-outlines? (and (nil? transform)
(not panning)
(not edition)
(not drawing-obj)
(not (#{:comments :path :curve} drawing-tool)))
@@ -561,7 +562,7 @@
:shift? @shift?}])
[:> widgets/frame-titles*
{:objects (with-meta objects-modified nil)
{:objects objects-modified
:selected selected
:zoom zoom
:is-show-artboard-names show-artboard-names?

View File

@@ -39,6 +39,7 @@ pub use images::*;
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3;
const MAX_BLOCKING_TIME_MS: i32 = 32;
const NODE_BATCH_THRESHOLD: i32 = 3;
const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0;
type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>;
@@ -787,6 +788,25 @@ impl RenderState {
shape.to_mut().set_blur(None);
}
// For non-text, non-SVG shapes in the normal rendering path, apply blur
// via a single save_layer on each render surface
// Clip correctness is preserved
let blur_sigma_for_layers: Option<f32> = if !fast_mode
&& apply_to_current_surface
&& fills_surface_id == SurfaceId::Fills
&& !matches!(shape.shape_type, Type::Text(_))
&& !matches!(shape.shape_type, Type::SVGRaw(_))
{
if let Some(blur) = shape.blur.filter(|b| !b.hidden) {
shape.to_mut().set_blur(None);
Some(blur.value)
} else {
None
}
} else {
None
};
let center = shape.center();
let mut matrix = shape.transform;
matrix.post_translate(center);
@@ -1001,6 +1021,24 @@ impl RenderState {
s.canvas().concat(&matrix);
});
// Wrap ALL fill/stroke/shadow rendering so a single GPU blur pass calls
let blur_filter_for_layers: Option<skia::ImageFilter> = blur_sigma_for_layers
.and_then(|sigma| skia::image_filters::blur((sigma, sigma), None, None, None));
if let Some(ref filter) = blur_filter_for_layers {
let mut layer_paint = skia::Paint::default();
layer_paint.set_image_filter(filter.clone());
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&layer_paint);
self.surfaces
.canvas(fills_surface_id)
.save_layer(&layer_rec);
self.surfaces
.canvas(strokes_surface_id)
.save_layer(&layer_rec);
self.surfaces
.canvas(innershadows_surface_id)
.save_layer(&layer_rec);
}
let shape = &shape;
if shape.fills.is_empty()
@@ -1053,7 +1091,12 @@ impl RenderState {
innershadows_surface_id,
);
}
// bools::debug_render_bool_paths(self, shape, shapes, modifiers, structure);
if blur_filter_for_layers.is_some() {
self.surfaces.canvas(innershadows_surface_id).restore();
self.surfaces.canvas(strokes_surface_id).restore();
self.surfaces.canvas(fills_surface_id).restore();
}
}
};
@@ -1145,6 +1188,11 @@ impl RenderState {
let _start = performance::begin_timed_log!("render_preview");
performance::begin_measure!("render_preview");
// Enable fast_mode during preview to skip expensive effects (blur, shadows).
// Restore the previous state afterward so the final render is full quality.
let current_fast_mode = self.options.is_fast_mode();
self.options.set_fast_mode(true);
// Skip tile rebuilding during preview - we'll do it at the end
// Just rebuild tiles for touched shapes and render synchronously
self.rebuild_touched_tiles(tree);
@@ -1152,6 +1200,8 @@ impl RenderState {
// Use the sync render path
self.start_render_loop(None, tree, timestamp, true)?;
self.options.set_fast_mode(current_fast_mode);
performance::end_measure!("render_preview");
performance::end_timed_log!("render_preview", _start);
@@ -1322,11 +1372,16 @@ impl RenderState {
paint.set_blend_mode(element.blend_mode().into());
paint.set_alpha_f(element.opacity());
if let Some(frame_blur) = Self::frame_clip_layer_blur(element) {
let scale = self.get_scale();
let sigma = frame_blur.value * scale;
if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) {
paint.set_image_filter(filter);
// Skip frame-level blur in fast mode (pan/zoom)
if !self.options.is_fast_mode() {
if let Some(frame_blur) = Self::frame_clip_layer_blur(element) {
let scale = self.get_scale();
let sigma = frame_blur.value * scale;
if let Some(filter) =
skia::image_filters::blur((sigma, sigma), None, None, None)
{
paint.set_image_filter(filter);
}
}
}
@@ -1513,9 +1568,7 @@ impl RenderState {
Self::combine_blur_values(self.combined_layer_blur(shape.blur), extra_layer_blur);
let blur_filter = combined_blur
.and_then(|blur| skia::image_filters::blur((blur.value, blur.value), None, None, None));
// Legacy path is only stable up to 1.0 zoom: the canvas is scaled and the shadow
// filter is evaluated in that scaled space, so for scale > 1 it over-inflates blur/spread.
// We also disable it when combined layer blur is present to avoid incorrect composition.
let use_low_zoom_path = scale <= 1.0 && combined_blur.is_none();
if use_low_zoom_path {
@@ -1598,13 +1651,27 @@ impl RenderState {
return;
}
let filter_result =
filters::render_into_filter_surface(self, bounds, |state, temp_surface| {
// Adaptive downscale for large blur values (lossless GPU optimization).
// Bounds above were computed from the original sigma so filter surface coverage is correct.
// Maximum downscale is 1/BLUR_DOWNSCALE_THRESHOLD (i.e. 8×): beyond that the
// filter surface becomes too small and quality degrades noticeably.
const MIN_BLUR_DOWNSCALE: f32 = 1.0 / BLUR_DOWNSCALE_THRESHOLD;
let blur_downscale = if shadow.blur > BLUR_DOWNSCALE_THRESHOLD {
(BLUR_DOWNSCALE_THRESHOLD / shadow.blur).max(MIN_BLUR_DOWNSCALE)
} else {
1.0
};
let filter_result = filters::render_into_filter_surface(
self,
bounds,
blur_downscale,
|state, temp_surface| {
{
let canvas = state.surfaces.canvas(temp_surface);
let mut shadow_paint = skia::Paint::default();
shadow_paint.set_image_filter(drop_filter.clone());
shadow_paint.set_image_filter(drop_filter);
shadow_paint.set_blend_mode(skia::BlendMode::SrcOver);
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&shadow_paint);
@@ -1629,7 +1696,8 @@ impl RenderState {
let canvas = state.surfaces.canvas(temp_surface);
canvas.restore();
}
});
},
);
if let Some((mut surface, filter_scale)) = filter_result {
let drop_canvas = self.surfaces.canvas(SurfaceId::DropShadows);
@@ -1716,6 +1784,7 @@ impl RenderState {
if shadow_shape.hidden {
continue;
}
let nested_clip_bounds =
node_render_state.get_nested_shadow_clip_bounds(element, shadow);
@@ -1776,7 +1845,6 @@ impl RenderState {
self.surfaces
.canvas(SurfaceId::DropShadows)
.draw_paint(&paint);
self.surfaces.canvas(SurfaceId::DropShadows).restore();
}

View File

@@ -40,7 +40,9 @@ pub fn render_with_filter_surface<F>(
where
F: FnOnce(&mut RenderState, SurfaceId),
{
if let Some((mut surface, scale)) = render_into_filter_surface(render_state, bounds, draw_fn) {
if let Some((mut surface, scale)) =
render_into_filter_surface(render_state, bounds, 1.0, draw_fn)
{
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
// If we scaled down, we need to scale the source rect and adjust the destination
@@ -69,9 +71,15 @@ where
/// down so that everything fits; the returned `scale` tells the caller how much the
/// content was reduced so it can be re-scaled on compositing. The `draw_fn` should
/// render the untransformed shape (i.e. in document coordinates) onto `SurfaceId::Filter`.
///
/// `extra_downscale` is an additional scale factor applied on top of the overflow-fit scale.
/// Use values < 1.0 to pre-downscale before applying Gaussian blur filters, which dramatically
/// reduces GPU kernel work for large blur sigmas (Gaussian blur is scale-equivariant, so the
/// caller must also reduce the sigma proportionally). Pass 1.0 for no extra downscale.
pub fn render_into_filter_surface<F>(
render_state: &mut RenderState,
bounds: Rect,
extra_downscale: f32,
draw_fn: F,
) -> Option<(skia::Surface, f32)>
where
@@ -86,16 +94,28 @@ where
let bounds_width = bounds.width().ceil().max(1.0) as i32;
let bounds_height = bounds.height().ceil().max(1.0) as i32;
// Minimum scale floor for fit_scale alone; prevents extreme downscaling when
// the shape is much larger than the filter surface.
const MIN_FIT_SCALE: f32 = 0.1;
// Absolute minimum for the combined scale (fit × extra_downscale). Below this
// the offscreen surface would have sub-pixel dimensions and produce artifacts or
// crashes. At 0.03 a shape must be at least ~34 px wide to render as a single
// pixel, which is a safe lower bound in practice.
const MIN_COMBINED_SCALE: f32 = 0.03;
// Calculate scale factor if bounds exceed filter surface size
let scale = if bounds_width > filter_width || bounds_height > filter_height {
let fit_scale = if bounds_width > filter_width || bounds_height > filter_height {
let scale_x = filter_width as f32 / bounds_width as f32;
let scale_y = filter_height as f32 / bounds_height as f32;
// Use the smaller scale to ensure everything fits
scale_x.min(scale_y).max(0.1) // Clamp to minimum 0.1 to avoid extreme scaling
scale_x.min(scale_y).max(MIN_FIT_SCALE)
} else {
1.0
};
// Combine overflow-fit scale with caller-requested extra downscale
let scale = (fit_scale * extra_downscale).max(MIN_COMBINED_SCALE);
{
let canvas = render_state.surfaces.canvas(filter_id);
canvas.clear(skia::Color::TRANSPARENT);