diff --git a/crates/render/src/layer.rs b/crates/render/src/layer.rs new file mode 100644 index 0000000..f4ffa6a --- /dev/null +++ b/crates/render/src/layer.rs @@ -0,0 +1,764 @@ +//! Retained layer tree and damage tracking for minimal repaints. +//! +//! The layer tree maintains a persistent record of layout box state between +//! frames. Each frame, it compares new layout boxes against previous state and +//! computes damage rectangles — screen regions that need repainting. +//! +//! The GPU and software renderers can use these damage rects as scissor regions +//! to avoid full-screen repaints when only small areas changed (e.g. cursor +//! blink, hover effects). + +use std::collections::HashMap; + +use we_css::values::Color; +use we_dom::NodeId; +use we_layout::{LayoutBox, LayoutTree, Rect}; +use we_style::computed::{Overflow, Position, Visibility}; + +use crate::{build_display_list_with_page_scroll, node_id_from_box_type, DisplayList, ScrollState}; + +// --------------------------------------------------------------------------- +// Damage rectangles +// --------------------------------------------------------------------------- + +/// An axis-aligned damage rectangle in screen coordinates. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DamageRect { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +impl DamageRect { + pub fn new(x: f32, y: f32, width: f32, height: f32) -> Self { + Self { + x, + y, + width, + height, + } + } + + /// Create a damage rect from a layout `Rect`. + pub fn from_rect(r: Rect) -> Self { + Self { + x: r.x, + y: r.y, + width: r.width, + height: r.height, + } + } + + /// Union of two damage rects: the smallest rect containing both. + pub fn union(self, other: DamageRect) -> DamageRect { + let x0 = self.x.min(other.x); + let y0 = self.y.min(other.y); + let x1 = (self.x + self.width).max(other.x + other.width); + let y1 = (self.y + self.height).max(other.y + other.height); + DamageRect { + x: x0, + y: y0, + width: x1 - x0, + height: y1 - y0, + } + } + + /// Returns true if this rect has zero or negative area. + pub fn is_empty(&self) -> bool { + self.width <= 0.0 || self.height <= 0.0 + } + + /// Returns true if this rect intersects with another. + pub fn intersects(&self, other: &DamageRect) -> bool { + self.x < other.x + other.width + && self.x + self.width > other.x + && self.y < other.y + other.height + && self.y + self.height > other.y + } + + /// Expand this rect by a margin on all sides. Used for merging nearby rects. + pub fn expand(&self, margin: f32) -> DamageRect { + DamageRect { + x: self.x - margin, + y: self.y - margin, + width: self.width + margin * 2.0, + height: self.height + margin * 2.0, + } + } +} + +/// Merge a list of damage rects, coalescing nearby or overlapping ones to +/// reduce draw calls. Two rects are merged if they overlap or are within +/// `merge_threshold` pixels of each other. +pub fn merge_damage_rects(rects: &[DamageRect], merge_threshold: f32) -> Vec { + if rects.is_empty() { + return Vec::new(); + } + + let mut merged: Vec = Vec::new(); + + for &rect in rects { + if rect.is_empty() { + continue; + } + + let mut did_merge = false; + for existing in &mut merged { + if existing.expand(merge_threshold).intersects(&rect) { + *existing = existing.union(rect); + did_merge = true; + break; + } + } + if !did_merge { + merged.push(rect); + } + } + + // Second pass: merge any newly-overlapping rects created by the first pass. + let mut stable = false; + while !stable { + stable = true; + let mut i = 0; + while i < merged.len() { + let mut j = i + 1; + while j < merged.len() { + if merged[i].expand(merge_threshold).intersects(&merged[j]) { + let b = merged.remove(j); + merged[i] = merged[i].union(b); + stable = false; + } else { + j += 1; + } + } + i += 1; + } + } + + merged +} + +// --------------------------------------------------------------------------- +// Box fingerprint (for change detection) +// --------------------------------------------------------------------------- + +/// A lightweight fingerprint of a layout box's paint-relevant properties. +/// Used for fast comparison to detect changes between frames. +#[derive(Debug, Clone, PartialEq)] +struct BoxFingerprint { + rect: Rect, + background_color: Color, + color: Color, + opacity: f32, + visibility: Visibility, + overflow: Overflow, + position: Position, + z_index: Option, + border_widths: [u32; 4], // stored as f32 bit patterns + border_colors: [Color; 4], + text_hash: u64, + child_count: usize, + content_height_bits: u32, +} + +impl BoxFingerprint { + fn from_layout_box(b: &LayoutBox) -> Self { + let text_hash = b.lines.iter().fold(0u64, |acc, line| { + let mut h = acc; + for byte in line.text.as_bytes() { + h = h.wrapping_mul(31).wrapping_add(*byte as u64); + } + h = h.wrapping_mul(31).wrapping_add(line.x.to_bits() as u64); + h = h.wrapping_mul(31).wrapping_add(line.y.to_bits() as u64); + h + }); + + BoxFingerprint { + rect: b.rect, + background_color: b.background_color, + color: b.color, + opacity: b.opacity, + visibility: b.visibility, + overflow: b.overflow, + position: b.position, + z_index: b.z_index, + border_widths: [ + b.border.top.to_bits(), + b.border.right.to_bits(), + b.border.bottom.to_bits(), + b.border.left.to_bits(), + ], + border_colors: b.border_colors, + text_hash, + child_count: b.children.len(), + content_height_bits: b.content_height.to_bits(), + } + } + + /// Compute a damage rect from this fingerprint (border box approximation). + fn damage_rect(&self) -> DamageRect { + let bt = f32::from_bits(self.border_widths[0]); + let br = f32::from_bits(self.border_widths[1]); + let bb = f32::from_bits(self.border_widths[2]); + let bl = f32::from_bits(self.border_widths[3]); + + DamageRect { + x: self.rect.x - bl, + y: self.rect.y - bt, + width: self.rect.width + bl + br, + height: self.rect.height + bt + bb, + } + } +} + +// --------------------------------------------------------------------------- +// Retained layer tree +// --------------------------------------------------------------------------- + +/// Retained layer tree that persists between frames and tracks damage. +/// +/// On each frame, call [`update`] with the new layout tree. The layer tree +/// compares the new layout against the previous frame's snapshot and produces +/// a set of damage rects indicating which screen regions need repainting. +/// +/// For the first frame or after a full invalidation, [`damage_rects`] returns +/// `None`, indicating that the entire viewport should be repainted. +pub struct LayerTree { + /// Previous frame's box fingerprints, keyed by NodeId. + prev_fingerprints: HashMap, + /// Collected damage rects from the most recent update. + damage: Option>, + /// Viewport dimensions. + viewport_width: f32, + viewport_height: f32, + /// Whether this is the first frame (no previous state). + first_frame: bool, + /// The display list from the most recent frame. + cached_display_list: DisplayList, + /// Repaint metrics for debugging/testing. + metrics: RepaintMetrics, +} + +/// Metrics about what was repainted in the last frame. +#[derive(Debug, Clone, Default)] +pub struct RepaintMetrics { + /// Number of damage rects produced. + pub damage_rect_count: usize, + /// Total area of damage rects (in px^2). + pub damage_area: f32, + /// Whether a full repaint was performed. + pub full_repaint: bool, + /// Number of boxes that changed between frames. + pub changed_box_count: usize, +} + +impl LayerTree { + /// Create a new layer tree for the given viewport size. + pub fn new(viewport_width: f32, viewport_height: f32) -> Self { + Self { + prev_fingerprints: HashMap::new(), + damage: None, + viewport_width, + viewport_height, + first_frame: true, + cached_display_list: Vec::new(), + metrics: RepaintMetrics::default(), + } + } + + /// Resize the viewport. Triggers a full repaint on the next frame. + pub fn resize(&mut self, width: f32, height: f32) { + if (self.viewport_width - width).abs() > 0.001 + || (self.viewport_height - height).abs() > 0.001 + { + self.viewport_width = width; + self.viewport_height = height; + self.invalidate(); + } + } + + /// Force a full repaint on the next frame. + pub fn invalidate(&mut self) { + self.first_frame = true; + self.damage = None; + } + + /// Update the layer tree with a new layout. Computes damage rects by + /// comparing the new layout against the previous frame's state. + /// + /// After calling this, use [`damage_rects`] to query which regions need + /// repainting, and [`display_list`] to get the full display list. + pub fn update(&mut self, tree: &LayoutTree, page_scroll_y: f32, scroll_state: &ScrollState) { + // Always rebuild the full display list. + let new_list = build_display_list_with_page_scroll(tree, page_scroll_y, scroll_state); + self.cached_display_list = new_list; + + if self.first_frame { + let mut new_fingerprints = HashMap::new(); + collect_fingerprints(&tree.root, &mut new_fingerprints); + self.prev_fingerprints = new_fingerprints; + self.first_frame = false; + self.damage = None; + self.metrics = RepaintMetrics { + damage_rect_count: 0, + damage_area: 0.0, + full_repaint: true, + changed_box_count: 0, + }; + return; + } + + let mut new_fingerprints = HashMap::new(); + collect_fingerprints(&tree.root, &mut new_fingerprints); + + let mut damage_rects = Vec::new(); + let mut changed_count = 0; + + // Changed or new boxes. + for (node_id, new_fp) in &new_fingerprints { + match self.prev_fingerprints.get(node_id) { + Some(old_fp) if old_fp == new_fp => {} + Some(old_fp) => { + changed_count += 1; + let old_d = old_fp.damage_rect(); + let new_d = new_fp.damage_rect(); + if !old_d.is_empty() { + damage_rects.push(old_d); + } + if !new_d.is_empty() { + damage_rects.push(new_d); + } + } + None => { + changed_count += 1; + let d = new_fp.damage_rect(); + if !d.is_empty() { + damage_rects.push(d); + } + } + } + } + + // Removed boxes. + for (node_id, old_fp) in &self.prev_fingerprints { + if !new_fingerprints.contains_key(node_id) { + changed_count += 1; + let d = old_fp.damage_rect(); + if !d.is_empty() { + damage_rects.push(d); + } + } + } + + let merged = merge_damage_rects(&damage_rects, 16.0); + let total_area: f32 = merged.iter().map(|r| r.width * r.height).sum(); + + self.metrics = RepaintMetrics { + damage_rect_count: merged.len(), + damage_area: total_area, + full_repaint: false, + changed_box_count: changed_count, + }; + + let viewport_area = self.viewport_width * self.viewport_height; + if viewport_area > 0.0 && total_area > viewport_area * 0.75 { + self.metrics.full_repaint = true; + self.damage = None; + } else if merged.is_empty() { + self.damage = Some(Vec::new()); + } else { + self.damage = Some(merged); + } + + self.prev_fingerprints = new_fingerprints; + } + + /// Get the damage rects from the most recent update. + /// + /// Returns `None` if a full repaint is needed (first frame, resize, or + /// damage covers most of the viewport). Returns `Some(empty)` if nothing + /// changed. Returns `Some(rects)` for partial damage. + pub fn damage_rects(&self) -> Option<&[DamageRect]> { + self.damage.as_deref() + } + + /// Get the full display list from the most recent update. + pub fn display_list(&self) -> &DisplayList { + &self.cached_display_list + } + + /// Get repaint metrics from the most recent frame. + pub fn metrics(&self) -> &RepaintMetrics { + &self.metrics + } + + /// Check if any repaint is needed. Returns `false` only when zero damage. + pub fn needs_repaint(&self) -> bool { + match &self.damage { + None => true, + Some(rects) => !rects.is_empty(), + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Collect box fingerprints from the layout tree, keyed by NodeId. +fn collect_fingerprints(b: &LayoutBox, out: &mut HashMap) { + if let Some(id) = node_id_from_box_type(&b.box_type) { + out.insert(id, BoxFingerprint::from_layout_box(b)); + } + for child in &b.children { + collect_fingerprints(child, out); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // -- DamageRect tests --------------------------------------------------- + + #[test] + fn damage_rect_union() { + let a = DamageRect::new(10.0, 10.0, 20.0, 20.0); + let b = DamageRect::new(25.0, 25.0, 20.0, 20.0); + let u = a.union(b); + assert_eq!(u.x, 10.0); + assert_eq!(u.y, 10.0); + assert_eq!(u.width, 35.0); + assert_eq!(u.height, 35.0); + } + + #[test] + fn damage_rect_union_overlapping() { + let a = DamageRect::new(0.0, 0.0, 50.0, 50.0); + let b = DamageRect::new(10.0, 10.0, 20.0, 20.0); + let u = a.union(b); + assert_eq!(u.x, 0.0); + assert_eq!(u.y, 0.0); + assert_eq!(u.width, 50.0); + assert_eq!(u.height, 50.0); + } + + #[test] + fn damage_rect_is_empty() { + assert!(DamageRect::new(0.0, 0.0, 0.0, 10.0).is_empty()); + assert!(DamageRect::new(0.0, 0.0, 10.0, 0.0).is_empty()); + assert!(DamageRect::new(0.0, 0.0, -1.0, 10.0).is_empty()); + assert!(!DamageRect::new(0.0, 0.0, 1.0, 1.0).is_empty()); + } + + #[test] + fn damage_rect_intersects() { + let a = DamageRect::new(0.0, 0.0, 20.0, 20.0); + let b = DamageRect::new(10.0, 10.0, 20.0, 20.0); + assert!(a.intersects(&b)); + + let c = DamageRect::new(50.0, 50.0, 10.0, 10.0); + assert!(!a.intersects(&c)); + } + + #[test] + fn damage_rect_expand() { + let r = DamageRect::new(10.0, 10.0, 20.0, 20.0); + let e = r.expand(5.0); + assert_eq!(e.x, 5.0); + assert_eq!(e.y, 5.0); + assert_eq!(e.width, 30.0); + assert_eq!(e.height, 30.0); + } + + #[test] + fn damage_rect_from_layout_rect() { + let r = Rect { + x: 5.0, + y: 10.0, + width: 100.0, + height: 200.0, + }; + let d = DamageRect::from_rect(r); + assert_eq!(d.x, 5.0); + assert_eq!(d.y, 10.0); + assert_eq!(d.width, 100.0); + assert_eq!(d.height, 200.0); + } + + // -- merge_damage_rects tests ------------------------------------------- + + #[test] + fn merge_empty_rects() { + let result = merge_damage_rects(&[], 16.0); + assert!(result.is_empty()); + } + + #[test] + fn merge_single_rect() { + let rects = vec![DamageRect::new(10.0, 10.0, 50.0, 50.0)]; + let result = merge_damage_rects(&rects, 16.0); + assert_eq!(result.len(), 1); + assert_eq!(result[0].x, 10.0); + } + + #[test] + fn merge_overlapping_rects() { + let rects = vec![ + DamageRect::new(0.0, 0.0, 30.0, 30.0), + DamageRect::new(20.0, 20.0, 30.0, 30.0), + ]; + let result = merge_damage_rects(&rects, 0.0); + assert_eq!(result.len(), 1); + assert_eq!(result[0].x, 0.0); + assert_eq!(result[0].y, 0.0); + assert_eq!(result[0].width, 50.0); + assert_eq!(result[0].height, 50.0); + } + + #[test] + fn merge_nearby_rects() { + let rects = vec![ + DamageRect::new(0.0, 0.0, 20.0, 20.0), + DamageRect::new(30.0, 0.0, 20.0, 20.0), + ]; + let result = merge_damage_rects(&rects, 16.0); + assert_eq!(result.len(), 1); + } + + #[test] + fn merge_distant_rects_stay_separate() { + let rects = vec![ + DamageRect::new(0.0, 0.0, 20.0, 20.0), + DamageRect::new(200.0, 200.0, 20.0, 20.0), + ]; + let result = merge_damage_rects(&rects, 16.0); + assert_eq!(result.len(), 2); + } + + #[test] + fn merge_skips_empty_rects() { + let rects = vec![ + DamageRect::new(10.0, 10.0, 0.0, 0.0), + DamageRect::new(20.0, 20.0, 30.0, 30.0), + ]; + let result = merge_damage_rects(&rects, 16.0); + assert_eq!(result.len(), 1); + assert_eq!(result[0].x, 20.0); + } + + #[test] + fn merge_chain_of_nearby_rects() { + let rects = vec![ + DamageRect::new(0.0, 0.0, 20.0, 20.0), + DamageRect::new(30.0, 0.0, 20.0, 20.0), + DamageRect::new(60.0, 0.0, 20.0, 20.0), + ]; + let result = merge_damage_rects(&rects, 16.0); + assert_eq!(result.len(), 1); + } + + // -- LayerTree tests (using real HTML parsing) -------------------------- + + use we_dom::Document; + use we_style::computed::{extract_stylesheets, resolve_styles}; + use we_text::font::Font; + + fn test_font() -> Font { + let paths = [ + "/System/Library/Fonts/Geneva.ttf", + "/System/Library/Fonts/Monaco.ttf", + ]; + for path in &paths { + let p = std::path::Path::new(path); + if p.exists() { + return Font::from_file(p).expect("failed to parse font"); + } + } + panic!("no test font found"); + } + + fn layout_html(html: &str) -> LayoutTree { + let font = test_font(); + let doc = we_html::parse_html(html); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); + we_layout::layout( + &styled, + &doc, + 800.0, + 600.0, + &font, + &std::collections::HashMap::new(), + ) + } + + #[test] + fn layer_tree_first_frame_full_repaint() { + let lt = LayerTree::new(800.0, 600.0); + assert!(lt.damage_rects().is_none()); + assert!(lt.needs_repaint()); + } + + #[test] + fn layer_tree_resize_triggers_full_repaint() { + let mut lt = LayerTree::new(800.0, 600.0); + lt.first_frame = false; + lt.damage = Some(Vec::new()); + + lt.resize(1024.0, 768.0); + assert!(lt.damage_rects().is_none()); + assert!(lt.needs_repaint()); + } + + #[test] + fn layer_tree_invalidate_triggers_full_repaint() { + let mut lt = LayerTree::new(800.0, 600.0); + lt.first_frame = false; + lt.damage = Some(Vec::new()); + + lt.invalidate(); + assert!(lt.damage_rects().is_none()); + } + + #[test] + fn layer_tree_identical_layout_no_damage() { + let mut lt = LayerTree::new(800.0, 600.0); + let scroll_state = HashMap::new(); + + let tree1 = layout_html("

