From a76401596e4ac13ca8d6f9048ecb7ea87b26d1f6 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 6 Aug 2026 15:58:11 +0200 Subject: [PATCH] :zap: Skip imperceptible shadows and simplify low-scale strokes (#11102) * :zap: Skip drop shadows that are imperceptible at current scale Filter drop shadows by on-screen footprint (stricter for recursive shapes) so overview HQ avoids expensive blur passes that barely show. * :zap: Simplify Path and Bool strokes at low scale At overview zooms, Inner/Outer strokes fall back to Center and dash/dotted styles become solid when the pattern is subpixel. Strokes are never skipped so stroke-only icons stay visible. * :zap: Drain GPU work on partial render frames Partial frames only flushed the Backbuffer, so tile GPU commands queued until present_frame's flush_and_submit and stalled the browser on large files. Submit the context each partial frame without presenting Target or re-composing the tile atlas. * :zap: Prefer direct painting when effects are imperceptible Skip the Fills/Strokes layered path when drop/inner shadows would not paint at the current scale, and allow stroke-only shapes (fills_none) on the direct path. Apply the same footprint LOD to inner-shadow painting. --- render-wasm/src/render.rs | 153 +++++++++++++++++++++--------- render-wasm/src/render/shadows.rs | 42 +++++--- render-wasm/src/render/strokes.rs | 14 ++- render-wasm/src/shapes/shadows.rs | 79 +++++++++++++++ render-wasm/src/shapes/strokes.rs | 108 +++++++++++++++++++++ 5 files changed, 333 insertions(+), 63 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 5574816b3a..5403eb98b6 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -935,10 +935,6 @@ impl RenderState { Ok(()) } - pub fn flush(&mut self) { - self.surfaces.flush(SurfaceId::Backbuffer); - } - pub fn flush_and_submit(&mut self) { self.surfaces.flush_and_submit(SurfaceId::Target); } @@ -1229,11 +1225,16 @@ impl RenderState { } fn get_inherited_drop_shadows(&self) -> Option> { + let scale = self.get_scale(); let drop_shadows: Vec<&Shadow> = self .nested_shadows .iter() .flat_map(|shadows| shadows.iter()) - .filter(|shadow| !shadow.hidden() && shadow.style() == crate::shapes::ShadowStyle::Drop) + .filter(|shadow| { + !shadow.hidden() + && shadow.style() == crate::shapes::ShadowStyle::Drop + && shadow.is_perceptible_at_scale(scale) + }) .collect(); if drop_shadows.is_empty() { @@ -1337,6 +1338,7 @@ impl RenderState { | text_drop_shadows_surface_id as u32; let fast_mode = self.options.is_fast_mode(); + let skip_drop_shadows = self.should_skip_drop_shadows(); // Skip anti-aliasing entirely during fast_mode (interactive // gestures + pan/zoom). AA edge sampling is per-pixel and adds // up across many shapes; reverts to full quality on commit. @@ -1367,11 +1369,25 @@ impl RenderState { return Ok(()); } + // Only perceptible shadows need the layered Fills/Strokes path. Use the + // same footprint LOD as when painting drop and inner shadows. + let scale = self.get_scale(); + let shadows_need_layered = !skip_drop_shadows + && (shape + .drop_shadows_visible() + .any(|s| s.is_perceptible_at_scale_for(scale, shape.is_recursive())) + || shape + .inner_shadows_visible() + .any(|s| s.is_perceptible_at_scale_for(scale, shape.is_recursive()))); + // Clip is allowed: we apply the same stack on Current after scale+translate. // Opacity < 1 with SrcOver is OK: render_shape_enter already opened a // save_layer on Current; painting fills/strokes into that layer matches // the layered path without Fills/Strokes blits. // Non-SrcOver blend, frame clip blur, and masked groups stay layered. + // Stroke-only (fills_none) can go direct: empty fills are a no-op and + // strokes paint into Current. Requires Partial GPU drain (dc1ab) so + // large SVG-icon files do not backlog commands until Full present. let can_render_directly = apply_to_current_surface && offset.is_none() && parent_shadows.is_none() @@ -1381,20 +1397,15 @@ impl RenderState { && shape.blur.is_none() && shape.background_blur.is_none() && !has_inherited_blur - && shape.shadows.is_empty() + && !shadows_need_layered && matches!( shape.shape_type, Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) | Type::Frame(_) ) && !(shape.fills.is_empty() && has_nested_fills) - && !shape - .svg_attrs - .as_ref() - .is_some_and(|attrs| attrs.fill_none) && target_surface != SurfaceId::Export; if can_render_directly { - let scale = self.get_scale(); let translation = self .surfaces .get_render_context_translation(self.render_area, scale); @@ -1661,10 +1672,25 @@ impl RenderState { ); } } else { - let mut drop_shadows = shape.drop_shadow_paints(); + let shape_scale = self.get_scale(); + let mut drop_shadows = if skip_drop_shadows { + Vec::new() + } else { + shape + .drop_shadows_visible() + .filter(|s| s.is_perceptible_at_scale(shape_scale)) + .map(|shadow| { + let mut paint = skia_safe::Paint::default(); + paint.set_image_filter(shadow.get_drop_shadow_filter()); + paint + }) + .collect() + }; - if let Some(inherited_shadows) = self.get_inherited_drop_shadows() { - drop_shadows.extend(inherited_shadows); + if !skip_drop_shadows { + if let Some(inherited_shadows) = self.get_inherited_drop_shadows() { + drop_shadows.extend(inherited_shadows); + } } let inner_shadows = shape.inner_shadow_paints(); @@ -1688,32 +1714,34 @@ impl RenderState { .unzip(); if let Some(parent_shadows) = parent_shadows { - if !shape.has_visible_strokes() { - for shadow in parent_shadows { - text::render( - Some(self), - None, + if !skip_drop_shadows { + if !shape.has_visible_strokes() { + for shadow in parent_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + text_drop_shadows_surface_id.into(), + Some(&shadow), + blur_filter.as_ref(), + None, + None, + )?; + } + } else { + shadows::render_text_shadows( + self, &shape, &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, text_drop_shadows_surface_id.into(), - Some(&shadow), - blur_filter.as_ref(), - None, - None, + &parent_shadows, + &blur_filter, + &stroke_kinds, + text_content, )?; } - } else { - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - text_drop_shadows_surface_id.into(), - &parent_shadows, - &blur_filter, - &stroke_kinds, - text_content, - )?; } } else { // 1. Text drop shadows @@ -2433,9 +2461,12 @@ impl RenderState { panic!("FrameType::None"); } FrameType::Partial => { - // Partial frame: just flush GPU work. The display shows the last - // fully submitted frame; no need to copy or draw UI overlays here. - self.flush(); + // Drain tile GPU work (Current / tile atlas / cache) without + // presenting Target and without re-snapshotting the tile atlas — + // composition stays deferred to Full. A Backbuffer flush alone + // left commands queued until present_frame's flush_and_submit, + // which stalled the browser on large files. + crate::get_gpu_state().context.flush_and_submit(); } FrameType::Full => { // A full-quality frame is now complete. Rebuild the per-shape crop @@ -2638,6 +2669,19 @@ impl RenderState { true } + /// Skip all drop/inner shadows in fast mode, or when even a large design-space + /// shadow would be subpixel. Otherwise filter per shadow via + /// [`Shadow::is_perceptible_at_scale_for`] (stricter for recursive shapes). + #[inline] + pub(crate) fn should_skip_drop_shadows(&self) -> bool { + if self.options.is_fast_mode() { + return true; + } + let scale = self.get_scale(); + scale * crate::shapes::DROP_SHADOW_LARGE_DESIGN_PX + < crate::shapes::DROP_SHADOW_MIN_DEVICE_PX + } + #[inline] fn clip_target_surface_to_stack( &mut self, @@ -3185,10 +3229,15 @@ impl RenderState { node_render_state: &NodeRenderState, target_surface: SurfaceId, ) -> Result { - // Avoid a blank DropShadows→Current blit + clear on every shape without - // shadows. Callers must still touch DropShadows once per tile when this - // returns false (see `drop_shadows_ops_warmed`). - if element.drop_shadows_visible().next().is_none() { + // Avoid a blank DropShadows→Current blit + clear when nothing will paint + // (no shadows, fast/overview skip, or all footprints subpixel). Callers + // must still touch DropShadows once per tile when this returns false + // (see `drop_shadows_ops_warmed`). + if self.should_skip_drop_shadows() + || !element + .drop_shadows_visible() + .any(|s| s.is_perceptible_at_scale_for(scale, element.is_recursive())) + { return Ok(false); } @@ -3198,7 +3247,13 @@ impl RenderState { _ => None, }; + let recursive = element.is_recursive(); + let mut rendered_any = false; for shadow in element.drop_shadows_visible() { + if !shadow.is_perceptible_at_scale_for(scale, recursive) { + continue; + } + rendered_any = true; let paint = skia::Paint::default(); let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); self.surfaces @@ -3290,6 +3345,10 @@ impl RenderState { self.surfaces.canvas(SurfaceId::DropShadows).restore(); } + if !rendered_any { + return Ok(false); + } + if let Some(clips) = clip_bounds.as_ref() { let antialias = !self.options.is_fast_mode() && element.should_use_antialias(scale, self.options.antialias_threshold); @@ -3541,10 +3600,12 @@ impl RenderState { // the layer blur (which would make it more diffused than without clipping) let shadow_before_layer = !node_render_state.is_root() && self.focus_mode.is_active() - && !self.options.is_fast_mode() + && !self.should_skip_drop_shadows() && !matches!(element.shape_type, Type::Text(_)) && Self::frame_clip_layer_blur(element).is_some() - && element.drop_shadows_visible().next().is_some(); + && element + .drop_shadows_visible() + .any(|s| s.is_perceptible_at_scale_for(scale, element.is_recursive())); if shadow_before_layer && self.render_element_drop_shadows_and_composite( @@ -3570,8 +3631,8 @@ impl RenderState { } if !node_render_state.is_root() && self.focus_mode.is_active() { - // Skip expensive drop shadow rendering in fast mode (during pan/zoom). - let skip_shadows = self.options.is_fast_mode(); + // Skip expensive drop shadows in fast mode and at overview zooms. + let skip_shadows = self.should_skip_drop_shadows(); // Skip shadow block when already rendered before the layer (frame_clip_layer_blur) let shadows_already_rendered = Self::frame_clip_layer_blur(element).is_some(); diff --git a/render-wasm/src/render/shadows.rs b/render-wasm/src/render/shadows.rs index c3de172ad8..c6baadc0b3 100644 --- a/render-wasm/src/render/shadows.rs +++ b/render-wasm/src/render/shadows.rs @@ -13,10 +13,16 @@ pub fn render_fill_inner_shadows( antialias: bool, surface_id: SurfaceId, ) { - if shape.has_fills() { - for shadow in shape.inner_shadows_visible() { - render_fill_inner_shadow(render_state, shape, shadow, antialias, surface_id); + if !shape.has_fills() || render_state.should_skip_drop_shadows() { + return; + } + let scale = render_state.get_scale(); + let recursive = shape.is_recursive(); + for shadow in shape.inner_shadows_visible() { + if !shadow.is_perceptible_at_scale_for(scale, recursive) { + continue; } + render_fill_inner_shadow(render_state, shape, shadow, antialias, surface_id); } } @@ -38,19 +44,25 @@ pub fn render_stroke_inner_shadows( antialias: bool, surface_id: SurfaceId, ) -> Result<()> { - if !shape.has_fills() { - for shadow in shape.inner_shadows_visible() { - let filter = shadow.get_inner_shadow_filter(); - strokes::render_single( - render_state, - shape, - stroke, - Some(surface_id), - filter.as_ref(), - antialias, - None, // Inner shadows don't use spread - )?; + if shape.has_fills() || render_state.should_skip_drop_shadows() { + return Ok(()); + } + let scale = render_state.get_scale(); + let recursive = shape.is_recursive(); + for shadow in shape.inner_shadows_visible() { + if !shadow.is_perceptible_at_scale_for(scale, recursive) { + continue; } + let filter = shadow.get_inner_shadow_filter(); + strokes::render_single( + render_state, + shape, + stroke, + Some(surface_id), + filter.as_ref(), + antialias, + None, // Inner shadows don't use spread + )?; } Ok(()) } diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index 5e5901d53a..b4ac78eb05 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -561,6 +561,13 @@ fn draw_image_stroke_in_container( surface_id: SurfaceId, ) -> Result<()> { let scale = render_state.get_scale(); + let lod_stroke; + let stroke = if matches!(shape.shape_type, Type::Path(_) | Type::Bool(_)) { + lod_stroke = stroke.path_lod_at_scale(shape.is_open(), scale); + &lod_stroke + } else { + stroke + }; let Some(image) = get_resources().images.get(&image_fill.id()) else { return Ok(()); }; @@ -938,12 +945,13 @@ fn render_merged( shape_type @ (Type::Path(_) | Type::Bool(_)) => { if let Some(path) = shape_type.path() { let is_open = path.is_open(); + let lod_stroke = representative.path_lod_at_scale(is_open, scale); let mut paint = - representative.to_stroked_paint(is_open, &selrect, svg_attrs, antialias); + lod_stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias); paint.set_shader(merged.shader()); draw_stroke_on_path( canvas, - representative, + &lod_stroke, path, &paint, path_transform.as_ref(), @@ -1097,6 +1105,8 @@ fn render_single_internal( shape_type @ (Type::Path(_) | Type::Bool(_)) => { if let Some(path) = shape_type.path() { let is_open = path.is_open(); + let lod_stroke = stroke.path_lod_at_scale(is_open, scale); + let stroke = &lod_stroke; let mut paint = stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias); // Apply outset by increasing stroke width diff --git a/render-wasm/src/shapes/shadows.rs b/render-wasm/src/shapes/shadows.rs index 6cfa912659..6f446e3223 100644 --- a/render-wasm/src/shapes/shadows.rs +++ b/render-wasm/src/shapes/shadows.rs @@ -4,6 +4,18 @@ use super::blurs::radius_to_sigma; use super::Color; use crate::render::filters::compose_filters; +/// Soft visibility floor in device pixels for leaf shapes. Below this, a drop +/// shadow is visual noise relative to its blur cost. +pub const DROP_SHADOW_MIN_DEVICE_PX: f32 = 2.0; + +/// Recursive shapes (frames/groups) redraw children into the shadow layer; they +/// need a clearer on-screen footprint before that cost is worthwhile. +pub const DROP_SHADOW_RECURSIVE_MIN_DEVICE_PX: f32 = 4.0; + +/// Generous design-space shadow budget used with [`DROP_SHADOW_MIN_DEVICE_PX`] +/// for a hard global early-out (subpixel even for huge shadows). +pub const DROP_SHADOW_LARGE_DESIGN_PX: f32 = 64.0; + #[derive(Debug, Default, Clone, Copy, PartialEq)] pub enum ShadowStyle { #[default] @@ -48,6 +60,32 @@ impl Shadow { self.hidden } + /// Approximate on-screen footprint (blur/spread + offset) at `scale` (zoom×dpr). + #[inline] + pub fn device_extent(&self, scale: f32) -> f32 { + let soft = self.blur.max(self.spread); + let offset = self.offset.0.abs().max(self.offset.1.abs()); + (soft + offset) * scale + } + + /// True when this shadow still has a perceptible footprint at `scale`. + /// Recursive shapes use a higher floor because compositing children into + /// the shadow layer is far more expensive than a leaf silhouette. + #[inline] + pub fn is_perceptible_at_scale(&self, scale: f32) -> bool { + self.is_perceptible_at_scale_for(scale, false) + } + + #[inline] + pub fn is_perceptible_at_scale_for(&self, scale: f32, recursive: bool) -> bool { + let min = if recursive { + DROP_SHADOW_RECURSIVE_MIN_DEVICE_PX + } else { + DROP_SHADOW_MIN_DEVICE_PX + }; + self.device_extent(scale) >= min + } + pub fn get_drop_shadow_filter(&self) -> Option { let sigma = radius_to_sigma(self.blur); let mut filter = image_filters::drop_shadow_only( @@ -112,3 +150,44 @@ impl Shadow { self.offset.1 *= value; } } + +#[cfg(test)] +mod tests { + use super::*; + + fn shadow(blur: f32, spread: f32, ox: f32, oy: f32) -> Shadow { + Shadow::new( + skia::Color::BLACK, + blur, + spread, + (ox, oy), + ShadowStyle::Drop, + false, + ) + } + + #[test] + fn leaf_floor_at_moderate_zoom() { + // blur 16 @ 0.13 ≈ 2.08px → keep leaf + assert!(shadow(16.0, 0.0, 0.0, 0.0).is_perceptible_at_scale_for(0.13, false)); + // blur 8 @ 0.13 ≈ 1.04px → skip leaf (below 2px) + assert!(!shadow(8.0, 0.0, 0.0, 0.0).is_perceptible_at_scale_for(0.13, false)); + } + + #[test] + fn recursive_floor_is_stricter() { + // blur 24 @ 0.13 ≈ 3.12px → keep leaf, skip recursive (needs 4px) + let s = shadow(24.0, 0.0, 0.0, 0.0); + assert!(s.is_perceptible_at_scale_for(0.13, false)); + assert!(!s.is_perceptible_at_scale_for(0.13, true)); + // blur 32 @ 0.13 ≈ 4.16px → keep recursive + assert!(shadow(32.0, 0.0, 0.0, 0.0).is_perceptible_at_scale_for(0.13, true)); + } + + #[test] + fn overview_scale_vs_extent() { + // At 0.038 even blur 50 is only ~1.9px — below leaf floor. + assert!(!shadow(50.0, 0.0, 0.0, 0.0).is_perceptible_at_scale_for(0.038, false)); + assert!(shadow(60.0, 0.0, 0.0, 0.0).is_perceptible_at_scale_for(0.038, false)); + } +} diff --git a/render-wasm/src/shapes/strokes.rs b/render-wasm/src/shapes/strokes.rs index f774a70457..2e80abafe0 100644 --- a/render-wasm/src/shapes/strokes.rs +++ b/render-wasm/src/shapes/strokes.rs @@ -7,6 +7,14 @@ use super::StrokeLineCap; use super::StrokeLineJoin; use super::SvgAttrs; +/// Soft floor in device pixels for dropping dash/dotted PathEffects when the +/// pattern period is effectively invisible. +pub const STROKE_MIN_DEVICE_PX: f32 = 0.75; + +/// When Inner/Outer doubled-width footprint is below this (device px), paint +/// as Center to avoid save_layer / Clear paths. +pub const STROKE_INNER_OUTER_SIMPLIFY_DEVICE_PX: f32 = 2.0; + #[derive(Debug, Clone, PartialEq, Copy)] pub enum StrokeStyle { Solid, @@ -69,6 +77,62 @@ impl Stroke { } } + /// Inner/Outer use a doubled-width Center stroke plus clip/clear. When that + /// footprint is thin on screen, fall back to a plain Center stroke. + #[inline] + pub fn simplified_kind_at_scale(&self, is_open: bool, scale: f32) -> StrokeKind { + let kind = self.render_kind(is_open); + match kind { + StrokeKind::Inner | StrokeKind::Outer + if 2.0 * self.max_width() * scale < STROKE_INNER_OUTER_SIMPLIFY_DEVICE_PX => + { + StrokeKind::Center + } + other => other, + } + } + + /// Drop dash/dotted PathEffects when the pattern period is subpixel. + #[inline] + pub fn style_at_scale(&self, scale: f32) -> StrokeStyle { + if self.style == StrokeStyle::Solid { + return StrokeStyle::Solid; + } + let period = match self.style { + StrokeStyle::Dotted => self.width + 5.0, + StrokeStyle::Dashed => { + let dash = self.dash.unwrap_or(self.width + 10.); + let gap = self.gap.unwrap_or(self.width + 10.); + dash.min(gap) + } + StrokeStyle::Mixed => self.width + 1.0, + StrokeStyle::Solid => return StrokeStyle::Solid, + }; + if period * scale < STROKE_MIN_DEVICE_PX { + StrokeStyle::Solid + } else { + self.style + } + } + + /// Path/Bool overview LOD: simplify Inner/Outer and dash/dotted at low + /// scale. Never skips painting; stroke-only icons would otherwise go blank. + pub fn path_lod_at_scale(&self, is_open: bool, scale: f32) -> Stroke { + let kind = self.simplified_kind_at_scale(is_open, scale); + let style = self.style_at_scale(scale); + let kind_unchanged = kind == self.render_kind(is_open); + let style_unchanged = style == self.style; + if kind_unchanged && style_unchanged { + return self.clone(); + } + let mut stroke = self.clone(); + if !is_open { + stroke.kind = kind; + } + stroke.style = style; + stroke + } + /// Per-side widths [top, right, bottom, left] when they actually differ. /// Returns `None` when unset or when all sides are equal, so the uniform /// render path (which supports dashed/dotted styles) keeps handling that @@ -493,4 +557,48 @@ mod tests { assert_eq!(stroke.widths, Some([2.0, 4.0, 6.0, 8.0])); assert_eq!(stroke.width, 4.0); } + + fn solid_center(width: f32) -> Stroke { + Stroke::new_center_stroke(width, StrokeStyle::Solid, None, None, None, None) + } + + #[test] + fn inner_outer_simplify_to_center_when_thin() { + let inner = Stroke::new_inner_stroke(8.0, StrokeStyle::Solid, None, None, None, None); + // 2 * 8 * 0.1 = 1.6 < 2.0, simplify to Center + assert_eq!( + inner.simplified_kind_at_scale(false, 0.1), + StrokeKind::Center + ); + // 2 * 8 * 0.2 = 3.2 >= 2.0, keep Inner + assert_eq!( + inner.simplified_kind_at_scale(false, 0.2), + StrokeKind::Inner + ); + } + + #[test] + fn dash_becomes_solid_when_period_subpixel() { + let dashed = + Stroke::new_center_stroke(2.0, StrokeStyle::Dashed, None, None, Some(20.0), Some(20.0)); + // period 20 * 0.03 = 0.6 < 0.75, solid + assert_eq!(dashed.style_at_scale(0.03), StrokeStyle::Solid); + // period 20 * 0.05 = 1.0 >= 0.75, keep dashed + assert_eq!(dashed.style_at_scale(0.05), StrokeStyle::Dashed); + } + + #[test] + fn path_lod_never_drops_thin_stroke() { + // Hairline strokes must still paint (stroke-only icons). + let thin = solid_center(1.0).path_lod_at_scale(false, 0.5); + assert_eq!(thin.width, 1.0); + assert_eq!(thin.kind, StrokeKind::Center); + } + + #[test] + fn path_lod_simplifies_inner_at_overview() { + let inner = Stroke::new_inner_stroke(8.0, StrokeStyle::Solid, None, None, None, None); + let lod = inner.path_lod_at_scale(false, 0.1); + assert_eq!(lod.kind, StrokeKind::Center); + } }