From ce878ef74ee1a05be27786acaf601461da14e64c Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 20:49:02 +0300 Subject: [PATCH] Render: budget shape path-gradient work on dense outlines (#404) A shape path gradient tested every raster pixel against every flattened outline edge, so an outline with tens of thousands of segments cost billions of tests and stalled rendering. The outline is now simplified before rasterising so pixels x edges stays under 64M: points closer than a raster pixel are dropped first, then every k-th point is kept, with each polygon left closed and at least 8 edges. Co-authored-by: Cursor --- crates/render/src/pathgrad.rs | 122 +++++++++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 1 deletion(-) diff --git a/crates/render/src/pathgrad.rs b/crates/render/src/pathgrad.rs index 7402ab2c..168d9e0a 100644 --- a/crates/render/src/pathgrad.rs +++ b/crates/render/src/pathgrad.rs @@ -18,6 +18,13 @@ use vello_cpu::{Pixmap, RenderContext, peniko}; /// Longest side of the rasterised gradient, in pixels (bilinear sampling smooths the rest). const MAX_SIDE: f64 = 384.0; +/// Most pixel × outline-edge tests one `shape` gradient may cost; a denser outline is simplified +/// to fit (each pixel tests every edge). +const MAX_EDGE_WORK: u64 = 64_000_000; + +/// Fewest edges a simplified polygon keeps, so a coarse outline still has a shape. +const MIN_POLY_EDGES: usize = 8; + /// Colour position (0 at the focus, 1 at the edge) of `p` for path `kind` in `bounds` with the /// focus rect `focus` (fractions inset from left, top, right, bottom), `outline` being the /// shape's flattened polygon(s) for `shape`. @@ -136,6 +143,65 @@ pub fn polygons(path: &BezPath) -> Vec> { out } +/// `outline` cut down to at most about `max_edges` edges in total: first by dropping points +/// closer than `tol` to the last kept one (detail finer than a raster pixel), then, if still too +/// many, by keeping every k-th point. Each polygon stays closed. Returns it unchanged when it fits. +fn simplify(outline: Vec>, tol: f64, max_edges: usize) -> Vec> { + let edges = |o: &[Vec]| o.iter().map(|p| p.len().saturating_sub(1)).sum::(); + if edges(&outline) <= max_edges { + return outline; + } + let tol = if tol.is_finite() && tol > 0.0 { tol } else { 0.0 }; + let mut out: Vec> = outline + .into_iter() + .map(|poly| { + let mut kept: Vec = Vec::with_capacity(poly.len()); + let last = poly.last().copied(); + for p in poly { + if kept.last().is_none_or(|q| (p - *q).hypot() >= tol) { + kept.push(p); + } + } + if let (Some(l), Some(k)) = (last, kept.last().copied()) + && k != l + { + kept.push(l); + } + kept + }) + .filter(|p| p.len() > 2) + .collect(); + let total = edges(&out); + if total > max_edges { + let k = total.div_ceil(max_edges.max(1)).max(2); + for poly in &mut out { + let n = poly.len(); + if n <= MIN_POLY_EDGES + 1 { + continue; + } + let k = k.min((n - 1) / MIN_POLY_EDGES).max(1); + let last = poly.last().copied(); + let mut kept: Vec = poly.iter().step_by(k).copied().collect(); + if let (Some(l), Some(q)) = (last, kept.last().copied()) + && q != l + { + kept.push(l); + } + *poly = kept; + } + // Polygons too small to thin stay whole; drop the smallest ones if they alone overflow. + if edges(&out) > max_edges { + out.sort_by_key(|p| std::cmp::Reverse(p.len())); + let mut sum = 0usize; + out.retain(|p| { + sum = sum.saturating_add(p.len().saturating_sub(1)); + sum <= max_edges + }); + } + } + out +} + /// A 256-entry colour ramp for the sorted, already expanded `stops` (linear in sRGB between them). pub fn ramp(stops: &[(f64, Rgba)]) -> Vec { (0..256) @@ -166,7 +232,14 @@ pub fn fill(ctx: &mut RenderContext, kind: &str, focus: [f64; 4], stops: &[(f64, let longest = bounds.width().max(bounds.height()) * k; let s = (MAX_SIDE / longest).min(1.0) * k; let (pw, ph) = (((bounds.width() * s).ceil() as u32).clamp(1, 4096), ((bounds.height() * s).ceil() as u32).clamp(1, 4096)); - let outline = if kind == "shape" { polygons(path) } else { Vec::new() }; + let outline = if kind == "shape" { + let pixels = u64::from(pw).saturating_mul(u64::from(ph)).max(1); + let max_edges = usize::try_from(MAX_EDGE_WORK / pixels).unwrap_or(usize::MAX).max(MIN_POLY_EDGES); + let pixel = (bounds.width() / f64::from(pw)).min(bounds.height() / f64::from(ph)); + simplify(polygons(path), pixel, max_edges) + } else { + Vec::new() + }; let lut = ramp(stops); let a = alpha.clamp(0.0, 1.0); let mut data = Vec::with_capacity((pw * ph) as usize); @@ -252,6 +325,53 @@ mod tests { assert!((half - 0.5).abs() < 0.02, "{half}"); } + fn many_segment_square(n: usize) -> BezPath { + // A 200x200 square whose sides are split into `n` segments each. + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + let corners = [(200.0, 0.0), (200.0, 200.0), (0.0, 200.0), (0.0, 0.0)]; + let mut from = (0.0, 0.0); + for to in corners { + for i in 1..=n { + let t = i as f64 / n as f64; + p.line_to((from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t)); + } + from = to; + } + p.close_path(); + p + } + + #[test] + fn dense_outlines_are_simplified_to_the_work_budget() { + // Below the per-polygon flatten cap, so the simplifier has to do the work. + let polys = polygons(&many_segment_square(20_000)); + assert!(polys.iter().map(|p| p.len() - 1).sum::() >= 80_000); + let b = Rect::new(0.0, 0.0, 200.0, 200.0); + let simple = simplify(polys, 200.0 / 384.0, 400); + let edges: usize = simple.iter().map(|p| p.len() - 1).sum(); + assert!((MIN_POLY_EDGES..=400).contains(&edges), "{edges}"); + // Still the square: half way to a side or a corner is 0.5. + for q in [Point::new(150.0, 100.0), Point::new(150.0, 150.0), Point::new(100.0, 50.0)] { + let t = position("shape", b, CENTRE, &simple, q); + assert!((t - 0.5).abs() < 0.01, "{q:?} {t}"); + } + // Small outlines pass through untouched. + let tri = vec![vec![Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(0.0, 1.0), Point::new(0.0, 0.0)]]; + assert_eq!(simplify(tri.clone(), 10.0, 400), tri); + } + + #[test] + fn a_dense_shape_gradient_renders_promptly() { + let path = many_segment_square(20_000); + let mut ctx = RenderContext::new(400, 400); + let stops = [(0.0, Rgba::rgb(0, 0, 0)), (1.0, Rgba::rgb(255, 255, 255))]; + let t = std::time::Instant::now(); + fill(&mut ctx, "shape", CENTRE, &stops, Rect::new(0.0, 0.0, 200.0, 200.0), &path, Affine::scale(2.0), 1.0); + // Unbudgeted this is 147k pixels × 80k edges ≈ 1.2e10 tests (minutes); budgeted, well under a second in release. + assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed()); + } + #[test] fn the_ramp_runs_through_the_stops() { let stops = [(0.0, Rgba::rgb(0, 0, 0)), (0.5, Rgba::rgb(128, 128, 128)), (1.0, Rgba::rgb(255, 255, 255))];