M5.2: render perf — drop shadows/outer glows from a cached CPU-blurred raster composited per draw (600 shadows: ~300–1500 ms → 17–70 ms/frame); object blend modes on single plain fills applied per draw instead of a full-viewport layer; effect catalogue indexed once (lookups 300× faster)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 23:31:22 -04:00
co-authored by Claude Opus 5.5
parent 4745f39c6c
commit 3b48776607
5 changed files with 264 additions and 8 deletions
+12 -6
View File
@@ -210,20 +210,26 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
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::collections::HashMap<&'static str, EffectInfo>> = 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<EffectInfo> {
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<Value> {
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> {
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?
+12
View File
@@ -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());
}
+162 -1
View File
@@ -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<vello_cpu::Pixmap>,
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<f32>, 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<f32> = 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 {
+26 -1
View File
@@ -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<Node>) {
// 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
+52
View File
@@ -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]);
}
}