Merge pull request #2331 from Silvertree2010/perf/export-speed

Speed up PNG export: stroke runs of hairline polylines as one path
This commit is contained in:
aiongg
2026-10-27 13:41:15 -04:00
committed by GitHub
3 changed files with 274 additions and 5 deletions
+79
View File
@@ -0,0 +1,79 @@
//! Export timing on a realistic large drawing: `examples/apartment.dxf` repeated on a grid
//! (a floor plan of many apartments).
//!
//! `cargo run --release -p cadcraft-io --example export_bench -- [FORMAT] [GRID] [RUNS] [OUT_DIR]`
//!
//! FORMAT is `png`, `pdf`, `svg`, `dxf` or `all` (default `png`), GRID the apartments per side
//! (default 10), RUNS the timed runs per format (default 7). Prints the median and minimum wall
//! time of each format's export (from the drawing in memory to the file's bytes). With OUT_DIR,
//! the last run's output is written there as `bench.FORMAT` to compare builds.
use std::time::{Duration, Instant};
use cadcraft_doc::{Drawing, Space};
use cadcraft_geom::{Mat3, Vec2};
fn drawing(grid: usize) -> Drawing {
let src = include_bytes!("../../../examples/apartment.dxf");
let Ok(mut d) = cadcraft_io::read(src, "apartment.dxf") else {
eprintln!("apartment.dxf did not read");
std::process::exit(1);
};
let ents: Vec<_> = d.space(&Space::Model).map(|s| s.iter().map(|e| (**e).clone()).collect()).unwrap_or_default();
let ext = d.extents(&Space::Model);
let step = Vec2::new(ext.width() * 1.1, ext.height() * 1.1);
for i in 0..grid {
for j in 0..grid {
if (i, j) == (0, 0) {
continue;
}
let m = Mat3::translate(Vec2::new(step.x * j as f64, step.y * i as f64));
for e in &ents {
let mut e = e.clone();
e.handle = d.new_handle();
e.kind.transform(&m);
if let Some(s) = d.space_mut(&Space::Model) {
s.push(e);
}
}
}
}
d
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let format = args.first().map(String::as_str).unwrap_or("png").to_string();
let grid: usize = args.get(1).and_then(|a| a.parse().ok()).unwrap_or(10);
let runs: usize = args.get(2).and_then(|a| a.parse().ok()).unwrap_or(7).max(1);
let out_dir = args.get(3);
let d = drawing(grid);
eprintln!("{} entities", d.entity_count());
let formats: Vec<&str> = if format == "all" { vec!["dxf", "png", "pdf", "svg"] } else { vec![format.as_str()] };
for f in formats {
let name = format!("bench.{f}");
let mut times: Vec<Duration> = Vec::new();
let mut last = Vec::new();
for _ in 0..runs {
let t = Instant::now();
match cadcraft_io::write(&d, &name) {
Ok(bytes) => {
times.push(t.elapsed());
last = bytes;
}
Err(e) => {
eprintln!("{f}: {e}");
std::process::exit(1);
}
}
}
times.sort();
let ms = |t: Duration| t.as_secs_f64() * 1000.0;
println!("{f}: median {:.1} ms, min {:.1} ms, {} runs, {} bytes", ms(times[times.len() / 2]), ms(times[0]), runs, last.len());
if let Some(dir) = out_dir
&& let Err(e) = std::fs::write(std::path::Path::new(dir).join(&name), &last)
{
eprintln!("{name}: {e}");
}
}
}
+72 -5
View File
@@ -78,6 +78,8 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option<Pixm
let mut pm = Pixmap::new(view.width, view.height)?;
pm.fill(tiny_skia::Color::from_rgba8(o.background.0, o.background.1, o.background.2, 255));
let vis = view.world_bounds();
let mut hairlines = Hairlines::default();
let mut screen: Vec<(f32, f32)> = Vec::new();
for p in &list.prims {
let c = p.display_rgb(o.background);
let mut paint = Paint::default();
@@ -85,23 +87,32 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option<Pixm
paint.set_color_rgba8(c.0, c.1, c.2, p.alpha());
paint.anti_alias = o.antialias;
let pts = list.points(p);
if p.kind != Kind::Polyline {
hairlines.flush(&mut pm, o.antialias);
}
match p.kind {
Kind::Polyline => {
let bb = Bounds2::from_points(pts.iter().copied());
if !bb.intersects(&vis) {
continue;
}
let w = if o.px_per_mm > 0.0 && p.lw > 0.0 { (p.lw * o.px_per_mm).max(o.hairline) } else { o.hairline };
screen.clear();
screen.extend(pts.iter().map(|q| view.to_screen(*q)));
if is_hairline(w, o.antialias) && screen.len() >= 2 && screen.iter().all(|&(x, y)| batchable(x) && batchable(y)) {
hairlines.add(&mut pm, [c.0, c.1, c.2, p.alpha()], w, &screen, o.antialias);
continue;
}
hairlines.flush(&mut pm, o.antialias);
let mut pb = PathBuilder::new();
for (i, q) in pts.iter().enumerate() {
let (x, y) = view.to_screen(*q);
for (i, (x, y)) in screen.iter().enumerate() {
if i == 0 {
pb.move_to(x, y);
pb.move_to(*x, *y);
} else {
pb.line_to(x, y);
pb.line_to(*x, *y);
}
}
if let Some(path) = pb.finish() {
let w = if o.px_per_mm > 0.0 && p.lw > 0.0 { (p.lw * o.px_per_mm).max(o.hairline) } else { o.hairline };
let stroke = Stroke { width: w, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() };
pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
}
@@ -152,9 +163,65 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option<Pixm
}
}
}
hairlines.flush(&mut pm, o.antialias);
Some(pm)
}
/// Whether tiny-skia strokes a line `width` pixels wide (untransformed) as a hairline: zero
/// width, or at most one pixel when anti-aliased.
fn is_hairline(width: f32, antialias: bool) -> bool {
width == 0.0 || (antialias && width <= 1.0)
}
/// Largest screen coordinate (pixels) of a polyline stroked together with others. Beyond it a
/// path's integer bounds could overflow, so such a polyline is stroked on its own as before.
const MAX_BATCH_COORD: f32 = 1.0e8;
fn batchable(v: f32) -> bool {
v.is_finite() && v.abs() <= MAX_BATCH_COORD
}
/// Consecutive hairline polylines with the same colour, alpha and width, stroked as one path.
///
/// tiny-skia draws a hairline segment by segment straight onto the pixmap (each segment with
/// its own caps, a polyline's first and last segment extended for round caps), so one path of
/// several polylines paints exactly the pixels, in the same order, as stroking them one by one;
/// it saves building a paint pipeline and a path per polyline. Thicker strokes are filled
/// outlines, where overlaps would cover once instead of twice, and are never batched.
#[derive(Default)]
struct Hairlines {
key: Option<([u8; 4], f32)>,
pb: PathBuilder,
}
impl Hairlines {
fn add(&mut self, pm: &mut Pixmap, rgba: [u8; 4], width: f32, pts: &[(f32, f32)], antialias: bool) {
if self.key.is_some_and(|k| k != (rgba, width)) {
self.flush(pm, antialias);
}
self.key = Some((rgba, width));
for (i, (x, y)) in pts.iter().enumerate() {
if i == 0 {
self.pb.move_to(*x, *y);
} else {
self.pb.line_to(*x, *y);
}
}
}
fn flush(&mut self, pm: &mut Pixmap, antialias: bool) {
let Some(([r, g, b, a], width)) = self.key.take() else { return };
let pb = std::mem::take(&mut self.pb);
let Some(path) = pb.finish() else { return };
let mut paint = Paint::default();
paint.set_color_rgba8(r, g, b, a);
paint.anti_alias = antialias;
let stroke = Stroke { width, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() };
pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
self.pb = path.clear();
}
}
/// Render and encode as PNG.
pub fn render_png(list: &DisplayList, view: &View, o: &RasterOptions) -> Option<Vec<u8>> {
render(list, view, o)?.encode_png().ok()
+123
View File
@@ -999,3 +999,126 @@ fn wide_polyline_centre_line_is_drawn_on_screen_only() {
assert_eq!(lines(&see_through), 0, "{lt}: a transparent band gets no hairline");
}
}
/// Each primitive stroked or filled on its own, as `render` did before it batched hairlines.
fn render_one_by_one(list: &DisplayList, view: &View, o: &RasterOptions) -> tiny_skia::Pixmap {
use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform};
let mut pm = Pixmap::new(view.width, view.height).unwrap();
pm.fill(tiny_skia::Color::from_rgba8(o.background.0, o.background.1, o.background.2, 255));
for p in &list.prims {
let c = p.display_rgb(o.background);
let mut paint = Paint::default();
paint.set_color_rgba8(c.0, c.1, c.2, p.alpha());
paint.anti_alias = o.antialias;
let mut pb = PathBuilder::new();
match p.kind {
Kind::Polyline => {
for (i, q) in list.points(p).iter().enumerate() {
let (x, y) = view.to_screen(*q);
if i == 0 {
pb.move_to(x, y);
} else {
pb.line_to(x, y);
}
}
if let Some(path) = pb.finish() {
let w = if o.px_per_mm > 0.0 && p.lw > 0.0 { (p.lw * o.px_per_mm).max(o.hairline) } else { o.hairline };
let stroke = Stroke { width: w, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() };
pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
}
}
Kind::Tris => {
for t in list.points(p).chunks(3) {
let [a, b, c] = t else { continue };
let ((ax, ay), (bx, by), (cx, cy)) = (view.to_screen(*a), view.to_screen(*b), view.to_screen(*c));
pb.move_to(ax, ay);
pb.line_to(bx, by);
pb.line_to(cx, cy);
pb.close();
}
if let Some(path) = pb.finish() {
pm.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
}
}
_ => panic!("not drawn by this reference"),
}
}
pm
}
fn push_prim(l: &mut DisplayList, kind: Kind, color: Rgb, transparency: u8, lw: f32, pts: &[Vec2]) {
let arr = if kind == Kind::Tris { &mut l.tris } else { &mut l.verts };
let start = arr.len() as u32;
arr.extend_from_slice(pts);
for p in pts.iter().filter(|p| p.is_finite() && p.x.abs() < 1e6) {
l.bounds.add(*p);
}
l.prims.push(DPrim { handle: VIEWPORT_CONTENT, color, aci7: false, lw, transparency, kind, start, len: pts.len() as u32 });
}
/// Hairline polylines with the same paint are stroked as one path; the pixels must be exactly
/// those of stroking each on its own, whatever breaks a run (colour, transparency, lineweight,
/// fills, unusable coordinates) and with overlaps, caps and dots.
#[test]
fn raster_hairline_batches_match_one_by_one() {
let red = Rgb(255, 0, 0);
let blue = Rgb(0, 0, 255);
let mut l = DisplayList::default();
// A hatch-like run of crossing, overlapping and touching lines in one colour.
for i in 0..40 {
let x = f64::from(i) * 0.37;
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(x, 0.0), Vec2::new(x + 6.0, 9.0)]);
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(x + 3.0, 0.0), Vec2::new(x - 2.0, 9.0), Vec2::new(x + 1.0, 4.0)]);
}
// Zero-length (a dot), single-point (nothing), and repeated lines.
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(2.0, 2.0), Vec2::new(2.0, 2.0)]);
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(3.0, 3.0)]);
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(1.0, 1.0), Vec2::new(8.0, 2.0)]);
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(1.0, 1.0), Vec2::new(8.0, 2.0)]);
// Transparent lines build up where they overlap, within a run and across runs.
for i in 0..10 {
let y = f64::from(i) * 0.2;
push_prim(&mut l, Kind::Polyline, blue, 50, 0.0, &[Vec2::new(0.0, y), Vec2::new(12.0, 8.0 - y)]);
}
push_prim(&mut l, Kind::Polyline, blue, 20, 0.0, &[Vec2::new(0.0, 5.0), Vec2::new(12.0, 5.0)]);
// A lineweight (a filled outline when lineweights are shown), a fill, then the run again.
push_prim(&mut l, Kind::Polyline, red, 0, 0.5, &[Vec2::new(0.0, 6.0), Vec2::new(12.0, 7.0), Vec2::new(5.0, 2.0)]);
push_prim(&mut l, Kind::Tris, blue, 30, 0.0, &[Vec2::new(4.0, 4.0), Vec2::new(9.0, 4.0), Vec2::new(6.0, 8.0)]);
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(0.0, 8.0), Vec2::new(12.0, 1.0)]);
// Unusable or far-out coordinates are stroked on their own, as before.
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(1.0, 1.0), Vec2::new(f64::NAN, 3.0), Vec2::new(9.0, 9.0)]);
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(-1e12, 4.0), Vec2::new(1e12, 4.5)]);
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(5.0, -1e7), Vec2::new(5.5, 1e7)]);
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(0.0, 0.5), Vec2::new(12.0, 0.5)]);
// Partly and wholly off screen.
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(-30.0, -30.0), Vec2::new(40.0, 30.0)]);
push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(100.0, 100.0), Vec2::new(120.0, 101.0)]);
let view = View::fit(&l.bounds, 160, 120, 0.05);
let bg = Rgb(33, 40, 48);
for o in [
RasterOptions::default(),
RasterOptions { antialias: false, ..Default::default() },
RasterOptions { hairline: 0.5, ..Default::default() },
RasterOptions { hairline: 0.0, antialias: false, ..Default::default() },
RasterOptions { px_per_mm: 4.0, background: Rgb(255, 255, 255), ..Default::default() },
RasterOptions { hairline: 1.5, background: bg, ..Default::default() },
] {
let fast = render(&l, &view, &o).unwrap();
let reference = render_one_by_one(&l, &view, &o);
assert!(fast.data() == reference.data(), "pixels differ with {o:?}");
}
}
/// A real hatch pattern (many parallel lines in one colour) renders exactly as stroked line by
/// line.
#[test]
fn raster_hatch_pattern_matches_one_by_one() {
let mut d = sample();
d.add(&Space::Model, Common::default(), EntityKind::Hatch(pattern_hatch("ANSI31", 0.2, (0.0, 0.0, 10.0, 10.0)))).unwrap();
let l = build(&d, &Space::Model, &Options::default());
assert!(l.prims.len() > 20);
let view = View::fit(&l.bounds, 300, 200, 0.05);
for o in [RasterOptions::default(), RasterOptions { background: Rgb(255, 255, 255), ..Default::default() }] {
assert!(render(&l, &view, &o).unwrap().data() == render_one_by_one(&l, &view, &o).data());
}
}