diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index ca3d2b10b8..3a3311747c 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -457,6 +457,7 @@ pub extern "C" fn set_modifiers_start() -> Result<()> { opts.set_fast_mode(true); opts.set_interactive_transform(true); state.render_state.clear_picture_cache(); + state.render_state.clear_texture_cache(); performance::end_measure!("set_modifiers_start"); }); Ok(()) @@ -475,6 +476,7 @@ pub extern "C" fn set_modifiers_end() -> Result<()> { opts.set_fast_mode(false); opts.set_interactive_transform(false); state.render_state.clear_picture_cache(); + state.render_state.clear_texture_cache(); state.render_state.cancel_animation_frame(); performance::end_measure!("set_modifiers_end"); }); @@ -975,7 +977,16 @@ pub extern "C" fn set_modifiers() -> Result<()> { with_state_mut!(state, { state.set_modifiers(modifiers); - state.rebuild_modifier_tiles(ids)?; + // During interactive transforms (drag/resize/rotate) `set_modifiers` can be called + // every frame. Rebuilding tiles here invalidates cached tiles continuously and can + // dominate frame time even for very simple scenes. + // + // Instead, keep the tile cache stable during the gesture and let the drag fast-path + // (overlay/texture/picture) handle the moving visuals. A final full-quality render + // after `set_modifiers_end` + commit will rebuild tiles as needed. + 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 e09ab4fcd0..0dc2c042c1 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -337,6 +337,16 @@ pub(crate) struct RenderState { /// Per-gesture cache of recorded draw commands for leaf shapes. /// Used to speed up interactive transforms by replaying commands with a modifier matrix. picture_cache: HashMap, + /// Per-gesture cache of rasterized leaf shapes for interactive transforms. + /// Capture once, then blit each frame with the modifier matrix applied. + texture_cache: HashMap, +} + +#[derive(Clone)] +struct CachedTexture { + image: skia::Image, + source_doc_rect: skia::Rect, + captured_scale: f32, } pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize { @@ -411,6 +421,7 @@ impl RenderState { export_context: None, cache_cleared_this_render: false, picture_cache: HashMap::new(), + texture_cache: HashMap::new(), }) } @@ -418,6 +429,10 @@ impl RenderState { self.picture_cache.clear(); } + pub fn clear_texture_cache(&mut self) { + self.texture_cache.clear(); + } + /// Combines every visible layer blur currently active (ancestors + shape) /// into a single equivalent blur. Layer blur radii compound by adding their /// variances (σ² = radius²), so we: @@ -1689,7 +1704,6 @@ impl RenderState { // 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 @@ -1700,7 +1714,12 @@ impl RenderState { // 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() { + if self.options.is_interactive_transform() + && self.surfaces.has_atlas() + // Drawing the whole atlas every rAF is expensive. Only do it when we expect + // visible uncached tiles (otherwise we already have stable content in cached tiles). + && self.pending_tiles.has_visible_uncached(&self.tile_viewbox, &self.surfaces) + { self.surfaces.draw_atlas_to_target( self.viewbox, self.options.dpr(), @@ -1747,6 +1766,103 @@ impl RenderState { performance::end_measure!("tile_cache"); performance::end_timed_log!("tile_cache_update", _tile_start); + // Fast-path for interactive transforms: + // If every visible tile is already cached, avoid the tile-walker entirely and just + // present a stable backdrop plus the transformed selection overlay. + // + // This prevents per-frame blits of dozens of cached tiles (and other walker overhead), + // which can tank FPS even in simple documents. + if self.options.is_interactive_transform() + && self.surfaces.has_atlas() + && !self.pending_tiles.has_visible_uncached(&self.tile_viewbox, &self.surfaces) + { + // Backdrop. + self.surfaces.draw_atlas_to_target( + self.viewbox, + self.options.dpr(), + self.background_color, + ); + + // Overlay: currently only the "simple rect + solid fill" texture path. + let mut ok = true; + let modifier_ids = tree.modifier_ids(); + for id in modifier_ids.iter() { + let (Some(modifier), Some(base_shape)) = (tree.get_modifier(id), tree.get_base(id)) + else { + continue; + }; + + let eligible = !base_shape.is_recursive() + && base_shape.transform.is_identity() + && base_shape.opacity() == 1.0 + && base_shape.shadows.is_empty() + && base_shape.blur.is_none() + && base_shape.strokes.is_empty() + && matches!(base_shape.shape_type, Type::Rect(_)) + && base_shape.fills.len() == 1 + && matches!(base_shape.fills[0], Fill::Solid(_)); + if !eligible { + ok = false; + break; + } + + let antialias = base_shape.should_use_antialias( + self.get_scale(), + self.options.antialias_threshold, + ); + let Some(tex) = self.get_or_capture_simple_rect_texture( + id, + base_shape, + &base_shape.fills[0], + antialias, + ) else { + ok = false; + break; + }; + + let scale = self.get_scale(); + let translation = self + .surfaces + .get_render_context_translation(self.render_area, scale); + let dst = tex.source_doc_rect; + let image = tex.image.clone(); + let src = skia::Rect::from_xywh( + 0.0, + 0.0, + image.width() as f32, + image.height() as f32, + ); + let sampling = self.sampling_options; + self.surfaces.apply_mut(SurfaceId::Target as u32, |s| { + let canvas = s.canvas(); + canvas.save(); + canvas.scale((scale, scale)); + canvas.translate(translation); + canvas.concat(modifier); + canvas.draw_image_rect_with_sampling_options( + &image, + Some((&src, skia::canvas::SrcRectConstraint::Strict)), + dst, + sampling, + &skia::Paint::default(), + ); + canvas.restore(); + }); + } + + if ok { + if self.options.is_debug_visible() { + debug::render(self); + } + ui::render(self, tree); + debug::render_wasm_label(self); + self.flush_and_submit(); + performance::end_measure!("start_render_loop"); + performance::end_timed_log!("start_render_loop", _start); + return Ok(()); + } + } + self.pending_nodes.clear(); if self.pending_nodes.capacity() < tree.len() { self.pending_nodes @@ -1811,6 +1927,49 @@ impl RenderState { Some(picture) } + fn get_or_capture_simple_rect_texture( + &mut self, + shape_id: &Uuid, + base_shape: &Shape, + fill: &Fill, + antialias: bool, + ) -> Option { + if let Some(entry) = self.texture_cache.get(shape_id) { + + return Some(entry.clone()); + } + + let scale = self.get_scale(); + let bounds = base_shape.selrect(); + if bounds.is_empty() || scale <= 0.0 { + return None; + } + + // Render into Export surface in pixel space, drawing in document coords scaled by `scale`. + self.surfaces.resize_export_surface(scale, bounds); + let canvas = self.surfaces.canvas(SurfaceId::Export); + canvas.clear(skia::Color::TRANSPARENT); + canvas.save(); + canvas.scale((scale, scale)); + canvas.translate((-bounds.left, -bounds.top)); + let paint = fill.to_paint(&bounds, antialias); + canvas.draw_rect(bounds, &paint); + canvas.restore(); + + self.surfaces + .flush_and_submit(&mut self.gpu_state, SurfaceId::Export); + let image = self.surfaces.snapshot(SurfaceId::Export); + + let entry = CachedTexture { + image, + source_doc_rect: bounds, + captured_scale: scale, + }; + self.texture_cache.insert(*shape_id, entry.clone()); + + Some(entry) + } + fn compute_document_bounds( &mut self, base_object: Option<&Uuid>, @@ -2008,18 +2167,6 @@ impl RenderState { return false; } - // During interactive shape transforms we must complete every - // visible tile in a single rAF so the user never sees tiles - // popping in sequentially. Only yield once all visible work is - // done and we are processing the interest-area pre-render. - if self.options.is_interactive_transform() { - if let Some(tile) = self.current_tile { - if self.tile_viewbox.is_visible(&tile) { - return false; - } - } - } - true } @@ -2816,9 +2963,9 @@ impl RenderState { .draw_into(SurfaceId::DropShadows, target_surface, None); } - // Interactive-transform fast path: replay a recorded Picture for very simple - // leaf rects, applying only the modifier matrix per frame. - let mut rendered_via_picture = false; + // Interactive-transform fast path: replay a recorded Picture or blit a cached + // texture for very simple leaf rects, applying only the modifier matrix per frame. + let mut rendered_fast = false; if self.options.is_interactive_transform() && !element.is_recursive() && clip_bounds.is_none() @@ -2843,13 +2990,50 @@ impl RenderState { self.get_scale(), self.options.antialias_threshold, ); - if let Some(picture) = self.get_or_record_simple_rect_picture( + + // Prefer texture blit (raster) over picture replay during drag. + if let Some(tex) = self.get_or_capture_simple_rect_texture( &node_id, base_shape, &base_shape.fills[0], antialias, ) { - println!("draw picture"); + let scale = self.get_scale(); + let translation = self + .surfaces + .get_render_context_translation(self.render_area, scale); + let dst = tex.source_doc_rect; + let image = tex.image.clone(); + let src = skia::Rect::from_xywh( + 0.0, + 0.0, + image.width() as f32, + image.height() as f32, + ); + let sampling = self.sampling_options; + self.surfaces.apply_mut(target_surface as u32, |s| { + let canvas = s.canvas(); + canvas.save(); + canvas.scale((scale, scale)); + canvas.translate(translation); + canvas.concat(modifier); + canvas.draw_image_rect_with_sampling_options( + &image, + Some((&src, skia::canvas::SrcRectConstraint::Strict)), + dst, + sampling, + &skia::Paint::default(), + ); + canvas.restore(); + }); + rendered_fast = true; + } else if let Some(picture) = self.get_or_record_simple_rect_picture( + &node_id, + base_shape, + &base_shape.fills[0], + antialias, + ) { + let picture = picture.clone(); let scale = self.get_scale(); let translation = self @@ -2864,14 +3048,14 @@ impl RenderState { canvas.draw_picture(&picture, None, None); canvas.restore(); }); - rendered_via_picture = true; + rendered_fast = true; } } } } - if !rendered_via_picture { - println!("render_shape"); + if !rendered_fast { + self.render_shape( element, clip_bounds.clone(), diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index 63b849b2a9..0ecc932733 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -191,6 +191,17 @@ impl ShapesPoolImpl { self.modifiers.get(&idx) } + /// Returns the list of UUIDs that currently have a modifier applied. + pub fn modifier_ids(&self) -> Vec { + if self.modifiers.is_empty() { + return Vec::new(); + } + self.uuid_to_idx + .iter() + .filter_map(|(uuid, idx)| self.modifiers.contains_key(idx).then_some(*uuid)) + .collect() + } + // Given an id, returns the depth in the tree-shaped structure // of shapes. pub fn get_depth(&self, id: &Uuid) -> usize { diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index 13ed4c1aeb..f1cee11d7e 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -297,6 +297,12 @@ impl PendingTiles { self.list.extend(visible_cached); } + pub fn has_visible_uncached(&self, tile_viewbox: &TileViewbox, surfaces: &Surfaces) -> bool { + self.list.iter().any(|tile| { + tile_viewbox.visible_rect.contains(tile) && !surfaces.has_cached_tile_surface(*tile) + }) + } + pub fn pop(&mut self) -> Option { self.list.pop() }