mirror of
https://github.com/penpot/penpot.git
synced 2026-08-18 02:39:02 +00:00
⚡ Skip imperceptible shadows and simplify low-scale strokes (#11102)
* ⚡ 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. * ⚡ 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. * ⚡ 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. * ⚡ 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.
This commit is contained in:
parent
de8d8ca401
commit
a76401596e
@ -935,10 +935,6 @@ impl RenderState {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn flush(&mut self) {
|
|
||||||
self.surfaces.flush(SurfaceId::Backbuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn flush_and_submit(&mut self) {
|
pub fn flush_and_submit(&mut self) {
|
||||||
self.surfaces.flush_and_submit(SurfaceId::Target);
|
self.surfaces.flush_and_submit(SurfaceId::Target);
|
||||||
}
|
}
|
||||||
@ -1229,11 +1225,16 @@ impl RenderState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn get_inherited_drop_shadows(&self) -> Option<Vec<skia_safe::Paint>> {
|
fn get_inherited_drop_shadows(&self) -> Option<Vec<skia_safe::Paint>> {
|
||||||
|
let scale = self.get_scale();
|
||||||
let drop_shadows: Vec<&Shadow> = self
|
let drop_shadows: Vec<&Shadow> = self
|
||||||
.nested_shadows
|
.nested_shadows
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|shadows| 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();
|
.collect();
|
||||||
|
|
||||||
if drop_shadows.is_empty() {
|
if drop_shadows.is_empty() {
|
||||||
@ -1337,6 +1338,7 @@ impl RenderState {
|
|||||||
| text_drop_shadows_surface_id as u32;
|
| text_drop_shadows_surface_id as u32;
|
||||||
|
|
||||||
let fast_mode = self.options.is_fast_mode();
|
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
|
// Skip anti-aliasing entirely during fast_mode (interactive
|
||||||
// gestures + pan/zoom). AA edge sampling is per-pixel and adds
|
// gestures + pan/zoom). AA edge sampling is per-pixel and adds
|
||||||
// up across many shapes; reverts to full quality on commit.
|
// up across many shapes; reverts to full quality on commit.
|
||||||
@ -1367,11 +1369,25 @@ impl RenderState {
|
|||||||
return Ok(());
|
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.
|
// 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
|
// Opacity < 1 with SrcOver is OK: render_shape_enter already opened a
|
||||||
// save_layer on Current; painting fills/strokes into that layer matches
|
// save_layer on Current; painting fills/strokes into that layer matches
|
||||||
// the layered path without Fills/Strokes blits.
|
// the layered path without Fills/Strokes blits.
|
||||||
// Non-SrcOver blend, frame clip blur, and masked groups stay layered.
|
// 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
|
let can_render_directly = apply_to_current_surface
|
||||||
&& offset.is_none()
|
&& offset.is_none()
|
||||||
&& parent_shadows.is_none()
|
&& parent_shadows.is_none()
|
||||||
@ -1381,20 +1397,15 @@ impl RenderState {
|
|||||||
&& shape.blur.is_none()
|
&& shape.blur.is_none()
|
||||||
&& shape.background_blur.is_none()
|
&& shape.background_blur.is_none()
|
||||||
&& !has_inherited_blur
|
&& !has_inherited_blur
|
||||||
&& shape.shadows.is_empty()
|
&& !shadows_need_layered
|
||||||
&& matches!(
|
&& matches!(
|
||||||
shape.shape_type,
|
shape.shape_type,
|
||||||
Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) | Type::Frame(_)
|
Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) | Type::Frame(_)
|
||||||
)
|
)
|
||||||
&& !(shape.fills.is_empty() && has_nested_fills)
|
&& !(shape.fills.is_empty() && has_nested_fills)
|
||||||
&& !shape
|
|
||||||
.svg_attrs
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|attrs| attrs.fill_none)
|
|
||||||
&& target_surface != SurfaceId::Export;
|
&& target_surface != SurfaceId::Export;
|
||||||
|
|
||||||
if can_render_directly {
|
if can_render_directly {
|
||||||
let scale = self.get_scale();
|
|
||||||
let translation = self
|
let translation = self
|
||||||
.surfaces
|
.surfaces
|
||||||
.get_render_context_translation(self.render_area, scale);
|
.get_render_context_translation(self.render_area, scale);
|
||||||
@ -1661,10 +1672,25 @@ impl RenderState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} 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() {
|
if !skip_drop_shadows {
|
||||||
drop_shadows.extend(inherited_shadows);
|
if let Some(inherited_shadows) = self.get_inherited_drop_shadows() {
|
||||||
|
drop_shadows.extend(inherited_shadows);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let inner_shadows = shape.inner_shadow_paints();
|
let inner_shadows = shape.inner_shadow_paints();
|
||||||
@ -1688,32 +1714,34 @@ impl RenderState {
|
|||||||
.unzip();
|
.unzip();
|
||||||
|
|
||||||
if let Some(parent_shadows) = parent_shadows {
|
if let Some(parent_shadows) = parent_shadows {
|
||||||
if !shape.has_visible_strokes() {
|
if !skip_drop_shadows {
|
||||||
for shadow in parent_shadows {
|
if !shape.has_visible_strokes() {
|
||||||
text::render(
|
for shadow in parent_shadows {
|
||||||
Some(self),
|
text::render(
|
||||||
None,
|
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,
|
&shape,
|
||||||
&mut paragraphs_with_shadows,
|
&mut paragraphs_with_shadows,
|
||||||
|
&mut stroke_paragraphs_with_shadows_list,
|
||||||
text_drop_shadows_surface_id.into(),
|
text_drop_shadows_surface_id.into(),
|
||||||
Some(&shadow),
|
&parent_shadows,
|
||||||
blur_filter.as_ref(),
|
&blur_filter,
|
||||||
None,
|
&stroke_kinds,
|
||||||
None,
|
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 {
|
} else {
|
||||||
// 1. Text drop shadows
|
// 1. Text drop shadows
|
||||||
@ -2433,9 +2461,12 @@ impl RenderState {
|
|||||||
panic!("FrameType::None");
|
panic!("FrameType::None");
|
||||||
}
|
}
|
||||||
FrameType::Partial => {
|
FrameType::Partial => {
|
||||||
// Partial frame: just flush GPU work. The display shows the last
|
// Drain tile GPU work (Current / tile atlas / cache) without
|
||||||
// fully submitted frame; no need to copy or draw UI overlays here.
|
// presenting Target and without re-snapshotting the tile atlas —
|
||||||
self.flush();
|
// 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 => {
|
FrameType::Full => {
|
||||||
// A full-quality frame is now complete. Rebuild the per-shape crop
|
// A full-quality frame is now complete. Rebuild the per-shape crop
|
||||||
@ -2638,6 +2669,19 @@ impl RenderState {
|
|||||||
true
|
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]
|
#[inline]
|
||||||
fn clip_target_surface_to_stack(
|
fn clip_target_surface_to_stack(
|
||||||
&mut self,
|
&mut self,
|
||||||
@ -3185,10 +3229,15 @@ impl RenderState {
|
|||||||
node_render_state: &NodeRenderState,
|
node_render_state: &NodeRenderState,
|
||||||
target_surface: SurfaceId,
|
target_surface: SurfaceId,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
// Avoid a blank DropShadows→Current blit + clear on every shape without
|
// Avoid a blank DropShadows→Current blit + clear when nothing will paint
|
||||||
// shadows. Callers must still touch DropShadows once per tile when this
|
// (no shadows, fast/overview skip, or all footprints subpixel). Callers
|
||||||
// returns false (see `drop_shadows_ops_warmed`).
|
// must still touch DropShadows once per tile when this returns false
|
||||||
if element.drop_shadows_visible().next().is_none() {
|
// (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);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3198,7 +3247,13 @@ impl RenderState {
|
|||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let recursive = element.is_recursive();
|
||||||
|
let mut rendered_any = false;
|
||||||
for shadow in element.drop_shadows_visible() {
|
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 paint = skia::Paint::default();
|
||||||
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
|
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
|
||||||
self.surfaces
|
self.surfaces
|
||||||
@ -3290,6 +3345,10 @@ impl RenderState {
|
|||||||
self.surfaces.canvas(SurfaceId::DropShadows).restore();
|
self.surfaces.canvas(SurfaceId::DropShadows).restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !rendered_any {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(clips) = clip_bounds.as_ref() {
|
if let Some(clips) = clip_bounds.as_ref() {
|
||||||
let antialias = !self.options.is_fast_mode()
|
let antialias = !self.options.is_fast_mode()
|
||||||
&& element.should_use_antialias(scale, self.options.antialias_threshold);
|
&& 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)
|
// the layer blur (which would make it more diffused than without clipping)
|
||||||
let shadow_before_layer = !node_render_state.is_root()
|
let shadow_before_layer = !node_render_state.is_root()
|
||||||
&& self.focus_mode.is_active()
|
&& self.focus_mode.is_active()
|
||||||
&& !self.options.is_fast_mode()
|
&& !self.should_skip_drop_shadows()
|
||||||
&& !matches!(element.shape_type, Type::Text(_))
|
&& !matches!(element.shape_type, Type::Text(_))
|
||||||
&& Self::frame_clip_layer_blur(element).is_some()
|
&& 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
|
if shadow_before_layer
|
||||||
&& self.render_element_drop_shadows_and_composite(
|
&& self.render_element_drop_shadows_and_composite(
|
||||||
@ -3570,8 +3631,8 @@ impl RenderState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !node_render_state.is_root() && self.focus_mode.is_active() {
|
if !node_render_state.is_root() && self.focus_mode.is_active() {
|
||||||
// Skip expensive drop shadow rendering in fast mode (during pan/zoom).
|
// Skip expensive drop shadows in fast mode and at overview zooms.
|
||||||
let skip_shadows = self.options.is_fast_mode();
|
let skip_shadows = self.should_skip_drop_shadows();
|
||||||
|
|
||||||
// Skip shadow block when already rendered before the layer (frame_clip_layer_blur)
|
// 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();
|
let shadows_already_rendered = Self::frame_clip_layer_blur(element).is_some();
|
||||||
|
|||||||
@ -13,10 +13,16 @@ pub fn render_fill_inner_shadows(
|
|||||||
antialias: bool,
|
antialias: bool,
|
||||||
surface_id: SurfaceId,
|
surface_id: SurfaceId,
|
||||||
) {
|
) {
|
||||||
if shape.has_fills() {
|
if !shape.has_fills() || render_state.should_skip_drop_shadows() {
|
||||||
for shadow in shape.inner_shadows_visible() {
|
return;
|
||||||
render_fill_inner_shadow(render_state, shape, shadow, antialias, surface_id);
|
}
|
||||||
|
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,
|
antialias: bool,
|
||||||
surface_id: SurfaceId,
|
surface_id: SurfaceId,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if !shape.has_fills() {
|
if shape.has_fills() || render_state.should_skip_drop_shadows() {
|
||||||
for shadow in shape.inner_shadows_visible() {
|
return Ok(());
|
||||||
let filter = shadow.get_inner_shadow_filter();
|
}
|
||||||
strokes::render_single(
|
let scale = render_state.get_scale();
|
||||||
render_state,
|
let recursive = shape.is_recursive();
|
||||||
shape,
|
for shadow in shape.inner_shadows_visible() {
|
||||||
stroke,
|
if !shadow.is_perceptible_at_scale_for(scale, recursive) {
|
||||||
Some(surface_id),
|
continue;
|
||||||
filter.as_ref(),
|
|
||||||
antialias,
|
|
||||||
None, // Inner shadows don't use spread
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -561,6 +561,13 @@ fn draw_image_stroke_in_container(
|
|||||||
surface_id: SurfaceId,
|
surface_id: SurfaceId,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let scale = render_state.get_scale();
|
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 {
|
let Some(image) = get_resources().images.get(&image_fill.id()) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
@ -938,12 +945,13 @@ fn render_merged(
|
|||||||
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
|
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
|
||||||
if let Some(path) = shape_type.path() {
|
if let Some(path) = shape_type.path() {
|
||||||
let is_open = path.is_open();
|
let is_open = path.is_open();
|
||||||
|
let lod_stroke = representative.path_lod_at_scale(is_open, scale);
|
||||||
let mut paint =
|
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());
|
paint.set_shader(merged.shader());
|
||||||
draw_stroke_on_path(
|
draw_stroke_on_path(
|
||||||
canvas,
|
canvas,
|
||||||
representative,
|
&lod_stroke,
|
||||||
path,
|
path,
|
||||||
&paint,
|
&paint,
|
||||||
path_transform.as_ref(),
|
path_transform.as_ref(),
|
||||||
@ -1097,6 +1105,8 @@ fn render_single_internal(
|
|||||||
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
|
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
|
||||||
if let Some(path) = shape_type.path() {
|
if let Some(path) = shape_type.path() {
|
||||||
let is_open = path.is_open();
|
let is_open = path.is_open();
|
||||||
|
let lod_stroke = stroke.path_lod_at_scale(is_open, scale);
|
||||||
|
let stroke = &lod_stroke;
|
||||||
let mut paint =
|
let mut paint =
|
||||||
stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias);
|
stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias);
|
||||||
// Apply outset by increasing stroke width
|
// Apply outset by increasing stroke width
|
||||||
|
|||||||
@ -4,6 +4,18 @@ use super::blurs::radius_to_sigma;
|
|||||||
use super::Color;
|
use super::Color;
|
||||||
use crate::render::filters::compose_filters;
|
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)]
|
#[derive(Debug, Default, Clone, Copy, PartialEq)]
|
||||||
pub enum ShadowStyle {
|
pub enum ShadowStyle {
|
||||||
#[default]
|
#[default]
|
||||||
@ -48,6 +60,32 @@ impl Shadow {
|
|||||||
self.hidden
|
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> {
|
pub fn get_drop_shadow_filter(&self) -> Option<ImageFilter> {
|
||||||
let sigma = radius_to_sigma(self.blur);
|
let sigma = radius_to_sigma(self.blur);
|
||||||
let mut filter = image_filters::drop_shadow_only(
|
let mut filter = image_filters::drop_shadow_only(
|
||||||
@ -112,3 +150,44 @@ impl Shadow {
|
|||||||
self.offset.1 *= value;
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -7,6 +7,14 @@ use super::StrokeLineCap;
|
|||||||
use super::StrokeLineJoin;
|
use super::StrokeLineJoin;
|
||||||
use super::SvgAttrs;
|
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)]
|
#[derive(Debug, Clone, PartialEq, Copy)]
|
||||||
pub enum StrokeStyle {
|
pub enum StrokeStyle {
|
||||||
Solid,
|
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.
|
/// Per-side widths [top, right, bottom, left] when they actually differ.
|
||||||
/// Returns `None` when unset or when all sides are equal, so the uniform
|
/// Returns `None` when unset or when all sides are equal, so the uniform
|
||||||
/// render path (which supports dashed/dotted styles) keeps handling that
|
/// 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.widths, Some([2.0, 4.0, 6.0, 8.0]));
|
||||||
assert_eq!(stroke.width, 4.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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user