From 3b48776607b3a8e0e2011ca7c3dbf739e343c209 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 23:31:22 -0400 Subject: [PATCH] =?UTF-8?q?M5.2:=20render=20perf=20=E2=80=94=20drop=20shad?= =?UTF-8?q?ows/outer=20glows=20from=20a=20cached=20CPU-blurred=20raster=20?= =?UTF-8?q?composited=20per=20draw=20(600=20shadows:=20~300=E2=80=931500?= =?UTF-8?q?=20ms=20=E2=86=92=2017=E2=80=9370=20ms/frame);=20object=20blend?= =?UTF-8?q?=20modes=20on=20single=20plain=20fills=20applied=20per=20draw?= =?UTF-8?q?=20instead=20of=20a=20full-viewport=20layer;=20effect=20catalog?= =?UTF-8?q?ue=20indexed=20once=20(lookups=20300=C3=97=20faster)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 18 ++-- crates/effects/src/tests.rs | 12 +++ crates/render/src/fx.rs | 163 +++++++++++++++++++++++++++++++++++- crates/render/src/lib.rs | 27 +++++- crates/render/src/tests.rs | 52 ++++++++++++ 5 files changed, 264 insertions(+), 8 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 494e2cbe9..17c4911f6 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -210,20 +210,26 @@ pub fn effect_catalog() -> Vec { v } +/// The catalogue built once, indexed by id (lookups run per effect per rendered frame). +fn catalog_index() -> &'static std::collections::HashMap<&'static str, EffectInfo> { + static INDEX: std::sync::OnceLock> = std::sync::OnceLock::new(); + INDEX.get_or_init(|| effect_catalog().into_iter().map(|e| (e.id, e)).collect()) +} + /// Catalogue entry for `id`. pub fn effect_info(id: &str) -> Option { - effect_catalog().into_iter().find(|e| e.id == id) + catalog_index().get(id).cloned() } /// Dialog defaults for `id` (`None` for unknown effects). pub fn default_params(id: &str) -> Option { - effect_info(id).map(|e| e.defaults) + catalog_index().get(id).map(|e| e.defaults.clone()) } /// `params` layered over the defaults of `id` (unknown keys are kept). pub fn merged_params(id: &str, params: &Value) -> Value { - let mut out = match default_params(id) { - Some(Value::Object(m)) => m, + let mut out = match catalog_index().get(id).map(|e| &e.defaults) { + Some(Value::Object(m)) => m.clone(), _ => Map::new(), }; if let Value::Object(p) = params { @@ -236,7 +242,7 @@ pub fn merged_params(id: &str, params: &Value) -> Value { /// A new effect record with default parameters (overridden by `params`). pub fn new_effect(id: &str, params: &Value) -> Option { - effect_info(id)?; + catalog_index().get(id)?; Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true }) } @@ -247,7 +253,7 @@ pub fn is_raster(id: &str) -> bool { /// Does `id` change geometry? pub fn is_geometry(id: &str) -> bool { - !is_raster(id) && !is_pathfinder(id) && effect_info(id).is_some() + !is_raster(id) && !is_pathfinder(id) && catalog_index().contains_key(id) } /// Any visible geometry effect in the list? diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index a18ffe13b..1286b3d60 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -323,3 +323,15 @@ fn bad_params_fall_back_to_defaults() { let out = run("path.offsetPath", json!({"offset": "12 pt"}), &square()); assert!(close(out.bounds().unwrap().width(), 124.0, 0.1)); } + +#[test] +#[ignore] +fn bench_catalog_lookup() { + let t = std::time::Instant::now(); + let mut n = 0; + for _ in 0..10_000 { + n += crate::is_geometry("distort.roughen") as usize; + n += crate::merged_params("stylize.dropShadow", &serde_json::Value::Null).as_object().unwrap().len(); + } + eprintln!("10k lookups: {:?} ({n})", t.elapsed()); +} diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 3696652fa..c0d3f4aff 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -129,7 +129,165 @@ fn pcolor(c: &vectorcraft_doc::color::Color) -> peniko::Color { peniko::Color::new([r, g, b, 1.0]) } +/// A cached shadow / glow raster: premultiplied, already tinted and faded, positioned relative to +/// the view-space position of `anchor` (a document point). +pub(crate) struct ShadowEntry { + node: Node, + linear: [u64; 4], + anchor: vectorcraft_geom::Point, + /// Pixel offset of the raster's top-left from the anchor's view position. + rel: (f64, f64), + image: std::sync::Arc, + pub(crate) stamp: u64, +} + +/// Box sizes approximating a Gaussian of `sigma` with three box blurs. +fn gauss_boxes(sigma: f64) -> [usize; 3] { + let n = 3.0; + let w_ideal = (12.0 * sigma * sigma / n + 1.0).sqrt(); + let mut wl = w_ideal.floor() as i64; + if wl % 2 == 0 { + wl -= 1; + } + let wu = wl + 2; + let m_ideal = (12.0 * sigma * sigma - n * (wl * wl) as f64 - 4.0 * n * wl as f64 - 3.0 * n) / (-4.0 * wl as f64 - 4.0); + let m = m_ideal.round() as i64; + let r = |i: i64| (((if i < m { wl } else { wu }) - 1) / 2).max(0) as usize; + [r(0), r(1), r(2)] +} + +/// One horizontal box-blur pass of radius `r` over rows of `w` (running sum, edges as zero). +fn box_blur_h(src: &[f32], dst: &mut [f32], w: usize, h: usize, r: usize) { + if r == 0 { + dst.copy_from_slice(src); + return; + } + let norm = 1.0 / (2 * r + 1) as f32; + for y in 0..h { + let row = &src[y * w..(y + 1) * w]; + let out = &mut dst[y * w..(y + 1) * w]; + let mut acc: f32 = row.iter().take(r + 1).sum(); + for x in 0..w { + out[x] = acc * norm; + if x + r + 1 < w { + acc += row[x + r + 1]; + } + if x >= r { + acc -= row[x - r]; + } + } + } +} + +fn transpose(src: &[f32], dst: &mut [f32], w: usize, h: usize) { + for y in 0..h { + for x in 0..w { + dst[x * h + y] = src[y * w + x]; + } + } +} + +/// Gaussian-blur an alpha plane in place (three box passes per axis). +fn blur_alpha(a: &mut Vec, w: usize, h: usize, sigma: f64) { + if sigma < 0.2 { + return; + } + let boxes = gauss_boxes(sigma); + let mut tmp = vec![0.0; a.len()]; + for r in boxes { + box_blur_h(a, &mut tmp, w, h, r); + std::mem::swap(a, &mut tmp); + } + transpose(a, &mut tmp, w, h); + std::mem::swap(a, &mut tmp); + for r in boxes { + box_blur_h(a, &mut tmp, h, w, r); + std::mem::swap(a, &mut tmp); + } + transpose(a, &mut tmp, h, w); + std::mem::swap(a, &mut tmp); +} + impl Renderer { + /// Drop shadow / outer glow `fx` (index `i` in the object's raster effects) from a cached raster: + /// the silhouette is rendered once into a small crop, blurred and tinted on the CPU, and reused + /// while the object and the zoom/rotation are unchanged (pans only move it). Returns false when + /// the effect isn't a shadow/glow or the raster would be too large (caller falls back). + #[allow(clippy::too_many_arguments)] + fn draw_shadow_cached( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + n: &Node, + i: usize, + fx: &RasterFx, + g: &BezPath, + rule: FillRule, + gctx: &GeomContext, + reach: Rect, + ) -> bool { + let (mode, opacity, dx, dy, blur, color) = match fx { + RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => (*mode, *opacity, *dx, *dy, *blur, *color), + RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color), + _ => return false, + }; + let c = f.view.as_coeffs(); + let linear = [c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()]; + let key = (n as *const Node as usize, i); + let stamp = self.stamp; + let hit = self.shadows.get_mut(&key).filter(|e| e.linear == linear && e.node == *n); + let (image, x, y) = match hit { + Some(e) => { + e.stamp = stamp; + let p = f.view * e.anchor; + (e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round()) + } + None => { + let spread = blur.max(0.0) * 1.5 / f.px + 2.0; + let reach = reach + vectorcraft_geom::Vec2::new(dx, dy); + let r = f.view.transform_rect_bbox(reach).inflate(spread, spread); + let (x0, y0) = (r.x0.floor(), r.y0.floor()); + let (w, h) = (r.x1.ceil() - x0, r.y1.ceil() - y0); + if !(1.0..=8192.0).contains(&w) || !(1.0..=8192.0).contains(&h) || w * h > 4.0e6 { + return false; + } + let (w, h) = (w as u16, h as u16); + // Silhouette (the object's painted alpha), offset by the shadow distance. + let mut off = crate::single_threaded_context(w, h); + let shifted = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view * Affine::translate((dx, dy)), ..*f }; + self.paint_items(&mut off, &shifted, n, g, rule, gctx); + off.flush(); + let mut pm = vello_cpu::Pixmap::new(w, h); + off.render(&mut pm, &mut self.resources); + let (wu, hu) = (w as usize, h as usize); + let mut a: Vec = pm.data().iter().map(|p| p.a as f32).collect(); + blur_alpha(&mut a, wu, hu, blur / 2.0 / f.px); + let [cr, cg, cb] = color.to_rgb(); + let tint = |v: f32, ch: f32| (v * ch).round().clamp(0.0, 255.0) as u8; + for (px, av) in pm.data_mut().iter_mut().zip(&a) { + let al = (av * opacity).clamp(0.0, 255.0); + *px = vello_cpu::color::PremulRgba8 { r: tint(al, cr), g: tint(al, cg), b: tint(al, cb), a: al.round() as u8 }; + } + let image = std::sync::Arc::new(pm); + let anchor = reach.origin(); + let p = f.view * anchor; + self.shadows.insert(key, ShadowEntry { node: n.clone(), linear, anchor, rel: (x0 - p.x, y0 - p.y), image: image.clone(), stamp }); + (image, x0, y0) + } + }; + // The blend mode applies per draw: a blend layer would be a full-viewport compositing pass + // per shadow. The raster sits on whole pixels, so the cheapest sampler is exact. + let (w, h) = (image.width() as f64, image.height() as f64); + ctx.set_transform(Affine::translate((x, y))); + ctx.set_blend_mode(blend_mode(mode)); + let sampler = peniko::ImageSampler { quality: peniko::ImageQuality::Low, ..Default::default() }; + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler }); + ctx.fill_rect(&Rect::new(0.0, 0.0, w, h)); + ctx.set_blend_mode(blend_mode(vectorcraft_doc::color::BlendMode::Normal)); + ctx.set_transform(Affine::IDENTITY); + true + } + /// `draw_shape` for nodes with live effects. pub(crate) fn draw_shape_fx(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) { let g = effected_path(n, bp); @@ -146,7 +304,10 @@ impl Renderer { let outset = n.appearance.outset(); let reach = g.bounding_box().inflate(outset, outset); // Below the object: shadows and outer glows. - for fx in rfx.iter().filter(|x| x.is_below()) { + for (i, fx) in rfx.iter().enumerate().filter(|(_, x)| x.is_below()) { + if self.draw_shadow_cached(ctx, f, n, i, fx, &g, rule, &gctx, reach) { + continue; + } // The offset is applied to the geometry rather than in the filter: vello_cpu drops // layer content that lies entirely outside the viewport before filtering. let (mode, opacity, dx, dy, blur, filter) = match fx { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 55d9846ec..95a4598ce 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -195,6 +195,8 @@ pub struct Renderer { brushes: brush_fx::BrushCache, /// Evaluated blends/envelopes and tessellated meshes. live: live::LiveCache, + /// Blurred, tinted drop shadow / outer glow rasters per object and effect (see `fx`). + shadows: PtrMap<(usize, usize), fx::ShadowEntry>, } #[derive(Clone, Copy, Debug, Default)] @@ -239,6 +241,7 @@ impl Renderer { images: HashMap::new(), stats: FrameStats::default(), brushes: Default::default(), + shadows: PtrMap::default(), live: live::LiveCache::default(), } } @@ -305,6 +308,9 @@ impl Renderer { if self.strokes.len() > 1024 { self.strokes.retain(|_, e| g - e.stamp <= 3); } + if self.shadows.len() > 256 { + self.shadows.retain(|_, e| g - e.stamp <= 3); + } ctx.flush(); let mut pm = Pixmap::new(w, h); ctx.render(&mut pm, &mut self.resources); @@ -441,9 +447,12 @@ impl Renderer { /// Everything [`Self::draw_arc`] does after culling. fn draw_arc_body(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc) { // Fast path for plain paths: cached geometry, opacity folded into the paint. + // A blend mode on a single plain fill applies per draw (exact for one paint operation); + // a blend layer would composite the whole viewport. + let blended = a.blend != vectorcraft_color::BlendMode::Normal; if let NodeKind::Path { rule, guide: false, .. } = &a.kind && !f.opts.outline - && a.blend == vectorcraft_color::BlendMode::Normal + && (!blended || single_plain_fill(a)) && !a.isolate && !fx::has_fx(a) && (a.opacity >= 1.0 || painted_items(a) == 1) @@ -451,7 +460,13 @@ impl Renderer { { self.alpha = a.opacity.clamp(0.0, 1.0); self.cur = Some(a.clone()); + if blended { + ctx.set_blend_mode(blend_mode(a.blend)); + } self.draw_shape(ctx, f, a, &bp, *rule); + if blended { + ctx.set_blend_mode(blend_mode(vectorcraft_color::BlendMode::Normal)); + } self.cur = None; self.alpha = 1.0; self.stats.drawn += 1; @@ -840,6 +855,16 @@ pub(crate) fn single_threaded_context(w: u16, h: u16) -> RenderContext { RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }) } +/// Exactly one visible painted item, a fill with its own Normal blend and full opacity (one +/// paint operation, so an object blend mode can be applied per draw). +fn single_plain_fill(n: &Node) -> bool { + let mut painted = n.appearance.items.iter().filter(|i| match i { + AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(), + AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0, + }); + matches!((painted.next(), painted.next()), (Some(AppearanceItem::Fill(f)), None) if f.blend == vectorcraft_color::BlendMode::Normal && f.opacity >= 1.0 && !matches!(f.paint, vectorcraft_color::Paint::Pattern { .. })) +} + /// Number of visible painted fill/stroke items (opacity folding is exact only for one). fn painted_items(n: &Node) -> usize { n.appearance diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index b723cf267..1397f2ba6 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -270,3 +270,55 @@ fn trim_view_clips_to_artboards() { assert_eq!(r.pixel(75, 75), [255, 255, 255, 255]); assert_eq!(render(&d).pixel(75, 75), [255, 0, 0, 255]); } + +#[test] +fn drop_shadow_profile_and_cache() { + let mut d = Document::new(100.0, 100.0); + let mut n = rect_node(&mut d, Rect::new(20.0, 20.0, 60.0, 60.0), Paint::solid(Color::WHITE), Paint::None, 0.0); + n.appearance.effects.push(vectorcraft_doc::Effect { + id: "stylize.dropShadow".into(), + params: serde_json::json!({"x": 10.0, "y": 10.0, "blur": 4.0, "opacity": 75.0}), + visible: true, + }); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + for threads in [0, 4] { + let mut r = Renderer::new(); + r.threads = threads; + let a = r.render(&d, 100, 100, Affine::IDENTITY, &opts); + // Inside the shadow (not under the object): 75% black. At its blurred edge: about half. + let inside = a.pixel(65, 50)[0] as i32; + assert!((inside - 64).abs() <= 6, "inside {inside}"); + let edge = a.pixel(70, 50)[0] as i32; + assert!((edge - 160).abs() <= 20, "edge {edge}"); + assert_eq!(a.pixel(85, 50), [255, 255, 255, 255]); + // Second frame comes from the cache: identical. A pan moves it rigidly. + let b = r.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!(a.pixels, b.pixels); + let c = r.render(&d, 100, 100, Affine::translate((-5.0, 0.0)), &opts); + assert_eq!(c.pixel(60, 50), a.pixel(65, 50)); + assert_eq!(c.pixel(65, 50), a.pixel(70, 50)); + } +} + +#[test] +fn blend_mode_on_a_single_fill_is_exact_without_a_layer() { + let mut d = Document::new(100.0, 100.0); + let red = rect_node(&mut d, Rect::new(0.0, 0.0, 50.0, 100.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let mut over = rect_node(&mut d, Rect::new(25.0, 0.0, 75.0, 100.0), Paint::solid(Color::rgb(0.5, 0.5, 1.0)), Paint::None, 0.0); + over.blend = vectorcraft_color::BlendMode::Multiply; + let l = d.layers[0].id; + d.insert(Some(l), 0, red).unwrap(); + d.insert(Some(l), 1, over).unwrap(); + for threads in [0, 4] { + let mut r = Renderer::new(); + r.threads = threads; + let img = r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }); + let [rr, g, b, _] = img.pixel(35, 50); + assert!((rr as i32 - 128).abs() <= 2 && g <= 1 && b <= 1, "multiply over red: {:?}", img.pixel(35, 50)); + let [rr, g, b, _] = img.pixel(65, 50); + assert!((rr as i32 - 128).abs() <= 2 && (g as i32 - 128).abs() <= 2 && b >= 253, "multiply over white"); + assert_eq!(img.pixel(10, 50), [255, 0, 0, 255]); + } +}