Skip to main content

ps_anyrender/
backdrop.rs

1//! Deciding how many passes a frame's `backdrop-filter` layers cost.
2//!
3//! A `backdrop-filter` layer needs, as its input, the pixels behind it. No
4//! immediate-mode scene API can express that while the scene is still being
5//! built, so the only way to produce one is to stop, render what has been drawn
6//! so far, filter it, and carry on. That is a render pass, and passes are the
7//! unit of cost this module exists to control.
8//!
9//! The naive rule is one pass per filtered layer. Six glass panels would then
10//! cost seven passes for a frame that changes nothing between them. The rule
11//! here is one pass per *backdrop root*: panels sitting at the same level, not
12//! overlapping each other, all see the same thing behind them, so one snapshot
13//! serves all six and the frame costs two passes.
14//!
15//! # What makes a batch
16//!
17//! A batch is a maximal run of backdrop ops that can share one snapshot. An op
18//! joins the open batch when the region its filter reads from has not been
19//! painted into since the snapshot would have been taken. Anything else - a
20//! panel drawn over another panel's blur region, a stray fill between them,
21//! glass stacked on glass - closes the batch and buys another pass.
22//!
23//! That last case is deliberate and is an application constraint rather than
24//! something to hide in the renderer: glass over glass costs an extra pass, and
25//! the counters say so.
26//!
27//! # Why the planner is separate from any backend
28//!
29//! Pass count is decided entirely by the shape of the scene, not by the GPU. A
30//! planner that is pure data can be driven by a real document and asked "how
31//! many passes?" on a machine with no GPU at all, which is what makes the
32//! anti-regression assertion runnable in CI. The backend's job is to execute a
33//! plan, not to decide one.
34
35use crate::{Filter, Glyph, NormalizedCoord, PaintRef, PaintScene, RenderContext};
36use kurbo::{Affine, BezPath, Rect, Shape, Stroke};
37use peniko::{BlendMode, Color, Fill, FontData, StyleRef};
38use std::sync::Arc;
39
40/// Whether two device-space rectangles share any area.
41///
42/// Written out rather than taken from [`Rect::intersect`], whose result for
43/// disjoint inputs is a rectangle with `x1 < x0` - not an empty one - so the
44/// obvious `is_zero_area` test on it reports disjoint rectangles as
45/// overlapping. Touching edges are not an overlap: a blur reading up to `x1`
46/// and a fill starting at `x1` share no pixel.
47fn overlaps(a: Rect, b: Rect) -> bool {
48    a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1
49}
50
51/// One `backdrop-filter` layer: blur what is behind it, inside `clip`.
52///
53/// Every geometry here is device space. The planner is fed by a backend that
54/// has already applied the layer transform, because the whole point of the
55/// occupancy test below is comparing regions from different parts of the tree
56/// against each other, and they are only comparable once flattened.
57#[derive(Debug, Clone)]
58pub struct BackdropOp {
59    /// The filter graph to run over the backdrop.
60    pub filter: Arc<Filter>,
61    /// The shape the filtered backdrop is drawn through.
62    pub clip: BezPath,
63    /// Device-space bounding box of [`clip`](Self::clip). What the result covers.
64    pub bounds: Rect,
65    /// What the filter has to *read* to produce [`bounds`](Self::bounds).
66    ///
67    /// Wider than `bounds` by the filter's expansion, roughly 3 standard
68    /// deviations for a gaussian: a blurred pixel at the edge of the panel
69    /// samples from outside it. This, not `bounds`, is what the occupancy test
70    /// uses, because a fill landing just outside the panel still changes the
71    /// pixels inside it.
72    pub source: Rect,
73}
74
75/// Backdrop ops that can share one snapshot, and so cost one pass between them.
76#[derive(Debug, Clone, Default)]
77pub struct BackdropBatch {
78    pub ops: Vec<BackdropOp>,
79}
80
81/// What one painted frame's backdrop layers cost.
82///
83/// `batches[i]` runs after segment `i` and before segment `i + 1`, so a frame
84/// with `n` batches renders `n + 1` segments.
85#[derive(Debug, Clone, Default)]
86pub struct FramePlan {
87    pub batches: Vec<BackdropBatch>,
88}
89
90impl FramePlan {
91    /// Scene renders this frame costs. Always at least one.
92    ///
93    /// The number the six-panel anti-regression asserts on: it must be 2 for a
94    /// page of non-overlapping glass, not one per panel.
95    pub fn render_passes(&self) -> u32 {
96        self.batches.len() as u32 + 1
97    }
98
99    /// Filtered regions produced this frame, across every batch.
100    ///
101    /// Distinct from [`render_passes`](Self::render_passes) and it is worth
102    /// keeping them apart: batching removes render passes, it does not remove
103    /// blurs. Six panels in one batch is 2 render passes and still 6 blurs.
104    /// Removing those is what caching them across frames is for, and this is
105    /// the number that will have to reach zero on a still frame.
106    pub fn blur_passes(&self) -> u32 {
107        self.batches
108            .iter()
109            .map(|batch| batch.ops.len() as u32)
110            .sum()
111    }
112
113    /// Whether the frame has any backdrop-filtered layer at all.
114    pub fn is_empty(&self) -> bool {
115        self.batches.is_empty()
116    }
117}
118
119/// Whether a backdrop op could share the open batch's snapshot.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum Boundary {
122    /// It joined the open batch. No additional render pass.
123    SameSegment,
124    /// It could not, so the scene is cut here and a pass is added.
125    NewSegment,
126}
127
128/// How many separate painted regions a batch tracks before it stops trying.
129///
130/// The occupied region has to be a *list*, not one union rectangle. A page has
131/// content at the top and content at the bottom, and their union is the whole
132/// page: a single rectangle would report every panel as painted-over and cut
133/// the batch at every op, which is the naive one-pass-per-panel behaviour this
134/// module exists to avoid.
135///
136/// A list needs a bound, because it is walked once per op. Sixteen is far more
137/// than a real backdrop root holds - occupancy is only recorded at the batch's
138/// own depth, so a panel's whole subtree contributes one entry - and past it
139/// the batch collapses to a union and gets conservative. Conservative here
140/// means an extra render pass, never a stale backdrop.
141const MAX_OCCUPIED_REGIONS: usize = 16;
142
143/// The open batch, and what has been painted into its plane since it opened.
144#[derive(Debug)]
145struct OpenBatch {
146    /// Layer depth the batch opened at. Content pushed deeper than this is
147    /// bounded by the layer that was pushed, so it is accounted once at the
148    /// push instead of once per draw.
149    depth: u32,
150    /// Everything that could have landed in this plane since the snapshot. A
151    /// new op reading from any of it cannot use the snapshot.
152    occupied: Vec<Rect>,
153    ops: Vec<BackdropOp>,
154}
155
156impl OpenBatch {
157    fn occupy(&mut self, bounds: Rect) {
158        if self.occupied.len() < MAX_OCCUPIED_REGIONS {
159            self.occupied.push(bounds);
160            return;
161        }
162        // Collapse into the first slot rather than growing without bound. From
163        // here the batch is answering with a bounding box, which can only ever
164        // say "painted over" where the truth was "clear".
165        let collapsed = self
166            .occupied
167            .iter()
168            .copied()
169            .fold(bounds, |acc, rect| acc.union(rect));
170        self.occupied.clear();
171        self.occupied.push(collapsed);
172    }
173
174    fn is_clear(&self, region: Rect) -> bool {
175        !self
176            .occupied
177            .iter()
178            .any(|painted| overlaps(*painted, region))
179    }
180}
181
182/// Turns a painted scene's layer and draw events into a [`FramePlan`].
183///
184/// Fed by a backend as it walks the scene. Costs nothing at all until the first
185/// backdrop op arrives: a page with no glass on it never allocates and never
186/// computes a bounding box.
187#[derive(Debug, Default)]
188pub struct BackdropPlanner {
189    depth: u32,
190    batches: Vec<BackdropBatch>,
191    open: Option<OpenBatch>,
192}
193
194impl BackdropPlanner {
195    pub fn new() -> Self {
196        Self::default()
197    }
198
199    /// Whether the planner is currently tracking anything.
200    ///
201    /// A backend checks this before computing a bounding box to hand to
202    /// [`draw`](Self::draw): while it is false, that box would be discarded,
203    /// and computing one per draw command is exactly the per-frame CPU cost
204    /// this whole design exists to avoid.
205    pub fn is_tracking(&self) -> bool {
206        self.open.is_some()
207    }
208
209    /// Record a layer push whose clip bounds everything drawn until its pop.
210    ///
211    /// Accounting content at the layer rather than per draw is what keeps this
212    /// affordable. A panel's whole subtree is clipped to the layer, so one
213    /// bounding box covers all of it however many commands it contains.
214    pub fn push_layer(&mut self, bounds: Rect) {
215        if let Some(open) = &mut self.open {
216            if self.depth <= open.depth {
217                open.occupy(bounds);
218            }
219        }
220        self.depth += 1;
221    }
222
223    pub fn pop_layer(&mut self) {
224        self.depth = self.depth.saturating_sub(1);
225    }
226
227    /// Record a draw issued with no layer bounding it.
228    ///
229    /// Only meaningful at or above the open batch's depth; deeper draws are
230    /// already covered by the layer they are inside. Backends should gate the
231    /// call on [`is_tracking`](Self::is_tracking) rather than compute a
232    /// bounding box unconditionally.
233    pub fn draw(&mut self, bounds: Rect) {
234        if let Some(open) = &mut self.open {
235            if self.depth <= open.depth {
236                open.occupy(bounds);
237            }
238        }
239    }
240
241    /// Record a backdrop-filtered layer that is about to be pushed.
242    ///
243    /// Returns whether the scene has to be cut here. The op's own bounds join
244    /// the occupied region, so a second panel overlapping this one gets its own
245    /// segment whether or not the backend also reports the layer push.
246    pub fn backdrop(&mut self, filter: Arc<Filter>, clip: BezPath, bounds: Rect) -> Boundary {
247        let expansion = filter.expansion_rect();
248        let source = Rect::new(
249            bounds.x0 + expansion.x0,
250            bounds.y0 + expansion.y0,
251            bounds.x1 + expansion.x1,
252            bounds.y1 + expansion.y1,
253        );
254        let op = BackdropOp {
255            filter,
256            clip,
257            bounds,
258            source,
259        };
260
261        let can_share = self.open.as_ref().is_some_and(|open| open.is_clear(source));
262
263        if can_share {
264            let open = self.open.as_mut().expect("checked above");
265            open.occupy(bounds);
266            open.ops.push(op);
267            return Boundary::SameSegment;
268        }
269
270        if let Some(previous) = self.open.take() {
271            self.batches.push(BackdropBatch { ops: previous.ops });
272        }
273        self.open = Some(OpenBatch {
274            depth: self.depth,
275            occupied: vec![bounds],
276            ops: vec![op],
277        });
278        Boundary::NewSegment
279    }
280
281    /// Close the frame and hand back what it costs.
282    pub fn finish(mut self) -> FramePlan {
283        if let Some(open) = self.open.take() {
284            self.batches.push(BackdropBatch { ops: open.ops });
285        }
286        FramePlan {
287            batches: self.batches,
288        }
289    }
290}
291
292/// A [`PaintScene`] that draws nothing and only plans.
293///
294/// The point of it is that pass count is a property of the scene, not of the
295/// GPU. Painting a real document into this answers "how many passes does this
296/// page cost?" on a machine with no adapter, no surface and no window, which is
297/// what lets the six-panels-two-passes assertion run in CI instead of being a
298/// thing someone checks by eye on a laptop.
299///
300/// It is also the reference for how a backend should feed the planner: the
301/// order of the calls in here is the order a backend has to make them in.
302#[derive(Debug, Default)]
303pub struct PlanningScene {
304    planner: BackdropPlanner,
305}
306
307impl PlanningScene {
308    pub fn new() -> Self {
309        Self::default()
310    }
311
312    pub fn finish(self) -> FramePlan {
313        self.planner.finish()
314    }
315
316    /// The bounding box of a shape, in device space, or `None` when nothing is
317    /// tracking and the answer would be thrown away.
318    fn device_bounds(&self, transform: Affine, shape: &impl Shape) -> Option<Rect> {
319        self.planner
320            .is_tracking()
321            .then(|| transform.transform_rect_bbox(shape.bounding_box()))
322    }
323}
324
325impl RenderContext for PlanningScene {}
326
327impl PaintScene for PlanningScene {
328    fn reset(&mut self) {
329        self.planner = BackdropPlanner::new();
330    }
331
332    fn push_layer(
333        &mut self,
334        _blend: impl Into<BlendMode>,
335        _alpha: f32,
336        transform: Affine,
337        clip: &impl Shape,
338        _filter: Option<Arc<Filter>>,
339        backdrop_filter: Option<Arc<Filter>>,
340    ) {
341        let bounds = transform.transform_rect_bbox(clip.bounding_box());
342        if let Some(backdrop_filter) = backdrop_filter {
343            let clip = transform * clip.into_path(0.1);
344            self.planner.backdrop(backdrop_filter, clip, bounds);
345        }
346        self.planner.push_layer(bounds);
347    }
348
349    fn push_clip_layer(&mut self, transform: Affine, clip: &impl Shape) {
350        let bounds = transform.transform_rect_bbox(clip.bounding_box());
351        self.planner.push_layer(bounds);
352    }
353
354    fn pop_layer(&mut self) {
355        self.planner.pop_layer();
356    }
357
358    fn stroke<'a>(
359        &mut self,
360        style: &Stroke,
361        transform: Affine,
362        _brush: impl Into<PaintRef<'a>>,
363        _brush_transform: Option<Affine>,
364        shape: &impl Shape,
365    ) {
366        if let Some(bounds) = self.device_bounds(transform, shape) {
367            // A stroke straddles the path by half its width on each side.
368            let half = style.width / 2.0;
369            self.planner.draw(bounds.inflate(half, half));
370        }
371    }
372
373    fn fill<'a>(
374        &mut self,
375        _style: Fill,
376        transform: Affine,
377        _brush: impl Into<PaintRef<'a>>,
378        _brush_transform: Option<Affine>,
379        shape: &impl Shape,
380    ) {
381        if let Some(bounds) = self.device_bounds(transform, shape) {
382            self.planner.draw(bounds);
383        }
384    }
385
386    fn draw_glyphs<'a, 's: 'a>(
387        &'s mut self,
388        _font: &'a FontData,
389        font_size: f32,
390        _hint: bool,
391        _normalized_coords: &'a [NormalizedCoord],
392        _embolden: kurbo::Vec2,
393        _style: impl Into<StyleRef<'a>>,
394        _brush: impl Into<PaintRef<'a>>,
395        _brush_alpha: f32,
396        transform: Affine,
397        _glyph_transform: Option<Affine>,
398        glyphs: impl Iterator<Item = Glyph> + Clone,
399    ) {
400        if !self.planner.is_tracking() {
401            return;
402        }
403        // Estimated from the run's origins and its size rather than measured
404        // from outlines: a glyph's ink stays well inside one em above the
405        // baseline and a third of one below it for the scripts in use, and
406        // resolving real outlines here would mean shaping the run twice per
407        // frame to answer a question whose only consumer is an overlap test.
408        // Wrong in the loose direction costs a render pass; measuring costs
409        // every frame.
410        let size = f64::from(font_size);
411        let mut run: Option<Rect> = None;
412        for glyph in glyphs {
413            let x = f64::from(glyph.x);
414            let y = f64::from(glyph.y);
415            let cell = Rect::new(x, y - size, x + size, y + size / 3.0);
416            run = Some(match run {
417                Some(existing) => existing.union(cell),
418                None => cell,
419            });
420        }
421        if let Some(run) = run {
422            self.planner.draw(transform.transform_rect_bbox(run));
423        }
424    }
425
426    fn draw_box_shadow(
427        &mut self,
428        transform: Affine,
429        rect: Rect,
430        _brush: Color,
431        radius: f64,
432        std_dev: f64,
433    ) {
434        if !self.planner.is_tracking() {
435            return;
436        }
437        // A gaussian reaches about three standard deviations, and the corner
438        // radius pushes the drawn shape out no further than the rect itself.
439        let reach = std_dev * 3.0 + radius;
440        self.planner
441            .draw(transform.transform_rect_bbox(rect.inflate(reach, reach)));
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use crate::filters::FilterEffect;
449    use kurbo::Shape;
450
451    /// Built the way `blitz-paint` builds it from `filter: blur(<px>)`.
452    fn blur(std_dev: f32) -> Arc<Filter> {
453        Arc::new(Filter::single(FilterEffect::blur(std_dev)))
454    }
455
456    /// A panel `width` wide at `x`, the shape the app's `.rounded-panel` has.
457    fn panel(x: f64, width: f64) -> (BezPath, Rect) {
458        let bounds = Rect::new(x, 0.0, x + width, 100.0);
459        (bounds.into_path(0.1), bounds)
460    }
461
462    fn plan_panels(count: usize, pitch: f64, width: f64, std_dev: f32) -> FramePlan {
463        let mut planner = BackdropPlanner::new();
464        for index in 0..count {
465            let (clip, bounds) = panel(index as f64 * pitch, width);
466            planner.backdrop(blur(std_dev), clip, bounds);
467            // Every panel's own content goes inside its effect layer.
468            planner.push_layer(bounds);
469            planner.pop_layer();
470        }
471        planner.finish()
472    }
473
474    #[test]
475    fn a_frame_with_no_glass_costs_one_pass() {
476        let plan = BackdropPlanner::new().finish();
477        assert!(plan.is_empty());
478        assert_eq!(plan.render_passes(), 1);
479        assert_eq!(plan.blur_passes(), 0);
480    }
481
482    /// The anti-regression, in miniature: six panels, two passes.
483    #[test]
484    fn six_separated_panels_share_one_snapshot() {
485        // Pitch 200 for 100-wide panels leaves a 100px gap, comfortably more
486        // than the ~36px a 12px blur reaches on each side.
487        let plan = plan_panels(6, 200.0, 100.0, 12.0);
488        assert_eq!(plan.batches.len(), 1, "{plan:?}");
489        assert_eq!(plan.batches[0].ops.len(), 6);
490        assert_eq!(plan.render_passes(), 2);
491        // Batching removes passes, not blurs. Six panels still blur six times.
492        assert_eq!(plan.blur_passes(), 6);
493    }
494
495    #[test]
496    fn overlapping_panels_cannot_share_a_snapshot() {
497        // Pitch 50 on 100-wide panels: each panel sits over the previous one.
498        let plan = plan_panels(6, 50.0, 100.0, 12.0);
499        assert_eq!(plan.batches.len(), 6, "glass over glass costs a pass each");
500        assert_eq!(plan.render_passes(), 7);
501    }
502
503    /// The gap has to clear the blur's *reach*, not just the panel.
504    ///
505    /// Two panels 10px apart do not overlap and would pass a bounds-only test,
506    /// but a 12px blur reads about 36px past its own edge, so the second
507    /// panel's blur samples the first panel's pixels. Sharing the snapshot
508    /// there would blur a stale backdrop.
509    #[test]
510    fn a_gap_smaller_than_the_blur_radius_still_cuts() {
511        let touching = plan_panels(2, 110.0, 100.0, 12.0);
512        assert_eq!(
513            touching.batches.len(),
514            2,
515            "a 10px gap is inside a 12px blur's reach"
516        );
517
518        // The same geometry with a blur small enough to stay inside the gap.
519        let clear = plan_panels(2, 110.0, 100.0, 1.0);
520        assert_eq!(clear.batches.len(), 1);
521    }
522
523    #[test]
524    fn a_fill_between_two_panels_cuts_only_if_it_lands_in_the_blur_region() {
525        let elsewhere = {
526            let mut planner = BackdropPlanner::new();
527            let (clip, bounds) = panel(0.0, 100.0);
528            planner.backdrop(blur(4.0), clip, bounds);
529            planner.draw(Rect::new(1000.0, 0.0, 1100.0, 100.0));
530            let (clip, bounds) = panel(200.0, 100.0);
531            planner.backdrop(blur(4.0), clip, bounds);
532            planner.finish()
533        };
534        assert_eq!(elsewhere.batches.len(), 1, "a distant fill is irrelevant");
535
536        let underneath = {
537            let mut planner = BackdropPlanner::new();
538            let (clip, bounds) = panel(0.0, 100.0);
539            planner.backdrop(blur(4.0), clip, bounds);
540            // Straight across where the second panel is about to go.
541            planner.draw(Rect::new(200.0, 0.0, 300.0, 100.0));
542            let (clip, bounds) = panel(200.0, 100.0);
543            planner.backdrop(blur(4.0), clip, bounds);
544            planner.finish()
545        };
546        assert_eq!(
547            underneath.batches.len(),
548            2,
549            "the second panel's backdrop changed after the snapshot"
550        );
551    }
552
553    /// Content inside a panel is accounted once, at the layer, not per command.
554    ///
555    /// This is what makes the planner affordable: a panel with ten thousand
556    /// glyphs in it costs one bounding box, and the draws inside it are ignored
557    /// because the layer already bounds them.
558    #[test]
559    fn draws_inside_a_layer_are_covered_by_the_layer() {
560        let mut planner = BackdropPlanner::new();
561        let (clip, bounds) = panel(0.0, 100.0);
562        planner.backdrop(blur(4.0), clip, bounds);
563        planner.push_layer(bounds);
564        // A draw the layer clips away. Reported at the wrong depth it would
565        // occupy the second panel's region and cut the batch for nothing.
566        planner.draw(Rect::new(200.0, 0.0, 300.0, 100.0));
567        planner.pop_layer();
568
569        let (clip, bounds) = panel(200.0, 100.0);
570        planner.backdrop(blur(4.0), clip, bounds);
571        let plan = planner.finish();
572        assert_eq!(plan.batches.len(), 1, "{plan:?}");
573    }
574
575    /// Popping out past the batch's depth must not stop the accounting.
576    ///
577    /// The panels are siblings, so the walk returns to the container between
578    /// them and can go shallower still. A depth test written as equality would
579    /// silently drop every draw made out there.
580    #[test]
581    fn draws_shallower_than_the_batch_still_count() {
582        let mut planner = BackdropPlanner::new();
583        planner.push_layer(Rect::new(0.0, 0.0, 1000.0, 100.0));
584        let (clip, bounds) = panel(0.0, 100.0);
585        planner.backdrop(blur(4.0), clip, bounds);
586        planner.pop_layer();
587
588        planner.draw(Rect::new(200.0, 0.0, 300.0, 100.0));
589
590        planner.push_layer(Rect::new(0.0, 0.0, 1000.0, 100.0));
591        let (clip, bounds) = panel(200.0, 100.0);
592        planner.backdrop(blur(4.0), clip, bounds);
593        let plan = planner.finish();
594        assert_eq!(plan.batches.len(), 2, "{plan:?}");
595    }
596
597    /// Past [`MAX_OCCUPIED_REGIONS`] the batch answers with a bounding box.
598    ///
599    /// Worth asserting rather than leaving as a comment, because the failure it
600    /// guards against is the silent kind: the collapse must lose passes, never
601    /// correctness. Twenty scattered fills that leave the second panel's region
602    /// clear still cut the batch once the list has collapsed, and that is the
603    /// intended trade.
604    #[test]
605    fn a_batch_gets_conservative_once_it_is_tracking_too_many_regions() {
606        let mut planner = BackdropPlanner::new();
607        let (clip, bounds) = panel(0.0, 100.0);
608        planner.backdrop(blur(4.0), clip, bounds);
609        // All far away, none of them near the second panel.
610        for index in 0..MAX_OCCUPIED_REGIONS + 4 {
611            let x = 1000.0 + index as f64 * 10.0;
612            planner.draw(Rect::new(x, 0.0, x + 5.0, 100.0));
613        }
614        let (clip, bounds) = panel(200.0, 100.0);
615        planner.backdrop(blur(4.0), clip, bounds);
616        let plan = planner.finish();
617        assert_eq!(
618            plan.batches.len(),
619            2,
620            "a collapsed occupancy list spans the gap, so the batch cuts"
621        );
622    }
623
624    #[test]
625    fn the_source_region_is_the_panel_grown_by_the_filter() {
626        let mut planner = BackdropPlanner::new();
627        let (clip, bounds) = panel(100.0, 100.0);
628        planner.backdrop(blur(10.0), clip, bounds);
629        let plan = planner.finish();
630        let op = &plan.batches[0].ops[0];
631        assert_eq!(op.bounds, bounds);
632        assert!(
633            op.source.x0 < bounds.x0 && op.source.x1 > bounds.x1,
634            "the read region must grow past the panel, got {:?}",
635            op.source
636        );
637    }
638}