diff --git a/crates/render/src/band_tests.rs b/crates/render/src/band_tests.rs new file mode 100644 index 000000000..152f78438 --- /dev/null +++ b/crates/render/src/band_tests.rs @@ -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 { + 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()); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 21be55ae4..8b0d1b653 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -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 { + // 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 { 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 = 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 }; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0aa858a16..6e6c14df7 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -53,44 +53,80 @@ fn new_pixmap(w: u32, h: u32) -> Option { 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 { #[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)> = (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 { + 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)> = (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 { let mut pix = new_pixmap(view.width, view.height)?; @@ -202,5 +238,7 @@ pub fn encode_webp(p: &Pixmap) -> Result, String> { Ok(out) } +#[cfg(test)] +mod band_tests; #[cfg(test)] mod tests;