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(¤t) {
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}