mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Render: gradient meshes in CMYK documents draw several times faster
A PDF of thousands of gradient meshes in a CMYK document with transparency took minutes to export (186 s at 72 ppi for an 18,577-mesh page). Each mesh was cut into quads coloured in RGB, and drawing a CMYK document by ink planes separated every quad's colour back to CMYK through the profile, twice: the 4,096-colour separation cache, flooded by the meshes' interpolated colours, cleared over and over. - doc: GradientMesh::color_at mixes corners that are all CMYK (or all grey) in that space, as a PDF shading mixes in its own: exact inks, nothing to separate. - render: the separation cache holds 65,536 colours. - render: a mesh's quads are filled into one image by scanline (every pixel to the quad its centre is in: no seams) and drawn through the mesh's outline, instead of one path per quad; meshes over 16 MP on screen are drawn quad by quad as before, backed by each opaque patch's outline (#2456). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
ec410552cf
commit
ed69938e0e
+38
-7
@@ -730,19 +730,35 @@ impl GradientMesh {
|
||||
.to_point()
|
||||
}
|
||||
|
||||
/// Bilinear colour and opacity of patch (r, c) at local (u, v).
|
||||
/// Bilinear colour and opacity of patch (r, c) at local (u, v). Corners all in CMYK (or all
|
||||
/// grey) mix in that space, as a PDF shading mixes in its own colour space: the inks come out
|
||||
/// exact, and a CMYK document separates no colour to draw them. Other corners mix in RGB.
|
||||
pub fn color_at(&self, r: usize, c: usize, u: f64, v: f64) -> (Color, f32) {
|
||||
let q =
|
||||
[&self.points[self.idx(r, c)], &self.points[self.idx(r, c + 1)], &self.points[self.idx(r + 1, c)], &self.points[self.idx(r + 1, c + 1)]];
|
||||
let w = [(1.0 - u) * (1.0 - v), u * (1.0 - v), (1.0 - u) * v, u * v];
|
||||
let op = q.iter().zip(w).map(|(p, w)| p.opacity * w as f32).sum();
|
||||
let cmyk: Option<Vec<[f32; 4]>> =
|
||||
q.iter().map(|p| if let Color::Cmyk { c, m, y, k } = p.color { Some([c, m, y, k]) } else { None }).collect();
|
||||
if let Some(inks) = cmyk {
|
||||
let mut mix = [0.0f32; 4];
|
||||
for (ink, w) in inks.iter().zip(w) {
|
||||
for (m, x) in mix.iter_mut().zip(ink) {
|
||||
*m += x * w as f32;
|
||||
}
|
||||
}
|
||||
let [c, m, y, k] = mix;
|
||||
return (Color::Cmyk { c, m, y, k }, op);
|
||||
}
|
||||
let grey: Option<Vec<f32>> = q.iter().map(|p| if let Color::Gray { k } = p.color { Some(k) } else { None }).collect();
|
||||
if let Some(ks) = grey {
|
||||
return (Color::Gray { k: ks.iter().zip(w).map(|(k, w)| k * w as f32).sum() }, op);
|
||||
}
|
||||
let mut rgb = [0.0f32; 3];
|
||||
let mut op = 0.0f32;
|
||||
for k in 0..4 {
|
||||
let c = q[k].color.to_rgb();
|
||||
for (ch, x) in rgb.iter_mut().zip(c) {
|
||||
*ch += x * w[k] as f32;
|
||||
for (p, w) in q.iter().zip(w) {
|
||||
for (ch, x) in rgb.iter_mut().zip(p.color.to_rgb()) {
|
||||
*ch += x * w as f32;
|
||||
}
|
||||
op += q[k].opacity * w[k] as f32;
|
||||
}
|
||||
(Color::rgb(rgb[0], rgb[1], rgb[2]), op)
|
||||
}
|
||||
@@ -1823,4 +1839,19 @@ mod tests {
|
||||
assert!(m.remove_point_lines(i));
|
||||
assert_eq!((m.rows, m.cols), (1, 1));
|
||||
}
|
||||
|
||||
/// Corners all in CMYK mix in CMYK (the inks halfway), not in RGB.
|
||||
#[test]
|
||||
fn a_cmyk_mesh_mixes_its_inks() {
|
||||
let cyan = Color::Cmyk { c: 1.0, m: 0.0, y: 0.0, k: 0.0 };
|
||||
let mut m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), 1, 1, cyan, MeshAppearance::Flat, 0.0).unwrap();
|
||||
m.points[0].color = Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: 1.0 };
|
||||
let (c, op) = m.color_at(0, 0, 0.0, 0.0);
|
||||
assert_eq!((c, op), (Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: 1.0 }, 1.0));
|
||||
let Color::Cmyk { c, k, .. } = m.color_at(0, 0, 0.5, 0.5).0 else { panic!("mixed out of CMYK") };
|
||||
assert!((c - 0.75).abs() < 1e-6 && (k - 0.25).abs() < 1e-6, "{c} {k}");
|
||||
// A corner in another space mixes them all in RGB.
|
||||
m.points[1].color = Color::rgb(1.0, 0.0, 0.0);
|
||||
assert!(matches!(m.color_at(0, 0, 0.5, 0.5).0, Color::Rgb { .. }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,9 @@ fn inks(c: &Color) -> [f32; 4] {
|
||||
let cms = cms::active();
|
||||
SEPARATED.with(|s| {
|
||||
let (owner, seen) = &mut *s.borrow_mut();
|
||||
if !owner.as_ref().is_some_and(|o| Arc::ptr_eq(o, &cms)) || seen.len() >= 4096 {
|
||||
// Large enough for the many colours of gradient meshes and blends, which otherwise cleared
|
||||
// it over and over and separated every colour through the profile again.
|
||||
if !owner.as_ref().is_some_and(|o| Arc::ptr_eq(o, &cms)) || seen.len() >= 1 << 16 {
|
||||
seen.clear();
|
||||
*owner = Some(cms.clone());
|
||||
}
|
||||
|
||||
+142
-3
@@ -17,7 +17,7 @@ use std::sync::{Arc, Weak};
|
||||
use vectorcraft_doc::live::{GradientMesh, MeshQuad};
|
||||
use vectorcraft_doc::placed_document::ReadDocument;
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
use vectorcraft_geom::{BezPath, Vec2};
|
||||
use vectorcraft_geom::{Affine, BezPath, Point, Vec2};
|
||||
use vello_cpu::RenderContext;
|
||||
use vello_cpu::peniko;
|
||||
|
||||
@@ -172,6 +172,108 @@ fn mesh_level(m: &GradientMesh, bounds_px: f64) -> usize {
|
||||
((per / 5.0).ceil() as usize).clamp(2, 32)
|
||||
}
|
||||
|
||||
/// The most pixels a gradient mesh is rasterized into at once ([`draw_mesh_raster`]); a larger
|
||||
/// one is drawn quad by quad.
|
||||
const MESH_RASTER_MAX_PIXELS: usize = 16 << 20;
|
||||
|
||||
/// Draw a tessellated gradient mesh as one image: its quads are filled into a pixel buffer of the
|
||||
/// mesh's on-screen box by scanline (each pixel takes the quad its centre is in, so neighbouring
|
||||
/// quads meet without seams or overlap), and the buffer is drawn through the mesh's outline, which
|
||||
/// antialiases the outer edge. Drawing every quad as a path of its own costs a path's setup per
|
||||
/// quad: a page of thousands of meshes made millions of them at print size (minutes), where this
|
||||
/// costs about one write per covered pixel. → false (nothing drawn) when the box is too large for
|
||||
/// one buffer, so the caller draws the quads.
|
||||
fn draw_mesh_raster(ctx: &mut RenderContext, f: &Frame, m: &GradientMesh, quads: &[MeshQuad], alpha: f32) -> bool {
|
||||
let outline = m.outline().to_bezpath();
|
||||
let dev = f.view.transform_rect_bbox(vectorcraft_geom::Shape::bounding_box(&outline));
|
||||
if ![dev.x0, dev.y0, dev.x1, dev.y1].iter().all(|v| v.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
let (cw, ch) = (f64::from(ctx.width()), f64::from(ctx.height()));
|
||||
let (x0, y0) = ((dev.x0.floor() - 1.0).max(0.0), (dev.y0.floor() - 1.0).max(0.0));
|
||||
let (x1, y1) = ((dev.x1.ceil() + 1.0).min(cw), (dev.y1.ceil() + 1.0).min(ch));
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
// Off the context: nothing to draw.
|
||||
return true;
|
||||
}
|
||||
let (w, h) = ((x1 - x0) as usize, (y1 - y0) as usize);
|
||||
if w * h > MESH_RASTER_MAX_PIXELS || w > usize::from(u16::MAX) || h > usize::from(u16::MAX) {
|
||||
return false;
|
||||
}
|
||||
let clear = vello_cpu::color::PremulRgba8 { r: 0, g: 0, b: 0, a: 0 };
|
||||
let mut px = vec![clear; w * h];
|
||||
let mut filled = vec![false; w * h];
|
||||
let to_buffer = Affine::translate((-x0, -y0)) * f.view;
|
||||
for q in quads {
|
||||
// A fully transparent quad still claims its pixels, so the edge fill below leaves them clear.
|
||||
let c = if q.opacity > 0.0 { f.ink.color(&q.color, q.opacity * alpha).premultiply().to_rgba8() } else { clear };
|
||||
fill_quad(&mut px, &mut filled, w, h, q.pts.map(|p| to_buffer * p), c);
|
||||
}
|
||||
spread_to_edges(&mut px, &filled, w, h);
|
||||
let pm = Arc::new(vello_cpu::Pixmap::from_parts(px, w as u16, h as u16));
|
||||
ctx.set_transform(Affine::translate((x0, y0)));
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() });
|
||||
ctx.fill_path(&(Affine::translate((-x0, -y0)) * f.view * outline));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
true
|
||||
}
|
||||
|
||||
/// Fill the pixels of the `w`×`h` buffer whose centres are inside quad `p` (buffer pixels, even-odd
|
||||
/// so a twisted quad is filled as drawn) with `c`, and mark them `filled`.
|
||||
fn fill_quad(px: &mut [vello_cpu::color::PremulRgba8], filled: &mut [bool], w: usize, h: usize, p: [Point; 4], c: vello_cpu::color::PremulRgba8) {
|
||||
if !p.iter().all(|p| p.x.is_finite() && p.y.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let (min_y, max_y) = p.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), q| (a.min(q.y), b.max(q.y)));
|
||||
let first = (min_y - 0.5).ceil().max(0.0) as usize;
|
||||
let last = ((max_y - 0.5).ceil().max(0.0) as usize).min(h);
|
||||
for y in first..last {
|
||||
let yc = y as f64 + 0.5;
|
||||
let mut xs = [0.0f64; 4];
|
||||
let mut n = 0;
|
||||
for i in 0..4 {
|
||||
let (a, b) = (p[i], p[(i + 1) % 4]);
|
||||
if (a.y <= yc) != (b.y <= yc) {
|
||||
xs[n] = a.x + (yc - a.y) * (b.x - a.x) / (b.y - a.y);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
let xs = &mut xs[..n];
|
||||
xs.sort_by(f64::total_cmp);
|
||||
for [a, b] in xs.as_chunks::<2>().0 {
|
||||
let from = (a - 0.5).ceil().clamp(0.0, w as f64) as usize;
|
||||
let to = (b - 0.5).ceil().clamp(0.0, w as f64) as usize;
|
||||
let row = y * w;
|
||||
for (d, k) in
|
||||
px.get_mut(row + from..row + to).unwrap_or_default().iter_mut().zip(filled.get_mut(row + from..row + to).unwrap_or_default())
|
||||
{
|
||||
*d = c;
|
||||
*k = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Give each pixel no quad covered the colour of a covered neighbour: the outline's antialiased
|
||||
/// edge samples pixels whose centres fall just outside the quads, which would otherwise fade it.
|
||||
fn spread_to_edges(px: &mut [vello_cpu::color::PremulRgba8], filled: &[bool], w: usize, h: usize) {
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
if filled.get(i).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let near = [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)];
|
||||
if let Some(j) = near.into_iter().flatten().find(|j| filled.get(*j).copied().unwrap_or(false))
|
||||
&& let Some(c) = px.get(j).copied()
|
||||
&& let Some(d) = px.get_mut(i)
|
||||
{
|
||||
*d = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// The [`resources_key`] of `n` in `doc`, with the frame's [`DocInputs`] ([`Found`]): `n` is a
|
||||
/// live node, or an object with effects whose art ([`Renderer::fx_art`]) can be made from
|
||||
@@ -286,10 +388,13 @@ impl Renderer {
|
||||
let Some(b) = m.bounds() else { return };
|
||||
let level = mesh_level(m, b.width().max(b.height()) / f.px.max(1e-12));
|
||||
let quads = self.mesh_quads(f.doc, a, m, level, cache);
|
||||
ctx.set_transform(f.view);
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
let grow = f.px * 0.5;
|
||||
let alpha = self.alpha;
|
||||
if draw_mesh_raster(ctx, f, m, &quads, alpha) {
|
||||
ctx.set_transform(f.view);
|
||||
return;
|
||||
}
|
||||
ctx.set_transform(f.view);
|
||||
// Where a patch narrows to slivers, many quads share each pixel, each covering a little of it: their edges
|
||||
// composite to part coverage, and hard edges drop each one. An opaque patch is backed by its own outline in
|
||||
// its middle colour, one path that covers fully, so the background can't show through.
|
||||
@@ -310,6 +415,7 @@ impl Renderer {
|
||||
ctx.fill_path(&bp);
|
||||
}
|
||||
}
|
||||
let grow = f.px * 0.5;
|
||||
for q in quads.iter() {
|
||||
if q.opacity <= 0.0 {
|
||||
continue;
|
||||
@@ -382,6 +488,39 @@ mod tests {
|
||||
(0..120).flat_map(|y| (0..440).map(move |x| (x, y))).filter(|&(x, y)| smooth.pixel(x, y)[3] >= 200 && hard.pixel(x, y)[3] == 0).count();
|
||||
assert!(holes < 60, "{holes} pixels the smooth render covers are empty with hard edges");
|
||||
}
|
||||
|
||||
/// Quads that share edges fill every pixel of their union once: no gap, no overlap.
|
||||
#[test]
|
||||
fn quads_meeting_edge_to_edge_fill_their_union_once() {
|
||||
let (w, h) = (20, 20);
|
||||
let clear = vello_cpu::color::PremulRgba8 { r: 0, g: 0, b: 0, a: 0 };
|
||||
let red = vello_cpu::color::PremulRgba8 { r: 255, g: 0, b: 0, a: 255 };
|
||||
let mut px = vec![clear; w * h];
|
||||
let mut count = vec![0u8; w * h];
|
||||
// A 2×2 grid over (2.3, 2.7)–(17.6, 16.2) with an inner corner off the pixel grid.
|
||||
let xs = [2.3, 9.71, 17.6];
|
||||
let ys = [2.7, 8.35, 16.2];
|
||||
for j in 0..2 {
|
||||
for i in 0..2 {
|
||||
let pts = [Point::new(xs[i], ys[j]), Point::new(xs[i + 1], ys[j]), Point::new(xs[i + 1], ys[j + 1]), Point::new(xs[i], ys[j + 1])];
|
||||
let mut mine = vec![false; w * h];
|
||||
fill_quad(&mut px, &mut mine, w, h, pts, red);
|
||||
for (c, m) in count.iter_mut().zip(&mine) {
|
||||
*c += u8::from(*m);
|
||||
}
|
||||
}
|
||||
}
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (cx, cy) = (x as f64 + 0.5, y as f64 + 0.5);
|
||||
let inside = (2.3..17.6).contains(&cx) && (2.7..16.2).contains(&cy);
|
||||
assert_eq!(count[y * w + x], u8::from(inside), "pixel {x},{y}");
|
||||
}
|
||||
}
|
||||
let filled: Vec<bool> = count.iter().map(|c| *c > 0).collect();
|
||||
spread_to_edges(&mut px, &filled, w, h);
|
||||
assert_eq!(px[2 * w + 2].a, 255, "the pixel outside the corner takes its neighbour's colour");
|
||||
}
|
||||
use vectorcraft_doc::{Appearance, EnvelopeKind, Symbol};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user