mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 18:46:57 -06:00
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 <cursoragent@cursor.com>
386 lines
17 KiB
Rust
386 lines
17 KiB
Rust
//! Path gradients (`a:path`) that follow a rectangle or the shape's own outline, which no vello
|
||
//! gradient draws: they're rasterised into a small image of the bounds and painted with it.
|
||
//!
|
||
//! Measured on PowerPoint's export (black to grey to white from the centre):
|
||
//! - `rect`: the colour position is the larger of the horizontal and vertical distances from the
|
||
//! focus, each as a share of the distance to that side's edge (X-shaped contours);
|
||
//! - `shape`: contours are the outline scaled about the focus (an ellipse gives ellipses, a
|
||
//! triangle triangles): the share of the way from the focus to the outline along the same ray.
|
||
//!
|
||
//! `circle` stays a radial vello gradient (see `paint::gradient`).
|
||
|
||
use std::sync::Arc;
|
||
|
||
use deckcraft_color::Rgba;
|
||
use kurbo::{Affine, BezPath, PathEl, Point, Rect};
|
||
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`.
|
||
pub fn position(kind: &str, bounds: Rect, focus: [f64; 4], outline: &[Vec<Point>], p: Point) -> f64 {
|
||
let f = Rect::new(
|
||
bounds.x0 + bounds.width() * focus[0].clamp(0.0, 1.0),
|
||
bounds.y0 + bounds.height() * focus[1].clamp(0.0, 1.0),
|
||
bounds.x1 - bounds.width() * focus[2].clamp(0.0, 1.0),
|
||
bounds.y1 - bounds.height() * focus[3].clamp(0.0, 1.0),
|
||
);
|
||
let f = if f.x0 <= f.x1 && f.y0 <= f.y1 { f } else { Rect::from_center_size(f.center(), (0.0, 0.0)) };
|
||
match kind {
|
||
"shape" if !outline.is_empty() => {
|
||
// A focus rectangle (not a point) is the first colour throughout, as PowerPoint draws
|
||
// it; outside, the colour runs from the rectangle's edge to the outline along the ray
|
||
// from its centre.
|
||
if f.width() > 1e-9 && f.height() > 1e-9 {
|
||
if f.contains(p) {
|
||
return 0.0;
|
||
}
|
||
let c = f.center();
|
||
let to_outline = shape_position(outline, c, p);
|
||
let d = (p - c).hypot();
|
||
if to_outline <= 0.0 || d <= 0.0 {
|
||
return 1.0;
|
||
}
|
||
// Where the ray leaves the focus rectangle, as a share of the way to `p`.
|
||
let v = p - c;
|
||
let kx = if v.x.abs() > 1e-12 { (f.width() / 2.0) / v.x.abs() } else { f64::INFINITY };
|
||
let ky = if v.y.abs() > 1e-12 { (f.height() / 2.0) / v.y.abs() } else { f64::INFINITY };
|
||
let rect_share = kx.min(ky).min(1.0);
|
||
// p is `to_outline` of the way to the outline; the rectangle edge `rect_share` of
|
||
// the way to p.
|
||
let outline_share = 1.0 / to_outline; // outline distance in units of |p - c|
|
||
return ((1.0 - rect_share) / (outline_share - rect_share).max(1e-9)).clamp(0.0, 1.0);
|
||
}
|
||
shape_position(outline, f.center(), p)
|
||
}
|
||
_ => {
|
||
// Distance outside the focus rect along each axis, over the room to that side's edge.
|
||
let axis = |v: f64, lo: f64, hi: f64, edge_lo: f64, edge_hi: f64| {
|
||
if v < lo {
|
||
(lo - v) / (lo - edge_lo).max(1e-9)
|
||
} else if v > hi {
|
||
(v - hi) / (edge_hi - hi).max(1e-9)
|
||
} else {
|
||
0.0
|
||
}
|
||
};
|
||
axis(p.x, f.x0, f.x1, bounds.x0, bounds.x1).max(axis(p.y, f.y0, f.y1, bounds.y0, bounds.y1)).clamp(0.0, 1.0)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The share of the way from `c` to the outline along the ray through `p` (1 when the ray meets
|
||
/// no edge).
|
||
fn shape_position(outline: &[Vec<Point>], c: Point, p: Point) -> f64 {
|
||
let d = p - c;
|
||
let len = d.hypot();
|
||
if len < 1e-9 {
|
||
return 0.0;
|
||
}
|
||
// The outline scaled about `c` by t reaches `p` first (smallest t) where the ray's farthest
|
||
// crossing does: the only crossing for a focus inside a convex outline, and the one that
|
||
// still works for a focus off the shape (a corner of an ellipse's box).
|
||
let mut best = f64::INFINITY;
|
||
for poly in outline {
|
||
for w in poly.windows(2) {
|
||
let (a, b) = (w[0], w[1]);
|
||
let e = b - a;
|
||
let den = d.cross(e);
|
||
if den.abs() < 1e-12 {
|
||
continue;
|
||
}
|
||
let ac = a - c;
|
||
let s = ac.cross(e) / den;
|
||
let u = ac.cross(d) / den;
|
||
if s > 1e-9 && (-1e-9..=1.0 + 1e-9).contains(&u) {
|
||
best = if best.is_finite() { best.max(s) } else { s };
|
||
}
|
||
}
|
||
}
|
||
if best.is_finite() { (1.0 / best).clamp(0.0, 1.0) } else { 1.0 }
|
||
}
|
||
|
||
/// The outline flattened into closed polygons.
|
||
pub fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
|
||
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];
|
||
}
|
||
PathEl::LineTo(p) => cur.push(p),
|
||
PathEl::ClosePath => {
|
||
if let Some(f) = cur.first().copied() {
|
||
cur.push(f);
|
||
}
|
||
}
|
||
_ => {}
|
||
});
|
||
if cur.len() > 1 {
|
||
out.push(cur);
|
||
}
|
||
for poly in &mut out {
|
||
if let (Some(f), Some(l)) = (poly.first().copied(), poly.last().copied())
|
||
&& (f - l).hypot() > 1e-9
|
||
{
|
||
poly.push(f);
|
||
}
|
||
}
|
||
out.retain(|p| p.len() > 2 && p.len() < 100_000);
|
||
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)
|
||
.map(|i| {
|
||
let t = i as f64 / 255.0;
|
||
let after = stops.iter().position(|s| s.0 >= t);
|
||
match after {
|
||
Some(0) | None => stops.get(after.unwrap_or(stops.len().saturating_sub(1))).map(|s| s.1).unwrap_or(Rgba::BLACK),
|
||
Some(j) => {
|
||
let (Some(a), Some(b)) = (stops.get(j - 1), stops.get(j)) else { return Rgba::BLACK };
|
||
let k = if b.0 - a.0 > 1e-12 { (t - a.0) / (b.0 - a.0) } else { 1.0 };
|
||
a.1.lerp(b.1, k)
|
||
}
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Fill `path` with a `rect` or `shape` path gradient of `stops` (sorted and expanded, see
|
||
/// `ramp::expand`) over `bounds`, faded by `alpha`; `m` maps the path to the output.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn fill(ctx: &mut RenderContext, kind: &str, focus: [f64; 4], stops: &[(f64, Rgba)], bounds: Rect, path: &BezPath, m: Affine, alpha: f64) {
|
||
if bounds.width() <= 0.0 || bounds.height() <= 0.0 || !bounds.is_finite() || stops.is_empty() {
|
||
return;
|
||
}
|
||
let k = crate::scale_of(m);
|
||
let k = if k.is_finite() && k > 0.0 { k } else { 1.0 };
|
||
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 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);
|
||
for j in 0..ph {
|
||
for i in 0..pw {
|
||
let p = Point::new(bounds.x0 + (i as f64 + 0.5) / pw as f64 * bounds.width(), bounds.y0 + (j as f64 + 0.5) / ph as f64 * bounds.height());
|
||
let t = position(kind, bounds, focus, &outline, p);
|
||
let c = lut.get((t * 255.0).round().clamp(0.0, 255.0) as usize).copied().unwrap_or(Rgba::BLACK);
|
||
let ca = (c.a as f64 * a).round().clamp(0.0, 255.0);
|
||
let mul = |v: u8| (v as f64 * ca / 255.0).round().clamp(0.0, 255.0) as u8;
|
||
data.push(vello_cpu::color::PremulRgba8 { r: mul(c.r), g: mul(c.g), b: mul(c.b), a: ca as u8 });
|
||
}
|
||
}
|
||
let pm = Arc::new(Pixmap::from_parts(data, pw as u16, ph as u16));
|
||
ctx.set_transform(m);
|
||
ctx.set_paint(vello_cpu::Image {
|
||
image: vello_cpu::ImageSource::Pixmap(pm),
|
||
sampler: peniko::ImageSampler { quality: peniko::ImageQuality::Medium, ..Default::default() },
|
||
});
|
||
ctx.set_paint_transform(
|
||
Affine::translate(bounds.origin().to_vec2()) * Affine::scale_non_uniform(bounds.width() / pw as f64, bounds.height() / ph as f64),
|
||
);
|
||
ctx.fill_path(path);
|
||
ctx.reset_paint_transform();
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use kurbo::Shape;
|
||
|
||
const CENTRE: [f64; 4] = [0.5, 0.5, 0.5, 0.5];
|
||
|
||
#[test]
|
||
fn rect_paths_take_the_larger_axis_share() {
|
||
// PowerPoint, 280x200 box from the centre: 20 px across is 1/7, 20 px down is 1/5.
|
||
let b = Rect::new(0.0, 0.0, 280.0, 200.0);
|
||
let at = |x: f64, y: f64| position("rect", b, CENTRE, &[], Point::new(x, y));
|
||
assert!((at(160.0, 100.0) - 20.0 / 140.0).abs() < 1e-9);
|
||
assert!((at(140.0, 120.0) - 20.0 / 100.0).abs() < 1e-9);
|
||
assert!((at(168.0, 120.0) - 0.2).abs() < 1e-9, "the diagonal takes the larger share");
|
||
assert_eq!(at(0.0, 0.0), 1.0);
|
||
// From a corner (fillToRect 0,0,100%,100%): the far corner is the end.
|
||
let corner = [0.0, 0.0, 1.0, 1.0];
|
||
assert!((position("rect", b, corner, &[], Point::new(140.0, 50.0)) - 0.5).abs() < 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn shape_paths_follow_the_outline() {
|
||
let b = Rect::new(0.0, 0.0, 280.0, 200.0);
|
||
let ellipse = polygons(&kurbo::Ellipse::from_rect(b).to_path(0.01));
|
||
let at = |x: f64, y: f64| position("shape", b, CENTRE, &ellipse, Point::new(x, y));
|
||
assert!((at(160.0, 100.0) - 20.0 / 140.0).abs() < 0.01);
|
||
assert!((at(140.0, 120.0) - 0.2).abs() < 0.01);
|
||
// Half way to the outline along any ray is 0.5.
|
||
let (dx, dy) = (140.0 * 0.6, 100.0 * 0.8);
|
||
assert!((at(140.0 + dx * 0.5, 100.0 + dy * 0.5) - 0.5).abs() < 0.01);
|
||
// A triangle: half way from its centre to its apex is 0.5.
|
||
let mut tri = BezPath::new();
|
||
tri.move_to((140.0, 0.0));
|
||
tri.line_to((280.0, 200.0));
|
||
tri.line_to((0.0, 200.0));
|
||
tri.close_path();
|
||
let polys = polygons(&tri);
|
||
assert!((position("shape", b, CENTRE, &polys, Point::new(140.0, 50.0)) - 0.5).abs() < 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn shape_paths_from_off_the_shape_or_a_focus_box() {
|
||
let b = Rect::new(0.0, 0.0, 280.0, 200.0);
|
||
let ellipse = polygons(&kurbo::Ellipse::from_rect(b).to_path(0.01));
|
||
// Focus on the box's corner, outside the ellipse: contours are the ellipse shrunk towards
|
||
// the corner, so its far edge is the end and its middle part way (it was all the end
|
||
// colour).
|
||
let corner = [0.0, 0.0, 1.0, 1.0];
|
||
let mid = position("shape", b, corner, &ellipse, Point::new(140.0, 100.0));
|
||
assert!(mid > 0.3 && mid < 0.8, "{mid}");
|
||
assert!(position("shape", b, corner, &ellipse, Point::new(279.0, 100.0)) > 0.95);
|
||
// A focus rectangle is the first colour inside, and the colour runs from its edge out.
|
||
let boxed = [0.25, 0.25, 0.25, 0.25];
|
||
assert_eq!(position("shape", b, boxed, &ellipse, Point::new(150.0, 110.0)), 0.0);
|
||
let half = position("shape", b, boxed, &ellipse, Point::new(140.0 + 70.0 + 35.0, 100.0));
|
||
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))];
|
||
let r = ramp(&stops);
|
||
assert_eq!(r.len(), 256);
|
||
assert_eq!(r[0], Rgba::rgb(0, 0, 0));
|
||
assert_eq!(r[255], Rgba::rgb(255, 255, 255));
|
||
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)));
|
||
}
|
||
}
|