From 18804456c5b3df787f5243a0c2e8c5d9f7ef22bb Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Tue, 21 Apr 2026 12:41:12 +0200 Subject: [PATCH] :wrench: Improve drag&drop on atlas --- render-wasm/src/render.rs | 248 +++++++++++++++++++++++---- render-wasm/src/render/surfaces.rs | 16 ++ render-wasm/src/state/shapes_pool.rs | 33 ++-- render-wasm/src/tiles.rs | 15 +- 4 files changed, 260 insertions(+), 52 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 6272d8d9a3..4b3a5abc41 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -334,6 +334,12 @@ pub(crate) struct RenderState { /// Cleared at the beginning of a render pass; set to true after we clear Cache the first /// time we are about to blit a tile into Cache for this pass. pub cache_cleared_this_render: bool, + /// Subtree visited by the main pruned drag walk (modified + ancestors + descendants). + pub drag_main_include: Option>, + /// Subtree excluded from the drag-start backdrop capture (dragged shape + descendants). + pub drag_capture_exclude: Option>, + /// Set once the drag-start backdrop capture has run; cleared with modifiers. + pub drag_backdrop_captured: bool, } pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize { @@ -407,6 +413,9 @@ impl RenderState { preview_mode: false, export_context: None, cache_cleared_this_render: false, + drag_main_include: None, + drag_capture_exclude: None, + drag_backdrop_captured: false, }) } @@ -721,25 +730,30 @@ impl RenderState { self.cache_cleared_this_render = true; } let tile_rect = self.get_current_aligned_tile_bounds()?; - // In fast mode the viewport is moving (pan/zoom) so Cache surface - // positions would be wrong — only save to the tile HashMap. - self.surfaces.cache_current_tile_texture( - &mut self.gpu_state, - &self.tile_viewbox, - &self - .current_tile - .ok_or(Error::CriticalError("Current tile not found".to_string()))?, - &tile_rect, - fast_mode, - self.render_area, - ); - self.surfaces.draw_cached_tile_surface( - self.current_tile - .ok_or(Error::CriticalError("Current tile not found".to_string()))?, - rect, - self.background_color, - ); + if !self.is_pruned_drag_pass() { + // In fast mode the viewport is moving (pan/zoom) so Cache surface + // positions would be wrong — only save to the tile HashMap. + self.surfaces.cache_current_tile_texture( + &mut self.gpu_state, + &self.tile_viewbox, + &self + .current_tile + .ok_or(Error::CriticalError("Current tile not found".to_string()))?, + &tile_rect, + fast_mode, + self.render_area, + ); + + self.surfaces.draw_cached_tile_surface( + self.current_tile + .ok_or(Error::CriticalError("Current tile not found".to_string()))?, + rect, + self.background_color, + ); + } else { + self.surfaces.draw_current_to_target_no_bg(&rect); + } Ok(()) } @@ -1674,21 +1688,35 @@ impl RenderState { self.cache_cleared_this_render = false; self.reset_canvas(); - // During an interactive shape transform (drag/resize/rotate) the - // Target is repainted tile-by-tile. If only a subset of the - // invalidated tiles finishes in this rAF the remaining area - // would either show stale content from the previous frame or, - // on buffer swaps, show blank pixels — either way the user - // perceives tiles appearing sequentially. Paint the persistent - // 1:1 atlas as a stable backdrop so every flush presents a - // coherent picture: unchanged tiles come from the atlas and - // invalidated tiles are overwritten on top as they finish. - if self.options.is_interactive_transform() && self.surfaces.has_atlas() { - self.surfaces.draw_atlas_to_target( - self.viewbox, - self.options.dpr(), - self.background_color, - ); + // Interactive transforms repaint tile-by-tile on top of the + // persistent 1:1 atlas. A one-shot backdrop-capture at drag start + // bakes scene-minus-shape into the atlas so subsequent pruned + // frames leave no ghost at the old position. + let interactive = self.options.is_interactive_transform(); + + // Pruned walk: visit only modified shapes + ancestors + descendants; + // the atlas supplies pixels for unmodified siblings. + let modified = if interactive { + tree.modifier_uuids() + } else { + Vec::new() + }; + if modified.is_empty() { + self.drag_main_include = None; + self.drag_backdrop_captured = false; + } else { + let mut allowed: HashSet = all_with_ancestors(&modified, tree, true) + .into_iter() + .collect(); + for id in &modified { + if let Some(shape) = tree.get(id) { + allowed.extend(shape.all_children_iter(tree, true, true)); + } + } + self.drag_main_include = Some(allowed); + self.surfaces + .canvas(SurfaceId::Current) + .clear(skia::Color::TRANSPARENT); } let surface_ids = SurfaceId::Strokes as u32 @@ -1699,6 +1727,19 @@ impl RenderState { s.canvas().scale((scale, scale)); }); + if self.drag_main_include.is_some() && !self.drag_backdrop_captured { + self.capture_drag_backdrop(tree, timestamp)?; + self.drag_backdrop_captured = true; + } + + if interactive && self.surfaces.has_atlas() { + self.surfaces.draw_atlas_to_target( + self.viewbox, + self.options.dpr(), + self.background_color, + ); + } + let viewbox_cache_size = get_cache_size( self.viewbox, scale, @@ -1726,7 +1767,7 @@ impl RenderState { let _tile_start = performance::begin_timed_log!("tile_cache_update"); performance::begin_measure!("tile_cache"); self.pending_tiles - .update(&self.tile_viewbox, &self.surfaces); + .update(&self.tile_viewbox, &self.surfaces, interactive); performance::end_measure!("tile_cache"); performance::end_timed_log!("tile_cache_update", _tile_start); @@ -2811,6 +2852,9 @@ impl RenderState { let children_ids = sort_z_index(tree, element, children_ids); for child_id in children_ids.iter() { + if self.drag_walk_skips(child_id) { + continue; + } self.pending_nodes.push(NodeRenderState { id: *child_id, visited_children: false, @@ -2831,6 +2875,102 @@ impl RenderState { Ok((is_empty, false)) } + /// Collect what `capture_drag_backdrop` needs: the dragged subtree, + /// tiles covering its pre-modifier bounds, and their world-space union. + fn plan_drag_backdrop( + &self, + tree: ShapesPoolRef, + ) -> Option<(HashSet, HashSet, skia::Rect)> { + let modified = tree.modifier_uuids(); + if modified.is_empty() { + return None; + } + + let scale = self.get_scale(); + let tile_size = tiles::get_tile_size(scale); + let mut exclude: HashSet = HashSet::with_capacity(modified.len()); + let mut capture_tiles: HashSet = HashSet::new(); + let mut world_rect: Option = None; + + for id in &modified { + let Some(shape) = tree.get_raw(id) else { + continue; + }; + exclude.insert(*id); + exclude.extend(shape.all_children_iter(tree, true, true)); + + let bounds = shape.extrect(tree, scale); + if let Some(acc) = world_rect.as_mut() { + acc.join(bounds); + } else { + world_rect = Some(bounds); + } + + let tr = tiles::get_tiles_for_rect(bounds, tile_size); + for tx in tr.x1()..=tr.x2() { + for ty in tr.y1()..=tr.y2() { + let tile = tiles::Tile::from(tx, ty); + if self.tile_viewbox.interest_rect.contains(&tile) { + capture_tiles.insert(tile); + } + } + } + } + + let rect = world_rect?; + (!capture_tiles.is_empty()).then_some((exclude, capture_tiles, rect)) + } + + /// One-shot pass at drag start: re-render the tiles covering the + /// dragged subtree's pre-modifier bounds with that subtree excluded, + /// so the atlas holds scene-minus-shape pixels. + pub fn capture_drag_backdrop(&mut self, tree: ShapesPoolRef, timestamp: i32) -> Result<()> { + let Some((exclude, capture_tiles, world_rect)) = self.plan_drag_backdrop(tree) else { + return Ok(()); + }; + + // Invalidate stale atlas + tile textures so the re-render isn't + // short-circuited by `has_cached_tile_surface`. + let _ = self + .surfaces + .clear_doc_rect_in_atlas(&mut self.gpu_state, world_rect); + for tile in &capture_tiles { + self.surfaces.remove_cached_tile_surface(*tile); + } + + // Only the filter fields need round-tripping; start_render_loop + // re-inits pending_{tiles,nodes}, current_tile, render_in_progress. + let saved_include = self.drag_main_include.take(); + self.drag_capture_exclude = Some(exclude); + + self.pending_tiles.list = capture_tiles.iter().copied().collect(); + self.pending_nodes.clear(); + self.current_tile = None; + self.render_in_progress = true; + self.surfaces + .canvas(SurfaceId::Current) + .clear(self.background_color); + + let result = self.render_shape_tree_partial(None, tree, timestamp, false); + + self.drag_main_include = saved_include; + self.drag_capture_exclude = None; + self.surfaces + .canvas(SurfaceId::Current) + .clear(skia::Color::TRANSPARENT); + + result?; + + // Flush the scene-minus-shape tile textures written by the nested + // render; main drag frames must draw the pruned subtree on top of + // the atlas instead. + for tile in &capture_tiles { + self.surfaces.remove_cached_tile_surface(*tile); + } + + Ok(()) + } + pub fn render_shape_tree_partial( &mut self, base_object: Option<&Uuid>, @@ -2908,6 +3048,8 @@ impl RenderState { tile_rect, ); } + } else if self.is_pruned_drag_pass() { + // Keep the atlas backdrop; skip the empty-tile bg clear. } else { self.surfaces.apply_mut(SurfaceId::Target as u32, |s| { let mut paint = skia::Paint::default(); @@ -2936,9 +3078,14 @@ impl RenderState { } } + let current_clear_color = if self.is_pruned_drag_pass() { + skia::Color::TRANSPARENT + } else { + self.background_color + }; self.surfaces .canvas(SurfaceId::Current) - .clear(self.background_color); + .clear(current_clear_color); // If we finish processing every node rendering is complete // let's check if there are more pending nodes @@ -2946,6 +3093,10 @@ impl RenderState { self.update_render_context(next_tile); if !self.surfaces.has_cached_tile_surface(next_tile) { + // Disjoint borrows: take the drag filters as shared refs + // before the mutable borrow of self.tiles below. + let drag_include = self.drag_main_include.as_ref(); + let drag_exclude = self.drag_capture_exclude.as_ref(); if let Some(ids) = self.tiles.get_shapes_at(next_tile) { // Check if any shape on this tile has a background blur. // If so, we need ALL root shapes rendered (not just those @@ -2962,7 +3113,14 @@ impl RenderState { // We only need first level shapes, in the same order as the parent node let mut valid_ids = Vec::with_capacity(ids.len()); for root_id in root_ids.iter() { - if tile_has_bg_blur || ids.contains(root_id) { + let drag_skips = if let Some(keep) = drag_include { + !keep.contains(root_id) + } else if let Some(skip) = drag_exclude { + skip.contains(root_id) + } else { + false + }; + if (tile_has_bg_blur || ids.contains(root_id)) && !drag_skips { valid_ids.push(*root_id); } } @@ -3306,6 +3464,24 @@ impl RenderState { Ok(()) } + /// True during the main pruned drag frame (atlas-backed). + #[inline] + fn is_pruned_drag_pass(&self) -> bool { + self.drag_main_include.is_some() + } + + /// Whether the drag walk should skip `id` in the current pass. + #[inline] + fn drag_walk_skips(&self, id: &Uuid) -> bool { + if let Some(keep) = &self.drag_main_include { + !keep.contains(id) + } else if let Some(skip) = &self.drag_capture_exclude { + skip.contains(id) + } else { + false + } + } + 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/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index e3a9512e08..f569683252 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -868,6 +868,22 @@ impl Surfaces { } } + /// Blit `Current` onto `Target` at `tile_rect`; no bg fill, no cache + /// writes. Used by pruned drag frames where the atlas is the backdrop. + pub fn draw_current_to_target_no_bg(&mut self, tile_rect: &skia::Rect) { + // `Current` has a margin on all sides (shadow/blur sampling); + // offset so the margin-inset pixel lands at tile_rect's top-left. + self.current.clone().draw( + self.target.canvas(), + ( + tile_rect.left - self.margins.width as f32, + tile_rect.top - self.margins.height as f32, + ), + self.sampling_options, + None, + ); + } + /// Draws the current tile directly to the target and cache surfaces without /// creating a snapshot. This avoids GPU stalls from ReadPixels but doesn't /// populate the tile texture cache (suitable for one-shot renders like tests). diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index 7e03befa01..c0f3054349 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -288,19 +288,7 @@ impl ShapesPoolImpl { pub fn clean_all(&mut self) -> Vec { self.clean_shape_cache(); - let modified_uuids: Vec = if self.modifiers.is_empty() { - Vec::new() - } else { - let mut idx_to_uuid: HashMap = - HashMap::with_capacity(self.uuid_to_idx.len()); - for (uuid, idx) in self.uuid_to_idx.iter() { - idx_to_uuid.insert(*idx, *uuid); - } - self.modifiers - .keys() - .filter_map(|idx| idx_to_uuid.get(idx).copied()) - .collect() - }; + let modified_uuids = self.modifier_uuids(); self.modifiers = HashMap::default(); self.structure = HashMap::default(); @@ -309,6 +297,25 @@ impl ShapesPoolImpl { modified_uuids } + pub fn modifier_uuids(&self) -> Vec { + if self.modifiers.is_empty() { + return Vec::new(); + } + let mut idx_to_uuid: HashMap = HashMap::with_capacity(self.uuid_to_idx.len()); + for (uuid, idx) in self.uuid_to_idx.iter() { + idx_to_uuid.insert(*idx, *uuid); + } + self.modifiers + .keys() + .filter_map(|idx| idx_to_uuid.get(idx).copied()) + .collect() + } + + pub fn get_raw(&self, id: &Uuid) -> Option<&Shape> { + let idx = *self.uuid_to_idx.get(id)?; + Some(&self.shapes[idx]) + } + pub fn subtree(&self, id: &Uuid) -> ShapesPoolImpl { let Some(shape) = self.get(id) else { panic!("Subtree not found"); diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index 13ed4c1aeb..ccaba298b6 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -261,11 +261,20 @@ impl PendingTiles { result } - pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces) { + pub fn update( + &mut self, + tile_viewbox: &TileViewbox, + surfaces: &Surfaces, + skip_interest_margin: bool, + ) { self.list.clear(); - // Generate spiral for the interest area (viewport + margin) - let spiral = Self::generate_spiral(&tile_viewbox.interest_rect); + let source_rect = if skip_interest_margin { + &tile_viewbox.visible_rect + } else { + &tile_viewbox.interest_rect + }; + let spiral = Self::generate_spiral(source_rect); // Partition tiles into 4 priority groups (highest priority = processed last due to pop()): // 1. visible + cached (fastest - just blit from cache)