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..7c1aec66d8 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. pub fn update_shape_tiles_incremental( &mut self, shape: &Shape, @@ -4381,19 +4367,10 @@ impl RenderState { 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` and `rebuild_modifier_tiles` invalidate + /// 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,22 +4416,17 @@ 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. 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)?; } else { diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index e5202e651a..e93fd35e24 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, @@ -1124,21 +1132,21 @@ impl Shape { } fn calculate_extrect_uncached(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect { + // Own outsets (strokes, shadows, blur) are local-space, so they expand before the + // shape transform. Children extrects are already world-space: join them after it. 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..90345895ba 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -239,6 +239,33 @@ impl ShapesPoolImpl { self.modified_shape_cache.clear() } + /// Drops the extrect cache of every ancestor whose extrect grows with this shape. + /// Stops at the first ancestor that clips: its extrect no longer follows the child, + /// and neither does anything above it. + pub fn invalidate_ancestors_extrect(&mut self, id: &Uuid) { + let mut current = self + .uuid_to_idx + .get(id) + .and_then(|idx| self.shapes[*idx].parent_id); + + while let Some(parent_id) = current.filter(|parent_id| !parent_id.is_nil()) { + let Some(parent_idx) = self.uuid_to_idx.get(&parent_id).copied() else { + break; + }; + if !self.shapes[parent_idx].extrect_depends_on_children() { + break; + } + + self.shapes[parent_idx].invalidate_extrect(); + // The cached modified shape is a clone, with its own copy of the extrect cache. + if let Some(cell) = self.modified_shape_cache.get_mut(&parent_idx) { + *cell = OnceCell::new(); + } + + current = self.shapes[parent_idx].parent_id; + } + } + 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 +281,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 +314,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>) {