Hello

"); + lt.update(&tree1, 0.0, &scroll_state); + assert!(lt.metrics().full_repaint); + + let tree2 = layout_html("

Hello

"); + lt.update(&tree2, 0.0, &scroll_state); + assert!(!lt.metrics().full_repaint); + assert_eq!(lt.metrics().changed_box_count, 0); + assert!(!lt.needs_repaint()); + } + + #[test] + fn layer_tree_detects_text_change() { + let mut lt = LayerTree::new(800.0, 600.0); + let scroll_state = HashMap::new(); + + let tree1 = layout_html("

Hello

"); + lt.update(&tree1, 0.0, &scroll_state); + + let tree2 = layout_html("

World

"); + lt.update(&tree2, 0.0, &scroll_state); + + assert!(lt.needs_repaint()); + assert!(lt.metrics().changed_box_count >= 1); + } + + #[test] + fn layer_tree_detects_style_change() { + let mut lt = LayerTree::new(800.0, 600.0); + let scroll_state = HashMap::new(); + + let tree1 = layout_html( + "
A
", + ); + lt.update(&tree1, 0.0, &scroll_state); + + let tree2 = layout_html( + "
A
", + ); + lt.update(&tree2, 0.0, &scroll_state); + + assert!(lt.needs_repaint()); + assert!(lt.metrics().changed_box_count >= 1); + } + + #[test] + fn layer_tree_detects_added_element() { + let mut lt = LayerTree::new(800.0, 600.0); + let scroll_state = HashMap::new(); + + let tree1 = layout_html("

