mirror of
https://github.com/penpot/penpot.git
synced 2026-08-06 12:58:55 +00:00
⚡ 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.
This commit is contained in:
parent
3b98fbf70a
commit
dc95effd7d
@ -1405,11 +1405,16 @@ impl RenderState {
|
||||
}
|
||||
|
||||
fn get_inherited_drop_shadows(&self) -> Option<Vec<skia_safe::Paint>> {
|
||||
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() {
|
||||
@ -1504,6 +1509,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.
|
||||
@ -1875,10 +1881,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();
|
||||
@ -1902,32 +1923,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
|
||||
@ -2972,6 +2995,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
|
||||
}
|
||||
|
||||
/// Push pending GPU work without waiting so Partial `flush_and_submit`
|
||||
/// does not absorb an entire paint-region's worth of ops in one spike.
|
||||
#[inline]
|
||||
@ -3547,7 +3583,11 @@ impl RenderState {
|
||||
// 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() {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -3557,7 +3597,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
|
||||
@ -3649,6 +3695,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);
|
||||
@ -3900,10 +3950,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 {
|
||||
let t_shadow = if self.zoom_perf_active {
|
||||
@ -3954,8 +4006,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();
|
||||
|
||||
@ -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<ImageFilter> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user