From 6231abf2ea309b9267499ed49db6b31db951661e Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 3 Aug 2026 09:33:13 +0200 Subject: [PATCH] :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. --- render-wasm/src/render.rs | 119 +++++++++++++++++++++--------- render-wasm/src/shapes/shadows.rs | 79 ++++++++++++++++++++ 2 files changed, 165 insertions(+), 33 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 5574816b3a..362b57f3a1 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1229,11 +1229,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 +1342,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. @@ -1661,10 +1667,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 +1709,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 @@ -2638,6 +2661,19 @@ impl RenderState { true } + /// Skip all drop 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] + 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 +3221,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 +3239,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 +3337,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 +3592,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 +3623,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/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)); + } +}