One

"); + lt.update(&tree1, 0.0, &scroll_state); + + let tree2 = layout_html("

One

Two

"); + lt.update(&tree2, 0.0, &scroll_state); + + assert!(lt.needs_repaint()); + assert!(lt.metrics().changed_box_count >= 1); + } + + #[test] + fn layer_tree_detects_removed_element() { + let mut lt = LayerTree::new(800.0, 600.0); + let scroll_state = HashMap::new(); + + let tree1 = layout_html("

One

Two

"); + lt.update(&tree1, 0.0, &scroll_state); + + let tree2 = layout_html("

One

"); + lt.update(&tree2, 0.0, &scroll_state); + + assert!(lt.needs_repaint()); + assert!(lt.metrics().changed_box_count >= 1); + } + + #[test] + fn layer_tree_display_list_always_available() { + let mut lt = LayerTree::new(800.0, 600.0); + let scroll_state = HashMap::new(); + + let tree = layout_html("

Test

"); + lt.update(&tree, 0.0, &scroll_state); + + assert!(!lt.display_list().is_empty()); + } + + #[test] + fn layer_tree_damage_covers_changed_region() { + let mut lt = LayerTree::new(800.0, 600.0); + let scroll_state = HashMap::new(); + + // Two distinct divs at known positions. + let tree1 = layout_html(concat!( + "", + "
A
", + "
B
", + "" + )); + lt.update(&tree1, 0.0, &scroll_state); + + // Change only the second div's background. + let tree2 = layout_html(concat!( + "", + "
A
", + "
B
", + "" + )); + lt.update(&tree2, 0.0, &scroll_state); + + assert!(lt.needs_repaint()); + let rects = lt.damage_rects(); + // Should have damage, not a full repaint for this small change. + assert!(rects.is_some()); + } + + #[test] + fn layer_tree_metrics_accurate_on_no_change() { + let mut lt = LayerTree::new(800.0, 600.0); + let scroll_state = HashMap::new(); + + let tree = layout_html("Static"); + lt.update(&tree, 0.0, &scroll_state); + assert!(lt.metrics().full_repaint); + + let tree2 = layout_html("Static"); + lt.update(&tree2, 0.0, &scroll_state); + assert!(!lt.metrics().full_repaint); + assert_eq!(lt.metrics().damage_rect_count, 0); + assert_eq!(lt.metrics().damage_area, 0.0); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e390780..f92963f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -5,6 +5,7 @@ pub mod atlas; pub mod gpu; +pub mod layer; use std::collections::HashMap; @@ -422,7 +423,7 @@ fn border_box(layout_box: &LayoutBox) -> Rect { } /// Extract the NodeId from a BoxType, if it has one. -fn node_id_from_box_type(box_type: &BoxType) -> Option { +pub(crate) fn node_id_from_box_type(box_type: &BoxType) -> Option { match box_type { BoxType::Block(id) | BoxType::Inline(id) => Some(*id), BoxType::TextRun { node, .. } => Some(*node), @@ -1232,6 +1233,58 @@ impl RenderBackend { gpu.clear_image_cache(); } } + + /// Render using damage tracking from a [`layer::LayerTree`]. + /// + /// If the layer tree indicates no repaint is needed (zero damage), this + /// skips rendering entirely and returns `false`. Otherwise renders the + /// full display list and returns `true`. + /// + /// For the Metal backend, this delegates to [`render_metal`]. For the + /// software backend, this delegates to [`render_software`]. In both + /// cases the layer tree's cached display list is used. + /// + /// Future optimization: use damage rects as scissor regions to limit + /// GPU/software rendering to only the damaged areas. + #[allow(clippy::too_many_arguments)] + pub fn render_with_layer_tree( + &mut self, + layer_tree: &layer::LayerTree, + font: &Font, + images: &HashMap, + metal_layer: Option<&MetalLayer>, + queue: Option<&CommandQueue>, + clear_color: ClearColor, + viewport_width: f32, + viewport_height: f32, + ) -> bool { + if !layer_tree.needs_repaint() { + return false; + } + + let display_list = layer_tree.display_list(); + + match self { + RenderBackend::Metal(gpu) => { + if let (Some(layer), Some(q)) = (metal_layer, queue) { + gpu.render( + display_list, + font, + images, + layer, + q, + clear_color, + viewport_width, + viewport_height, + ); + } + } + RenderBackend::Software(renderer) => { + renderer.paint_display_list(display_list, font, images); + } + } + true + } } #[cfg(test)]