⚡ 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.
This commit is contained in:
Elena Torró 2026-10-02 11:39:24 +02:00 committed by GitHub
parent 13a9da3bc3
commit c148ba5267
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 93 additions and 20 deletions

View File

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

View File

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

View File

@ -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<Uuid, InteractiveDragCrop>,
/// Extrect of each moved shape at the previous interactive frame; reset per gesture.
pub moving_extrects: HashMap<Uuid, Rect>,
/// 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 {

View File

@ -319,37 +319,50 @@ impl ShapesPoolImpl {
})
.collect();
let mut descendants_idxs: Vec<usize> = 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<Uuid> = 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<Uuid, Vec<StructureEntry>>) {
@ -473,7 +486,13 @@ impl ShapesPoolImpl {
}
}
fn collect_all_descendants(&self, idx: usize) -> Vec<usize> {
/// 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<usize, skia::Matrix>,
) -> Vec<usize> {
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) {