Renderer: sample a mesh shading over its triangles alone, not the whole path

A BricsCAD plan page (3370 × 1524 pt, 251,000 single-segment strokes and 310
one-triangle type 4 shadings painted with `sh` at 50% alpha) took 2 s per
1024 px tile at every zoom, and 7.9 s for a fit-to-width render; on a busy
machine the 20 s watchdog gave the page up ("page 57 took longer than 20 s and
was skipped"). A 16 px tile of the same page took 65 ms, so neither parsing nor
the strokes were the cost: `sh` fills the whole canvas with the shading, and
`set_paint` sampled the mesh texture over the whole path, one hash lookup and
one texel per canvas pixel, 310 times per tile.

hayro-interpret patch (35): `EncodedShadingPattern::extent`, the device-space
pixels a mesh's triangles touched when it was encoded (`None` for shadings that
paint everywhere, and for a mesh with a `/Background`). hayro patch (19):
`set_paint` returns `PaintSetup::Nothing` for a mesh with no triangle on the
canvas (an empty extent, tested before it is intersected: `Rect::ZERO` sits at
the canvas origin), which the fill, stroke, rect and stencil callers skip, and
otherwise intersects the texture with the extent and pads instead of repeating it.

Measured with `page_timing` on the page: fit 7864 → 880 ms, a 1024 px tile
2030 → 85 ms at 100% and 2030 → 70 ms at 400% (the 16 px tile's 65 ms is the
page's re-interpretation, paid per tile), with byte-identical output hashes.
The three `mesh_extent_tests` pin what the texture must and must not cover.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Hagmann
2026-10-18 18:32:33 +02:00
co-authored by Claude Fable 5.1
parent 98cbd38a8e
commit fe7169c8eb
5 changed files with 227 additions and 19 deletions
+2
View File
@@ -567,3 +567,5 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-13 (M7):** Edit a PDF ▸ Add content: Header and footer, Watermark and Background open an Add / Update / Remove menu (Update and Remove enabled only when the document has that mark), as Acrobat's Watermark control does. The catalogue gains `ToolItem::actions`, so any tool can list more commands (#1192).
- **2026-10-18 (M8, #1350):** Published the history-preserving integration; simplified the inherited-form resource guard for Rust 1.96 Clippy. Formatting, 64 redaction tests, scoped Clippy, layering, assets and the scoped wasm check pass locally. CI remains pending; estimates unchanged.
- **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged.
+3 -3
View File
File diff suppressed because one or more lines are too long
+36
View File
@@ -18,6 +18,13 @@ pub struct EncodedShadingPattern {
pub(crate) shading_type: EncodedShadingType,
pub(crate) opacity: f32,
pub(crate) transfer_function: Option<ActiveTransferFunction>,
/// PdfCraft patch (35): where the shading can paint at all, in device space: for a mesh
/// (types 4–7), the pixels its triangles touched when it was encoded (empty when none
/// lies in the area); `None` for the shading types that paint everywhere (function-based,
/// axial, radial). The renderer samples a texture only there, and skips a mesh that
/// touches nothing: painted over the whole canvas, 310 one-triangle meshes on a plan cost
/// 310 full tiles per tile, and the watchdog gave the page up.
pub extent: Option<kurbo::Rect>,
}
impl EncodedShadingPattern {
@@ -50,6 +57,8 @@ impl ShadingPattern {
/// Mesh shadings are sampled pixel by pixel, and only there (see `TriangleSampler`).
pub fn encode_within(&self, area: Option<kurbo::Rect>) -> EncodedShadingPattern {
let base_transform;
// PdfCraft patch (35): see `EncodedShadingPattern::extent`.
let mut extent = None;
let shading_type = match self.shading.shading_type.as_ref() {
ShadingType::FunctionBased {
@@ -81,6 +90,7 @@ impl ShadingPattern {
let full_transform = self.matrix;
let mut sampler = TriangleSampler::new(area);
sampler.sample(triangles, full_transform);
extent = Some(sampler.extent());
let samples = sampler.map;
base_transform = Affine::IDENTITY;
@@ -106,6 +116,7 @@ impl ShadingPattern {
break;
}
}
extent = Some(sampler.extent());
let samples = sampler.map;
base_transform = Affine::IDENTITY;
@@ -130,6 +141,7 @@ impl ShadingPattern {
break;
}
}
extent = Some(sampler.extent());
let samples = sampler.map;
base_transform = Affine::IDENTITY;
@@ -162,6 +174,9 @@ impl ShadingPattern {
base_transform,
opacity: self.opacity,
transfer_function: self.transfer_function.clone(),
// A `/Background` is painted wherever the triangles aren't (pattern fills), so such
// a mesh has no extent to confine it to.
extent: if self.shading.background.is_some() { None } else { extent },
}
}
}
@@ -249,6 +264,8 @@ struct TriangleSampler {
map: FxHashMap<(u16, u16), ColorComponents>,
area: kurbo::Rect,
visits_left: u64,
/// PdfCraft patch (35): the pixels sampled so far, as `(x0, y0, x1, y1)` inclusive.
touched: Option<(u16, u16, u16, u16)>,
}
impl TriangleSampler {
@@ -263,6 +280,21 @@ impl TriangleSampler {
map: FxHashMap::default(),
area: area.unwrap_or(kurbo::Rect::new(0.0, 0.0, 65536.0, 65536.0)),
visits_left,
touched: None,
}
}
/// PdfCraft patch (35): the device-space rectangle covering every pixel sampled so far
/// (whole pixels), or an empty rectangle when no triangle touched the area.
fn extent(&self) -> kurbo::Rect {
match self.touched {
Some((x0, y0, x1, y1)) => kurbo::Rect::new(
f64::from(x0),
f64::from(y0),
f64::from(x1) + 1.0,
f64::from(y1) + 1.0,
),
None => kurbo::Rect::ZERO,
}
}
@@ -324,6 +356,10 @@ impl TriangleSampler {
let point = Point::new(x as f64, y as f64);
if t.contains_point(point) {
self.map.insert((x, y), t.interpolate(point));
self.touched = Some(match self.touched {
Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)),
None => (x, y, x, y),
});
}
}
}
+134
View File
@@ -675,3 +675,137 @@ mod color_replacement_render_tests {
assert_eq!(sample(&no_line_art, 40, 25), [0, 0, 0, 255]);
}
}
/// PdfCraft patch (19): a mesh shading paints its triangles alone (`EncodedShadingPattern::extent`),
/// and one with no triangle on the canvas paints nothing.
#[cfg(test)]
mod mesh_extent_tests {
use super::{InterpreterSettings, Pdf, Pixmap, RenderCache, RenderSettings, render};
use vello_cpu::color::palette::css::WHITE;
/// A one-page 200×200 pt PDF whose content is `body`, with a type 4 (free-form triangle)
/// shading `/Sh0`: one red triangle with corners (x0, y0), (x1, y0) and (x0, y1) in user
/// space, and an ExtGState `/Half` with 50% alpha.
fn fixture(body: &str, (x0, y0, x1, y1): (i32, i32, i32, i32)) -> Vec<u8> {
// Three vertices: a flag, x and y (8 bits each, decoded into the ranges) and RGB.
let mesh: [u8; 18] = [
0, 0, 0, 255, 0, 0, //
0, 255, 0, 255, 0, 0, //
0, 0, 255, 255, 0, 0,
];
let mut objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> \
/ExtGState << /Half << /ca 0.5 /CA 0.5 >> >> >> >>"
.to_vec(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(),
];
let mut shading = format!(
"<< /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 \
/BitsPerFlag 8 /Decode [{x0} {x1} {y0} {y1} 0 1 0 1 0 1] /Length {} >>\nstream\n",
mesh.len()
)
.into_bytes();
shading.extend_from_slice(&mesh);
shading.extend_from_slice(b"\nendstream");
objs.push(shading);
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(
format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes(),
);
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
objs.len() + 1
)
.as_bytes(),
);
out
}
fn render_at(pdf: Vec<u8>, scale: f32) -> Pixmap {
let pdf = Pdf::new(pdf).unwrap();
let cache = RenderCache::new();
let settings = RenderSettings {
x_scale: scale,
y_scale: scale,
bg_color: WHITE,
..RenderSettings::default()
};
render(
pdf.pages().get(0).expect("the fixture has one page"),
&cache,
&InterpreterSettings::default(),
&settings,
)
}
fn px(pixmap: &Pixmap, x: u16, y: u16) -> [u8; 4] {
let p = pixmap.sample(x, y);
[p.r, p.g, p.b, p.a]
}
fn inked(pixmap: &Pixmap) -> usize {
(0..pixmap.height())
.flat_map(|y| (0..pixmap.width()).map(move |x| (x, y)))
.filter(|&(x, y)| px(pixmap, x, y) != [255, 255, 255, 255])
.count()
}
/// The triangle (100, 100)–(110, 100)–(100, 110) in a 200 pt page: in device space (y
/// down) the pixel (102, 97) is inside it, (108, 91) past its hypotenuse, and the rest of
/// the page is nowhere near it.
#[test]
fn a_mesh_paints_its_triangles_alone() {
let white = [255, 255, 255, 255];
for scale in [1.0_f32, 4.0] {
let p = render_at(fixture("/Sh0 sh", (100, 100, 110, 110)), scale);
let at = |x: f32, y: f32| ((x * scale) as u16, (y * scale) as u16);
let (x, y) = at(102.5, 97.5);
assert_eq!(px(&p, x, y), [255, 0, 0, 255], "scale {scale}: inside the triangle");
let (x, y) = at(108.5, 91.5);
assert_eq!(px(&p, x, y), white, "scale {scale}: past the hypotenuse");
let (x, y) = at(150.0, 50.0);
assert_eq!(px(&p, x, y), white, "scale {scale}: far from it");
let (x, y) = at(10.0, 190.0);
assert_eq!(px(&p, x, y), white, "scale {scale}: the texture is not repeated");
// A 10 × 10 pt right triangle covers 50 pt²; the sampler counts the pixels whose
// corner lies inside it (36 at scale 1, nearer 50 as the scale grows), and nothing
// beyond its own bounding box is touched.
let area = inked(&p) as f32 / (scale * scale);
assert!((30.0..=60.0).contains(&area), "scale {scale}: {area} pt² inked");
}
}
/// The alpha of the graphics state still applies: red at 50% over white is pink.
#[test]
fn a_mesh_keeps_the_graphics_state_alpha() {
let p = render_at(fixture("/Half gs /Sh0 sh", (100, 100, 110, 110)), 1.0);
let [r, g, b, a] = px(&p, 102, 97);
assert!(r == 255 && (120..=136).contains(&g) && g == b && a == 255, "{:?}", [r, g, b, a]);
}
/// A mesh whose triangle lies off the canvas paints nothing, and costs no texture.
#[test]
fn a_mesh_off_the_canvas_paints_nothing() {
let p = render_at(fixture("/Sh0 sh", (300, 300, 310, 310)), 1.0);
assert_eq!(inked(&p), 0);
// One straddling the edge paints its part on the canvas.
let p = render_at(fixture("/Sh0 sh", (195, 100, 205, 110)), 1.0);
assert_eq!(px(&p, 196, 98), [255, 0, 0, 255]);
assert_eq!(px(&p, 150, 50), [255, 255, 255, 255]);
}
}
+52 -16
View File
@@ -427,6 +427,15 @@ fn snap_minified_image(
(w.abs_diff(planned.0) <= 1 && h.abs_diff(planned.1) <= 1).then_some((w, h, x0, y0))
}
/// PdfCraft patch (19): what `Renderer::set_paint` found out: the clip path to apply while
/// painting (if any), or that the paint touches no pixel of the canvas, so the caller skips
/// the fill or stroke (a mesh shading whose triangles all lie outside the path or canvas).
#[must_use]
enum PaintSetup {
Clip(Option<BezPath>),
Nothing,
}
pub(crate) struct Renderer {
pub(crate) ctx: RenderContext,
/// PdfCraft patch: when set, solid text and line-art paint colours are remapped
@@ -1046,9 +1055,12 @@ impl Renderer {
}
#[must_use]
fn set_paint(&mut self, paint: &Paint<'_>, path: &BezPath, is_stroke: bool) -> Option<BezPath> {
fn set_paint(&mut self, paint: &Paint<'_>, path: &BezPath, is_stroke: bool) -> PaintSetup {
let mut paint_transform = Affine::IDENTITY;
let mut clip_path = None;
// PdfCraft patch (19): `Extend::Repeat` tiles the texture over the path; a mesh texture
// covers its triangles alone and must not repeat outside them.
let mut extend = peniko::Extend::Repeat;
let paint: PaintType = match paint.clone() {
Paint::Color(c) => {
@@ -1081,6 +1093,28 @@ impl Renderer {
// pixel around it: texels at a fractional edge look one pixel further).
let encoded = s.encode_within(Some(bbox.inflate(1.0, 1.0)));
// PdfCraft patch (19): a mesh paints only where its triangles are. The
// texture below used to cover the whole path (the canvas, for `sh`)
// and was sampled pixel by pixel: 310 one-triangle meshes on a plan
// cost 310 full tiles per tile, and the watchdog gave the page up. It
// now covers the triangles alone (a pixel around them, which stays
// transparent and is what `Extend::Pad` repeats), and a mesh with no
// triangle on the canvas paints nothing at all.
if let Some(extent) = encoded.extent {
// An empty extent (no triangle in the area) is `Rect::ZERO`, which
// sits at the canvas origin: test it before intersecting, or a
// 1 × 1 transparent texture would still be painted over the whole
// path (310 of them cost 230 ms per tile).
if extent.width() <= 0.0 || extent.height() <= 0.0 {
return PaintSetup::Nothing;
}
bbox = bbox.intersect(extent.inflate(1.0, 1.0));
if bbox.width() <= 0.0 || bbox.height() <= 0.0 {
return PaintSetup::Nothing;
}
extend = peniko::Extend::Pad;
}
// PdfCraft patch (backport of hayro 0.8): axial and radial shadings
// become native gradients instead of a texture sampled pixel by pixel.
// Upstream also shifts by half a pixel for vello_cpu 0.3; 0.0.8 already
@@ -1096,7 +1130,7 @@ impl Renderer {
self.ctx
.set_paint_transform(path_transform.inverse() * native.transform);
self.ctx.set_paint(native_gradient(native));
return clip_path;
return PaintSetup::Clip(clip_path);
}
let (image, width, height, transform, may_have_transparency) =
@@ -1115,8 +1149,8 @@ impl Renderer {
let image = Image {
image: ImageSource::Pixmap(Arc::new(pixmap)),
sampler: ImageSampler {
x_extend: peniko::Extend::Repeat,
y_extend: peniko::Extend::Repeat,
x_extend: extend,
y_extend: extend,
quality: ImageQuality::Medium,
alpha: 1.0,
},
@@ -1215,7 +1249,7 @@ impl Renderer {
self.ctx.set_paint_transform(paint_transform);
self.ctx.set_paint(paint);
clip_path
PaintSetup::Clip(clip_path)
}
fn stroke_path(
@@ -1235,7 +1269,7 @@ impl Renderer {
// PdfCraft patch: apply the reading-colours mapping before painting.
let paint = self.replaced(paint, is_text);
let clip_path = self.set_paint(&paint, path, true);
let PaintSetup::Clip(clip_path) = self.set_paint(&paint, path, true) else { return };
if let Some(clip_path) = clip_path.as_ref() {
self.push_clip_path_inner(clip_path, FillRule::NonZero);
}
@@ -1264,7 +1298,7 @@ impl Renderer {
// PdfCraft patch: apply the reading-colours mapping before painting.
let paint = self.replaced(paint, is_text);
let clip_path = self.set_paint(&paint, path, false);
let PaintSetup::Clip(clip_path) = self.set_paint(&paint, path, false) else { return };
if let Some(clip_path) = clip_path.as_ref() {
self.push_clip_path_inner(clip_path, fill_rule);
}
@@ -1510,14 +1544,16 @@ impl<'a> Device<'a> for Renderer {
);
self.ctx.set_transform(transform);
let clip_path =
self.set_paint(paint, &stencil_rect.to_path(0.1), true);
if let Some(clip_path) = clip_path.as_ref() {
self.push_clip_path_inner(clip_path, FillRule::NonZero);
}
self.ctx.fill_rect(&stencil_rect);
if clip_path.is_some() {
self.ctx.pop_clip_path();
if let PaintSetup::Clip(clip_path) =
self.set_paint(paint, &stencil_rect.to_path(0.1), true)
{
if let Some(clip_path) = clip_path.as_ref() {
self.push_clip_path_inner(clip_path, FillRule::NonZero);
}
self.ctx.fill_rect(&stencil_rect);
if clip_path.is_some() {
self.ctx.pop_clip_path();
}
}
self.ctx.pop_layer();
@@ -1654,7 +1690,7 @@ impl<'a> Device<'a> for Renderer {
// PdfCraft patch: apply the reading-colours mapping before painting.
let paint = self.replaced(paint, false);
let clip_path = self.set_paint(&paint, &path, false);
let PaintSetup::Clip(clip_path) = self.set_paint(&paint, &path, false) else { return };
if let Some(clip_path) = clip_path.as_ref() {
self.push_clip_path_inner(clip_path, *fill_rule);
}