diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index c5bff2105f..a88cd03bda 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -911,7 +911,7 @@ pub extern "C" fn clean_modifiers() -> Result<()> { // the same tiles for the active modifier set, so the eviction // here is redundant and doubles the per-emission cost. if !prev_modifier_ids.is_empty() && !render_state.options.is_interactive_transform() { - render_state.update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?; + render_state.update_tiles_shapes(&prev_modifier_ids, &state.shapes)?; } }); Ok(()) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 985a2ca57a..2a7d651519 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -4202,22 +4202,8 @@ impl RenderState { result } - /* - * Incremental version of update_shape_tiles for pan/zoom operations. - * Updates the tile index and returns ONLY tiles that need cache invalidation. - * - * During pan operations, shapes don't move in world coordinates. The interest - * area (viewport) moves, which changes which tiles we track in the index, but - * tiles that were already cached don't need re-rendering just because the - * viewport moved. - * - * This function: - * 1. Updates the tile index (adds/removes shapes from tiles based on interest area) - * 2. Returns empty vec for cache invalidation (pan doesn't change tile content) - * - * Tile cache invalidation only happens when shapes actually move or change, - * which is handled by rebuild_touched_tiles, not during pan/zoom. - */ + /// Diffs the shape's tile set, leaving cached tiles alone. For callers where the + /// index moves but painted content does not: pan/zoom, and re-indexed ancestors. pub fn update_shape_tiles_incremental( &mut self, shape: &Shape, @@ -4378,22 +4364,14 @@ impl RenderState { } } + self.index_dependent_ancestors(&ids, tree); + performance::end_measure!("rebuild_touched_tiles"); } - /// Invalidates extended rectangles and updates tiles for a set of shapes - /// - /// This function takes a set of shape IDs and for each one: - /// 1. Invalidates the extrect cache - /// 2. Updates the tiles to ensure proper rendering - /// - /// This is useful when you have a pre-computed set of shape IDs that need to be refreshed, - /// regardless of their relationship to other shapes (e.g., ancestors, descendants, or any other collection). - pub fn update_tiles_shapes( - &mut self, - shape_ids: &[Uuid], - tree: ShapesPoolMutRef<'_>, - ) -> Result<()> { + /// Re-indexes a set of shapes and evicts the cached tiles they dirty. Extrect caches + /// are not dropped here; `State::touch_shape` invalidates them at the source. + pub fn update_tiles_shapes(&mut self, shape_ids: &[Uuid], tree: ShapesPoolRef) -> Result<()> { performance::begin_measure!("invalidate_and_update_tiles"); for shape_id in shape_ids { if let Some(shape) = tree.get(shape_id) { @@ -4439,24 +4417,51 @@ impl RenderState { self.surfaces.invalidate_cached_tiles_intersecting(dirty); } - /// Rebuilds tiles for shapes with modifiers and processes their ancestors - /// - /// This function applies transformation modifiers to shapes and updates their tiles. - /// Additionally, it processes all ancestors of modified shapes to ensure their - /// extended rectangles are properly recalculated and their tiles are updated. - /// This is crucial for frames and groups that contain transformed children. + fn index_dependent_ancestors(&mut self, ids: &HashSet, tree: ShapesPoolRef) { + if ids.is_empty() { + return; + } + + let mut ancestors = Vec::::new(); + let mut seen = HashSet::::new(); + + for id in ids.iter() { + ancestors.clear(); + tree.collect_dependent_ancestors(id, &mut ancestors); + + for ancestor_id in ancestors.iter() { + if ids.contains(ancestor_id) || !seen.insert(*ancestor_id) { + continue; + } + let Some(shape) = tree.get(ancestor_id) else { + continue; + }; + // A hidden ancestor paints nothing, but keep climbing: its parent may. + if shape.hidden() { + continue; + } + // Diffs the tile set instead of removing and re-adding every tile, which + // matters because this runs once per gesture frame. + let _ = self.update_shape_tiles_incremental(shape, tree); + } + } + } + pub fn rebuild_modifier_tiles( &mut self, tree: ShapesPoolMutRef<'_>, ids: &[Uuid], ) -> Result<()> { - // During interactive transform, skip ancestor invalidation: walking up to the - // parent frame evicts every tile the frame covers, including dense tiles with - // many siblings. Ancestor extrect caches are already invalidated by - // `ShapesPool::set_modifiers`; the tile index is reconciled post-gesture by - // the committing code path (rebuild_touched_tiles). + // `set_modifiers` runs per pointer move, this runs once per rAF, so the ancestor + // caches are dropped here. Must precede any read of their tile coverage below. + for id in ids { + tree.invalidate_ancestors_extrect(id); + } + if self.options.is_interactive_transform() { self.update_tiles_shapes(ids, tree)?; + let modified: HashSet = ids.iter().copied().collect(); + self.index_dependent_ancestors(&modified, tree); } else { let ancestors = all_with_ancestors(ids, tree, false); self.update_tiles_shapes(&ancestors, tree)?; diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index e5202e651a..789748301f 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -960,6 +960,14 @@ impl Shape { Bounds::from_rect(&rect) } + pub fn extrect_depends_on_children(&self) -> bool { + match self.shape_type { + Type::Group(Group { masked: true }) => true, + Type::Group(_) | Type::Frame(_) => !self.clip_content, + _ => false, + } + } + fn apply_children_bounds( &self, bounds: Bounds, @@ -1125,20 +1133,18 @@ impl Shape { fn calculate_extrect_uncached(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect { let mut bounds = self.own_extrect_bounds(); - bounds = self.apply_children_bounds(bounds, shapes_pool, scale); - bounds = self.apply_children_blur(bounds, shapes_pool); if !self.transform.is_identity() { - // Expand everything in the shape's local axis-aligned space first (strokes, - // shadows, blur, children). Only after that do we map the resulting bounds - // through the shape transform so rotation/skew is reflected in the final - // extrect. let mut matrix = self.transform; let center = self.center(); matrix.post_translate(center); matrix.pre_translate(-center); bounds.transform_mut(&matrix); } + + bounds = self.apply_children_bounds(bounds, shapes_pool, scale); + bounds = self.apply_children_blur(bounds, shapes_pool); + bounds.to_rect() } diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 8e9be844d7..6a29bef62a 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -341,21 +341,13 @@ impl State { } pub fn touch_current(&mut self) { - // `mark_touched` only drives incremental on-screen tile invalidation; - // the headless export path has no render state, so skip it there. - if self.loading || !has_render_state() { - return; - } if let Some(current_id) = self.current_id { - let prev = self - .shapes - .get(¤t_id) - .map(|shape| shape.extrect(&self.shapes, 1.0)); - get_render_state().mark_touched_with_prev(current_id, prev); + self.touch_shape(current_id); } } pub fn touch_shape(&mut self, id: Uuid) { + self.shapes.invalidate_ancestors_extrect(&id); if self.loading || !has_render_state() { return; } diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index f57bbcb51b..55d4566385 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -59,6 +59,8 @@ pub struct ShapesPoolImpl { structure: HashMap>, /// Scale content values, keyed by index scale_content: HashMap, + /// Scratch for ancestor walks: `invalidate_ancestors_extrect` runs on every mutation. + ancestor_scratch: Vec, } // Type aliases - no longer need lifetimes! @@ -78,6 +80,7 @@ impl ShapesPoolImpl { modifier_uuids: Vec::new(), structure: HashMap::default(), scale_content: HashMap::default(), + ancestor_scratch: Vec::new(), } } @@ -239,6 +242,54 @@ impl ShapesPoolImpl { self.modified_shape_cache.clear() } + pub fn collect_dependent_ancestors(&self, id: &Uuid, out: &mut Vec) { + let Some(idx) = self.uuid_to_idx.get(id).copied() else { + return; + }; + + let mut current = self.shapes[idx].parent_id; + let mut depth = 0; + + while let Some(parent_id) = current { + if parent_id.is_nil() { + break; + } + let Some(parent_idx) = self.uuid_to_idx.get(&parent_id).copied() else { + break; + }; + if !self.shapes[parent_idx].extrect_depends_on_children() { + break; + } + + out.push(parent_id); + current = self.shapes[parent_idx].parent_id; + + // A chain longer than the pool means the parent links form a cycle. + depth += 1; + if depth >= self.shapes.len() { + break; + } + } + } + + pub fn invalidate_ancestors_extrect(&mut self, id: &Uuid) { + let mut scratch = std::mem::take(&mut self.ancestor_scratch); + scratch.clear(); + self.collect_dependent_ancestors(id, &mut scratch); + + for parent_id in scratch.iter() { + let Some(parent_idx) = self.uuid_to_idx.get(parent_id).copied() else { + continue; + }; + self.shapes[parent_idx].invalidate_extrect(); + if let Some(cell) = self.modified_shape_cache.get_mut(&parent_idx) { + *cell = OnceCell::new(); + } + } + + self.ancestor_scratch = scratch; + } + pub fn set_modifiers(&mut self, modifiers: HashMap) { let mut ids = Vec::::new(); let mut modifiers_with_idx = HashMap::with_capacity(modifiers.len()); @@ -254,10 +305,8 @@ impl ShapesPoolImpl { // When CLJS sends only root shapes (translation on drag), descendants // need the same matrix. // For resize/rotate, propagate-modifiers already includes all descendants. - // Descendants are NOT pushed into `ids` / `modifier_uuids`: tile invalidation - // via rebuild_modifier_tiles only runs for roots, which is sufficient because - // descendants always lie inside the parent's bounding box and are therefore - // covered by the parent's old/new tile ranges. + // Descendants are NOT pushed into `ids` / `modifier_uuids`: rebuild_modifier_tiles + // runs for roots, and drops the non-clipping ancestors' extrects separately. let root_pairs: Vec<(usize, skia::Matrix)> = ids .iter() .filter_map(|uuid| { @@ -289,13 +338,15 @@ impl ShapesPoolImpl { // Compute ancestors before consuming `ids` so we can move it into // `modifier_uuids` without a clone. let all_ids = shapes::all_with_ancestors(&ids, self, true); - // rebuild_modifier_tiles doesn't process every descendant individually. - self.modifier_uuids = ids; + for uuid in all_ids { if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() { self.modified_shape_cache.insert(idx, OnceCell::new()); } } + + // rebuild_modifier_tiles doesn't process every descendant individually. + self.modifier_uuids = ids; } pub fn set_structure(&mut self, structure: HashMap>) { @@ -402,6 +453,7 @@ impl ShapesPoolImpl { modifier_uuids: Vec::new(), structure: HashMap::default(), scale_content: HashMap::default(), + ancestor_scratch: Vec::new(), } } @@ -484,6 +536,7 @@ impl Clone for ShapesPoolImpl { modifier_uuids: self.modifier_uuids.clone(), structure: self.structure.clone(), scale_content: self.scale_content.clone(), + ancestor_scratch: Vec::new(), } } }