diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 0cc4dec336..b120658a5b 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -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? diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 35e0e78721..4670272b88 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -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, 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 = 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); @@ -1005,6 +1025,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 = 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() @@ -1057,7 +1095,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(); + } } }; @@ -1149,6 +1192,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); @@ -1156,6 +1204,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); @@ -1326,11 +1376,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); + } } } @@ -1517,9 +1572,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 { @@ -1602,13 +1655,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); @@ -1633,7 +1700,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); @@ -1720,6 +1788,7 @@ impl RenderState { if shadow_shape.hidden { continue; } + let nested_clip_bounds = node_render_state.get_nested_shadow_clip_bounds(element, shadow); @@ -1780,7 +1849,6 @@ impl RenderState { self.surfaces .canvas(SurfaceId::DropShadows) .draw_paint(&paint); - self.surfaces.canvas(SurfaceId::DropShadows).restore(); } diff --git a/render-wasm/src/render/filters.rs b/render-wasm/src/render/filters.rs index 557f92bb75..149c598e94 100644 --- a/render-wasm/src/render/filters.rs +++ b/render-wasm/src/render/filters.rs @@ -40,7 +40,9 @@ pub fn render_with_filter_surface( 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( 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);