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}