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))];