M1: fix inner shadow bands: shadow from the shape, not the layer's crop; banded rendering matches one piece (#24)

The canvas renders large views in parallel horizontal bands, and each band crops a layer to its
own rows. Three effects went wrong at those crops:

- Inner shadow (the reported bug): the padded shadow buffer treated everything beyond the cropped
  layer as outside the shape, so every band edge cast shadow inwards, drawing dark bands across
  the layer (any shape and fill; also at the view's edge when a layer was partly off screen). The
  shape itself is now rasterised over the padded buffer at the shadow's offset (sub-pixel
  accurate), so only its real edges cast shadow.
- Layers starting above or left of the pixmap (inside the blur margin) were sized from the
  clamped origin and came out short by the margin: a drop-shadowed or background-blurred layer
  crossing a band edge lost a strip at its far side.
- Background blur read a backdrop that stopped at the band's edge (streaks at band edges). Bands
  now overlap by what background blurs read (3 sigma + box rounding) and keep only their own
  rows; a blur wider than a band renders the view in one piece.

Regression tests (crates/render/src/band_tests.rs): an image-filled ellipse and a rotated solid
rectangle with inner shadows (spread, offsets) under a background-blurred panel, at 1x and 2x,
rendered in 2, 3 and 7 bands, must match the one-piece render row by row; the ellipse's interior
far from its edge must equal the render without the shadow on every row; a huge backdrop blur
falls back to one piece.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-10 04:09:04 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ff0f5a40d7
commit 15ccc379ab
3 changed files with 215 additions and 57 deletions
+125
View File
@@ -0,0 +1,125 @@
//! Rendering in parallel bands matches rendering in one piece: effects that look past a band's
//! rows (inner shadows, background blurs) must not draw the band's edges into the picture.
#![cfg(not(target_arch = "wasm32"))]
use std::sync::Arc;
use super::*;
use figcraft_color::{ImageScaleMode, Paint};
use figcraft_doc::{Effect, EffectKind, ImageData, Shadow};
fn inner_shadow(offset: (f64, f64), radius: f64, spread: f64) -> Effect {
Effect {
kind: EffectKind::InnerShadow(Shadow { color: Color::from_hex("000000B3").unwrap(), offset, radius, spread, ..Default::default() }),
visible: true,
}
}
fn png() -> Vec<u8> {
let img = image::RgbaImage::from_fn(64, 64, |x, y| image::Rgba([(x * 4) as u8, (y * 4) as u8, 160, 255]));
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// An image-filled ellipse and a rotated solid rectangle, with inner shadows (`shadows`), and a
/// glass panel (background blur) over both (`glass`).
fn scene(shadows: bool, glass: bool) -> (Document, NodeId) {
let mut d = Document::new("bands");
let p = d.first_page().unwrap();
d.images.insert("img".into(), ImageData { bytes: Arc::new(png()), width: 64, height: 64 });
let e = d.create(p, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "E", 0.0, 0.0, 200.0, 200.0).unwrap();
let n = d.get_mut(e).unwrap();
n.fills = vec![Paint::image("img", ImageScaleMode::Fill)];
if shadows {
n.effects = vec![inner_shadow((0.0, 4.0), 8.0, 0.0)];
}
let r = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 150.0, 180.0).unwrap();
let n = d.get_mut(r).unwrap();
n.fills = vec![Paint::solid(Color::from_hex("3C8CE6").unwrap())];
n.transform = Affine::translate((260.0, 10.0)) * Affine::rotate(0.35);
if shadows {
n.effects = vec![inner_shadow((4.0, 4.0), 12.0, 2.0)];
}
if glass {
let g = d.create(p, NodeKind::Rectangle, "Glass", 40.0, 60.0, 320.0, 120.0).unwrap();
let n = d.get_mut(g).unwrap();
n.fills = vec![Paint::solid(Color::from_hex("FFFFFF33").unwrap())];
n.effects = vec![Effect { kind: EffectKind::BackgroundBlur { radius: 12.0 }, visible: true }];
}
(d, p)
}
fn view(scale: f64) -> View {
View { origin: (-10.0, -10.0), scale, width: (440.0 * scale) as u32, height: (240.0 * scale) as u32 }
}
/// Per row: the mean channel difference and how many pixels differ by more than 2. A band artifact
/// differs along a whole row; edge antialiasing may differ in a few pixels per edge (a curve
/// clipped at a band's edge rasterises a hair differently, most where it runs nearly level).
fn row_diffs(a: &Pixmap, b: &Pixmap) -> Vec<(f64, usize)> {
let row = a.width() as usize * 4;
a.data()
.chunks(row)
.zip(b.data().chunks(row))
.map(|(x, y)| {
let sum: f64 = x.iter().zip(y).map(|(p, q)| f64::from(p.abs_diff(*q))).sum();
let off = x.chunks(4).zip(y.chunks(4)).filter(|(p, q)| p.iter().zip(*q).any(|(c, d)| c.abs_diff(*d) > 2)).count();
(sum / row as f64, off)
})
.collect()
}
#[test]
fn bands_match_one_piece_rendering() {
let (d, p) = scene(true, true);
for scale in [1.0, 2.0] {
let v = view(scale);
let one = render_view_single(&d, p, &v, &Options::default()).unwrap();
for bands in [2, 3, 7] {
let banded = render_bands(&d, p, &v, &Options::default(), bands).unwrap();
assert_eq!((banded.width(), banded.height()), (one.width(), one.height()));
for (y, (mean, off)) in row_diffs(&one, &banded).into_iter().enumerate() {
assert!(
mean < 0.25 && off * 20 <= v.width as usize,
"scale {scale}, {bands} bands: row {y} differs ({off} pixels, {mean:.2} on average)"
);
}
}
}
}
#[test]
fn inner_shadow_leaves_the_interior_alone() {
// Far from the ellipse's edge, the image fill shows exactly as without the shadow, on every row
// (the shadow used to draw a band at each render band's edge).
let (with, p) = scene(true, false);
let (without, _) = scene(false, false);
let v = view(2.0);
let a = render_bands(&with, p, &v, &Options::default(), 6).unwrap();
let b = render_bands(&without, p, &v, &Options::default(), 6).unwrap();
// Ellipse centre (100, 100) page units; the shadow reaches 3 sigma + offset = 16 units in.
let (cx, cy, r) = (110.0 * 2.0, 110.0 * 2.0, (100.0 - 20.0) * 2.0);
let mut checked = 0;
for y in 0..v.height {
for x in 0..v.width {
let (dx, dy) = (f64::from(x) - cx, f64::from(y) - cy);
if dx * dx + dy * dy > r * r {
continue;
}
let i = ((y * v.width + x) * 4) as usize;
assert_eq!(a.data()[i..i + 4], b.data()[i..i + 4], "pixel {x},{y}");
checked += 1;
}
}
assert!(checked > 10_000, "{checked}");
}
#[test]
fn huge_backdrop_blur_renders_in_one_piece() {
let (mut d, p) = scene(true, true);
let g = d.find_by_name("Glass", None)[0];
d.get_mut(g).unwrap().effects = vec![Effect { kind: EffectKind::BackgroundBlur { radius: 1e6 }, visible: true }];
assert!(render_bands(&d, p, &view(1.0), &Options::default(), 4).is_none());
assert!(render_view(&d, p, &view(1.0), &Options::default()).is_some());
}
+25 -30
View File
@@ -321,16 +321,19 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
draw_content(ctx, pix, n, xf, depth);
return;
}
// Isolated layer covering the node's paint bounds (clipped to the visible area + margin).
let Some(ir) = int_rect(dev.intersect(view), (pw.saturating_add(blur_margin as u32 * 2), ph.saturating_add(blur_margin as u32 * 2))) else {
// Isolated layer covering the node's paint bounds, clipped to the visible area + margin. Its
// origin can be negative (inside the margin); the size counts from there, or a layer starting
// above or left of the pixmap came out short by the margin (a band missing at its far edge).
let vis = dev.intersect(view);
let (x0, y0) = (vis.x0.floor(), vis.y0.floor());
let (w, h) = (vis.x1.ceil() - x0, vis.y1.ceil() - y0);
if !(x0.is_finite() && y0.is_finite() && w >= 1.0 && h >= 1.0) {
return;
};
// Allow negative origins: recompute without the 0-clamp for margins.
let lx = (dev.intersect(view).x0.floor()) as i32;
let ly = (dev.intersect(view).y0.floor()) as i32;
let lw = ir.width().min(16_384);
let lh = ir.height().min(16_384);
let Some(mut layer) = Pixmap::new(lw.max(1), lh.max(1)) else { return };
}
let (lx, ly) = (x0 as i32, y0 as i32);
let lw = w.min(16_384.0) as u32;
let lh = h.min(16_384.0) as u32;
let Some(mut layer) = Pixmap::new(lw, lh) else { return };
let lxf = Affine::translate((-f64::from(lx), -f64::from(ly))) * xf;
let shape = node_outline(ctx.doc, n);
// Background blur: blurred backdrop clipped to the shape, under the content.
@@ -351,40 +354,32 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
}
}
draw_content(ctx, &mut layer, n, lxf, depth);
// Shape alpha (for inner shadows / noise).
let shape_alpha = || -> Option<Pixmap> {
// The shape's alpha in a `w` × `h` pixmap through `xf` (for inner shadows / noise).
let shape_alpha_in = |w: u32, h: u32, xf: Affine| -> Option<Pixmap> {
let (path, rule) = shape.as_ref()?;
let tp = ts_path(path)?;
let mut s = Pixmap::new(lw.max(1), lh.max(1))?;
let mut s = Pixmap::new(w, h)?;
let paint = tiny_skia::Paint { shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::WHITE), anti_alias: true, ..Default::default() };
s.fill_path(&tp, &paint, *rule, ts_transform(lxf), None);
s.fill_path(&tp, &paint, *rule, ts_transform(xf), None);
Some(s)
};
let shape_alpha = || shape_alpha_in(lw, lh, lxf);
let mut inner_shadows: Vec<Pixmap> = Vec::new();
for e in n.effects.iter().filter(|e| e.visible) {
match &e.kind {
EffectKind::InnerShadow(s) => {
let Some(sa) = shape_alpha() else { continue };
let mut sh = sa.clone();
fx::invert_alpha(&mut sh);
// Work in a padded buffer: everything beyond the layer is "outside the shape", so
// the blur pulls shadow in from every edge, not just the offset side.
// Work in a buffer padded by the shadow's reach, with the shape drawn at its offset
// over all of it: everything outside the shape casts shadow inwards, from every
// edge. The shape itself (not the layer's box) decides what is outside: the layer
// can be cropped (to a render band or the view) where the shape carries on, and
// treating that crop as an edge drew shadow bands across the layer.
let (dx, dy) = (s.offset.0 * ctx.scale, s.offset.1 * ctx.scale);
let pad =
((s.radius / 2.0 * ctx.scale * 3.0) + s.spread.abs() * ctx.scale + dx.abs().max(dy.abs())).ceil().clamp(0.0, 2048.0) as u32 + 2;
let Some(mut big) = Pixmap::new(lw.max(1) + pad * 2, lh.max(1) + pad * 2) else { continue };
big.fill(tiny_skia::Color::WHITE);
let ox = pad as f32 + dx as f32;
let oy = pad as f32 + dy as f32;
if let Some(rect) = tiny_skia::Rect::from_xywh(ox, oy, lw as f32, lh as f32) {
let clear = tiny_skia::Paint {
shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::TRANSPARENT),
blend_mode: tiny_skia::BlendMode::Source,
..Default::default()
};
big.fill_rect(rect, &clear, Transform::identity(), None);
}
big.draw_pixmap(ox.round() as i32, oy.round() as i32, sh.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
let at = Affine::translate((f64::from(pad) + dx, f64::from(pad) + dy)) * lxf;
let Some(mut big) = shape_alpha_in(lw + pad * 2, lh + pad * 2, at) else { continue };
fx::invert_alpha(&mut big);
fx::morph(&mut big, (s.spread * ctx.scale).round() as i32);
fx::blur(&mut big, s.radius / 2.0 * ctx.scale);
let Some(mut cropped) = Pixmap::new(lw.max(1), lh.max(1)) else { continue };
+65 -27
View File
@@ -53,44 +53,80 @@ fn new_pixmap(w: u32, h: u32) -> Option<Pixmap> {
Pixmap::new(w, h)
}
/// How far (device pixels) background blurs read their backdrop: an upper bound over the document.
#[cfg(not(target_arch = "wasm32"))]
fn backdrop_reach(doc: &Document, scale: f64) -> f64 {
let radius = doc
.nodes()
.flat_map(|n| n.effects.iter())
.filter(|e| e.visible)
.filter_map(|e| match e.kind {
figcraft_doc::EffectKind::BackgroundBlur { radius } if radius.is_finite() => Some(radius),
_ => None,
})
.fold(0.0, f64::max);
// The three box passes of a blur with sigma = radius / 2 reach at most 3 sigma + 3 pixels.
if radius > 0.0 { radius * 1.5 * scale + 3.0 } else { 0.0 }
}
/// Render a page region, splitting it into horizontal bands rendered in parallel (desktop).
pub fn render_view(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option<Pixmap> {
#[cfg(not(target_arch = "wasm32"))]
{
let threads = rayon::current_num_threads().clamp(1, 16) as u32;
let bands = threads.min(view.height / 96).max(1);
if bands > 1 && view.width.saturating_mul(view.height) > 300_000 {
use rayon::prelude::*;
let band_h = view.height.div_ceil(bands);
let parts: Vec<(u32, Option<Pixmap>)> = (0..bands)
.into_par_iter()
.map(|i| {
let y0 = i * band_h;
let h = band_h.min(view.height.saturating_sub(y0));
let s = view.scale.max(1e-9);
let v =
View { origin: (view.origin.0, view.origin.1 + f64::from(y0) / s), scale: view.scale, width: view.width, height: h.max(1) };
(y0, render_view_single(doc, page, &v, opts))
})
.collect();
let mut out = new_pixmap(view.width, view.height)?;
for (y0, part) in parts {
let Some(part) = part else { continue };
out.draw_pixmap(
0,
y0 as i32,
part.as_ref(),
&tiny_skia::PixmapPaint { blend_mode: tiny_skia::BlendMode::Source, ..Default::default() },
tiny_skia::Transform::identity(),
None,
);
}
return Some(out);
if bands > 1
&& view.width.saturating_mul(view.height) > 300_000
&& let Some(p) = render_bands(doc, page, view, opts, bands)
{
return Some(p);
}
}
render_view_single(doc, page, view, opts)
}
/// Render `view` as `bands` horizontal bands in parallel. Bands overlap by what background blurs
/// read and each keeps only its own rows, so a backdrop never ends at a band's edge. `None` when a
/// blur reaches past a whole band (every band would render most of the view: render it in one
/// piece instead).
#[cfg(not(target_arch = "wasm32"))]
fn render_bands(doc: &Document, page: NodeId, view: &View, opts: &Options, bands: u32) -> Option<Pixmap> {
use rayon::prelude::*;
let band_h = view.height.div_ceil(bands.max(1));
let overlap = backdrop_reach(doc, view.scale).ceil();
if !overlap.is_finite() || overlap > f64::from(band_h) {
return None;
}
let overlap = overlap as u32;
let s = view.scale.max(1e-9);
let parts: Vec<(u32, Option<Pixmap>)> = (0..bands)
.into_par_iter()
.map(|i| {
let y0 = i * band_h;
let h = band_h.min(view.height.saturating_sub(y0)).max(1);
let top = y0.saturating_sub(overlap);
let bottom = (y0 + h + overlap).min(view.height).max(y0 + h);
let v = View { origin: (view.origin.0, view.origin.1 + f64::from(top) / s), scale: view.scale, width: view.width, height: bottom - top };
let part = render_view_single(doc, page, &v, opts);
let own = tiny_skia::IntRect::from_xywh(0, (y0 - top) as i32, view.width.clamp(1, 16_384), h);
(y0, if overlap == 0 { part } else { part.zip(own).and_then(|(p, r)| p.clone_rect(r)) })
})
.collect();
let mut out = new_pixmap(view.width, view.height)?;
for (y0, part) in parts {
let Some(part) = part else { continue };
out.draw_pixmap(
0,
y0 as i32,
part.as_ref(),
&tiny_skia::PixmapPaint { blend_mode: tiny_skia::BlendMode::Source, ..Default::default() },
tiny_skia::Transform::identity(),
None,
);
}
Some(out)
}
/// Render a page region on the calling thread.
pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option<Pixmap> {
let mut pix = new_pixmap(view.width, view.height)?;
@@ -202,5 +238,7 @@ pub fn encode_webp(p: &Pixmap) -> Result<Vec<u8>, String> {
Ok(out)
}
#[cfg(test)]
mod band_tests;
#[cfg(test)]
mod tests;