Render: bound bevel edge work, shape-gradient flattening and colour-glyph fills (#1786)

* Render: bound bevel edge work, shape-gradient flattening and colour-glyph fills

* address review: skip shape gradient fill when the outline is over the point limit

* address review: keep every contour of a compound bevel or shape gradient at a coarser raster, bound cubic subdivision before flattening

* Apply review fixes

- crates/render/src/pathgrad.rs

* Apply review fixes

- crates/render/src/pathgrad.rs

---------

Co-authored-by: autocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com>
This commit is contained in:
Dylan M. Taylorandautocraft-bot[bot] authored and GitHub committed 2026-11-11 14:33:29 -05:00
1 parent 46712d0293
commit 98eac5590d
3 files changed
+158 -29

No files matched your search

+4 -1
View File
@@ -125,10 +125,13 @@ pub fn draw(ctx: &mut RenderContext, bevel: &Bevel, fill: Option<Rgba>, path: &B
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, 2048), ((bounds.height() * s).ceil() as u32).clamp(1, 2048));
let polys = crate::pathgrad::polygons(path);
// Each pixel tests every edge, so a dense outline is simplified to the raster's edge budget.
let pixel = (bounds.width() / f64::from(pw)).min(bounds.height() / f64::from(ph));
let polys = crate::pathgrad::simplify(crate::pathgrad::polygons(path), pixel, crate::pathgrad::edge_budget(pw, ph));
if polys.is_empty() {
return;
}
let (pw, ph) = crate::pathgrad::fit_raster(polys.iter().map(|p| p.len().saturating_sub(1)).sum(), pw, ph);
let areas: Vec<f64> = polys.iter().map(|p| p.windows(2).map(|w| w[0].x * w[1].y - w[1].x * w[0].y).sum::<f64>() / 2.0).collect();
let face = lit.factor([0.0, 0.0, 1.0]);
let ratio = if bevel.w > 0.0 { bevel.h / bevel.w } else { 0.0 };
+15 -1
View File
@@ -11,6 +11,8 @@ use vello_cpu::{RenderContext, peniko};
/// Nesting depth of clips and layers past which a glyph opens no more (hostile fonts).
const MAX_DEPTH: usize = 64;
/// Fills and gradient stops one colour glyph may paint; the rest of a hostile glyph is skipped.
const MAX_WORK: usize = 100_000;
/// Paint `gid` of `face` as a colour glyph, its origin mapped by `at` (y down, points) at `scale`
/// points per font unit. `fg` is the text colour (palette index 0xFFFF) and `alpha` fades it all.
@@ -26,7 +28,7 @@ pub fn paint(ctx: &mut RenderContext, face: &FontFace, gid: u32, at: Affine, sca
.unwrap_or_default();
// Font units are y up; the slide is y down.
let base = at * Affine::scale(scale) * Affine::scale_non_uniform(1.0, -1.0);
let mut p = Painter { ctx, face, base, stack: vec![Affine::IDENTITY], palette, fg, opened: Vec::new() };
let mut p = Painter { ctx, face, base, stack: vec![Affine::IDENTITY], palette, fg, opened: Vec::new(), work: 0 };
p.ctx.push_layer(None, None, Some(alpha.clamp(0.0, 1.0) as f32), None, None);
let ok = glyph.paint(face.location(), &mut p).is_ok();
// A malformed glyph may leave layers open: close them all.
@@ -50,6 +52,8 @@ struct Painter<'a> {
/// Clips and composite groups pushed by the glyph, innermost last: true when a layer was
/// opened on the context for it (false past `MAX_DEPTH`, so its pop is skipped too).
opened: Vec<bool>,
/// Fills plus gradient stops painted so far, against [`MAX_WORK`].
work: usize,
}
fn affine(t: &Transform) -> Affine {
@@ -174,6 +178,16 @@ impl ColorPainter for Painter<'_> {
}
fn fill(&mut self, brush: Brush<'_>) {
let stops = match &brush {
Brush::Solid { .. } => 0,
Brush::LinearGradient { color_stops, .. } | Brush::RadialGradient { color_stops, .. } | Brush::SweepGradient { color_stops, .. } => {
color_stops.len()
}
};
self.work = self.work.saturating_add(1 + stops);
if self.work > MAX_WORK {
return;
}
match brush {
Brush::Solid { palette_index, alpha } => {
let c = self.color(palette_index, alpha);
+139 -27
View File
@@ -25,6 +25,39 @@ 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;
/// Most points `polygons` keeps from one path; a path flattening past this (a huge finite curve) is
/// not drawn rather than held in memory first.
const MAX_POINTS: usize = 2_000_000;
/// Quads beyond one per cubic that a path's cubics may subdivide into before [`polygons`] refuses it.
const MAX_EXTRA_QUADS: f64 = 200_000.0;
/// Flattening tolerance of [`polygons`], in path units.
const FLATTEN_TOLERANCE: f64 = 0.25;
/// The edge budget for a `pw` x `ph` raster where each pixel tests every edge.
pub(crate) fn edge_budget(pw: u32, ph: u32) -> usize {
let pixels = u64::from(pw).saturating_mul(u64::from(ph)).max(1);
usize::try_from(MAX_EDGE_WORK / pixels).unwrap_or(usize::MAX).max(MIN_POLY_EDGES)
}
/// `pw` x `ph` cut down (aspect kept) until every pixel testing all of `edges` stays within
/// [`MAX_EDGE_WORK`]; the raster is stretched when painted, so a many-contour outline keeps every
/// contour at a coarser sampling.
pub(crate) fn fit_raster(edges: usize, pw: u32, ph: u32) -> (u32, u32) {
let work = u64::from(pw).saturating_mul(u64::from(ph)).saturating_mul(edges.max(1) as u64);
if work <= MAX_EDGE_WORK {
return (pw, ph);
}
let k = (MAX_EDGE_WORK as f64 / work as f64).sqrt();
let w = ((f64::from(pw) * k) as u32).max(1);
let h = ((f64::from(ph) * k) as u32).max(1);
// A side held at 1 pixel leaves the other to take the rest of the cut.
let room = |side: u32| u32::try_from(MAX_EDGE_WORK / u64::from(side).saturating_mul(edges.max(1) as u64).max(1)).unwrap_or(u32::MAX).max(1);
let w = w.min(room(h));
(w, h.min(room(w)))
}
/// 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`.
@@ -112,23 +145,59 @@ fn shape_position(outline: &[Vec<Point>], c: Point, p: Point) -> f64 {
/// The outline flattened into closed polygons.
pub fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
// `kurbo::flatten` buffers all of a cubic's quads before calling back, and cannot be stopped
// once running, so bound the quads the cubics will make first (kurbo's own count: it splits
// cubics into quads at a tenth of the tolerance). An ordinary curve makes a few; only those
// past the first of each count.
let mut extra_quads = 0.0f64;
let mut at = Point::ZERO;
let mut start = Point::ZERO;
for el in path.iter() {
match el {
PathEl::MoveTo(p) => {
at = p;
start = p;
}
PathEl::LineTo(p) | PathEl::QuadTo(_, p) => at = p,
PathEl::CurveTo(a, b, p) => {
let err = ((b.to_vec2() * 3.0 - p.to_vec2()) - (a.to_vec2() * 3.0 - at.to_vec2())).hypot2();
extra_quads += (err / (432.0 * (FLATTEN_TOLERANCE * 0.1).powi(2))).powf(1.0 / 6.0).ceil().max(1.0) - 1.0;
// A non-finite estimate is over the limit too.
if extra_quads.is_nan() || extra_quads > MAX_EXTRA_QUADS {
return Vec::new();
}
at = p;
}
PathEl::ClosePath => at = start,
}
}
let mut out: Vec<Vec<Point>> = Vec::new();
let mut cur: Vec<Point> = Vec::new();
kurbo::flatten(path.iter(), 0.25, |el| match el {
PathEl::MoveTo(p) => {
if cur.len() > 1 {
out.push(std::mem::take(&mut cur));
}
cur = vec![p];
let mut total = 0usize;
kurbo::flatten(path.iter(), FLATTEN_TOLERANCE, |el| {
total += 1;
if total > MAX_POINTS {
return;
}
PathEl::LineTo(p) => cur.push(p),
PathEl::ClosePath => {
if let Some(f) = cur.first().copied() {
cur.push(f);
match el {
PathEl::MoveTo(p) => {
if cur.len() > 1 {
out.push(std::mem::take(&mut cur));
}
cur = vec![p];
}
PathEl::LineTo(p) => cur.push(p),
PathEl::ClosePath => {
if let Some(f) = cur.first().copied() {
cur.push(f);
}
}
_ => {}
}
_ => {}
});
if total > MAX_POINTS {
return Vec::new();
}
if cur.len() > 1 {
out.push(cur);
}
@@ -145,8 +214,9 @@ pub fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
/// `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>> {
/// many, by keeping every k-th point (a polygon too small to thin stays whole, and none is
/// dropped). Each polygon stays closed. Returns it unchanged when it fits.
pub(crate) 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;
@@ -189,15 +259,8 @@ fn simplify(outline: Vec<Vec<Point>>, tol: f64, max_edges: usize) -> Vec<Vec<Poi
}
*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
});
}
// Polygons too small to thin stay whole, so the total can still exceed `max_edges`: the
// caller shrinks its raster ([`fit_raster`]) rather than lose contours.
}
out
}
@@ -232,14 +295,20 @@ 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" {
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 (outline, pw, ph) = if kind == "shape" {
let max_edges = edge_budget(pw, ph);
let pixel = (bounds.width() / f64::from(pw)).min(bounds.height() / f64::from(ph));
simplify(polygons(path), pixel, max_edges)
let o = simplify(polygons(path), pixel, max_edges);
let (pw, ph) = fit_raster(o.iter().map(|p| p.len().saturating_sub(1)).sum(), pw, ph);
(o, pw, ph)
} else {
Vec::new()
(Vec::new(), pw, ph)
};
// An outline `polygons` refused (too many points) has no contours: skip the shape rather than
// paint it with the rectangle fallback's different gradient.
if kind == "shape" && outline.is_empty() {
return;
}
let lut = ramp(stops);
let a = alpha.clamp(0.0, 1.0);
let mut data = Vec::with_capacity((pw * ph) as usize);
@@ -372,6 +441,20 @@ mod tests {
assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed());
}
#[test]
fn a_shape_gradient_with_no_usable_outline_paints_nothing() {
// One polygon over the per-polygon cap is dropped by `polygons`.
let path = many_segment_square(26_000);
assert!(polygons(&path).is_empty());
let mut ctx = RenderContext::new(64, 64);
let stops = [(0.0, Rgba::rgb(0, 0, 0)), (1.0, Rgba::rgb(255, 255, 255))];
fill(&mut ctx, "shape", CENTRE, &stops, Rect::new(0.0, 0.0, 64.0, 64.0), &path, Affine::IDENTITY, 1.0);
ctx.flush();
let mut px = vec![0u8; 64 * 64 * 4];
ctx.render(vello_cpu::PixmapMut::new(64, 64, &mut px).unwrap(), &mut vello_cpu::Resources::new());
assert!(px.chunks(4).all(|p| p[3] == 0), "nothing was painted");
}
#[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))];
@@ -382,4 +465,33 @@ mod tests {
assert!((r[64].r as i32 - 64).abs() <= 2);
assert!(ramp(&[(0.3, Rgba::rgb(9, 9, 9))]).iter().all(|c| *c == Rgba::rgb(9, 9, 9)));
}
#[test]
fn huge_cubics_are_refused_before_flattening() {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
// About 250,000 quads each: over the limit.
for _ in 0..3 {
p.curve_to((1e15, -1e15), (-1e15, 1e15), (1.0, 1.0));
}
p.close_path();
assert!(polygons(&p).is_empty());
// A cubic of ordinary size still flattens.
let mut q = BezPath::new();
q.move_to((0.0, 0.0));
q.curve_to((100.0, 0.0), (100.0, 100.0), (0.0, 100.0));
q.close_path();
assert_eq!(polygons(&q).len(), 1);
}
#[test]
fn many_contours_keep_every_contour_at_a_coarser_raster() {
let square = |x: f64| vec![Point::new(x, 0.0), Point::new(x + 1.0, 0.0), Point::new(x + 1.0, 1.0), Point::new(x, 1.0), Point::new(x, 0.0)];
let polys: Vec<Vec<Point>> = (0..100).map(|i| square(f64::from(i) * 2.0)).collect();
let simple = simplify(polys, 0.001, 244);
assert_eq!(simple.len(), 100, "no contour is dropped");
let (pw, ph) = fit_raster(400, 512, 512);
assert!(u64::from(pw) * u64::from(ph) * 400 <= MAX_EDGE_WORK && pw > 100, "{pw}x{ph}");
assert_eq!(fit_raster(200, 512, 512), (512, 512));
}
}