From c148ba5267398a40cb936061cb8ef7c8f18f36a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Fri, 2 Oct 2026 11:39:24 +0200 Subject: [PATCH] :zap: Rebuild drag tiles per moved subtree (#12030) WASM receives only the transforms that differ from the parent (bool operands and masks are kept), rebuilds tiles once per moved subtree and invalidates only the previous frame coverage. --- .../app/main/data/workspace/modifiers.cljs | 19 ++++++- render-wasm/src/main.rs | 2 + render-wasm/src/render.rs | 36 +++++++++++- render-wasm/src/state/shapes_pool.rs | 56 +++++++++++++------ 4 files changed, 93 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 385dbb216d..5386fef3df 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -726,6 +726,21 @@ (vreset! cache (translate-selrect computed (- tx) (- ty))) computed)))) +(defn- without-inherited-transforms + "Drops the entries that repeat their parent's transform. Only for WASM, which applies it to + the subtree; the UI looks transforms up per shape. WASM does not expand into bool operands + nor masks, so those are kept." + [objects modifiers] + (let [transforms (into {} modifiers)] + (into [] + (remove (fn [[id transform]] + (let [parent (get objects (dm/get-in objects [id :parent-id]))] + (and (= transform (get transforms (:id parent))) + (not (cfh/bool-shape? parent)) + (not (and (cfh/mask-shape? parent) + (= id (first (:shapes parent))))))))) + modifiers))) + #_:clj-kondo/ignore (defn set-wasm-modifiers [modif-tree & {:keys [ignore-constraints ignore-snap-pixel snap-ignore-axis @@ -784,7 +799,9 @@ (let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel? snap-ignore-axis)] (if (seq propagated) propagated root-modifiers)))] (when wasm-ready? - (wasm.api/set-modifiers modifiers)) + (wasm.api/set-modifiers + (cond->> modifiers + translation? (without-inherited-transforms (dsh/lookup-page-objects state))))) (let [ids (into [] xf:map-key geometry-entries) selrect (when wasm-ready? (if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers)) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 125f553a0b..01a2217a07 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -354,6 +354,7 @@ pub extern "C" fn set_view_end() -> Result<()> { pub extern "C" fn set_modifiers_start() -> Result<()> { performance::begin_measure!("set_modifiers_start"); let render_state = get_render_state(); + render_state.moving_extrects.clear(); render_state.options.set_fast_mode(true); render_state.options.set_interactive_transform(true); performance::end_measure!("set_modifiers_start"); @@ -369,6 +370,7 @@ pub extern "C" fn set_modifiers_start() -> Result<()> { pub extern "C" fn set_modifiers_end() -> Result<()> { performance::begin_measure!("set_modifiers_end"); let render_state = get_render_state(); + render_state.moving_extrects.clear(); render_state.options.set_fast_mode(false); render_state.options.set_interactive_transform(false); performance::end_measure!("set_modifiers_end"); diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 26a99a499e..f95f324bfb 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -472,6 +472,8 @@ pub(crate) struct RenderState { /// GPU crops from `Backbuffer` or tile atlas keyed by shape id. Filled on full-frame completion; during /// drag, entries for the moved top-level selection are ensured here pub backbuffer_crop_cache: HashMap, + /// Extrect of each moved shape at the previous interactive frame; reset per gesture. + pub moving_extrects: HashMap, /// Whether we've already forced a GPU flush+submit before a tile-atlas /// snapshot this render. The first snapshot of a pass can otherwise capture /// a tile before its text glyph uploads complete (blank first/center tile). @@ -674,6 +676,7 @@ impl RenderState { interactive_target_seeded: false, preserve_target_during_render: false, backbuffer_crop_cache: HashMap::default(), + moving_extrects: HashMap::default(), tile_atlas_flushed: false, drop_shadows_ops_warmed: false, drop_shadow_filter_cache: shadows::DropShadowFilterCache::new(), @@ -4540,7 +4543,8 @@ impl RenderState { // // We intentionally skip this when there is NO modifier so that plain // zoom / pan tile-index rebuilds do NOT invalidate valid atlas content. - if tree.get_modifier(&shape.id).is_some() { + // Once per gesture: later frames only move away from pixels already cleared. + if tree.get_modifier(&shape.id).is_some() && !self.moving_extrects.contains_key(&shape.id) { if let Some(raw_shape) = tree.get_raw(&shape.id) { let old_extrect = raw_shape.extrect(tree, 1.0); self.surfaces @@ -4816,7 +4820,7 @@ impl RenderState { } if self.options.is_interactive_transform() { - self.update_tiles_shapes(ids, tree)?; + self.update_moving_tiles(ids, tree); } else { let ancestors = all_with_ancestors(ids, tree, false); self.update_tiles_shapes(&ancestors, tree)?; @@ -4824,6 +4828,34 @@ impl RenderState { Ok(()) } + /// Evicts each moved shape's previous-frame ∪ current coverage. Tiles it left + /// before the previous frame were already repainted without it. + fn update_moving_tiles(&mut self, ids: &[Uuid], tree: ShapesPoolRef) { + let mut next = HashMap::with_capacity(ids.len()); + for id in ids { + let Some(shape) = tree.get(id) else { + continue; + }; + let prev_extrect = self.moving_extrects.get(id).copied(); + self.invalidate_shape_and_update_tiles(shape, tree, prev_extrect); + let extrect = self.get_cached_extrect(shape, tree, 1.0); + next.insert(*id, extrect); + } + // Shapes that stopped moving (e.g. flex siblings no longer reflowed) leave their + // last position behind and return to one painted without them; dropping them also + // restarts their coverage if they re-enter. + let previous = std::mem::replace(&mut self.moving_extrects, next); + for (id, rect) in previous { + if !self.moving_extrects.contains_key(&id) { + self.surfaces.invalidate_cached_tiles_intersecting(rect); + if let Some(shape) = tree.get(&id) { + let extrect = self.get_cached_extrect(shape, tree, 1.0); + self.surfaces.invalidate_cached_tiles_intersecting(extrect); + } + } + } + } + pub fn get_scale(&self) -> f32 { // During export, use the export scale instead of the workspace zoom. if let Some((_, export_scale)) = self.export_context { diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index f39825ebaf..be0a34e8eb 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -319,37 +319,50 @@ impl ShapesPoolImpl { }) .collect(); - let mut descendants_idxs: Vec = Vec::new(); for (root_idx, matrix) in root_pairs { - for descendant_idx in self.collect_all_descendants(root_idx) { - if let std::collections::hash_map::Entry::Vacant(e) = - modifiers_with_idx.entry(descendant_idx) - { - e.insert(matrix); - descendants_idxs.push(descendant_idx); - } + for descendant_idx in self.collect_inheriting_descendants(root_idx, &modifiers_with_idx) + { + modifiers_with_idx.insert(descendant_idx, matrix); } } self.modifiers = modifiers_with_idx; - for descendant_idx in descendants_idxs { - self.modified_shape_cache - .insert(descendant_idx, OnceCell::new()); + for &idx in self.modifiers.keys() { + self.modified_shape_cache.insert(idx, OnceCell::new()); } - // 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); + // Snapped translations arrive propagated to every descendant. Keep only the shapes + // that move relative to their parent, so tiles are rebuilt per moved subtree. + let roots: Vec = ids + .into_iter() + .filter(|uuid| !self.moves_with_parent(uuid)) + .collect(); - for uuid in all_ids { + for uuid in shapes::all_with_ancestors(&roots, self, true) { 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; + self.modifier_uuids = roots; + } + + fn moves_with_parent(&self, id: &Uuid) -> bool { + let Some(&idx) = self.uuid_to_idx.get(id) else { + return false; + }; + let Some(&parent_idx) = self.shapes[idx] + .parent_id + .as_ref() + .and_then(|parent_id| self.uuid_to_idx.get(parent_id)) + else { + return false; + }; + self.modifiers + .get(&parent_idx) + .is_some_and(|parent| self.modifiers.get(&idx) == Some(parent)) } pub fn set_structure(&mut self, structure: HashMap>) { @@ -473,7 +486,13 @@ impl ShapesPoolImpl { } } - fn collect_all_descendants(&self, idx: usize) -> Vec { + /// Descendants of `idx` without a modifier of their own, stopping at the subtrees of + /// those that have one: they inherit from their nearest ancestor with a modifier. + fn collect_inheriting_descendants( + &self, + idx: usize, + modifiers: &HashMap, + ) -> Vec { let mut result = Vec::new(); let mut queue: VecDeque<&Uuid> = VecDeque::new(); let shape = &self.shapes[idx]; @@ -482,6 +501,9 @@ impl ShapesPoolImpl { } while let Some(child_id) = queue.pop_front() { if let Some(&child_idx) = self.uuid_to_idx.get(child_id) { + if modifiers.contains_key(&child_idx) { + continue; + } result.push(child_idx); let child_shape = &self.shapes[child_idx]; for grandchild_id in child_shape.children_ids_iter(false) {