mirror of
https://github.com/penpot/penpot.git
synced 2026-10-01 00:06:17 +00:00
* 🐛 Fix blocky and black drop shadows on masked groups * 🐛 Fix drop shadow spread on paths, bools and circles
325 lines
11 KiB
Rust
325 lines
11 KiB
Rust
use skia_safe::{self as skia, image_filters, ImageFilter, Paint};
|
||
|
||
use super::blurs::{radius_to_sigma, sigma_to_radius};
|
||
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]
|
||
Drop,
|
||
Inner,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct Shadow {
|
||
pub color: Color,
|
||
pub blur: f32,
|
||
pub spread: f32,
|
||
pub offset: (f32, f32),
|
||
style: ShadowStyle,
|
||
hidden: bool,
|
||
}
|
||
|
||
impl Shadow {
|
||
pub fn new(
|
||
color: Color,
|
||
blur: f32,
|
||
spread: f32,
|
||
offset: (f32, f32),
|
||
style: ShadowStyle,
|
||
hidden: bool,
|
||
) -> Self {
|
||
Self {
|
||
color,
|
||
blur,
|
||
spread,
|
||
offset,
|
||
style,
|
||
hidden,
|
||
}
|
||
}
|
||
|
||
pub fn style(&self) -> ShadowStyle {
|
||
self.style
|
||
}
|
||
|
||
pub fn hidden(&self) -> bool {
|
||
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(
|
||
(self.offset.0, self.offset.1),
|
||
(sigma, sigma),
|
||
self.color,
|
||
None,
|
||
None,
|
||
None,
|
||
);
|
||
|
||
if self.spread > 0. {
|
||
filter = image_filters::dilate((self.spread, self.spread), filter, None);
|
||
}
|
||
|
||
filter
|
||
}
|
||
|
||
/// Same shadow as [`Self::get_drop_shadow_filter`], for a `merge` input.
|
||
/// Skia defers color filters and offsets, and `merge` can draw them
|
||
/// unresolved in 8×8 cells (untinted source, or opaque black). A `blend`
|
||
/// always renders, so the tint is a SrcIn blend of the color over the
|
||
/// offset source.
|
||
pub fn get_layer_drop_shadow_filter(&self) -> Option<ImageFilter> {
|
||
let color = image_filters::shader(skia::shaders::color(self.color), None);
|
||
let offset = image_filters::offset((self.offset.0, self.offset.1), None, None);
|
||
let mut filter = image_filters::blend(skia::BlendMode::SrcIn, offset, color, None);
|
||
|
||
// Spread before blur, as in CSS and SVG. Dilating the blur instead
|
||
// works on Skia's downscaled blur output and comes out blocky.
|
||
if self.spread > 0. {
|
||
// Masked-group shadows are already in device space.
|
||
filter = Self::chained_dilate(self.spread, filter);
|
||
}
|
||
|
||
let sigma = radius_to_sigma(self.blur);
|
||
if sigma > 0.0 {
|
||
filter = image_filters::blur((sigma, sigma), None, filter, None);
|
||
}
|
||
|
||
filter
|
||
}
|
||
|
||
/// Square dilate by a device-space `radius`, split into steps Skia will
|
||
/// not clamp. Skia caps a single morphology radius at 256 px; square
|
||
/// dilates add up exactly, so chaining keeps the full spread.
|
||
fn chained_dilate(radius: f32, input: Option<ImageFilter>) -> Option<ImageFilter> {
|
||
const MAX_RADIUS: f32 = 255.0;
|
||
let steps = (radius / MAX_RADIUS).ceil().max(1.0) as usize;
|
||
let step = radius / steps as f32;
|
||
let mut filter = input;
|
||
for _ in 0..steps {
|
||
filter = image_filters::dilate((step, step), filter, None);
|
||
}
|
||
filter
|
||
}
|
||
|
||
pub fn get_inner_shadow_paint(
|
||
&self,
|
||
antialias: bool,
|
||
blur_filter: Option<&ImageFilter>,
|
||
) -> Paint {
|
||
let mut paint = Paint::default();
|
||
let shadow_filter = self.get_inner_shadow_filter();
|
||
let filter = compose_filters(blur_filter, shadow_filter.as_ref());
|
||
paint.set_image_filter(filter);
|
||
paint.set_anti_alias(antialias);
|
||
paint
|
||
}
|
||
|
||
pub fn get_inner_shadow_filter(&self) -> Option<ImageFilter> {
|
||
let sigma = radius_to_sigma(self.blur);
|
||
let mut filter = skia::image_filters::drop_shadow_only(
|
||
(self.offset.0, self.offset.1), // DPR?
|
||
(sigma, sigma),
|
||
skia::Color::WHITE,
|
||
None,
|
||
None,
|
||
None,
|
||
);
|
||
|
||
filter = skia::image_filters::color_filter(
|
||
skia::color_filters::blend(self.color, skia::BlendMode::SrcOut).unwrap(),
|
||
filter,
|
||
None,
|
||
);
|
||
|
||
if self.spread > 0. {
|
||
filter = skia::image_filters::dilate((self.spread, self.spread), filter, None);
|
||
}
|
||
|
||
filter = skia::image_filters::blend(skia::BlendMode::SrcIn, None, filter, None);
|
||
|
||
filter
|
||
}
|
||
|
||
pub fn scale_content(&mut self, value: f32) {
|
||
self.blur *= value;
|
||
self.spread *= value;
|
||
self.offset.0 *= value;
|
||
self.offset.1 *= value;
|
||
}
|
||
|
||
/// Scales this shadow into device units, for a filter built on a canvas
|
||
/// that carries no transform of its own.
|
||
///
|
||
/// Not the same as [`Self::scale_content`]. `radius_to_sigma` is affine
|
||
/// (`k·r + 0.5`), so scaling the radius applies its constant term once at
|
||
/// device scale, while a filter built in document space has that term
|
||
/// scaled by the canvas matrix along with everything else. The radius is
|
||
/// pre-compensated here so both land on the same sigma — otherwise the
|
||
/// same shadow blurs differently depending on which path drew it, by
|
||
/// `0.5 · (scale - 1)` sigma.
|
||
pub fn scale_to_device(&mut self, scale: f32) {
|
||
let device_sigma = radius_to_sigma(self.blur) * scale;
|
||
self.scale_content(scale);
|
||
self.blur = sigma_to_radius(device_sigma);
|
||
}
|
||
}
|
||
|
||
#[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));
|
||
}
|
||
|
||
/// A filter built on an untransformed canvas must reach the same sigma a
|
||
/// document-space filter does once the canvas matrix scales it, or the same
|
||
/// shadow blurs differently depending on which path drew it.
|
||
#[test]
|
||
fn scale_to_device_matches_a_document_space_sigma() {
|
||
for scale in [0.5_f32, 1.0, 2.0, 4.0, 8.0] {
|
||
let original = shadow(10.0, 6.0, 3.0, -2.0);
|
||
let mut device = original;
|
||
device.scale_to_device(scale);
|
||
|
||
assert!(
|
||
(radius_to_sigma(device.blur) - radius_to_sigma(original.blur) * scale).abs()
|
||
< 0.001,
|
||
"sigma disagreement at scale {scale}"
|
||
);
|
||
|
||
// Spread and offset are linear, so they scale straight through.
|
||
assert!((device.spread - original.spread * scale).abs() < 0.001);
|
||
assert!((device.offset.0 - original.offset.0 * scale).abs() < 0.001);
|
||
assert!((device.offset.1 - original.offset.1 * scale).abs() < 0.001);
|
||
}
|
||
}
|
||
|
||
/// Scaling the radius instead would apply the affine constant once at
|
||
/// device scale, blurring narrower by `0.5 · (scale - 1)` sigma.
|
||
#[test]
|
||
fn scale_to_device_differs_from_scale_content_above_unit_scale() {
|
||
let mut device = shadow(10.0, 0.0, 0.0, 0.0);
|
||
device.scale_to_device(4.0);
|
||
let mut naive = shadow(10.0, 0.0, 0.0, 0.0);
|
||
naive.scale_content(4.0);
|
||
|
||
let gap = radius_to_sigma(device.blur) - radius_to_sigma(naive.blur);
|
||
assert!(
|
||
(gap - 0.5 * 3.0).abs() < 0.001,
|
||
"expected 1.5 sigma, got {gap}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn chained_dilate_reaches_the_full_radius() {
|
||
let rect = skia::Rect::from_xywh(0.0, 0.0, 10.0, 10.0);
|
||
// 600 px is three steps of 200 px.
|
||
let filter = Shadow::chained_dilate(600.0, None).expect("dilate");
|
||
let bounds = filter.compute_fast_bounds(rect);
|
||
assert!((bounds.left + 600.0).abs() < 0.01);
|
||
assert!((bounds.right - 610.0).abs() < 0.01);
|
||
}
|
||
|
||
#[test]
|
||
fn layer_drop_shadow_filter_matches_drop_shadow_bounds() {
|
||
let rect = skia::Rect::from_xywh(0.0, 0.0, 100.0, 50.0);
|
||
for (blur, spread, ox, oy) in [
|
||
(0.0, 0.0, 4.0, 4.0),
|
||
(4.0, 0.0, 4.0, 4.0),
|
||
(12.0, 6.0, -3.0, 8.0),
|
||
] {
|
||
let s = shadow(blur, spread, ox, oy);
|
||
let expected = s
|
||
.get_drop_shadow_filter()
|
||
.expect("drop shadow filter")
|
||
.compute_fast_bounds(rect);
|
||
let actual = s
|
||
.get_layer_drop_shadow_filter()
|
||
.expect("layer drop shadow filter")
|
||
.compute_fast_bounds(rect);
|
||
assert!(
|
||
(expected.left - actual.left).abs() < 0.01
|
||
&& (expected.top - actual.top).abs() < 0.01
|
||
&& (expected.right - actual.right).abs() < 0.01
|
||
&& (expected.bottom - actual.bottom).abs() < 0.01,
|
||
"bounds differ for blur {blur}: {expected:?} vs {actual:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[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));
|
||
}
|
||
}
|