Merge pull request #1496 from samhyun/perf/web-band-render

M8: the browser draws a zoom's full render a band at a time
This commit is contained in:
BFlat
2026-10-30 00:23:01 +07:00
committed by GitHub
6 changed files with 655 additions and 67 deletions
+45 -26
View File
@@ -26,7 +26,7 @@ pub mod shadow;
pub use dots::Dots;
pub use draw::{node_outline, paint_bounds, stroke_fill, subtree_outline, ts_path};
pub use export_depth::{ExportDepthError, MAX_EXPORT_DEPTH, check_export_depth};
pub use retained::{Retained, Work};
pub use retained::{Begun, Retained, Steps, Work};
pub use tiny_skia::{FillRule, Pixmap};
use figcraft_color::{Color, ColorProfile};
@@ -89,7 +89,7 @@ pub struct Options {
pub dots: Option<Dots>,
}
fn new_pixmap(w: u32, h: u32) -> Option<Pixmap> {
pub(crate) fn new_pixmap(w: u32, h: u32) -> Option<Pixmap> {
let w = w.clamp(1, 16_384);
let h = h.clamp(1, 16_384);
Pixmap::new(w, h)
@@ -112,7 +112,7 @@ pub(crate) fn backdrop_reach(radius: f64, scale: f64) -> f64 {
static RENDERS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// The number of the render that just started.
fn start_render() -> u64 {
pub(crate) fn start_render() -> u64 {
RENDERS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1
}
@@ -146,42 +146,61 @@ pub fn render_view(doc: &Document, page: NodeId, view: &View, opts: &Options) ->
fn render_bands(doc: &Document, page: NodeId, view: &View, opts: &Options, bands: u32) -> Option<Pixmap> {
use rayon::prelude::*;
let band_h = view.height.div_ceil(bands.max(1));
let overlap = backdrop_reach(max_backdrop_blur(doc), view.scale).ceil();
if !overlap.is_finite() || overlap > f64::from(band_h) {
return None;
}
let overlap = overlap as u32;
let s = view.scale.max(1e-9);
let overlap = band_overlap(doc, view, band_h)?;
let parts: Vec<(u32, Option<Pixmap>)> = (0..bands)
.into_par_iter()
.map(|i| {
let y0 = i * band_h;
let h = band_h.min(view.height.saturating_sub(y0)).max(1);
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_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)) })
(y0, render_band(doc, page, view, opts, y0, band_h, overlap))
})
.collect();
let mut out = new_pixmap(view.width, view.height)?;
let row = out.width() as usize * 4;
for (y0, part) in parts {
let Some(part) = part else { continue };
// Bands are as wide as the view: their rows go straight into place.
let start = y0 as usize * row;
let dst = out.data_mut();
let n = part.data().len().min(dst.len().saturating_sub(start));
if part.width() == view.width.clamp(1, 16_384)
&& let (Some(d), Some(s)) = (dst.get_mut(start..start + n), part.data().get(..n))
{
d.copy_from_slice(s);
if let Some(part) = part {
put_band(&mut out, y0, &part);
}
}
Some(out)
}
/// How many rows bands of `band_h` rows of `view` overlap (what background blurs read); `None`
/// when that is more than a band.
pub(crate) fn band_overlap(doc: &Document, view: &View, band_h: u32) -> Option<u32> {
let overlap = backdrop_reach(max_backdrop_blur(doc), view.scale).ceil();
(overlap.is_finite() && overlap <= f64::from(band_h)).then_some(overlap as u32)
}
/// The rows `y0 .. y0 + band_h` of `view` (fewer at the bottom), rendered with `overlap` rows
/// more each way and cut back to their own.
pub(crate) fn render_band(doc: &Document, page: NodeId, view: &View, opts: &Options, y0: u32, band_h: u32, overlap: u32) -> Option<Pixmap> {
let s = view.scale.max(1e-9);
let h = band_h.min(view.height.saturating_sub(y0)).max(1);
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_one(doc, page, &v, opts);
if overlap == 0 {
return part;
}
let own = tiny_skia::IntRect::from_xywh(0, (y0 - top) as i32, view.width.clamp(1, 16_384), h);
part.zip(own).and_then(|(p, r)| p.clone_rect(r))
}
/// Copy a band's rows into `out` at row `y0` (bands are as wide as the view: their rows go
/// straight into place).
pub(crate) fn put_band(out: &mut Pixmap, y0: u32, part: &Pixmap) {
let row = out.width() as usize * 4;
let start = y0 as usize * row;
let width = out.width();
let dst = out.data_mut();
let n = part.data().len().min(dst.len().saturating_sub(start));
if part.width() == width
&& let (Some(d), Some(s)) = (dst.get_mut(start..start + n), part.data().get(..n))
{
d.copy_from_slice(s);
}
}
/// Render a page region on the calling thread.
pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option<Pixmap> {
start_render();
+129 -8
View File
@@ -127,6 +127,70 @@ impl Px {
}
}
/// What [`Retained::prepare`] found.
enum Prepared {
/// The previous render patched (or reused as is), now kept.
Patched(View, Work),
/// Only a full render will do: the snapped view, its top-left device pixel, the font
/// generation and what [`sensitive`] found when the previous render's document was checked.
Full { snapped: View, px: (i64, i64), fonts: u64, sensitive: Option<Arc<Sensitive>> },
}
/// What [`Retained::begin`] did.
pub enum Begun<'a> {
/// The view drawn, its image and what it took.
Done(View, &'a Pixmap, Work),
/// A full render to draw band by band (boxed: it holds the image).
Steps(Box<Steps>),
}
/// A full render drawn a band at a time ([`Retained::begin`]): bands overlap by what background
/// blurs read and keep only their own rows, as the desktop's parallel bands do.
pub struct Steps {
doc: Arc<Document>,
page: NodeId,
view: View,
px: (i64, i64),
fonts: u64,
opts: Options,
sensitive: Option<Arc<Sensitive>>,
pix: Pixmap,
/// The first row not drawn yet.
next: u32,
band_h: u32,
overlap: u32,
}
impl Steps {
/// Draw the next band; `true` once every row is drawn.
pub fn step(&mut self) -> bool {
if self.done() {
return true;
}
let y0 = self.next;
if let Some(part) = crate::render_band(&self.doc, self.page, &self.view, &self.opts, y0, self.band_h, self.overlap) {
crate::put_band(&mut self.pix, y0, &part);
}
self.next = y0.saturating_add(self.band_h);
self.done()
}
/// Whether every row is drawn.
pub fn done(&self) -> bool {
self.next >= self.view.height
}
/// The view drawn (snapped to device pixels).
pub fn view(&self) -> &View {
&self.view
}
/// The page drawn.
pub fn page(&self) -> NodeId {
self.page
}
}
/// Keeps the last rendered view; see the module docs.
#[derive(Default)]
pub struct Retained {
@@ -142,13 +206,73 @@ impl Retained {
/// Render `view` of `page`, reusing the previous render where nothing changed. Returns the
/// view actually drawn (snapped to device pixels, see [`snap`]), the image and what it took.
pub fn render(&mut self, doc: &Arc<Document>, page: NodeId, view: &View, opts: &Options) -> Option<(View, &Pixmap, Work)> {
match self.prepare(doc, page, view, opts)? {
Prepared::Patched(snapped, work) => Some((snapped, &self.last.as_ref()?.pix, work)),
Prepared::Full { snapped, px, fonts, sensitive } => {
let pix = render_view(doc, page, &snapped, opts)?;
let s = sensitive.unwrap_or_else(|| Arc::new(self::sensitive(doc)));
let frame = self.keep(doc, page, (&snapped, px, fonts), opts, pix, s);
Some((snapped, &frame.pix, Work::Full))
}
}
}
/// [`Self::render`], except that a full render comes back as [`Steps`] to draw `band_h` rows
/// at a time (the browser, whose renders block the page: input is handled between bands) and
/// keep with [`Self::finish`]. A render that is reused or patched is done at once.
pub fn begin(&mut self, doc: &Arc<Document>, page: NodeId, view: &View, opts: &Options, band_h: u32) -> Option<Begun<'_>> {
match self.prepare(doc, page, view, opts)? {
Prepared::Patched(snapped, work) => Some(Begun::Done(snapped, &self.last.as_ref()?.pix, work)),
Prepared::Full { snapped, px, fonts, sensitive } => {
// Bands at least twice what they overlap (each draws its overlap again both ways),
// so a background blur reaching past `band_h` makes the bands taller.
let overlap = crate::band_overlap(doc, &snapped, u32::MAX).map(|o| o.min(snapped.height));
let band_h = band_h.max(1).max(overlap.unwrap_or(0).saturating_mul(2));
// A blur that can't be measured: one piece.
let Some(overlap) = overlap else {
let pix = render_view(doc, page, &snapped, opts)?;
let s = sensitive.unwrap_or_else(|| Arc::new(self::sensitive(doc)));
let frame = self.keep(doc, page, (&snapped, px, fonts), opts, pix, s);
return Some(Begun::Done(snapped, &frame.pix, Work::Full));
};
let pix = crate::new_pixmap(snapped.width, snapped.height)?;
// One render, as the desktop's parallel bands are: glass keeps the shapes a
// later render asks for again, and the bands of this one are not later renders.
crate::start_render();
Some(Begun::Steps(Box::new(Steps {
doc: doc.clone(),
page,
view: snapped,
px,
fonts,
opts: opts.clone(),
sensitive,
pix,
next: 0,
band_h,
overlap,
})))
}
}
}
/// Keep the image `steps` drew (all its bands: see [`Steps::step`]) as a full render.
pub fn finish(&mut self, steps: Steps) -> (View, &Pixmap) {
let Steps { doc, page, view, px, fonts, opts, sensitive, pix, .. } = steps;
let s = sensitive.unwrap_or_else(|| Arc::new(self::sensitive(&doc)));
let frame = self.keep(&doc, page, (&view, px, fonts), &opts, pix, s);
(view, &frame.pix)
}
/// The previous render patched where it could be (kept, see [`Self::keep`]), or what a full
/// render of `view` needs.
fn prepare(&mut self, doc: &Arc<Document>, page: NodeId, view: &View, opts: &Options) -> Option<Prepared> {
// A view that can't be put on a pixel grid (non-finite or absurd) draws nothing.
let Some((snapped, px)) = snap(view) else {
self.last = None;
return None;
};
let fonts = figcraft_text::layout_generation();
let mut work = Work::Full;
let mut sens = None;
if let Some(last) = self.last.take()
&& last.page == page
@@ -173,16 +297,13 @@ impl Retained {
let same_doc = Arc::ptr_eq(&last.doc, doc);
let s = if same_doc { last.sensitive.clone() } else { Arc::new(sensitive(doc)) };
if let Some((pix, pixels)) = patch(last, doc, page, &snapped, px, opts, &s) {
work = if pixels == 0 { Work::Reused } else { Work::Partial { pixels } };
let frame = self.keep(doc, page, (&snapped, px, fonts), opts, pix, s);
return Some((snapped, &frame.pix, work));
let work = if pixels == 0 { Work::Reused } else { Work::Partial { pixels } };
self.keep(doc, page, (&snapped, px, fonts), opts, pix, s);
return Some(Prepared::Patched(snapped, work));
}
sens = Some(s);
}
let pix = render_view(doc, page, &snapped, opts)?;
let s = sens.unwrap_or_else(|| Arc::new(sensitive(doc)));
let frame = self.keep(doc, page, (&snapped, px, fonts), opts, pix, s);
Some((snapped, &frame.pix, work))
Some(Prepared::Full { snapped, px, fonts, sensitive: sens })
}
/// Keep `pix`, the render of `at`: the snapped view, its top-left device pixel and the font
+86
View File
@@ -287,3 +287,89 @@ fn overflowing_text_is_drawn_while_its_box_is_out_of_view() {
let dark = pix.data().as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count();
assert!(dark > 20, "glyphs drawn: {dark}");
}
/// Draw a full render through [`Retained::begin`] `band_h` rows at a time; how many steps it took.
fn stepped(r: &mut Retained, doc: &Arc<Document>, page: NodeId, v: &View, band_h: u32) -> (View, Pixmap, usize) {
match r.begin(doc, page, v, &Options::default(), band_h).unwrap() {
crate::Begun::Done(shown, pix, work) => {
assert_eq!(work, Work::Full, "one piece only when a blur can't be measured");
(shown, pix.clone(), 0)
}
crate::Begun::Steps(mut steps) => {
let mut n = 1;
while !steps.step() {
n += 1;
assert!(n < 1000, "steps end");
}
let (shown, pix) = r.finish(*steps);
(shown, pix.clone(), n)
}
}
}
#[test]
fn a_render_drawn_band_by_band_matches_one_in_one_piece() {
for cut_sensitive in [false, true] {
let s = scene(cut_sensitive);
// The whole scene in view, so the glass's backdrop is never cut by the view.
let v = View { origin: (-10.0, -10.0), scale: 1.0, width: 380, height: 360 };
let mut r = Retained::default();
let (shown, pix, n) = stepped(&mut r, &s.doc, s.page, &v, 40);
assert_eq!(n, shown.height.div_ceil(40) as usize, "a band a step");
let full = render_view(&s.doc, s.page, &shown, &Options::default()).unwrap();
assert_same(&pix, &full, "bands");
// Kept as a full render: the next pan patches it.
assert!(partial(check(&mut r, &s.doc, s.page, &View { origin: (0.0, -10.0), ..v })));
}
}
#[test]
fn bands_thinner_than_a_blur_reaches_grow_to_twice_its_reach() {
let s = scene(true);
let v = View { origin: (-10.0, -10.0), scale: 4.0, width: 380, height: 360 };
let reach = crate::band_overlap(&s.doc, &v, u32::MAX).unwrap();
assert!(reach > 4, "the blur reaches past the bands asked for");
let mut r = Retained::default();
let (shown, pix, n) = stepped(&mut r, &s.doc, s.page, &v, 4);
assert_eq!(n, shown.height.div_ceil(4.max(reach.min(shown.height) * 2)) as usize, "bands twice the reach");
assert_same(&pix, &render_view(&s.doc, s.page, &shown, &Options::default()).unwrap(), "taller bands");
}
#[test]
fn a_reused_or_patched_render_needs_no_steps() {
let s = scene(false);
let v = view(0.0, 0.0, 1.0);
let mut r = Retained::default();
check(&mut r, &s.doc, s.page, &v);
assert!(matches!(r.begin(&s.doc, s.page, &v, &Options::default(), 40), Some(crate::Begun::Done(_, _, Work::Reused))));
let panned = View { origin: (12.0, 0.0), ..v };
assert!(matches!(r.begin(&s.doc, s.page, &panned, &Options::default(), 40), Some(crate::Begun::Done(_, _, Work::Partial { .. }))));
}
/// Background blurs stacked on each other (the upper reads the lower's blurred pixels) across the
/// band boundaries: the bands' overlap, the reach of the largest blur, is enough for the image to
/// match one drawn in one piece, as it is for the desktop's parallel bands.
#[test]
fn stacked_backdrop_blurs_draw_the_same_in_bands() {
fonts();
let mut d = Document::new("stacked");
let page = d.first_page().unwrap();
// Stripes to blur, then three translucent blurred panels overlapping each other.
for i in 0..40 {
let r = d.create(page, NodeKind::Rectangle, "S", 0.0, f64::from(i) * 10.0, 400.0, 5.0).unwrap();
solid(d.get_mut(r).unwrap(), if i % 2 == 0 { "E03030" } else { "3070E0" });
}
for (x, y) in [(40.0, 60.0), (120.0, 120.0), (60.0, 200.0)] {
let b = d.create(page, NodeKind::Rectangle, "B", x, y, 220.0, 160.0).unwrap();
d.get_mut(b).unwrap().fills = vec![Paint::solid(Color::new(1.0, 1.0, 1.0, 0.25))];
d.get_mut(b).unwrap().effects.push(Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(24.0)), visible: true });
}
let doc = Arc::new(d);
let v = View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 400 };
for band_h in [64, 100, 200] {
let mut r = Retained::default();
let (shown, pix, n) = stepped(&mut r, &doc, page, &v, band_h);
assert!(n >= 3, "{n} bands of {band_h} rows");
assert_same(&pix, &render_view(&doc, page, &shown, &Options::default()).unwrap(), &format!("bands of {band_h} rows"));
}
}
+207 -16
View File
@@ -15,13 +15,16 @@
//! The browser renders on the UI thread, so a zoom's full render blocks the page: there a preview
//! is refined only once no newer request has come for [`SETTLE_MS`] (the zoom has paused), or for
//! as long as the last full render took when that is shorter (a wait never costs more than the
//! render it may save).
//! render it may save), and the full render is then drawn a band at a time
//! ([`figcraft_render::Steps`]), at least a band a frame and more while they fit in [`STEP_MS`],
//! so input is handled while it draws (on a page whose render takes seconds, a band is a
//! fraction of that: eight at most).
use std::sync::Arc;
use figcraft_color::ColorProfile;
use figcraft_doc::{Document, NodeId};
use figcraft_render::{Pixmap, Retained, View, Work};
use figcraft_render::{Begun, Pixmap, Retained, Steps, View, Work};
// A monotonic clock in the browser too: preview decisions rest on how long renders take there.
use web_time::Instant;
@@ -33,6 +36,17 @@ pub const PREVIEW_AFTER_MS: f64 = 12.0;
/// long as the last full render took, when shorter): a full render there blocks the page, and one
/// between every two wheel steps of a zoom held it up for seconds.
pub const SETTLE_MS: u64 = 120;
/// The browser draws a full render a band at a time ([`figcraft_render::Steps`]): at least one
/// band a frame, and more while they fit in this long, so input is handled while it draws. A
/// band of a render that takes seconds takes a fraction of that (see [`MAX_BANDS`]).
pub const STEP_MS: f64 = 24.0;
/// Bands are at least this many rows ...
const MIN_BAND_ROWS: u32 = 64;
/// ... and a view has at most this many: a frame draws at least one band, so the longest frame of
/// a render drawn in steps is about a band's time, a part of the render rather than the whole of it
/// (0.7–1.4 s where the render took 3.7–4.0 s, measured for #1496; more bands would repeat more of
/// the overlap and of the per-band work).
const MAX_BANDS: u32 = 8;
#[derive(Clone)]
pub struct Job {
@@ -100,11 +114,9 @@ fn mark_outside_srgb(image: &mut egui::ColorImage, pix: &Pixmap, profile: ColorP
}
}
/// Render `view` of the job (its own view, or the coarse one standing in for it).
fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option<Done> {
let t0 = Instant::now();
// The canvas is a preview: what's too small to see in detail at this zoom is approximated.
let opts = figcraft_render::Options {
/// The canvas is a preview: what's too small to see in detail at this zoom is approximated.
fn options(job: &Job) -> figcraft_render::Options {
figcraft_render::Options {
outline_mode: job.outline,
outline: job.outline_extras,
outlined: job.outlined.clone(),
@@ -114,14 +126,23 @@ fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option<
magnified_image_pixels: true,
dots: job.dots,
..Default::default()
};
let (view, pix, work) = retained.render(&job.doc, job.page, view, &opts)?;
}
}
/// Render `view` of the job (its own view, or the coarse one standing in for it).
fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option<Done> {
let t0 = Instant::now();
let (view, pix, work) = retained.render(&job.doc, job.page, view, &options(job))?;
Some(done(job, view, pix, work, t0.elapsed().as_secs_f64() * 1000.0, coarse))
}
/// The image `pix` of `view`, rendered for `job` in `ms`.
fn done(job: &Job, view: View, pix: &Pixmap, work: Work, ms: f64, coarse: bool) -> Done {
let mut image = screen_image(pix, job.doc.color_profile);
if job.proof {
mark_outside_srgb(&mut image, pix, job.doc.color_profile);
}
let ms = t0.elapsed().as_secs_f64() * 1000.0;
Some(Done { image, view, ms, work, uid: job.uid, page: job.page, coarse, at: job.at, seq: 0 })
Done { image, view, ms, work, uid: job.uid, page: job.page, coarse, at: job.at, seq: 0 }
}
/// `view` with [`PREVIEW_FACTOR`] times fewer pixels each way, showing at least the same area.
@@ -141,6 +162,56 @@ pub struct Renders {
full_ms: f64,
/// Previews come first only when renders from scratch take at least this long.
preview_after_ms: f64,
/// A full render being drawn band by band (the browser): the job it was started for, the
/// font generation then, the bands and the time they took so far.
steps: Option<Stepping>,
}
/// A full render drawn in steps and what it is of: a newer job of the same file, page, zoom, size
/// and render options keeps it going (its image is then patched to the job, as a pan or an edit
/// is), any other drops it.
struct Stepping {
job: Job,
fonts: u64,
steps: Box<Steps>,
spent: f64,
}
/// Whether `a` and `b` are drawn the same way: the same file, page, zoom, size and options (the
/// view may be elsewhere and the document edited: a render of one is patched to the other).
fn same_settings(a: &Job, b: &Job) -> bool {
a.uid == b.uid
&& a.page == b.page
&& a.view.scale.to_bits() == b.view.scale.to_bits()
&& (a.view.width, a.view.height) == (b.view.width, b.view.height)
&& a.outline == b.outline
&& a.outline_extras == b.outline_extras
&& a.outlined == b.outlined
&& a.skip == b.skip
&& a.solo == b.solo
&& a.proof == b.proof
&& a.dots == b.dots
}
/// Whether a render drawn for `a` (with fonts of generation `fonts`) is `b`'s image as it is:
/// the same settings, document and view, and no fonts arrived meanwhile.
fn same_request(a: &Job, fonts: u64, b: &Job) -> bool {
same_settings(a, b)
&& Arc::ptr_eq(&a.doc, &b.doc)
&& a.view.origin.0.to_bits() == b.view.origin.0.to_bits()
&& a.view.origin.1.to_bits() == b.view.origin.1.to_bits()
&& figcraft_text::layout_generation() == fonts
}
/// What [`Renders::refine_for`] got to.
pub enum Refined {
/// The image (`None`: nothing could be drawn).
Done(Option<Done>),
/// Bands remain: call again (next frame).
More,
/// A full render drawn for what has changed since (an edit, a pan or images that came while
/// it was drawn), shown meanwhile; it is drawn again: call again.
Shown(Done),
}
impl Default for Renders {
@@ -152,7 +223,7 @@ impl Default for Renders {
impl Renders {
/// Previews when renders from scratch take at least `ms` (tests: 0, always).
pub fn with_threshold(ms: f64) -> Self {
Self { full: Retained::default(), preview: Retained::default(), last: None, full_ms: 0.0, preview_after_ms: ms }
Self { full: Retained::default(), preview: Retained::default(), last: None, full_ms: 0.0, preview_after_ms: ms, steps: None }
}
/// Whether `job` is a render from scratch: the kept image is of another zoom, page or file, or
@@ -164,7 +235,13 @@ impl Renders {
/// Whether `job` gets a preview first: it renders from scratch and those have been slow on a
/// page already drawn (the first render has no measure).
fn wants_preview(&self, job: &Job) -> bool {
job.zoom_preview && self.last.is_some() && self.full_ms >= self.preview_after_ms && self.starts_over(job)
job.zoom_preview && self.last.is_some() && self.full_ms_so_far() >= self.preview_after_ms && self.starts_over(job)
}
/// How long the last render from scratch took, or what the one being drawn in steps has taken
/// so far when that is more: a render dropped mid-way (a zoom going on) leaves a measure too.
pub fn full_ms_so_far(&self) -> f64 {
self.full_ms.max(self.steps.as_ref().map_or(0.0, |s| s.spent))
}
/// Whether `job` should get a preview should its render run long: from scratch, with none first.
@@ -191,6 +268,107 @@ impl Renders {
}
done
}
/// [`Self::refine`] for about `budget_ms` at a time (the browser): a full render is drawn band by
/// band over several calls, the kept image (re-projected) or the preview showing meanwhile.
pub fn refine_for(&mut self, job: &Job, budget_ms: f64) -> Refined {
let t0 = Instant::now();
if let Some(s) = self.steps.as_ref()
&& !same_settings(&s.job, job)
{
// Dropped for a render of the new view. What its bands took is at least what a full
// render takes: enough for the next zoom to get its preview and its wait.
self.full_ms = self.full_ms.max(s.spent);
self.steps = None;
}
if self.steps.is_none()
&& let Some(r) = self.begin(job, t0, None)
{
return r;
}
let Some(s) = self.steps.as_mut() else { return Refined::Done(None) };
let steps = &mut s.steps;
let stepped = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
loop {
if steps.step() {
return true;
}
if t0.elapsed().as_secs_f64() * 1000.0 >= budget_ms {
return false;
}
}
}));
s.spent += t0.elapsed().as_secs_f64() * 1000.0;
match stepped {
Ok(false) => Refined::More,
Ok(true) => {
let Some(Stepping { job: drawn_for, fonts, steps, spent }) = self.steps.take() else { return Refined::Done(None) };
let (view, pix) = self.full.finish(*steps);
self.full_ms = spent;
// The image of the job it was started for: its document's colour profile, its
// proof setting and its request time.
let drawn = done(&drawn_for, view, pix, Work::Full, spent, false);
// Drawn for this very request: shown, done. Otherwise kept, then started on like
// any render: an edit, a pan or images that came meanwhile are patched in, or it
// is drawn again in steps (never all at once here), this one shown till then.
if same_request(&drawn_for, fonts, job) {
return Refined::Done(self.finished(job, Some(drawn)));
}
self.begin(job, t0, Some(drawn)).unwrap_or(Refined::More)
}
Err(_) => {
self.steps = None;
self.full.clear();
Refined::Done(None)
}
}
}
/// Start `job` on the kept image: done when it is reused or patched, `None` when a full render
/// is now drawn in steps. `drawn` is the kept image just drawn in steps: what a reuse shows,
/// else shown while it is drawn again.
fn begin(&mut self, job: &Job, t0: Instant, mut drawn: Option<Done>) -> Option<Refined> {
// The snapped view may be a row taller.
let rows = job.view.height.saturating_add(1).div_ceil(MAX_BANDS).max(MIN_BAND_ROWS);
let begun =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match self.full.begin(&job.doc, job.page, &job.view, &options(job), rows) {
Some(Begun::Done(view, pix, work)) => Err(Box::new(match drawn.take() {
Some(d) if work == Work::Reused => Some(d),
_ => Some(done(job, view, pix, work, t0.elapsed().as_secs_f64() * 1000.0, false)),
})),
Some(Begun::Steps(steps)) => Ok(steps),
None => Err(Box::new(None)),
}));
match begun {
Ok(Ok(steps)) => {
// A pan or an edit at this zoom builds on this render: no preview for it.
self.last = Some((job.uid, job.page, job.view.scale));
self.steps = Some(Stepping { job: job.clone(), fonts: figcraft_text::layout_generation(), steps, spent: 0.0 });
drawn.map(Refined::Shown)
}
Ok(Err(d)) => Some(Refined::Done(self.finished(job, *d))),
Err(_) => {
self.full.clear();
Some(Refined::Done(None))
}
}
}
/// Record a render done at once in [`Self::refine_for`].
fn finished(&mut self, job: &Job, d: Option<Done>) -> Option<Done> {
if let Some(d) = &d {
self.last = Some((job.uid, job.page, job.view.scale));
if d.work == Work::Full {
self.full_ms = d.ms;
}
}
d
}
/// Whether a full render is being drawn band by band.
pub fn stepping(&self) -> bool {
self.steps.is_some()
}
}
/// The coarse preview of the whole of `job`'s view, kept in `retained`.
@@ -401,7 +579,7 @@ impl Inline {
if let Some(d) = self.pending.as_ref().and_then(|job| self.renders.preview(job)) {
self.preview_shown = true;
self.since = Instant::now();
let full = std::time::Duration::try_from_secs_f64(self.renders.full_ms / 1000.0);
let full = std::time::Duration::try_from_secs_f64(self.renders.full_ms_so_far() / 1000.0);
self.wait = full.map_or(self.settle, |full| full.min(self.settle));
self.ctx.request_repaint_after(self.wait);
return Some(answering(d, self.submitted));
@@ -414,8 +592,21 @@ impl Inline {
return None;
}
}
let job = self.pending.take()?;
self.renders.refine(&job).map(|d| answering(d, self.submitted))
let job = self.pending.as_ref()?;
match self.renders.refine_for(job, STEP_MS) {
Refined::Done(d) => {
self.pending = None;
d.map(|d| answering(d, self.submitted))
}
Refined::More => {
self.ctx.request_repaint();
None
}
Refined::Shown(d) => {
self.ctx.request_repaint();
Some(answering(d, self.submitted))
}
}
}
}
+179 -16
View File
@@ -18,7 +18,7 @@ use super::{CanvasState, shows};
use figcraft_render::Retained;
use crate::render_worker::{
Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Renders, SETTLE_MS, Worker, better, coarse_view, preview_with, render_now,
Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Refined, Renders, SETTLE_MS, Worker, better, coarse_view, preview_with, render_now,
};
/// A page of ellipses with a rectangle on top; the session and the rectangle.
@@ -90,6 +90,45 @@ fn same_pixels(a: &Done, b: &Done) -> bool {
a.image.size == b.image.size && a.image.pixels == b.image.pixels
}
/// The same image up to where render bands meet ([`figcraft_render::Steps`] and the desktop's
/// parallel bands cut the view in different places): on each row, under a quarter of a level on
/// average and at most one pixel in twenty off.
fn close_pixels(a: &Done, b: &Done) -> bool {
let [w, h] = a.image.size;
a.image.size == b.image.size
&& (0..h).all(|y| {
let row = |d: &Done| d.image.pixels[y * w..(y + 1) * w].to_vec();
let (ra, rb) = (row(a), row(b));
let diffs: Vec<u32> =
ra.iter().zip(&rb).map(|(p, q)| p.to_array().iter().zip(q.to_array()).map(|(x, y)| u32::from(x.abs_diff(y))).sum()).collect();
let sum: u32 = diffs.iter().sum();
f64::from(sum) / (w as f64 * 4.0) < 0.25 && diffs.iter().filter(|d| **d > 0).count() * 20 <= w
})
}
/// Poll the browser's worker until it shows the full image (drawn band by band over polls).
fn refined(w: &mut Inline) -> Option<Done> {
for _ in 0..1000 {
match w.poll() {
Some(d) => return Some(d),
None if !w.in_flight() => return None,
None => {}
}
}
None
}
/// Refine `job` through `r` with no time to spare (a band a call); the image and how many calls.
fn stepped(r: &mut Renders, job: &Job) -> (Option<Done>, usize) {
for n in 1..1000 {
match r.refine_for(job, 0.0) {
Refined::Done(d) => return (d, n),
Refined::Shown(_) | Refined::More => {}
}
}
(None, 1000)
}
fn covers(outer: &View, inner: &View) -> bool {
let (o, i) = (outer.page_rect(), inner.page_rect());
let eps = 1e-6;
@@ -397,23 +436,23 @@ fn the_browser_worker_previews_by_measured_render_time_then_refines_the_next_fra
// The production threshold, as the browser has it; refined the next frame (settling: below).
let mut w = Inline::with_settle(egui::Context::default(), std::time::Duration::ZERO);
w.submit(at(2.0));
let first = w.poll().expect("the first image");
let first = refined(&mut w).expect("the first image");
assert!(!first.coarse && !w.in_flight(), "the first render has no measure to preview by");
assert!(first.ms >= PREVIEW_AFTER_MS, "renders are timed in the browser worker too (and this one is slow): {} ms", first.ms);
// A new zoom: the preview this frame, the refined image the next.
// A new zoom: the preview this frame, the refined image over the next (a band each).
w.submit(at(1.0));
let p = w.poll().expect("a preview");
assert!(p.coarse && w.in_flight());
let d = w.poll().expect("the refined image");
let d = refined(&mut w).expect("the refined image");
assert!(!d.coarse && !w.in_flight());
assert!(same_pixels(&d, &render_now(&at(1.0)).unwrap()));
assert!(close_pixels(&d, &render_now(&at(1.0)).unwrap()), "drawn band by band");
// A newer request before the refinement: refining the old one is skipped.
w.submit(at(0.5));
assert!(w.poll().is_some_and(|d| d.coarse));
w.submit(at(0.25));
let p = w.poll().expect("the newer one's preview");
assert!(p.coarse && (p.view.scale - 0.25 / PREVIEW_FACTOR).abs() < 1e-12);
assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 0.25));
assert!(refined(&mut w).is_some_and(|d| !d.coarse && d.view.scale == 0.25));
assert!(w.poll().is_none() && !w.in_flight());
}
@@ -426,7 +465,7 @@ fn the_browser_worker_refines_a_zoom_only_once_it_settles() {
let settle = std::time::Duration::from_millis(SETTLE_MS);
let mut w = Inline::with_settle(egui::Context::default(), settle);
w.submit(at(2.0));
assert!(w.poll().is_some_and(|d| !d.coarse), "the first render: no preview, no wait");
assert!(refined(&mut w).is_some_and(|d| !d.coarse), "the first render: no preview, no wait");
// Zooming: each step shows its preview, never a full render in between.
for scale in [1.5, 1.0] {
w.submit(at(scale));
@@ -434,11 +473,11 @@ fn the_browser_worker_refines_a_zoom_only_once_it_settles() {
assert!(w.poll().is_none() && w.in_flight(), "not refined while the zoom may go on");
}
std::thread::sleep(settle);
let d = w.poll().expect("refined once it settled");
let d = refined(&mut w).expect("refined once it settled");
assert!(!d.coarse && d.view.scale == 1.0 && !w.in_flight());
// A pan at the same zoom has no preview and is refined at once.
w.submit(job(&ds.doc, ds.page, ds.uid, view_at((340.0, 200.0), 1.0, 1200, 1200)));
assert!(w.poll().is_some_and(|d| !d.coarse));
assert!(refined(&mut w).is_some_and(|d| !d.coarse));
}
/// The wait counts from the preview being shown, not from the request: rendering the preview can
@@ -455,13 +494,13 @@ fn the_wait_counts_from_the_preview_not_from_the_request() {
let settle = std::time::Duration::from_millis(SETTLE_MS);
let mut w = Inline::with_settle(egui::Context::default(), settle);
w.submit(at(2.0));
assert!(w.poll().is_some_and(|d| !d.coarse));
assert!(refined(&mut w).is_some_and(|d| !d.coarse));
w.submit(at(1.0));
std::thread::sleep(settle + std::time::Duration::from_millis(30));
assert!(w.poll().is_some_and(|d| d.coarse), "the preview, longer after the request than the wait");
assert!(w.poll().is_none() && w.in_flight(), "the wait starts only now");
std::thread::sleep(settle);
assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 1.0));
assert!(refined(&mut w).is_some_and(|d| !d.coarse && d.view.scale == 1.0));
}
/// Until the zoom's full render has run, the kept image is of the old zoom, so a pan or an edit
@@ -477,7 +516,7 @@ fn same_zoom_requests_before_the_zoom_refines_keep_waiting_and_refine_at_once_af
let settle = std::time::Duration::from_millis(SETTLE_MS);
let mut w = Inline::with_settle(egui::Context::default(), settle);
w.submit(at((300.0, 200.0), 2.0));
assert!(w.poll().is_some_and(|d| !d.coarse), "the first render: no preview, no wait");
assert!(refined(&mut w).is_some_and(|d| !d.coarse), "the first render: no preview, no wait");
// A zoom step, then a pan at the new zoom before the pause: a preview each, no full render.
w.submit(at((300.0, 200.0), 1.0));
assert!(w.poll().is_some_and(|d| d.coarse));
@@ -486,11 +525,11 @@ fn same_zoom_requests_before_the_zoom_refines_keep_waiting_and_refine_at_once_af
assert!(w.poll().is_some_and(|d| d.coarse), "from scratch still: the kept image is of the old zoom");
assert!(w.poll().is_none() && w.in_flight(), "the wait starts over");
std::thread::sleep(settle);
let d = w.poll().expect("refined once it settled");
let d = refined(&mut w).expect("refined once it settled");
assert!(!d.coarse && d.view.scale == 1.0 && !w.in_flight());
// From then on a pan at this zoom builds on the kept image: no preview, refined at once.
w.submit(at((380.0, 200.0), 1.0));
let d = w.poll().expect("the refined image");
let d = refined(&mut w).expect("the refined image");
assert!(!d.coarse && !w.in_flight());
}
@@ -507,12 +546,136 @@ fn the_wait_is_capped_by_the_time_of_the_last_full_render() {
let settle = std::time::Duration::from_secs(10);
let mut w = Inline::with_settle(egui::Context::default(), settle);
w.submit(at(2.0));
let first = w.poll().expect("the first image");
let first = refined(&mut w).expect("the first image");
assert!(!first.coarse && first.ms >= PREVIEW_AFTER_MS, "slow enough to preview: {} ms", first.ms);
w.submit(at(1.0));
assert!(w.poll().is_some_and(|d| d.coarse));
assert!(w.poll().is_none() && w.in_flight(), "not refined in the very next frame");
std::thread::sleep(std::time::Duration::from_secs_f64(first.ms / 1000.0));
let d = w.poll().expect("refined once the last full render's time has passed, not the settle");
let d = refined(&mut w).expect("refined once the last full render's time has passed, not the settle");
assert!(!d.coarse && d.view.scale == 1.0 && !w.in_flight());
}
#[test]
fn the_browser_draws_a_full_render_band_by_band() {
let view = |x| view_at((x, 200.0), 1.0, 600, 600);
let s = slow_scene(&[view(300.0)], 3.0 * PREVIEW_AFTER_MS);
let ds = s.ds().unwrap();
let at = |x| job(&ds.doc, ds.page, ds.uid, view(x));
let mut r = Renders::default();
let (d, n) = stepped(&mut r, &at(300.0));
let d = d.expect("drawn");
assert!(n > 2 && !r.stepping(), "several calls, then done ({n})");
assert!(close_pixels(&d, &render_now(&at(300.0)).unwrap()));
// Kept: a pan now patches it at once.
assert!(matches!(r.refine_for(&at(320.0), 0.0), Refined::Done(Some(d)) if d.work != Work::Full));
}
#[test]
fn a_pan_while_drawing_finishes_the_render_then_patches_it_and_a_zoom_drops_it() {
let view = |x, scale| view_at((x, 200.0), scale, 600, 600);
let s = slow_scene(&[view(300.0, 1.0)], 3.0 * PREVIEW_AFTER_MS);
let ds = s.ds().unwrap();
let at = |x, scale| job(&ds.doc, ds.page, ds.uid, view(x, scale));
let mut r = Renders::default();
assert!(matches!(r.refine_for(&at(300.0, 1.0), 0.0), Refined::More));
// Panned meanwhile: the same render goes on, and what comes is the panned view.
let (d, _) = stepped(&mut r, &at(330.0, 1.0));
let d = d.expect("drawn");
assert!(close_pixels(&d, &render_now(&at(330.0, 1.0)).unwrap()), "the panned view");
// Zoomed meanwhile: the render under way is dropped for one of the new zoom.
let mut r = Renders::default();
assert!(matches!(r.refine_for(&at(300.0, 1.0), 0.0), Refined::More));
let (d, _) = stepped(&mut r, &at(300.0, 2.0));
assert_eq!(d.expect("drawn").view.scale, 2.0);
}
#[test]
fn an_edit_while_drawing_shows_in_what_comes() {
let v = view_at((300.0, 200.0), 1.0, 600, 600);
let mut s = slow_scene(std::slice::from_ref(&v), 3.0 * PREVIEW_AFTER_MS);
let (doc, page, uid) = {
let ds = s.ds().unwrap();
(ds.doc.clone(), ds.page, ds.uid)
};
let mut r = Renders::default();
assert!(matches!(r.refine_for(&job(&doc, page, uid, v.clone()), 0.0), Refined::More));
// Edited meanwhile, the view unchanged: the render goes on and what comes shows the edit.
s.execute("shape.rectangle", &json!({"x": 100, "y": 0, "width": 400, "height": 400})).unwrap();
let edited = job(&s.ds().unwrap().doc, page, uid, v);
let (d, _) = stepped(&mut r, &edited);
assert!(close_pixels(&d.expect("drawn"), &render_now(&edited).unwrap()), "the edit shows");
}
#[test]
fn a_render_overtaken_by_a_long_pan_is_shown_then_drawn_again() {
let v = view_at((300.0, 200.0), 1.0, 600, 600);
let s = slow_scene(std::slice::from_ref(&v), 3.0 * PREVIEW_AFTER_MS);
let ds = s.ds().unwrap();
let mut r = Renders::default();
assert!(matches!(r.refine_for(&job(&ds.doc, ds.page, ds.uid, v.clone()), 0.0), Refined::More));
// Panned more than a view meanwhile: nothing to patch. What was drawn shows, then the new view.
let far = job(&ds.doc, ds.page, ds.uid, view_at((1500.0, 200.0), 1.0, 600, 600));
let mut shown = vec![];
let last = (0..1000).find_map(|_| match r.refine_for(&far, 0.0) {
Refined::More => None,
Refined::Shown(d) => {
shown.push(d);
None
}
Refined::Done(d) => Some(d),
});
assert_eq!(shown.len(), 1, "the render drawn shows once");
assert!(close_pixels(&shown[0], &render_now(&job(&ds.doc, ds.page, ds.uid, v)).unwrap()), "the view it was drawn for");
assert!(close_pixels(&last.flatten().expect("drawn"), &render_now(&far).unwrap()), "then the new view");
}
/// Drawn for the very request, the render is done: it is not started on again, even where the
/// kept image can never be patched (outline mode drawing hidden layers), which drew it again
/// and again, the page never idle.
#[test]
fn a_render_drawn_for_the_request_is_done_even_where_it_could_never_be_patched() {
let v = view_at((300.0, 200.0), 1.0, 600, 600);
let s = slow_scene(std::slice::from_ref(&v), 3.0 * PREVIEW_AFTER_MS);
let ds = s.ds().unwrap();
let mut hidden = job(&ds.doc, ds.page, ds.uid, v);
hidden.outline = true;
hidden.outline_extras.hidden = true;
let mut r = Renders::default();
let (d, n) = stepped(&mut r, &hidden);
assert!(d.is_some_and(|d| !d.coarse && d.work == Work::Full) && !r.stepping(), "done after {n} calls");
assert!(n < 100, "drawn once, not over and over ({n} calls)");
}
/// A change of render options (outline mode here) mid-way drops the render, as a zoom does: the
/// bands of the old one are not drawn to the end first.
#[test]
fn a_change_of_options_while_drawing_drops_the_render() {
let v = view_at((300.0, 200.0), 1.0, 600, 600);
let s = slow_scene(std::slice::from_ref(&v), 3.0 * PREVIEW_AFTER_MS);
let ds = s.ds().unwrap();
let plain = job(&ds.doc, ds.page, ds.uid, v.clone());
let mut outlined = plain.clone();
outlined.outline = true;
let (_, alone) = stepped(&mut Renders::default(), &outlined);
let mut r = Renders::default();
assert!(matches!(r.refine_for(&plain, 0.0), Refined::More));
let (d, n) = stepped(&mut r, &outlined);
assert!(d.is_some_and(|d| !d.coarse));
assert!(n <= alone + 1, "the old render's bands are dropped: {n} calls against {alone} alone");
}
/// A render dropped mid-way leaves a measure behind: what its bands took is at least what a full
/// render takes, so the next zoom gets its preview (and, in the browser, its wait) instead of
/// starting from scratch at once, as many times as the zoom goes on.
#[test]
fn a_render_dropped_while_drawing_leaves_its_time_as_the_measure() {
let view = |scale| view_at((300.0, 200.0), scale, 600, 600);
let s = slow_scene(&[view(1.0)], 3.0 * PREVIEW_AFTER_MS);
let ds = s.ds().unwrap();
let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale));
// Previews once renders have taken any time at all: a band has, where no measure (0 ms) hasn't.
let mut r = Renders::with_threshold(1e-9);
assert!(matches!(r.refine_for(&at(1.0), 0.0), Refined::More));
assert!(r.preview(&at(2.0)).is_some(), "the zoom gets a preview by the dropped render's measure");
}
+9 -1
View File
File diff suppressed because one or more lines are too long