mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
co-authored by
Claude Opus 5.5
parent
4745f39c6c
commit
3b48776607
@@ -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?
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user