Skip to main content

blitz_dom/
paint_damage.rs

1//! What changed on screen since the previous painted frame.
2//!
3//! Blitz repaints the whole window every frame and has no notion of a dirty
4//! region: `damage.rs` carries Stylo's `RestyleDamage`, which is an input to
5//! layout, and [`resolve`](crate::BaseDocument::resolve) clears every node's
6//! damage before painting starts. A painter therefore sees a document that is
7//! uniformly undamaged, and asking "did anything behind this element change?"
8//! has had no answer at all.
9//!
10//! It needs one. `backdrop-filter` costs a render pass and a blur per filtered
11//! element per frame, and the only way that stops being a permanent CPU and GPU
12//! cost is to skip the ones whose input did not change. A still window must
13//! cost nothing, and a window where one paragraph is growing must cost nothing
14//! for the panels that paragraph is not behind.
15//!
16//! # How the region is found
17//!
18//! Two halves, because two different things make a pixel change.
19//!
20//! Geometry, by comparison. Every node's painted box is recorded in document
21//! space and compared against the one it had last frame. A box that moved,
22//! resized, appeared or disappeared contributes both its old and its new
23//! rectangle. That covers insertion, removal, reflow, and scrolling - a scroll
24//! offset moves every descendant's absolute position, so it falls out of the
25//! same comparison with nothing special written for it.
26//!
27//! Repaint in place, from Stylo. A colour change moves nothing, so the
28//! comparison above cannot see it. Those nodes are recorded during damage
29//! propagation instead, which is the one moment a node's *own* damage is
30//! readable before its children's is folded into it. After propagation every
31//! ancestor up to the root is marked, so a region built from damaged nodes at
32//! any later point would be the whole document, every time.
33//!
34//! # Cost
35//!
36//! Off by default, because a document nobody asks this question of should not
37//! pay to answer it. See
38//! [`set_paint_damage_tracking`](crate::BaseDocument::set_paint_damage_tracking).
39//!
40//! When on it is one pass over the node list, which `resolve` already makes to
41//! clear damage, plus one hash lookup and one rectangle comparison per node.
42//! Absolute positions are memoised across the pass, so the whole walk is linear
43//! rather than one root-ward recursion per node.
44
45use crate::node::{Node, NodeData};
46use crate::tree::NodeTree;
47use blitz_traits::node_id::NodeId;
48use kurbo::Rect;
49use rustc_hash::{FxHashMap, FxHashSet};
50
51/// How many separate rectangles a frame describes before it gives up on detail.
52///
53/// The list has to be a list. A single union rectangle spanning the top and the
54/// bottom of a page covers everything between them, which reports every element
55/// as changed and makes the whole mechanism a no-op that costs a walk.
56///
57/// Past this the frame collapses to one bounding rectangle. Conservative in the
58/// only direction that is safe: a cache is dropped that could have been kept,
59/// never kept when it should have been dropped.
60const MAX_REGIONS: usize = 32;
61
62/// The regions of the document whose pixels differ from the previous frame.
63///
64/// Coordinates are document space: CSS pixels, with ancestor scroll offsets
65/// already applied, and *without* the paint scale or the viewport offset. A
66/// consumer working in device pixels multiplies by the scale it painted at.
67#[derive(Debug, Clone, Default)]
68pub struct PaintDamage {
69    /// Bumped once per resolve that found anything at all.
70    ///
71    /// Cheaper than the regions for the coarse question. A consumer that only
72    /// wants "is this the same frame as last time" compares this and never
73    /// looks at the rectangles.
74    pub generation: u64,
75    /// Bumped once per resolve whose final box geometry differs from the
76    /// previous resolved frame.
77    ///
78    /// Unlike [`generation`](Self::generation), repaint-only changes such as a
79    /// colour update leave this counter alone. Debug and cache consumers can
80    /// therefore distinguish layout changes from paint changes without
81    /// treating observation itself as a mutation.
82    pub layout_generation: u64,
83    /// Document-space rectangles that changed. Empty when nothing did.
84    regions: Vec<Rect>,
85}
86
87impl PaintDamage {
88    /// Whether anything at all changed.
89    pub fn is_empty(&self) -> bool {
90        self.regions.is_empty()
91    }
92
93    /// The changed rectangles, in no particular order and possibly overlapping.
94    pub fn regions(&self) -> &[Rect] {
95        &self.regions
96    }
97
98    /// Whether any of it lands in `region`.
99    ///
100    /// The question a cache asks: given the area a filter reads from, is what
101    /// it read last time still valid? Touching edges do not count, for the same
102    /// reason they do not in the backdrop planner: a change ending at `x1` and
103    /// a read starting at `x1` share no pixel.
104    pub fn intersects(&self, region: Rect) -> bool {
105        self.regions.iter().any(|changed| {
106            changed.x0 < region.x1
107                && region.x0 < changed.x1
108                && changed.y0 < region.y1
109                && region.y0 < changed.y1
110        })
111    }
112
113    fn add(&mut self, rect: Rect) {
114        // A zero-area box paints nothing, so it cannot have changed anything.
115        // Worth dropping rather than storing: display:none subtrees and
116        // unstyled text nodes produce a great many of them, and every one would
117        // otherwise take a slot in the bounded list below and push the frame
118        // toward the collapse.
119        if rect.width() <= 0.0 || rect.height() <= 0.0 {
120            return;
121        }
122        if self.regions.len() < MAX_REGIONS {
123            self.regions.push(rect);
124            return;
125        }
126        let collapsed = self
127            .regions
128            .iter()
129            .copied()
130            .fold(rect, |acc, existing| acc.union(existing));
131        self.regions.clear();
132        self.regions.push(collapsed);
133    }
134}
135
136/// Tracks painted boxes between frames so [`PaintDamage`] can be produced.
137#[derive(Debug, Default)]
138pub(crate) struct PaintDamageTracker {
139    enabled: bool,
140    /// Every live node's painted box as of the previous captured frame.
141    previous: FxHashMap<NodeId, Rect>,
142    /// Nodes whose own Stylo damage was non-empty this resolve.
143    ///
144    /// Collected during propagation, because that is the only point at which
145    /// "this node changed" is distinguishable from "something below this node
146    /// changed". Deduplicated, since the repair path can propagate twice.
147    repainted: FxHashSet<NodeId>,
148    /// Whether propagation should still be recording into `repainted`.
149    ///
150    /// The line-break repair runs propagation a second time within one resolve,
151    /// and by then `set_damage` has written the *propagated* value back to every
152    /// node, so a second recording pass would mark every ancestor up to the root
153    /// and hand back a full-frame region. Text edits are exactly what triggers
154    /// the repair, so that is the common case rather than a corner.
155    recording: bool,
156    damage: PaintDamage,
157}
158
159impl PaintDamageTracker {
160    pub(crate) fn set_enabled(&mut self, enabled: bool) {
161        if self.enabled == enabled {
162            return;
163        }
164        self.enabled = enabled;
165        self.previous.clear();
166        self.repainted.clear();
167        self.damage = PaintDamage::default();
168    }
169
170    pub(crate) fn is_enabled(&self) -> bool {
171        self.enabled
172    }
173
174    pub(crate) fn damage(&self) -> &PaintDamage {
175        &self.damage
176    }
177
178    /// Open a resolve. Anything recorded by the previous one is finished with.
179    pub(crate) fn begin_resolve(&mut self) {
180        if !self.enabled {
181            return;
182        }
183        self.repainted.clear();
184        self.recording = true;
185        self.damage.regions.clear();
186    }
187
188    /// Stop recording repaints for the rest of this resolve.
189    pub(crate) fn end_propagation(&mut self) {
190        self.recording = false;
191    }
192
193    /// Note that `node_id` carried damage of its own, before its children's was
194    /// folded in.
195    pub(crate) fn note_own_damage(&mut self, node_id: NodeId) {
196        if self.enabled && self.recording {
197            self.repainted.insert(node_id);
198        }
199    }
200
201    /// Compare this frame's boxes against the last, and produce the region.
202    ///
203    /// Runs after layout and after transforms are resolved, so the boxes are
204    /// final, and before damage is cleared is not required: this half reads
205    /// geometry only.
206    pub(crate) fn capture(&mut self, nodes: &NodeTree) {
207        if !self.enabled {
208            return;
209        }
210
211        let mut origins = FxHashMap::with_capacity_and_hasher(nodes.len(), Default::default());
212        let mut current = FxHashMap::with_capacity_and_hasher(nodes.len(), Default::default());
213        let mut layout_changed = false;
214
215        for (node_id, node) in nodes.iter() {
216            // Text and comment nodes carry no box of their own: their ink lives
217            // in an ancestor's inline layout, and asking them for a layout
218            // panics rather than returning nothing. They are not skipped
219            // silently - a text change still has to be damage - the repaint
220            // pass below attributes them to the nearest ancestor that does have
221            // a box.
222            let Some(rect) = painted_box(nodes, node_id, node, &mut origins) else {
223                continue;
224            };
225            match self.previous.remove(&node_id) {
226                // Unchanged geometry. It may still have repainted in place,
227                // which the second half below answers.
228                Some(old) if old == rect => {}
229                // Moved or resized: both the space it left and the space it
230                // took are different from last frame.
231                Some(old) => {
232                    layout_changed = true;
233                    self.damage.add(old);
234                    self.damage.add(rect);
235                }
236                // New.
237                None => {
238                    layout_changed = true;
239                    self.damage.add(rect);
240                }
241            }
242            current.insert(node_id, rect);
243        }
244
245        // Whatever is left was removed from the tree. Its pixels are as changed
246        // as any others, and nothing else in this pass would have seen it.
247        for (_, old) in self.previous.drain() {
248            layout_changed = true;
249            self.damage.add(old);
250        }
251        self.previous = current;
252
253        // Repaints in place: same box, different pixels. A colour change moves
254        // nothing, so the comparison above cannot see it.
255        let mut repainted = std::mem::take(&mut self.repainted);
256        for node_id in repainted.iter() {
257            if let Some(rect) = painting_ancestor(nodes, *node_id, &self.previous) {
258                self.damage.add(rect);
259            }
260        }
261        repainted.clear();
262        self.repainted = repainted;
263
264        if !self.damage.is_empty() {
265            self.damage.generation = self.damage.generation.wrapping_add(1);
266        }
267        if layout_changed {
268            self.damage.layout_generation = self.damage.layout_generation.wrapping_add(1);
269        }
270    }
271}
272
273/// A node's border box in document space, memoising ancestor origins.
274///
275/// [`Node::absolute_position`](crate::node::Node::absolute_position) walks to
276/// the root on every call, which over a whole tree is one traversal per node.
277/// The memo turns the same arithmetic into a single linear pass: an ancestor is
278/// resolved once and every descendant reads it.
279fn painted_box(
280    nodes: &NodeTree,
281    node_id: NodeId,
282    node: &Node,
283    origins: &mut FxHashMap<NodeId, (f64, f64)>,
284) -> Option<Rect> {
285    if !has_layout(node) {
286        return None;
287    }
288    let (x, y) = absolute_origin(nodes, node_id, origins);
289    let size = node.final_layout().size;
290    Some(Rect::new(
291        x,
292        y,
293        x + f64::from(size.width),
294        y + f64::from(size.height),
295    ))
296}
297
298/// Whether this node kind has a box at all.
299///
300/// Only element, anonymous block and document nodes do. `Node::final_layout`
301/// panics for the rest rather than returning nothing, so every caller here has
302/// to ask first.
303fn has_layout(node: &Node) -> bool {
304    matches!(
305        node.data,
306        NodeData::Element(_) | NodeData::AnonymousBlock(_) | NodeData::Document(_)
307    )
308}
309
310/// The box a repaint of `node_id` actually lands in.
311///
312/// Two kinds of node have to be walked past, and both were found by a test
313/// rather than by reading.
314///
315/// Text and comment nodes have no layout at all. That is expected: their ink
316/// belongs to whichever element lays them out.
317///
318/// Inline elements have a layout whose size is zero, because their text is
319/// placed by the parent's inline context rather than by taffy. They still
320/// paint. A `<span>` recoloured or given new text reported a zero-area
321/// rectangle, which is dropped as "paints nothing", so the change vanished
322/// entirely - and that is the streaming-text case, the one this whole
323/// mechanism exists for.
324///
325/// So: walk up until a box with area. The result is coarser than the change,
326/// attributing a span's repaint to the block that contains it, which is the
327/// safe direction. It is also the honest one: text reflowing inside a
328/// fixed-size block genuinely can repaint anywhere in that block.
329fn painting_ancestor(
330    nodes: &NodeTree,
331    node_id: NodeId,
332    boxes: &FxHashMap<NodeId, Rect>,
333) -> Option<Rect> {
334    let mut current = node_id;
335    loop {
336        let node = nodes.get(current)?;
337        if let Some(rect) = boxes.get(&current) {
338            if rect.width() > 0.0 && rect.height() > 0.0 {
339                return Some(*rect);
340            }
341        }
342        current = node.layout_parent.get().or(node.parent)?;
343    }
344}
345
346fn absolute_origin(
347    nodes: &NodeTree,
348    node_id: NodeId,
349    origins: &mut FxHashMap<NodeId, (f64, f64)>,
350) -> (f64, f64) {
351    if let Some(cached) = origins.get(&node_id) {
352        return *cached;
353    }
354    let Some(node) = nodes.get(node_id) else {
355        return (0.0, 0.0);
356    };
357    // A boxless node contributes no offset of its own but still has to pass its
358    // ancestors' through, so a caller walking up from one lands in the right
359    // place rather than at the document origin.
360    let (mut x, mut y) = if has_layout(node) {
361        let layout = node.final_layout();
362        (f64::from(layout.location.x), f64::from(layout.location.y))
363    } else {
364        (0.0, 0.0)
365    };
366
367    // A scroll offset moves a node's descendants, not its own border box, which
368    // is why the parent's offset is subtracted here and never the node's own.
369    // This mirrors `Node::absolute_position` exactly; the two disagreeing would
370    // put the damage region somewhere the painter never drew.
371    if let Some(parent_id) = node.layout_parent.get() {
372        let (parent_x, parent_y) = absolute_origin(nodes, parent_id, origins);
373        if let Some(parent) = nodes.get(parent_id) {
374            let scroll = parent.scroll_offset();
375            x += parent_x - scroll.x;
376            y += parent_y - scroll.y;
377        } else {
378            x += parent_x;
379            y += parent_y;
380        }
381    }
382
383    origins.insert(node_id, (x, y));
384    (x, y)
385}