🔧 Improve drag&drop on atlas

This commit is contained in:
Elena Torro 2026-04-21 12:41:12 +02:00
parent 612855452a
commit 18804456c5
4 changed files with 260 additions and 52 deletions

View File

@ -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<HashSet<Uuid>>,
/// Subtree excluded from the drag-start backdrop capture (dragged shape + descendants).
pub drag_capture_exclude: Option<HashSet<Uuid>>,
/// 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<Uuid> = 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<Uuid>, HashSet<tiles::Tile>, 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<Uuid> = HashSet::with_capacity(modified.len());
let mut capture_tiles: HashSet<tiles::Tile> = HashSet::new();
let mut world_rect: Option<skia::Rect> = 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 {

View File

@ -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).

View File

@ -288,19 +288,7 @@ impl ShapesPoolImpl {
pub fn clean_all(&mut self) -> Vec<Uuid> {
self.clean_shape_cache();
let modified_uuids: Vec<Uuid> = if self.modifiers.is_empty() {
Vec::new()
} else {
let mut idx_to_uuid: HashMap<usize, Uuid> =
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<Uuid> {
if self.modifiers.is_empty() {
return Vec::new();
}
let mut idx_to_uuid: HashMap<usize, Uuid> = 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");

View File

@ -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)