Merge PR #476 from NikitkaRa: Render: budget shape path-gradient work on dense outlines (#404)

This commit is contained in:
Brandon Thomas
2026-10-14 13:08:44 -04:00
+121 -1
View File
@@ -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<Vec<Point>> {
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<Vec<Point>>, tol: f64, max_edges: usize) -> Vec<Vec<Point>> {
let edges = |o: &[Vec<Point>]| o.iter().map(|p| p.len().saturating_sub(1)).sum::<usize>();
if edges(&outline) <= max_edges {
return outline;
}
let tol = if tol.is_finite() && tol > 0.0 { tol } else { 0.0 };
let mut out: Vec<Vec<Point>> = outline
.into_iter()
.map(|poly| {
let mut kept: Vec<Point> = 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<Point> = 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<Rgba> {
(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::<usize>() >= 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))];