From 483ce8b1c9568da12dde716e1116cb76747105e6 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Mon, 27 Apr 2026 18:18:22 +0200 Subject: [PATCH 1/2] :zap: Improve drag performance --- render-wasm/src/main.rs | 34 +++++++++----- render-wasm/src/render.rs | 66 ++++++++++++++++++++++------ render-wasm/src/render/surfaces.rs | 7 +-- render-wasm/src/shapes.rs | 12 ++--- render-wasm/src/state/shapes_pool.rs | 18 ++++++++ render-wasm/src/tiles.rs | 4 ++ 6 files changed, 107 insertions(+), 34 deletions(-) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 9f298c3900..58b30c7ba1 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -225,6 +225,17 @@ pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> { pub extern "C" fn render(_: i32) -> Result<()> { with_state_mut!(state, { state.rebuild_touched_tiles(); + // Drain the throttled modifier-tile invalidation accumulated + // since the previous rAF. set_modifiers skips this work during + // interactive_transform; we do it once here, with the current + // modifier set, so the cost is paid once per rAF rather than + // once per pointer move. + if state.render_state.options.is_interactive_transform() { + let ids = state.shapes.modifier_ids(); + if !ids.is_empty() { + state.rebuild_modifier_tiles(ids)?; + } + } state .start_render_loop(performance::get_time()) .map_err(|_| Error::RecoverableError("Error rendering".to_string()))?; @@ -344,11 +355,9 @@ pub extern "C" fn render_loading_overlay() -> Result<()> { #[wasm_error] pub extern "C" fn process_animation_frame(timestamp: i32) -> Result<()> { let result = with_state_mut!(state, { state.process_animation_frame(timestamp) }); - if let Err(err) = result { eprintln!("process_animation_frame error: {}", err); } - Ok(()) } @@ -453,9 +462,8 @@ pub extern "C" fn set_view_end() -> Result<()> { pub extern "C" fn set_modifiers_start() -> Result<()> { with_state_mut!(state, { performance::begin_measure!("set_modifiers_start"); - let opts = &mut state.render_state.options; - opts.set_fast_mode(true); - opts.set_interactive_transform(true); + state.render_state.options.set_fast_mode(true); + state.render_state.options.set_interactive_transform(true); performance::end_measure!("set_modifiers_start"); }); Ok(()) @@ -470,9 +478,8 @@ pub extern "C" fn set_modifiers_start() -> Result<()> { pub extern "C" fn set_modifiers_end() -> Result<()> { with_state_mut!(state, { performance::begin_measure!("set_modifiers_end"); - let opts = &mut state.render_state.options; - opts.set_fast_mode(false); - opts.set_interactive_transform(false); + state.render_state.options.set_fast_mode(false); + state.render_state.options.set_interactive_transform(false); state.render_state.cancel_animation_frame(); performance::end_measure!("set_modifiers_end"); }); @@ -945,7 +952,11 @@ pub extern "C" fn set_structure_modifiers() -> Result<()> { pub extern "C" fn clean_modifiers() -> Result<()> { with_state_mut!(state, { let prev_modifier_ids = state.shapes.clean_all(); - if !prev_modifier_ids.is_empty() { + // Skip the tile-cache cleanup during interactive transform: the + // per-rAF `rebuild_modifier_tiles` in `render()` already evicts + // 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() && !state.render_state.options.is_interactive_transform() { state .render_state .update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?; @@ -973,7 +984,10 @@ pub extern "C" fn set_modifiers() -> Result<()> { with_state_mut!(state, { state.set_modifiers(modifiers); - state.rebuild_modifier_tiles(ids)?; + // TO CHECK + if !state.render_state.options.is_interactive_transform() { + state.rebuild_modifier_tiles(ids)?; + } }); Ok(()) } diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 0d249f06e7..71edd19fae 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -334,6 +334,13 @@ 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, + /// True iff the current tile had shapes assigned to it when we + /// started rendering it. Lets us distinguish a genuinely empty + /// tile (skip composite, just clear) from a tile whose walker + /// finished its work in a previous PAF and is now being resumed + /// (must composite to present the work). Reset when current_tile + /// changes. + pub current_tile_had_shapes: bool, } pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize { @@ -407,6 +414,7 @@ impl RenderState { preview_mode: false, export_context: None, cache_cleared_this_render: false, + current_tile_had_shapes: false, }) } @@ -624,6 +632,10 @@ impl RenderState { pub fn set_viewport_interest_area_threshold(&mut self, value: i32) { self.options.set_viewport_interest_area_threshold(value); + // The TileViewbox stores its own copy of `interest` (set at + // construction). Without propagating, options change wouldn't + // affect pending_tiles generation. + self.tile_viewbox.set_interest(value); } pub fn set_node_batch_threshold(&mut self, value: i32) { @@ -867,7 +879,7 @@ impl RenderState { let antialias = shape.should_use_antialias(self.get_scale(), self.options.antialias_threshold); - let fast_mode = self.options.is_fast_mode(); + let skip_effects = self.options.is_fast_mode(); let has_nested_fills = self .nested_fills .last() @@ -995,7 +1007,7 @@ impl RenderState { // Remove background blur from the shape so it doesn't get processed // as a layer blur. The actual rendering is done before the save_layer // in render_background_blur() so it's independent of shape opacity. - if !fast_mode + if !skip_effects && apply_to_current_surface && fills_surface_id == SurfaceId::Fills && !matches!(shape.shape_type, Type::Text(_)) @@ -1021,14 +1033,14 @@ impl RenderState { } else if shape_has_blur { shape.to_mut().set_blur(None); } - if fast_mode { + if skip_effects { shape.to_mut().set_blur(None); } // For non-text, non-SVG shapes in the normal rendering path, apply blur // via a single save_layer on each render surface // Clip correctness is preserved - let blur_sigma_for_layers: Option = if !fast_mode + let blur_sigma_for_layers: Option = if !skip_effects && apply_to_current_surface && fills_surface_id == SurfaceId::Fills && !matches!(shape.shape_type, Type::Text(_)) @@ -1107,7 +1119,7 @@ impl RenderState { ) }) .unzip(); - if fast_mode { + if skip_effects { // Fast path: render fills and strokes only (skip shadows/blur). text::render( Some(self), @@ -1396,7 +1408,7 @@ impl RenderState { antialias, outset, )?; - if !fast_mode { + if !skip_effects { for stroke in &visible_strokes { shadows::render_stroke_inner_shadows( self, @@ -1409,7 +1421,7 @@ impl RenderState { } } - if !fast_mode { + if !skip_effects { shadows::render_fill_inner_shadows( self, shape, @@ -2030,7 +2042,7 @@ impl RenderState { paint.set_blend_mode(element.blend_mode().into()); paint.set_alpha_f(element.opacity()); - // Skip frame-level blur in fast mode (pan/zoom) + // Skip frame-level blur in fast mode (pan/zoom). if !self.options.is_fast_mode() { if let Some(frame_blur) = Self::frame_clip_layer_blur(element) { let scale = self.get_scale(); @@ -2753,7 +2765,7 @@ impl RenderState { .surfaces .get_render_context_translation(self.render_area, scale); - // Skip expensive drop shadow rendering in fast mode (during pan/zoom) + // Skip expensive drop shadow rendering in fast mode (during pan/zoom). let skip_shadows = self.options.is_fast_mode(); // Skip shadow block when already rendered before the layer (frame_clip_layer_blur) @@ -2936,7 +2948,17 @@ impl RenderState { } performance::end_measure!("render_shape_tree::uncached"); let tile_rect = self.get_current_tile_bounds()?; - if !is_empty { + // Composite if the walker did work in this PAF + // (`!is_empty`) OR the tile has unfinished work from + // a previous PAF (`current_tile_had_shapes` was set + // when we populated pending_nodes for this tile). + // The explicit clear is reserved for tiles that + // genuinely have no shapes assigned to them — + // without this distinction, chunked-render + // resumption was painting completed tiles back to + // background, producing the disappearing-tile + // flicker during drag. + if !is_empty || self.current_tile_had_shapes { self.apply_render_to_final_canvas(tile_rect)?; if self.options.is_debug_visible() { @@ -2953,7 +2975,6 @@ impl RenderState { paint.set_color(self.background_color); s.canvas().draw_rect(tile_rect, &paint); }); - // Keep Cache surface coherent for render_from_cache. if !self.options.is_fast_mode() { if !self.cache_cleared_this_render { self.surfaces.clear_cache(self.background_color); @@ -2978,6 +2999,11 @@ impl RenderState { // let's check if there are more pending nodes if let Some(next_tile) = self.pending_tiles.pop() { self.update_render_context(next_tile); + // Reset for the new tile. We'll flip it to true if the + // tile has shapes, so a later "is_empty=true" reflects + // a resumed-from-yield case rather than a genuinely + // empty tile. + self.current_tile_had_shapes = false; if !self.surfaces.has_cached_tile_surface(next_tile) { if let Some(ids) = self.tiles.get_shapes_at(next_tile) { @@ -3001,6 +3027,9 @@ impl RenderState { } } + if !valid_ids.is_empty() { + self.current_tile_had_shapes = true; + } self.pending_nodes.extend(valid_ids.into_iter().map(|id| { NodeRenderState { id, @@ -3336,8 +3365,19 @@ impl RenderState { tree: ShapesPoolMutRef<'_>, ids: Vec, ) -> Result<()> { - let ancestors = all_with_ancestors(&ids, tree, false); - self.update_tiles_shapes(&ancestors, tree)?; + // During interactive transform, skip ancestor invalidation: walking + // up to the parent frame evicts every tile the frame covers, + // including dense tiles with hundreds of siblings. The anti-flicker + // guard then forces all of them to re-render in a single frame. + // 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). + if self.options.is_interactive_transform() { + self.update_tiles_shapes(&ids, tree)?; + } else { + let ancestors = all_with_ancestors(&ids, tree, false); + self.update_tiles_shapes(&ancestors, tree)?; + } Ok(()) } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 8d769eea0f..424642ecd5 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -399,10 +399,12 @@ impl Surfaces { /// Draw the persistent atlas onto the target using the current viewbox transform. /// Intended for fast pan/zoom-out previews (avoids per-tile composition). + /// Clears Target to `background` first so atlas-uncovered regions don't + /// show stale content when the atlas only partially covers the viewport. pub fn draw_atlas_to_target(&mut self, viewbox: Viewbox, dpr: f32, background: skia::Color) { if !self.has_atlas() { return; - }; + } let canvas = self.target.canvas(); canvas.save(); @@ -413,11 +415,10 @@ impl Surfaces { None, true, ); + canvas.clear(background); let s = viewbox.zoom * dpr; let atlas_scale = self.atlas_scale.max(0.01); - - canvas.clear(background); canvas.translate(( (self.atlas_origin.x + viewbox.pan_x) * s, (self.atlas_origin.y + viewbox.pan_y) * s, diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 98c2d13c9c..a122b1d8f6 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -195,7 +195,7 @@ pub struct Shape { pub shadows: Vec, pub layout_item: Option, pub bounds: OnceCell, - pub extrect_cache: RefCell>, + pub extrect_cache: RefCell>, pub svg_transform: Option, pub ignore_constraints: bool, deleted: bool, @@ -1015,17 +1015,13 @@ impl Shape { } pub fn calculate_extrect(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect { - let scale_key = (scale * 1000.0).round() as u32; - - if let Some((cached_extrect, cached_scale)) = *self.extrect_cache.borrow() { - if cached_scale == scale_key { - return cached_extrect; - } + if let Some(cached_extrect) = *self.extrect_cache.borrow() { + return cached_extrect; } let extrect = self.calculate_extrect_uncached(shapes_pool, scale); - *self.extrect_cache.borrow_mut() = Some((extrect, scale_key)); + *self.extrect_cache.borrow_mut() = Some(extrect); extrect } diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index 7e03befa01..95e229d3b7 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -309,6 +309,24 @@ impl ShapesPoolImpl { modified_uuids } + /// UUIDs of all shapes that currently have a transform modifier. + /// Used by the throttled drag path so per-rAF tile invalidation can + /// be done once with the current modifier set instead of once per + /// pointer move. + pub fn modifier_ids(&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 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..003ca52fdb 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -86,6 +86,10 @@ impl TileViewbox { self.center = get_tile_center_for_viewbox(viewbox, scale); } + pub fn set_interest(&mut self, interest: i32) { + self.interest = interest; + } + pub fn is_visible(&self, tile: &Tile) -> bool { // TO CHECK self.interest_rect.contains(tile) self.visible_rect.contains(tile) From e4af37a7ff5d8665df2c390f48701bc78544ae88 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 28 Apr 2026 09:35:00 +0200 Subject: [PATCH 2/2] :tada: Use backbuffer + direct-to-target tiles during drag --- render-wasm/src/main.rs | 3 + render-wasm/src/render.rs | 148 +++++++++++++++++------------ render-wasm/src/render/surfaces.rs | 106 +++++++++++++++++++++ render-wasm/src/tiles.rs | 15 ++- 4 files changed, 210 insertions(+), 62 deletions(-) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 58b30c7ba1..685afc898a 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -464,6 +464,9 @@ pub extern "C" fn set_modifiers_start() -> Result<()> { performance::begin_measure!("set_modifiers_start"); state.render_state.options.set_fast_mode(true); state.render_state.options.set_interactive_transform(true); + // Capture the last fully-rendered frame as a stable backdrop for the drag. + // This avoids relying on atlas/cache correctness during fast_mode. + state.render_state.surfaces.copy_target_to_backbuffer(); performance::end_measure!("set_modifiers_start"); }); Ok(()) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 71edd19fae..99c085c42a 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -341,6 +341,10 @@ pub(crate) struct RenderState { /// (must composite to present the work). Reset when current_tile /// changes. pub current_tile_had_shapes: bool, + /// During interactive transforms we keep `Target` between rAFs. Seed the + /// interactive backdrop exactly once per gesture (first rAF) so we don't + /// repeatedly overwrite tiles that have already been updated. + pub interactive_target_seeded: bool, } pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize { @@ -415,6 +419,7 @@ impl RenderState { export_context: None, cache_cleared_this_render: false, current_tile_had_shapes: false, + interactive_target_seeded: false, }) } @@ -724,6 +729,16 @@ impl RenderState { } pub fn apply_render_to_final_canvas(&mut self, rect: skia::Rect) -> Result<()> { + // During interactive transforms we render tiles directly into Target; updating the cache + // (snapshot -> atlas blit -> tiles.add) can force GPU stalls. Defer cache rebuild until + // the interaction ends. + if self.options.is_interactive_transform() { + let tile_rect = self.get_current_aligned_tile_bounds()?; + self.surfaces + .draw_current_tile_direct_target_only(&tile_rect, self.background_color); + return Ok(()); + } + let fast_mode = self.options.is_fast_mode(); // Decide *now* (at the first real cache blit) whether we need to clear Cache. // This avoids clearing Cache on renders that don't actually paint tiles (e.g. hover/UI), @@ -876,10 +891,14 @@ impl RenderState { s.canvas().save(); }); } + let fast_mode = self.options.is_fast_mode(); + // Skip anti-aliasing entirely during fast_mode (interactive + // gestures + pan/zoom). AA edge sampling is per-pixel and adds + // up across many shapes; reverts to full quality on commit. + let antialias = !fast_mode + && shape.should_use_antialias(self.get_scale(), self.options.antialias_threshold); + let skip_effects = fast_mode; - let antialias = - shape.should_use_antialias(self.get_scale(), self.options.antialias_threshold); - let skip_effects = self.options.is_fast_mode(); let has_nested_fills = self .nested_fills .last() @@ -922,7 +941,6 @@ impl RenderState { }); fills::render(self, shape, &shape.fills, antialias, target_surface, None)?; - // Pass strokes in natural order; stroke merging handles top-most ordering internally. let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect(); strokes::render( @@ -1681,30 +1699,28 @@ impl RenderState { performance::begin_measure!("render"); performance::begin_measure!("start_render_loop"); - self.cache_cleared_this_render = false; - self.reset_canvas(); - // Compute and set document-space bounds (1 unit == 1 doc px @ 100% zoom) // to clamp atlas updates. This prevents zoom-out tiles from forcing atlas // growth far beyond real content. let doc_bounds = self.compute_document_bounds(base_object, tree); self.surfaces.set_atlas_doc_bounds(doc_bounds); - // 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, - ); + self.cache_cleared_this_render = false; + if self.options.is_interactive_transform() { + // Keep `Target` as the previous frame and overwrite only the tiles + // that changed. This avoids clearing + redrawing an atlas backdrop + // every rAF during drag (a common source of GPU work/stalls). + self.surfaces + .reset_interactive_transform(self.background_color); + if !self.interactive_target_seeded { + // Seed from the last presented frame; this is stable even when + // fast_mode skips cache updates and regardless of atlas coverage. + self.surfaces.seed_target_from_backbuffer(); + self.interactive_target_seeded = true; + } + } else { + self.reset_canvas(); + self.interactive_target_seeded = false; } let surface_ids = SurfaceId::Strokes as u32 @@ -1741,8 +1757,9 @@ impl RenderState { let _tile_start = performance::begin_timed_log!("tile_cache_update"); performance::begin_measure!("tile_cache"); + let only_visible = self.options.is_interactive_transform(); self.pending_tiles - .update(&self.tile_viewbox, &self.surfaces); + .update(&self.tile_viewbox, &self.surfaces, only_visible); performance::end_measure!("tile_cache"); performance::end_timed_log!("tile_cache_update", _tile_start); @@ -1827,20 +1844,16 @@ impl RenderState { } // In a pure viewport interaction (pan/zoom), render_from_cache - // owns the Target surface — don't flush Target so we don't - // present stale tile positions. We still drain the GPU command - // queue with a non-Target `flush_and_submit` so the backlog - // of tile-render commands executes incrementally instead of - // piling up for hundreds of milliseconds and blowing up the - // next `render_from_cache` call into a multi-frame hitch. + // owns the Target surface — skip flush so we don't present + // stale tile positions. The rAF still populates the Cache + // surface and tile HashMap so render_from_cache progressively + // shows more complete content. // // During interactive shape transforms (drag/resize/rotate) we // still need to flush every rAF so the user sees the updated // shape position — render_from_cache is not in the loop here. if !self.options.is_viewport_interaction() { self.flush_and_submit(); - } else { - self.gpu_state.context.flush_and_submit(); } if self.render_in_progress { @@ -2561,7 +2574,8 @@ impl RenderState { } if let Some(clips) = clip_bounds.as_ref() { - let antialias = element.should_use_antialias(scale, self.options.antialias_threshold); + let antialias = !self.options.is_fast_mode() + && element.should_use_antialias(scale, self.options.antialias_threshold); self.surfaces.canvas(target_surface).save(); for (bounds, corners, transform) in clips.iter() { if target_surface == SurfaceId::Export { @@ -2905,12 +2919,17 @@ impl RenderState { if let Some(current_tile) = self.current_tile { if self.surfaces.has_cached_tile_surface(current_tile) { performance::begin_measure!("render_shape_tree::cached"); + // During interactive transforms, `Target` is preserved and seeded once + // from Backbuffer. Cached tiles are therefore already visible and + // re-blitting them costs extra GPU work. let tile_rect = self.get_current_tile_bounds()?; - self.surfaces.draw_cached_tile_surface( - current_tile, - tile_rect, - self.background_color, - ); + if !self.options.is_interactive_transform() { + self.surfaces.draw_cached_tile_surface( + current_tile, + tile_rect, + self.background_color, + ); + } // Also draw the cached tile to the Cache surface so // render_from_cache (used during pan) has the full scene. @@ -2948,18 +2967,19 @@ impl RenderState { } performance::end_measure!("render_shape_tree::uncached"); let tile_rect = self.get_current_tile_bounds()?; - // Composite if the walker did work in this PAF - // (`!is_empty`) OR the tile has unfinished work from - // a previous PAF (`current_tile_had_shapes` was set - // when we populated pending_nodes for this tile). - // The explicit clear is reserved for tiles that - // genuinely have no shapes assigned to them — - // without this distinction, chunked-render - // resumption was painting completed tiles back to - // background, producing the disappearing-tile - // flicker during drag. + // Composite if the walker did work in this PAF (`!is_empty`) OR + // the tile has unfinished work from a previous PAF + // (`current_tile_had_shapes` was set when we populated pending_nodes + // for this tile). if !is_empty || self.current_tile_had_shapes { - self.apply_render_to_final_canvas(tile_rect)?; + if self.options.is_interactive_transform() { + // During drag, avoid snapshot-based caching. Draw Current directly + // into Target (and Cache) to reduce stalls. + self.surfaces + .draw_current_tile_direct(&tile_rect, self.background_color); + } else { + self.apply_render_to_final_canvas(tile_rect)?; + } if self.options.is_debug_visible() { debug::render_workspace_current_tile( @@ -2975,6 +2995,7 @@ impl RenderState { paint.set_color(self.background_color); s.canvas().draw_rect(tile_rect, &paint); }); + // Keep Cache surface coherent for render_from_cache. if !self.options.is_fast_mode() { if !self.cache_cleared_this_render { self.surfaces.clear_cache(self.background_color); @@ -3019,17 +3040,28 @@ impl RenderState { }) }); - // We only need first level shapes, in the same order as the parent node + // We only need first level shapes, in the same order as the parent node. + // + // During interactive transforms we may invalidate only the modified shapes + // (to avoid massive ancestor eviction). However, we still composite full + // tiles (we clear the tile rect before drawing Current), so we must render + // all root shapes that can contribute to this tile; otherwise, unchanged + // siblings inside the same tile would disappear. 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) { - valid_ids.push(*root_id); + if self.options.is_interactive_transform() || tile_has_bg_blur { + valid_ids.extend(root_ids.iter().copied()); + } else { + for root_id in root_ids.iter() { + if ids.contains(root_id) { + valid_ids.push(*root_id); + } } } if !valid_ids.is_empty() { self.current_tile_had_shapes = true; } + self.pending_nodes.extend(valid_ids.into_iter().map(|id| { NodeRenderState { id, @@ -3365,13 +3397,11 @@ impl RenderState { tree: ShapesPoolMutRef<'_>, ids: Vec, ) -> Result<()> { - // During interactive transform, skip ancestor invalidation: walking - // up to the parent frame evicts every tile the frame covers, - // including dense tiles with hundreds of siblings. The anti-flicker - // guard then forces all of them to re-render in a single frame. - // 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). + // 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). if self.options.is_interactive_transform() { self.update_tiles_shapes(&ids, tree)?; } else { diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 424642ecd5..3fe27fdebd 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -42,6 +42,7 @@ pub enum SurfaceId { UI = 0b100_0000_0000, Debug = 0b100_0000_0001, Atlas = 0b100_0000_0010, + Backbuffer = 0b100_0000_0100, } pub struct Surfaces { @@ -67,6 +68,8 @@ pub struct Surfaces { debug: skia::Surface, // for drawing tiles. export: skia::Surface, + // Persistent viewport-sized surface used to keep the last presented frame. + backbuffer: skia::Surface, tiles: TileTextureCache, // Persistent 1:1 document-space atlas that gets incrementally updated as tiles render. @@ -112,6 +115,8 @@ impl Surfaces { let target = gpu_state.create_target_surface(width, height)?; let filter = gpu_state.create_surface_with_isize("filter".to_string(), extra_tile_dims)?; let cache = gpu_state.create_surface_with_dimensions("cache".to_string(), width, height)?; + let backbuffer = + gpu_state.create_surface_with_dimensions("backbuffer".to_string(), width, height)?; let current = gpu_state.create_surface_with_isize("current".to_string(), extra_tile_dims)?; @@ -148,6 +153,7 @@ impl Surfaces { ui, debug, export, + backbuffer, tiles, atlas, atlas_origin: skia::Point::new(0.0, 0.0), @@ -582,6 +588,9 @@ impl Surfaces { if ids & SurfaceId::Cache as u32 != 0 { f(self.get_mut(SurfaceId::Cache)); } + if ids & SurfaceId::Backbuffer as u32 != 0 { + f(self.get_mut(SurfaceId::Backbuffer)); + } if ids & SurfaceId::Fills as u32 != 0 { f(self.get_mut(SurfaceId::Fills)); } @@ -656,6 +665,7 @@ impl Surfaces { SurfaceId::Target => &mut self.target, SurfaceId::Filter => &mut self.filter, SurfaceId::Cache => &mut self.cache, + SurfaceId::Backbuffer => &mut self.backbuffer, SurfaceId::Current => &mut self.current, SurfaceId::DropShadows => &mut self.drop_shadows, SurfaceId::InnerShadows => &mut self.inner_shadows, @@ -674,6 +684,7 @@ impl Surfaces { SurfaceId::Target => &self.target, SurfaceId::Filter => &self.filter, SurfaceId::Cache => &self.cache, + SurfaceId::Backbuffer => &self.backbuffer, SurfaceId::Current => &self.current, SurfaceId::DropShadows => &self.drop_shadows, SurfaceId::InnerShadows => &self.inner_shadows, @@ -687,6 +698,29 @@ impl Surfaces { } } + /// Copy the current `Target` contents into the persistent `Backbuffer`. + /// This is a GPU→GPU copy via Skia (no ReadPixels). + pub fn copy_target_to_backbuffer(&mut self) { + let sampling_options = self.sampling_options; + self.target.clone().draw( + self.backbuffer.canvas(), + (0.0, 0.0), + sampling_options, + Some(&skia::Paint::default()), + ); + } + + /// Seed `Target` from `Backbuffer` (last presented frame). + pub fn seed_target_from_backbuffer(&mut self) { + let sampling_options = self.sampling_options; + self.backbuffer.clone().draw( + self.target.canvas(), + (0.0, 0.0), + sampling_options, + Some(&skia::Paint::default()), + ); + } + fn reset_from_target(&mut self, target: skia::Surface) -> Result<()> { let dim = (target.width(), target.height()); self.target = target; @@ -694,6 +728,10 @@ impl Surfaces { .target .new_surface_with_dimensions(dim) .ok_or(Error::CriticalError("Failed to create surface".to_string()))?; + self.backbuffer = self + .target + .new_surface_with_dimensions(dim) + .ok_or(Error::CriticalError("Failed to create surface".to_string()))?; self.debug = self .target .new_surface_with_dimensions(dim) @@ -850,6 +888,43 @@ impl Surfaces { self.clear_all_dirty(); } + /// Reset render surfaces for interactive transforms without clearing `Target`. + /// Keeping `Target` avoids having to redraw an atlas backdrop each frame; we + /// then overwrite only the tiles that changed. + pub fn reset_interactive_transform(&mut self, color: skia::Color) { + self.canvas(SurfaceId::Fills).restore_to_count(1); + self.canvas(SurfaceId::InnerShadows).restore_to_count(1); + self.canvas(SurfaceId::TextDropShadows).restore_to_count(1); + self.canvas(SurfaceId::DropShadows).restore_to_count(1); + self.canvas(SurfaceId::Strokes).restore_to_count(1); + self.canvas(SurfaceId::Current).restore_to_count(1); + self.canvas(SurfaceId::Export).restore_to_count(1); + + // Clear tile-sized/intermediate surfaces; leave Target intact. + self.apply_mut( + SurfaceId::Fills as u32 + | SurfaceId::Strokes as u32 + | SurfaceId::Current as u32 + | SurfaceId::InnerShadows as u32 + | SurfaceId::TextDropShadows as u32 + | SurfaceId::DropShadows as u32 + | SurfaceId::Export as u32, + |s| { + s.canvas().clear(color).reset_matrix(); + }, + ); + + // UI/debug can be redrawn; clearing them is fine. + self.canvas(SurfaceId::Debug) + .clear(skia::Color::TRANSPARENT) + .reset_matrix(); + self.canvas(SurfaceId::UI) + .clear(skia::Color::TRANSPARENT) + .reset_matrix(); + + self.clear_all_dirty(); + } + /// Clears the whole cache surface without disturbing its configured transform. pub fn clear_cache(&mut self, color: skia::Color) { let canvas = self.cache.canvas(); @@ -987,6 +1062,37 @@ impl Surfaces { ); } + /// Same as `draw_current_tile_direct` but draws only into Target. + /// Useful during interactive transforms to reduce extra GPU work. + pub fn draw_current_tile_direct_target_only( + &mut self, + tile_rect: &skia::Rect, + color: skia::Color, + ) { + let sampling_options = self.sampling_options; + let src_rect = IRect::from_xywh( + self.margins.width, + self.margins.height, + self.current.width() - TILE_SIZE_MULTIPLIER * self.margins.width, + self.current.height() - TILE_SIZE_MULTIPLIER * self.margins.height, + ); + let src_rect_f = skia::Rect::from(src_rect); + + let mut paint = skia::Paint::default(); + paint.set_color(color); + self.target.canvas().draw_rect(tile_rect, &paint); + + self.current.clone().draw( + self.target.canvas(), + ( + tile_rect.left - src_rect_f.left, + tile_rect.top - src_rect_f.top, + ), + sampling_options, + None, + ); + } + /// Full cache reset: clears both the tile texture cache and the cache canvas. /// Used by `rebuild_tiles` (full rebuild). For shallow rebuilds that preserve /// the cache canvas for scaled previews, use `invalidate_tile_cache` instead. diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index 003ca52fdb..432cfeb8ca 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -265,11 +265,20 @@ impl PendingTiles { result } - pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces) { + pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces, only_visible: bool) { self.list.clear(); - // Generate spiral for the interest area (viewport + margin) - let spiral = Self::generate_spiral(&tile_viewbox.interest_rect); + // During interactive transform, skip the interest-area ring + // entirely — the user is dragging, every rAF is on the critical + // path, and pre-rendering tiles outside the viewport is wasted + // work that just gets evicted on the next pointer move. The ring + // is repopulated naturally on gesture end / on idle rAFs. + let spiral_rect = if only_visible { + &tile_viewbox.visible_rect + } else { + &tile_viewbox.interest_rect + }; + let spiral = Self::generate_spiral(spiral_rect); // Partition tiles into 4 priority groups (highest priority = processed last due to pop()): // 1. visible + cached (fastest - just blit from cache)