diff --git a/crates/render/src/glass.rs b/crates/render/src/glass.rs index 70e0d582..3555ff44 100644 --- a/crates/render/src/glass.rs +++ b/crates/render/src/glass.rs @@ -6,9 +6,20 @@ //! where it faces `light_angle`, half as bright on the opposite side, spread wider by `splay`. //! `frost` blurs the backdrop first. Distances to the outline come from an exact Euclidean //! distance transform of the shape's coverage. +//! +//! The shape's coverage, distances and normals depend only on the shape and the zoom. Once a +//! later render asks for the same shape at the same zoom they are worked out for the whole shape, +//! in its own pixel frame, and kept in a cache of at most [`MAX_CACHED_BYTES`]: moving or editing +//! what lies under the glass, dragging its sliders, panning and patching the canvas reuse them. +//! Zooming doesn't (each zoom is new). + +use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::sync::{Arc, Mutex, OnceLock}; use figcraft_doc::Glass; use figcraft_geom::Affine; +use figcraft_geom::kurbo::Shape as _; use tiny_skia::Pixmap; use crate::fx; @@ -188,6 +199,211 @@ fn light_reach(band: f64, intensity: f64) -> f64 { /// zoom would otherwise need a huge margin of coverage around every cut). const MAX_LIGHT_PX: f64 = 512.0; +/// A shape's coverage (alpha), distance to its edge and smoothed inward normals over a window of +/// pixels whose top-left pixel is (`x0`, `y0`). +struct Fields { + x0: i64, + y0: i64, + w: usize, + h: usize, + cov: Vec, + dist: Vec, + normals: Vec<(f32, f32)>, +} + +/// Bytes a field pixel holds: coverage, distance and a normal. +const FIELD_PX_BYTES: u64 = 1 + 4 + 8; + +impl Fields { + /// The fields of `cov`, the shape's coverage on the window at (`x0`, `y0`), normals smoothed + /// over `r` pixels. + fn new(cov: &Pixmap, x0: i64, y0: i64, r: usize) -> Fields { + FIELDS_COMPUTED.with(|n| n.set(n.get() + 1)); + let dist = inside_distance(cov); + let (w, h) = (cov.width() as usize, cov.height() as usize); + let normals = smooth_normals(&dist, w, h, r); + let cov = cov.data().chunks_exact(4).map(|p| p.get(3).copied().unwrap_or(0)).collect(); + Fields { x0, y0, w, h, cov, dist, normals } + } + + /// Coverage (0–1), distance and inward normal at pixel (`x`, `y`); `None` outside the window. + fn at(&self, x: i64, y: i64) -> Option<(f32, f32, (f32, f32))> { + let (fx, fy) = (x.checked_sub(self.x0)?, y.checked_sub(self.y0)?); + if fx < 0 || fy < 0 || fx >= self.w as i64 || fy >= self.h as i64 { + return None; + } + let i = fy as usize * self.w + fx as usize; + Some((f32::from(*self.cov.get(i)?) / 255.0, *self.dist.get(i)?, *self.normals.get(i)?)) + } +} + +thread_local! { + static FIELDS_COMPUTED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many times this thread has worked out a glass shape's fields: what a cache miss costs. For +/// tests and benchmarks. +pub fn glass_fields_computed() -> u64 { + FIELDS_COMPUTED.with(std::cell::Cell::get) +} + +/// What a glass shape's fields depend on, with the transform's translation reduced to its fraction +/// of a pixel: the same shape at the same zoom has the same key wherever it is and however a render +/// cuts it. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct ShapeKey { + path: u64, + linear: [u64; 4], + frac: (i64, i64), + margin: u32, + r: usize, +} + +/// The cached fields of a shape whose window holds at most this many pixels; bigger shapes (a +/// pane far larger than the view) are worked out for the part a render shows each time. +#[cfg(not(target_arch = "wasm32"))] +const MAX_SHAPE_PX: u64 = 4 << 20; +#[cfg(target_arch = "wasm32")] +const MAX_SHAPE_PX: u64 = 1 << 20; +/// Bytes the glass shape cache holds at most (less on the web, where memory never shrinks). +#[cfg(not(target_arch = "wasm32"))] +const MAX_CACHED_BYTES: u64 = 128 << 20; +#[cfg(target_arch = "wasm32")] +const MAX_CACHED_BYTES: u64 = 32 << 20; + +/// A shape's fields once worked out; parallel bands wanting the same shape wait for one of them. +type Slot = Arc>>>; + +/// The shapes' fields by key, at most `budget` bytes; the least recently used go first. A shape is +/// kept from the second render that asks for it: one asked for once (each step of a zoom, a pane +/// dragged by fractions of a pixel) is cheaper worked out for just the part a render shows. +struct ShapeCache { + map: HashMap, + /// Shapes asked for but not kept, by the render that first asked. + seen: HashMap, + held: u64, + tick: u64, + budget: u64, +} + +/// Shapes remembered as asked for once; past this many they are forgotten. +const MAX_SEEN: usize = 256; + +impl ShapeCache { + fn new(budget: u64) -> Self { + Self { map: HashMap::new(), seen: HashMap::new(), held: 0, tick: 0, budget } + } + + /// The slot for `key` (filled or about to be) asked for in render number `render`, making room + /// for `bytes`; `None` when it isn't kept (first asked for in this render, or too big). + fn slot(&mut self, key: &ShapeKey, bytes: u64, render: u64) -> Option { + self.tick += 1; + if let Some((slot, _, used)) = self.map.get_mut(key) { + *used = self.tick; + return Some(slot.clone()); + } + if bytes > self.budget { + return None; + } + match self.seen.get(key) { + Some(first) if *first < render => { + self.seen.remove(key); + } + Some(_) => return None, + None => { + if self.seen.len() >= MAX_SEEN { + self.seen.clear(); + } + self.seen.insert(key.clone(), render); + return None; + } + } + while self.held + bytes > self.budget { + let oldest = self.map.iter().min_by_key(|(_, (_, _, used))| *used).map(|(k, _)| k.clone())?; + if let Some((_, b, _)) = self.map.remove(&oldest) { + self.held -= b; + } + } + let slot = Slot::default(); + self.map.insert(key.clone(), (slot.clone(), bytes, self.tick)); + self.held += bytes; + Some(slot) + } +} + +fn shape_cache() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(ShapeCache::new(MAX_CACHED_BYTES))) +} + +/// The fields `glass` reads for a `w` × `h` layer whose `shape` maps through `xf` (local → layer +/// pixels), exact for distances up to the margin `m`, normals smoothed over `r` pixels, and the +/// shift from layer pixels to the fields' pixels. +/// +/// A shape asked for again in a later render, whose window fits [`MAX_SHAPE_PX`], is worked out +/// whole, in its own frame (the translation's fraction of a pixel), and kept: a band, a canvas +/// patch or the next frame of a drag reads the same fields. Otherwise it is worked out for the part +/// shown plus the margin. +fn shape_fields(shape: &Outline, xf: Affine, w: u32, h: u32, m: u32, r: usize) -> Option<(Arc, (i64, i64))> { + if let Some(whole) = whole_shape(shape, xf, m, r) { + return Some(whole); + } + let cov = spread_alpha(std::slice::from_ref(shape), 0.0, Affine::translate((f64::from(m), f64::from(m))) * xf, w + 2 * m, h + 2 * m)?; + Some((Arc::new(Fields::new(&cov, -i64::from(m), -i64::from(m), r)), (0, 0))) +} + +/// [`shape_fields`] for a whole shape, from the cache; `None` when it isn't kept (first asked for +/// in this render, too big, or its transform too far out). +fn whole_shape(shape: &Outline, xf: Affine, m: u32, r: usize) -> Option<(Arc, (i64, i64))> { + let [a, b, c, d, e, f] = xf.as_coeffs(); + // The translation in steps of 1/65536 px (far finer than the rasteriser's 1/64, coarse enough + // that rounding errors in where a render starts land on the same step), its whole pixels (the + // shift) and the fraction left; rounding first carries 99.9999999 over to 100. + const STEPS: f64 = 65536.0; + let q = |v: f64| { + let q = (v * STEPS).round(); + (q.is_finite() && q.abs() < 1e15).then_some(q as i64) + }; + let (qe, qf) = (q(e)?, q(f)?); + let shift = (qe.div_euclid(STEPS as i64), qf.div_euclid(STEPS as i64)); + let frac = (qe.rem_euclid(STEPS as i64), qf.rem_euclid(STEPS as i64)); + let own = Affine::new([a, b, c, d, frac.0 as f64 / STEPS, frac.1 as f64 / STEPS]); + let bb = (own * shape.0.clone()).bounding_box(); + let (x0, y0, x1, y1) = (bb.x0.floor(), bb.y0.floor(), bb.x1.ceil(), bb.y1.ceil()); + if ![x0, y0, x1, y1].iter().all(|v| v.is_finite() && v.abs() < 1e9) { + return None; + } + // The margin in steps of 32 px, so dragging a slider that widens it a little keeps the same + // fields (a wider margin only adds exact distances further out: the same pixels). + let margin = m.div_ceil(32).checked_mul(32)?; + let (rw, rh) = ((x1 - x0) as u64 + 2, (y1 - y0) as u64 + 2); + let (ww, wh) = (rw + 2 * u64::from(margin), rh + 2 * u64::from(margin)); + if ww.checked_mul(wh)? > MAX_SHAPE_PX { + return None; + } + let mut hasher = DefaultHasher::new(); + crate::draw::hash_path(&shape.0, &mut hasher); + matches!(shape.1, tiny_skia::FillRule::EvenOdd).hash(&mut hasher); + let key = ShapeKey { path: hasher.finish(), linear: [a, b, c, d].map(f64::to_bits), frac, margin, r }; + let compute = || { + // Rastered against the shape's own bounds (not the window), so the margin can't move a + // coverage value. + let raster = spread_alpha(std::slice::from_ref(shape), 0.0, Affine::translate((1.0 - x0, 1.0 - y0)) * own, rw as u32, rh as u32)?; + let mut win = Pixmap::new(ww as u32, wh as u32)?; + let (row, at) = (raster.width() as usize * 4, (margin as usize, margin as usize)); + let stride = win.width() as usize * 4; + for (y, src) in raster.data().chunks_exact(row).enumerate() { + let start = (y + at.1) * stride + at.0 * 4; + win.data_mut().get_mut(start..start + row)?.copy_from_slice(src); + } + let origin = (x0 as i64 - 1 - i64::from(margin), y0 as i64 - 1 - i64::from(margin)); + Some(Fields::new(&win, origin.0, origin.1, r)) + }; + let slot = + shape_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).slot(&key, ww * wh * FIELD_PX_BYTES, crate::render_number())?; + Some((slot.get_or_init(|| compute().map(Arc::new)).clone()?, shift)) +} + /// The glass of a `w` × `h` layer whose `shape` maps through `xf` (local → layer pixels), from /// `back`, its backdrop padded by `pad` pixels each way, at `scale` device pixels per unit. #[allow(clippy::too_many_arguments)] @@ -201,10 +417,7 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32 // exact: the bend's (within `depth`), the light's (within `reach`) and those the normals are // smoothed from (2r + 1 further). let m = depth.max(reach).ceil() as u32 + 4 * r as u32 + 4; - let cov = spread_alpha(std::slice::from_ref(shape), 0.0, Affine::translate((f64::from(m), f64::from(m))) * xf, w + 2 * m, h + 2 * m)?; - let dist = inside_distance(&cov); - let (cw, ch) = (cov.width() as usize, cov.height() as usize); - let normals = smooth_normals(&dist, cw, ch, r); + let (fields, shift) = shape_fields(shape, xf, w, h, m, r)?; let mut frosted = back.clone(); fx::blur(&mut frosted, g.frost / 2.0 * scale); let ang = g.light_angle.to_radians(); @@ -221,17 +434,12 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32 let depth = depth as f32; let mut out = Pixmap::new(w, h)?; let dst = out.data_mut(); - let cd = cov.data(); - let d_at = |x: usize, y: usize| dist.get(y * cw + x).copied().unwrap_or(0.0); for y in 0..h as usize { for x in 0..w as usize { - let (mx, my) = (x + m as usize, y + m as usize); - let c = f32::from(cd.get((my * cw + mx) * 4 + 3).copied().unwrap_or(0)) / 255.0; + let Some((c, d, inward)) = fields.at(x as i64 - shift.0, y as i64 - shift.1) else { continue }; if c <= 0.0 { continue; } - let d = d_at(mx, my); - let inward = normals.get(my * cw + mx).copied().unwrap_or((0.0, 0.0)); let t = (d / depth).min(1.0); let bend = bend_max * (1.0 - t) * (1.0 - t); let (bx, by) = (x as f32 + pad as f32 + 0.5, y as f32 + pad as f32 + 0.5); @@ -284,6 +492,28 @@ mod tests { assert!((d[10 * 21 + 5] - 0.5).abs() < 1e-4, "{}", d[10 * 21 + 5]); } + #[test] + fn the_shape_cache_stays_within_its_bytes() { + let key = |i: u64| ShapeKey { path: i, linear: [0; 4], frac: (0, 0), margin: 0, r: 1 }; + let mut c = ShapeCache::new(100); + // Kept from the second render that asks, not the first (nor again within the first). + assert!(c.slot(&key(1), 40, 1).is_none()); + assert!(c.slot(&key(1), 40, 1).is_none()); + assert!(c.slot(&key(1), 40, 2).is_some()); + for k in [2, 3] { + assert!(c.slot(&key(k), 40, 2).is_none()); + } + assert!(c.slot(&key(2), 40, 3).is_some()); + // Using 1 again makes 2 the least recently used: 2 goes to make room for 3. + assert!(c.slot(&key(1), 40, 3).is_some()); + assert!(c.slot(&key(3), 40, 3).is_some()); + assert!(c.held <= 100, "{}", c.held); + assert!(c.map.contains_key(&key(1)) && c.map.contains_key(&key(3)) && !c.map.contains_key(&key(2))); + // More than it holds in all: never kept. + assert!(c.slot(&key(4), 101, 3).is_none() && c.slot(&key(4), 101, 4).is_none()); + assert!(c.held <= 100 && !c.map.contains_key(&key(4))); + } + #[test] fn light_reach_is_where_the_light_falls_under_half_a_level() { let r = light_reach(10.0, 1.0); diff --git a/crates/render/src/glass_bench_tests.rs b/crates/render/src/glass_bench_tests.rs index 64c95835..1a4b4cbe 100644 --- a/crates/render/src/glass_bench_tests.rs +++ b/crates/render/src/glass_bench_tests.rs @@ -80,6 +80,18 @@ fn set_glass(d: &mut Document, pane: NodeId, g: Glass) { d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }]; } +/// Writes the bench scene's render (rendered twice, so a kept shape is read) to the PNG named by +/// `FIGCRAFT_GLASS_PNG`, to compare builds by eye. +#[test] +#[ignore = "writes a PNG: FIGCRAFT_GLASS_PNG="] +fn glass_bench_png() { + let Ok(path) = std::env::var("FIGCRAFT_GLASS_PNG") else { return }; + let (d, p, _, _) = scene(); + let _ = render_view(&d, p, &view((0.0, 0.0), 2.0), &Options::default()); + let pix = render_view(&d, p, &view((0.0, 0.0), 2.0), &Options::default()).unwrap(); + pix.save_png(path).unwrap(); +} + #[test] #[ignore = "timing: run in release with --ignored --nocapture"] fn glass_render_timings() { @@ -96,6 +108,10 @@ fn glass_render_timings() { }), ), ("export: zoom, a new scale every frame", time(&mut d, p, render_view, |_, i| view((0.0, 0.0), 2.0 + 0.013 * f64::from(i)))), + ( + "export: zoomed in, a corner of the pane in view, a new scale every frame", + time(&mut d, p, render_view, |_, i| view((700.0, 400.0), 3.0 + 0.013 * f64::from(i))), + ), ("single: same view again", time(&mut d, p, render_view_single, |_, _| view((0.0, 0.0), 2.0))), ("canvas: drag the card under the glass", canvas(&d, p, 2.0, move_card)), ( diff --git a/crates/render/src/glass_cut_tests.rs b/crates/render/src/glass_cut_tests.rs index 43733c7f..93124377 100644 --- a/crates/render/src/glass_cut_tests.rs +++ b/crates/render/src/glass_cut_tests.rs @@ -99,7 +99,10 @@ fn glass_digests() { let (d, p, _) = pane_over_stripes(g.clone(), radius); for scale in [1.0, 1.37, 2.0] { let v = View { origin: (100.0, 100.0), scale, width: (1000.0 * scale) as u32, height: (700.0 * scale) as u32 }; + // Rendered twice: the second reads the whole shape the first asked for. + let first = render_view_single(&d, p, &v, &Options::default()).unwrap(); let pix = render_view_single(&d, p, &v, &Options::default()).unwrap(); + assert!(first.data() == pix.data(), "glass {i} radius {radius} scale {scale}: the kept shape renders differently"); let mut h = DefaultHasher::new(); pix.data().hash(&mut h); println!("glass {i} radius {radius} scale {scale}: {:016x}", h.finish()); @@ -133,3 +136,82 @@ fn a_cut_through_the_pane_matches_the_whole_render() { } } } + +/// A `w` × `h` pane of glass `g` at (`x`, `y`) over stripes, alone on its page: (document, page, +/// pane). Each test gives its own size, so tests running at once don't share cached shapes. +fn pane(g: Glass, x: f64, y: f64, w: f64, h: f64) -> (Document, NodeId, NodeId) { + let mut d = Document::new("glass"); + let p = d.first_page().unwrap(); + for i in 0..8u32 { + let s = d.create(p, NodeKind::Rectangle, "Stripe", f64::from(i) * 40.0, 0.0, 20.0, 400.0).unwrap(); + d.get_mut(s).unwrap().fills = vec![Paint::solid(Color::from_hex("2040E0").unwrap())]; + } + let pane = d.create(p, NodeKind::Rectangle, "Glass", 0.0, 0.0, w, h).unwrap(); + let n = d.get_mut(pane).unwrap(); + n.transform = Affine::translate((x, y)); + n.corners.radii = [12.0; 4]; + n.effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }]; + (d, p, pane) +} + +fn render(d: &Document, p: NodeId, origin: (f64, f64), scale: f64) -> Pixmap { + render_view_single(d, p, &View { origin, scale, width: 400, height: 300 }, &Options::default()).unwrap() +} + +/// How many glass shapes `f` works out on this thread. +fn computed(f: impl FnOnce()) -> u64 { + let before = crate::glass::glass_fields_computed(); + f(); + crate::glass::glass_fields_computed() - before +} + +#[test] +fn kept_fields_render_the_same_pixels_as_fresh_ones() { + let (d, p, _) = pane(Glass::default(), 40.0, 30.0, 211.0, 137.0); + let mut shots = Vec::new(); + // The first render works out the part it shows, the next the whole shape (and keeps it), the + // ones after reuse it. + for want in [1, 1, 0] { + assert_eq!(computed(|| shots.push(render(&d, p, (0.0, 0.0), 1.5))), want, "render {}", shots.len() + 1); + } + assert!(shots.windows(2).all(|w| w[0].data() == w[1].data())); +} + +#[test] +fn what_the_shape_depends_on_works_it_out_again_and_nothing_else_does() { + let (mut d, p, pane) = pane(Glass::default(), 40.0, 30.0, 223.0, 149.0); + let set = |d: &mut Document, g: Glass| d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }]; + // Asked for twice: kept. + let _ = render(&d, p, (0.0, 0.0), 1.0); + let _ = render(&d, p, (0.0, 0.0), 1.0); + // Reused: the look apart from depth, what lies under the glass, panning by whole pixels and + // moving the pane by whole pixels. + let reused = computed(|| { + set(&mut d, Glass { light_angle: 30.0, refraction: 0.2, dispersion: 1.0, frost: 9.0, ..Glass::default() }); + let _ = render(&d, p, (0.0, 0.0), 1.0); + let stripe = d.node(p).unwrap().children[0]; + d.get_mut(stripe).unwrap().transform = Affine::translate((5.0, 0.0)); + let _ = render(&d, p, (0.0, 0.0), 1.0); + let _ = render(&d, p, (7.0, -3.0), 1.0); + d.get_mut(pane).unwrap().transform = Affine::translate((52.0, 31.0)); + let _ = render(&d, p, (0.0, 0.0), 1.0); + }); + assert_eq!(reused, 0); + // Worked out again: another zoom, a move by part of a pixel, another shape, a much deeper edge. + assert_eq!(computed(|| drop(render(&d, p, (0.0, 0.0), 1.25))), 1, "zoom"); + d.get_mut(pane).unwrap().transform = Affine::translate((52.5, 31.0)); + assert_eq!(computed(|| drop(render(&d, p, (0.0, 0.0), 1.0))), 1, "part of a pixel"); + d.get_mut(pane).unwrap().width = 230.0; + assert_eq!(computed(|| drop(render(&d, p, (0.0, 0.0), 1.0))), 1, "shape"); + set(&mut d, Glass { depth: 70.0, ..Glass::default() }); + assert_eq!(computed(|| drop(render(&d, p, (0.0, 0.0), 1.0))), 1, "depth"); +} + +#[test] +fn a_pane_too_big_to_keep_still_renders() { + // 400 × 300 units at 8x: far past what is kept whole, so each render works out its part. + let (d, p, _) = pane(Glass { frost: 0.0, ..Glass::default() }, 0.0, 0.0, 400.0, 300.0); + let shown = render(&d, p, (10.0, 10.0), 8.0); + assert_eq!(computed(|| drop(render(&d, p, (10.0, 10.0), 8.0))), 1); + assert!(shown.data().chunks_exact(4).any(|px| px[2] > 200), "the stripes show through"); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9969a597..cf7a0693 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -99,8 +99,23 @@ pub(crate) fn backdrop_reach(radius: f64, scale: f64) -> f64 { if radius > 0.0 { radius * 1.5 * scale + 3.0 } else { 0.0 } } +/// Renders started (bands of one render count once): glass keeps the shapes a later render asks +/// for again. +static RENDERS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// The number of the render that just started. +fn start_render() -> u64 { + RENDERS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1 +} + +/// The number of the latest render started. +pub(crate) fn render_number() -> u64 { + RENDERS.load(std::sync::atomic::Ordering::Relaxed) +} + /// Render a page region, splitting it into horizontal bands rendered in parallel (desktop). pub fn render_view(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option { + start_render(); #[cfg(not(target_arch = "wasm32"))] { let threads = rayon::current_num_threads().clamp(1, 16) as u32; @@ -112,7 +127,7 @@ pub fn render_view(doc: &Document, page: NodeId, view: &View, opts: &Options) -> return Some(p); } } - render_view_single(doc, page, view, opts) + render_one(doc, page, view, opts) } /// Render `view` as `bands` horizontal bands in parallel. Bands overlap by what background blurs @@ -137,7 +152,7 @@ fn render_bands(doc: &Document, page: NodeId, view: &View, opts: &Options, bands let top = y0.saturating_sub(overlap); let bottom = (y0 + h + overlap).min(view.height).max(y0 + h); let v = View { origin: (view.origin.0, view.origin.1 + f64::from(top) / s), scale: view.scale, width: view.width, height: bottom - top }; - let part = render_view_single(doc, page, &v, opts); + let part = render_one(doc, page, &v, opts); let own = tiny_skia::IntRect::from_xywh(0, (y0 - top) as i32, view.width.clamp(1, 16_384), h); (y0, if overlap == 0 { part } else { part.zip(own).and_then(|(p, r)| p.clone_rect(r)) }) }) @@ -161,6 +176,12 @@ fn render_bands(doc: &Document, page: NodeId, view: &View, opts: &Options, bands /// Render a page region on the calling thread. pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option { + start_render(); + render_one(doc, page, view, opts) +} + +/// [`render_view_single`] as part of a render already started (one of its bands). +fn render_one(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option { let mut pix = new_pixmap(view.width, view.height)?; let bg = opts.background.or_else(|| match doc.node(page).map(|p| &p.kind) { Some(NodeKind::Page { background, .. }) => Some(*background),