SVG: rasterise each shape within its own bounds

Every fill and stroke allocated a coverage mask the size of the whole canvas and painted every
canvas pixel, so a vector file cost shapes × canvas area: SceneYard "Retro Desktop Movie
Popups" spent 556 s per frame on one 4001×2501 Illustrator layer (568 s a frame in all).

Each shape now rasterises and paints only inside its device-space bounding box (+1 px of
anti-aliasing) clipped to the canvas; a stroke uses the bounds of its outline. The frame
renders in 5–7 s (bench, no cache). Pixels match the whole-canvas version except a few
anti-aliased edge pixels (float rounding of the re-based transform). A test checks that every
pixel a shape covers lies inside its rectangle, and pins the output.

(cherry picked from commit 58d369b5044bf03698f0fae6915843c3d400b1d1)

(cherry picked from commit 7fdbbc5188ab5d420448f47a726bee6a046e6936)
(cherry picked from commit 5efd7aa000874a3a150abd808c7bce158b713535)
This commit is contained in:
Elias
2026-10-11 11:50:13 +00:00
committed by Claude
parent 9d85bfde72
commit ed5c3685e7
2 changed files with 158 additions and 13 deletions
+53 -13
View File
@@ -8,7 +8,7 @@ use rayon::prelude::*;
use crate::{BlendMode, Doc, FillRule, Gradient, GradientKind, Group, Node, Paint, Shape, Spread};
fn mat3(a: Affine) -> Mat3 {
pub(crate) fn mat3(a: Affine) -> Mat3 {
let c = a.as_coeffs();
Mat3([[c[0], c[2], c[4]], [c[1], c[3], c[5]], [0.0, 0.0, 1.0]])
}
@@ -395,14 +395,15 @@ fn draw_shape(s: &Shape, m: Affine, opacity: f64, dst: &mut Image) {
let m = m * s.transform;
let path = s.geom.to_path();
let bbox = path.bounding_box();
let (w, h) = (dst.width, dst.height);
if let Some(fill) = &s.fill {
let rule = match s.fill_rule {
FillRule::NonZero => effectcraft_path::FillRule::NonZero,
FillRule::EvenOdd => effectcraft_path::FillRule::EvenOdd,
};
let cov = effectcraft_path::fill_coverage(std::slice::from_ref(&path), &mat3(m), w, h, rule);
paint(dst, &cov, fill, (opacity * s.fill_opacity) as f32, m, bbox);
if let Some(r) = device_rect(&path, m, dst) {
let cov = effectcraft_path::fill_coverage(std::slice::from_ref(&path), &mat3(r.local(m)), r.w, r.h, rule);
paint(dst, &cov, r, fill, (opacity * s.fill_opacity) as f32, m, bbox);
}
}
if let Some(st) = &s.stroke {
let style = effectcraft_path::StrokeStyle {
@@ -422,15 +423,53 @@ fn draw_shape(s: &Shape, m: Affine, opacity: f64, dst: &mut Image) {
taper: None,
wave: None,
};
let cov = effectcraft_path::stroke_coverage(std::slice::from_ref(&path), &style, &mat3(m), w, h);
paint(dst, &cov, &st.paint, (opacity * st.opacity) as f32, m, bbox);
// The stroke's outline (what `stroke_coverage` fills), bounded like a fill.
if let Some(outline) = effectcraft_path::stroke_outline(std::slice::from_ref(&path), &style, mat3(m).mean_scale())
&& let Some(r) = device_rect(&outline, m, dst)
{
let cov = effectcraft_path::fill_coverage(std::slice::from_ref(&outline), &mat3(r.local(m)), r.w, r.h, effectcraft_path::FillRule::NonZero);
paint(dst, &cov, r, &st.paint, (opacity * st.opacity) as f32, m, bbox);
}
}
}
/// Composite paint through coverage.
fn paint(dst: &mut Image, cov: &Mask, p: &Paint, opacity: f32, m: Affine, bbox: kurbo::Rect) {
/// The pixels of a canvas a shape can touch: its device-space bounding box (plus a pixel of
/// anti-aliasing) clipped to the canvas. Rasterising and painting only there keeps a shape's
/// cost proportional to its own size, not the canvas'.
#[derive(Clone, Copy, Debug)]
pub(crate) struct PixRect {
pub(crate) x: u32,
pub(crate) y: u32,
pub(crate) w: u32,
pub(crate) h: u32,
}
impl PixRect {
/// `m` (shape → canvas) re-based so the rectangle's corner is the origin.
fn local(self, m: Affine) -> Affine {
Affine::translate((-(self.x as f64), -(self.y as f64))) * m
}
}
pub(crate) fn device_rect(path: &kurbo::BezPath, m: Affine, dst: &Image) -> Option<PixRect> {
let b = (m * path.clone()).bounding_box();
let (cw, ch) = (dst.width as f64, dst.height as f64);
if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) {
// Unknown extent: the whole canvas, as before.
return (dst.width > 0 && dst.height > 0).then_some(PixRect { x: 0, y: 0, w: dst.width, h: dst.height });
}
let x0 = (b.x0.floor() - 1.0).clamp(0.0, cw);
let y0 = (b.y0.floor() - 1.0).clamp(0.0, ch);
let x1 = (b.x1.ceil() + 1.0).clamp(0.0, cw);
let y1 = (b.y1.ceil() + 1.0).clamp(0.0, ch);
(x1 > x0 && y1 > y0).then_some(PixRect { x: x0 as u32, y: y0 as u32, w: (x1 - x0) as u32, h: (y1 - y0) as u32 })
}
/// Composite paint through coverage `cov` of the canvas rectangle `r`.
fn paint(dst: &mut Image, cov: &Mask, r: PixRect, p: &Paint, opacity: f32, m: Affine, bbox: kurbo::Rect) {
let w = dst.width as usize;
if w == 0 {
let (rx, ry, rw, rh) = (r.x as usize, r.y as usize, r.w as usize, r.h as usize);
if w == 0 || rw == 0 || cov.data.len() < rw * rh {
return;
}
let shader = match p {
@@ -444,13 +483,14 @@ fn paint(dst: &mut Image, cov: &Mask, p: &Paint, opacity: f32, m: Affine, bbox:
Shader::Gradient(g, to_grad)
}
};
dst.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
for (x, d) in row.iter_mut().enumerate() {
let c = cov.data[y * w + x];
dst.data.par_chunks_mut(w).enumerate().skip(ry).take(rh).for_each(|(y, row)| {
let Some(crow) = cov.data.get((y - ry) * rw..(y - ry + 1) * rw) else { return };
let Some(drow) = row.get_mut(rx..rx + rw) else { return };
for (i, (d, &c)) in drow.iter_mut().zip(crow).enumerate() {
if c <= 0.0 {
continue;
}
let col = shader.at(x as f64 + 0.5, y as f64 + 0.5);
let col = shader.at((rx + i) as f64 + 0.5, y as f64 + 0.5);
let a = col[3] * c * opacity;
if a <= 0.0 {
continue;
+105
View File
@@ -123,3 +123,108 @@ fn deep_nesting_errors_instead_of_overflowing_the_stack() {
assert_eq!(crate::xml_depth(&s), 2);
assert!(crate::parse(s.as_bytes()).is_ok());
}
/// Many small shapes over a large canvas: fills (both rules), strokes (miter, round, square
/// caps, dashes), linear and radial gradients in user and bounding-box units, rotated and
/// skewed shapes, shapes partly or wholly off the canvas and a clipped group.
fn stress_svg() -> String {
let mut s = String::from(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"640\" height=\"400\"><defs>\
<linearGradient id=\"lg\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\"><stop offset=\"0\" stop-color=\"#f00\"/><stop offset=\"1\" stop-color=\"#00f\" stop-opacity=\"0.5\"/></linearGradient>\
<radialGradient id=\"rg\" gradientUnits=\"userSpaceOnUse\" cx=\"320\" cy=\"200\" r=\"150\" fx=\"300\" fy=\"180\"><stop offset=\"0\" stop-color=\"#ff0\"/><stop offset=\"1\" stop-color=\"#0a0\"/></radialGradient>\
<clipPath id=\"cp\"><circle cx=\"500\" cy=\"300\" r=\"60\"/></clipPath></defs>",
);
for i in 0..60 {
let (x, y) = (((i * 53) % 700) as f64 - 30.0, ((i * 37) % 460) as f64 - 30.0);
let fill = ["#3a7", "url(#lg)", "url(#rg)", "none"][i % 4];
let stroke = ["none", "#222", "#c0c", "url(#lg)"][(i / 2) % 4];
let extra = match i % 5 {
0 => " stroke-linejoin=\"miter\" stroke-miterlimit=\"10\"".to_string(),
1 => " stroke-linecap=\"round\" stroke-linejoin=\"round\"".to_string(),
2 => " stroke-linecap=\"square\" stroke-dasharray=\"6 3\"".to_string(),
3 => format!(" transform=\"rotate({} {x} {y})\"", i * 17),
_ => format!(" transform=\"translate({x} {y}) skewX(20) scale(1.5 0.7)\" fill-rule=\"evenodd\""),
};
let w = 8 + (i * 7) % 50;
s += &format!(
"<path d=\"M{x} {y} l{w} 3 l-5 {w} l-{w} -9 z M{} {} l6 0 l0 6 z\" fill=\"{fill}\" stroke=\"{stroke}\" stroke-width=\"{}\" opacity=\"0.9\"{extra}/>",
x + 2.0,
y + 2.0,
1 + i % 7
);
}
s += "<g clip-path=\"url(#cp)\"><rect x=\"420\" y=\"220\" width=\"200\" height=\"200\" fill=\"url(#lg)\" stroke=\"#000\" stroke-width=\"9\"/></g></svg>";
s
}
fn fnv(img: &effectcraft_raster::Image) -> u64 {
let mut h = 0xcbf29ce484222325u64;
for p in &img.data {
for c in p {
for b in c.to_bits().to_le_bytes() {
h = (h ^ b as u64).wrapping_mul(0x100000001b3);
}
}
}
h
}
/// Rasterising each shape only within its own device bounds (it covered the whole canvas for
/// every shape: O(shapes × canvas), minutes per frame for an Illustrator file of thousands of
/// small shapes, SceneYard "Retro Desktop Movie Popups") keeps the output stable. Against the
/// whole-canvas version the pixels differ only on a few anti-aliased edges (float rounding of
/// the re-based transform: basic 26 of 20 000 pixels by at most 0.17, stress 5 of 256 000).
#[test]
fn rasterising_within_shape_bounds_is_stable() {
let stress = stress_svg();
let cases: [(&str, &[u8], u32, u32, f64, u64); 4] = [
("basic", BASIC, 200, 100, 1.0, PINNED[0]),
("paths", PATHS, 0, 0, 1.0, PINNED[1]),
("stress", stress.as_bytes(), 640, 400, 1.0, PINNED[2]),
("stress-2.5x", stress.as_bytes(), 1600, 1000, 2.5, PINNED[3]),
];
for (name, bytes, w, h, scale, pinned) in cases {
let d = parse(bytes).unwrap();
let (w, h) = if w == 0 { d.pixel_size() } else { (w, h) };
let img = rasterize(&d, w, h, scale);
let got = fnv(&img);
if std::env::var_os("SVG_RASTER_PRINT").is_some() {
println!("{name}: 0x{got:016x}");
continue;
}
assert!(img.data.iter().any(|p| p[3] > 0.0), "{name} draws something");
assert_eq!(got, pinned, "{name} pixels changed");
}
}
const PINNED: [u64; 4] = [0xcc3e2c3d49c37fbf, 0xd99451e0745d7c28, 0x8c6ad41b7c4042cc, 0x1f2d56138fa0bc95];
/// Everything a shape's fill or stroke covers on the whole canvas lies inside the rectangle it is
/// rasterised in: bounding nothing away.
#[test]
fn shape_rectangles_hold_all_coverage() {
let d = parse(stress_svg().as_bytes()).unwrap();
let (w, h) = (640u32, 400u32);
let dst = effectcraft_raster::Image::new(w, h);
let mut checked = 0;
for (m, _, s) in d.flatten() {
let m = m * s.transform;
let path = s.geom.to_path();
let style = effectcraft_path::StrokeStyle { width: s.stroke.as_ref().map_or(0.0, |st| st.width), miter: 10.0, ..Default::default() };
let outline = effectcraft_path::stroke_outline(std::slice::from_ref(&path), &style, crate::raster::mat3(m).mean_scale());
for p in [Some(path.clone()), outline].into_iter().flatten() {
let full = effectcraft_path::fill_coverage(std::slice::from_ref(&p), &crate::raster::mat3(m), w, h, effectcraft_path::FillRule::NonZero);
let r = crate::raster::device_rect(&p, m, &dst);
for y in 0..h {
for x in 0..w {
if full.data[(y * w + x) as usize] > 0.0 {
let r = r.unwrap_or_else(|| panic!("covered pixel ({x},{y}) but no rectangle"));
assert!(x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h, "({x},{y}) outside {r:?}");
checked += 1;
}
}
}
}
}
assert!(checked > 10_000, "{checked}");
}