M1: drop shadows under a background blur (#255)

A layer with a background blur (or glass) drew its blurred backdrop into
its own pixels before casting its drop shadows from them, so a translucent
fill cast a shadow as dark as an opaque one, and with "show behind
transparent areas" the shadow was cast from the backdrop's pixels. The
backdrop was also read before the shadows were drawn, so a shadow showing
behind was never seen through the blur.

When a layer has both, it now casts its shadows from what it paints (with
the layer blur, noise and texture it will get), draws them, and only then
reads the backdrop, which goes under its content; the content still sees
the first backdrop under it while it's drawn. Layers without both draw as
before.
This commit is contained in:
Barzabel
2026-10-14 18:45:15 +03:00
parent 833f0d2c27
commit 698e249fbf
3 changed files with 142 additions and 25 deletions
@@ -0,0 +1,79 @@
//! A drop shadow under a layer with a background blur (#255): the shadow is cast by what the layer
//! paints (a translucent fill casts a faint one), not by the blurred backdrop drawn through its
//! shape, and the blur reads what lies under the layer with its own shadow there, so a shadow
//! that shows behind transparent areas is seen, blurred, through the layer.
use figcraft_color::{Color, Paint};
use figcraft_doc::{Blur, Document, Effect, EffectKind, NodeId, NodeKind, Shadow};
use super::*;
/// A white 240 × 200 frame with blue and green stripes and, on top, a pane at (60, 40) 120 × 80
/// filled 20% black, with a hard black drop shadow 40 down (below it and under its lower half)
/// and, when `blur`, a background blur.
fn scene(show_behind: bool, blur: bool) -> (Document, NodeId) {
let mut d = Document::new("shadow behind blur");
let p = d.first_page().unwrap();
let f = d.create(p, NodeKind::Frame, "Frame", 0.0, 0.0, 240.0, 200.0).unwrap();
d.get_mut(f).unwrap().fills = vec![Paint::solid(Color::from_hex("FFFFFF").unwrap())];
for (i, x) in [20.0, 80.0, 140.0, 200.0].into_iter().enumerate() {
let s = d.create(f, NodeKind::Rectangle, "Stripe", x, 0.0, 20.0, 200.0).unwrap();
d.get_mut(s).unwrap().fills = vec![Paint::solid(Color::from_hex(if i % 2 == 0 { "2040E0" } else { "20C060" }).unwrap())];
}
let pane = d.create(f, NodeKind::Rectangle, "Pane", 60.0, 40.0, 120.0, 80.0).unwrap();
let n = d.get_mut(pane).unwrap();
n.fills = vec![Paint::solid(Color::new(0.0, 0.0, 0.0, 0.2))];
let shadow = Shadow { color: Color::new(0.0, 0.0, 0.0, 1.0), offset: (0.0, 40.0), radius: 0.0, show_behind, ..Shadow::default() };
n.effects = vec![Effect { kind: EffectKind::DropShadow(shadow), visible: true }];
if blur {
n.effects.push(Effect { kind: EffectKind::BackgroundBlur(Blur::uniform(8.0)), visible: true });
}
(d, f)
}
fn px(p: &Pixmap, x: u32, y: u32) -> [u8; 4] {
let i = ((y * p.width() + x) * 4) as usize;
[p.data()[i], p.data()[i + 1], p.data()[i + 2], p.data()[i + 3]]
}
/// The largest channel difference between `a` and `b` over the shadow below the pane
/// (60..180 × 122..160).
fn max_diff_below_pane(a: &Pixmap, b: &Pixmap) -> u8 {
let mut worst = 0;
for y in 122..160 {
for x in 60..180 {
worst = worst.max(px(a, x, y).iter().zip(px(b, x, y)).map(|(c, d)| c.abs_diff(d)).max().unwrap());
}
}
worst
}
#[test]
fn a_background_blur_doesnt_change_the_shadow_its_layer_casts() {
for show_behind in [false, true] {
let (with_blur, f) = scene(show_behind, true);
let (without, _) = scene(show_behind, false);
let got = render_node(&with_blur, f, 1.0).unwrap();
let want = render_node(&without, f, 1.0).unwrap();
let diff = max_diff_below_pane(&got, &want);
assert!(diff <= 2, "show behind {show_behind}: the shadow below the pane differs by {diff}");
// A 20% fill casts a faint shadow: the white between the stripes stays light.
let light = px(&got, 120, 140);
assert!(light[0] > 180, "show behind {show_behind}: {light:?}");
}
}
#[test]
fn a_shadow_showing_behind_is_seen_through_the_background_blur() {
let (d, f) = scene(true, true);
let got = render_node(&d, f, 1.0).unwrap();
// Between the stripes, the pane's top (no shadow under it) and its lower half (its own shadow
// under it, from y = 80).
let (top, low) = (px(&got, 120, 50), px(&got, 120, 110));
assert!(top[0] > low[0] + 20, "the shadow under the pane doesn't show through: top {top:?}, lower half {low:?}");
// Hidden behind the layer, it doesn't.
let (d, f) = scene(false, true);
let got = render_node(&d, f, 1.0).unwrap();
let (top, low) = (px(&got, 120, 50), px(&got, 120, 110));
assert!(top[0].abs_diff(low[0]) <= 2, "a hidden shadow shows: top {top:?}, lower half {low:?}");
}
+61 -25
View File
@@ -423,16 +423,15 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
let shape = node_outline(ctx.doc, n);
// Background blur and glass: the whole backdrop under the layer (every surface below this
// one, composited), through the shape, under the content.
if backdrop::has_effects(n)
&& let Some(sh) = &backdrop::outline(ctx.doc, n)
{
let pad = backdrop::pad(n, ctx.scale);
let p = i32::try_from(pad).unwrap_or(0);
if let Some(back) = backdrop::backdrop(pix, ctx.under, lx.saturating_sub(p), ly.saturating_sub(p), lw + pad * 2, lh + pad * 2)
&& let Some(b) = backdrop::effects(n, sh, &back, pad, lw, lh, lxf, ctx.scale)
{
layer.draw_pixmap(0, 0, b.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
}
let back_shape = if backdrop::has_effects(n) { backdrop::outline(ctx.doc, n) } else { None };
let read_backdrop = |pix: &Pixmap| back_shape.as_ref().and_then(|sh| backdrop_layer(ctx, pix, n, sh, (lx, ly), (lw, lh), lxf));
// With drop shadows too, the layer casts them from what it paints, not from the backdrop, and
// the backdrop is read again once they lie under it (a shadow showing behind is seen through
// it). Until then, the layer's content sees the backdrop read first under it (#255).
let casts = back_shape.is_some() && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::DropShadow(_)));
let first_back = read_backdrop(pix);
if !casts && let Some(b) = &first_back {
layer.draw_pixmap(0, 0, b.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
}
let op = n.opacity.clamp(0.0, 1.0);
{
@@ -448,9 +447,12 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
}
};
let has_inner = shape.is_some() && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::InnerShadow(_)));
// Backdrops further down see this layer composited onto `pix`.
// Backdrops further down see this layer composited onto `pix` (over its backdrop, when
// that is kept apart for now).
let under = Under { opacity: op, blend: n.blend, ..Under::new(pix, (lx, ly), ctx.under) };
draw_content(&Ctx { under: Some(&under), ..*ctx }, &mut layer, n, lxf, depth, has_inner.then_some(&inner as &dyn Fn(&mut Pixmap)));
let over_back = first_back.as_ref().filter(|_| casts).map(|b| Under::new(b, (0, 0), Some(&under)));
let content_under = over_back.as_ref().unwrap_or(&under);
draw_content(&Ctx { under: Some(content_under), ..*ctx }, &mut layer, n, lxf, depth, has_inner.then_some(&inner as &dyn Fn(&mut Pixmap)));
}
// A group is no outline of its own: its inner shadows are cast from around what its layers
// draw together, over them.
@@ -465,24 +467,58 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
}
}
// Layer blur, noise and texture, in list order, over everything else.
if !casts {
layer_effects(ctx, &mut layer, n, id, lxf);
}
// Drop shadows go under the layer, in effect-list order.
{
// Kept apart, the backdrop isn't in the layer yet: the shadows are cast from the content
// with the layer effects it will get.
let content = casts.then(|| {
let mut c = layer.clone();
layer_effects(ctx, &mut c, n, id, lxf);
c
});
let caster = shadow::Caster::new(ctx.doc, n, content.as_ref().unwrap_or(&layer), lxf, ctx.scale);
for e in n.effects.iter().filter(|e| e.visible) {
if let EffectKind::DropShadow(s) = &e.kind
&& let Some(sh) = shadow::drop_shadow(&caster, s)
{
composite(pix, (lx, ly), &sh, op, s.blend);
}
}
}
if casts {
// The backdrop with the shadows under it, the content over it, then the layer effects.
if let Some(mut b) = read_backdrop(pix) {
b.draw_pixmap(0, 0, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
layer = b;
}
layer_effects(ctx, &mut layer, n, id, lxf);
}
composite(pix, (lx, ly), &layer, op, n.blend);
}
/// Node `n`'s backdrop effects through its backdrop outline `sh`, for its `size` layer at `at` on
/// `pix` (local → layer pixels through `lxf`): what `pix` holds there now, over what lies under it.
fn backdrop_layer(ctx: &Ctx, pix: &Pixmap, n: &Node, sh: &shadow::Outline, at: (i32, i32), size: (u32, u32), lxf: Affine) -> Option<Pixmap> {
let ((lx, ly), (lw, lh)) = (at, size);
let pad = backdrop::pad(n, ctx.scale);
let p = i32::try_from(pad).unwrap_or(0);
let back = backdrop::backdrop(pix, ctx.under, lx.saturating_sub(p), ly.saturating_sub(p), lw + pad * 2, lh + pad * 2)?;
backdrop::effects(n, sh, &back, pad, lw, lh, lxf, ctx.scale)
}
/// Node `n`'s layer blur, noise and texture on its `layer`, in list order.
fn layer_effects(ctx: &Ctx, layer: &mut Pixmap, n: &Node, id: NodeId, lxf: Affine) {
for e in n.effects.iter().filter(|e| e.visible) {
match &e.kind {
EffectKind::LayerBlur(b) => blur::apply(&mut layer, b, (n.width, n.height), lxf, ctx.scale),
EffectKind::Noise(nz) => grain::noise(&mut layer, nz, lxf, id.0 as u32),
EffectKind::Texture(t) => grain::texture(&mut layer, t, lxf, ctx.scale, id.0 as u32),
EffectKind::LayerBlur(b) => blur::apply(layer, b, (n.width, n.height), lxf, ctx.scale),
EffectKind::Noise(nz) => grain::noise(layer, nz, lxf, id.0 as u32),
EffectKind::Texture(t) => grain::texture(layer, t, lxf, ctx.scale, id.0 as u32),
_ => {}
}
}
// Drop shadows go under the layer, in effect-list order.
let caster = shadow::Caster::new(ctx.doc, n, &layer, lxf, ctx.scale);
for e in n.effects.iter().filter(|e| e.visible) {
if let EffectKind::DropShadow(s) = &e.kind
&& let Some(sh) = shadow::drop_shadow(&caster, s)
{
composite(pix, (lx, ly), &sh, op, s.blend);
}
}
composite(pix, (lx, ly), &layer, op, n.blend);
}
fn draw_outline(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) {
+2
View File
@@ -425,6 +425,8 @@ pub fn png_insert_chunk(png: Vec<u8>, kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
out
}
#[cfg(test)]
mod backdrop_shadow_tests;
#[cfg(test)]
mod backdrop_tests;
#[cfg(test)]