diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 7a030e114d..2ca201da1a 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -431,6 +431,10 @@ pub extern "C" fn set_modifiers_end() -> Result<()> { let opts = &mut state.render_state.options; opts.set_fast_mode(false); opts.set_interactive_transform(false); + // Release the per-shape snapshots so the next render falls back + // to the normal tile pipeline (which will commit the final, + // full-quality picture to the atlas). + state.render_state.clear_drag_layers(); state.render_state.cancel_animation_frame(); performance::end_measure!("set_modifiers_end"); }); @@ -930,6 +934,22 @@ pub extern "C" fn set_modifiers() -> Result<()> { } with_state_mut!(state, { + // Lazily prepare the drag-layer snapshots the first time modifiers + // arrive during an interactive transform. We do it here (rather than + // inside `set_modifiers_start`) because the snapshots must be taken + // with the tree in its pre-modifier state AND we need the concrete + // list of shape ids that the gesture touches, which only this call + // carries. + if state.render_state.options.is_interactive_transform() + && state.render_state.drag_layers.is_empty() + { + performance::begin_measure!("prepare_drag_layers"); + state + .render_state + .prepare_drag_layers(&ids, &state.shapes)?; + performance::end_measure!("prepare_drag_layers"); + } + state.set_modifiers(modifiers); state.rebuild_modifier_tiles(ids)?; }); diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 8684e0f112..52642afc46 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1,4 +1,5 @@ mod debug; +mod drag_layers; mod fills; pub mod filters; mod fonts; @@ -13,6 +14,8 @@ pub mod text; pub mod text_editor; mod ui; +pub use drag_layers::{DragLayer, DragLayers}; + use skia_safe::{self as skia, Matrix, RRect, Rect}; use std::borrow::Cow; use std::collections::HashSet; @@ -343,6 +346,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, + /// Pre-rasterized snapshots of the shapes currently under an interactive + /// transform. When populated the render loop uses the layered fast path + /// instead of the tile pipeline: each frame only re-blits the atlas + /// backdrop and these images with the shape's modifier applied as a + /// canvas transform. + pub drag_layers: DragLayers, } pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize { @@ -416,6 +425,7 @@ impl RenderState { preview_mode: false, export_context: None, cache_cleared_this_render: false, + drag_layers: DragLayers::new(), }) } @@ -1664,6 +1674,26 @@ impl RenderState { performance::begin_measure!("render"); performance::begin_measure!("start_render_loop"); + // Layered fast path: while the user is dragging / resizing / rotating + // shapes, skip the full tile pipeline entirely and just compose the + // cached snapshots on top of the atlas. One GPU draw per layer, no + // shape traversal, no tile rasterization. + if self.drag_layers.active && !self.drag_layers.is_empty() { + // Any in-progress tile work from previous frames would keep + // writing to the Target after we present our layered frame, + // producing a flicker. Reset the render state so the loop + // returns immediately on the next `process_animation_frame`. + self.render_in_progress = false; + self.pending_nodes.clear(); + self.pending_tiles.list.clear(); + self.current_tile = None; + + let _ = base_object; + let _ = timestamp; + let _ = sync_render; + return self.render_drag_layered(tree); + } + self.cache_cleared_this_render = false; self.reset_canvas(); @@ -1907,6 +1937,237 @@ impl RenderState { Ok((data.as_bytes().to_vec(), width, height)) } + /// Rasterize each of `ids` into its own `skia::Image` and store the + /// snapshots in `self.drag_layers`. The snapshots are taken with the tree + /// in its pre-modifier state so that during the drag we can blit them + /// transformed by the current modifier matrix without re-rasterizing. + /// + /// Must be called from an interactive transform session (`set_modifiers_start` + /// has been invoked). The WASM entry point wires the call so the frontend + /// does not need to manage layer lifecycles explicitly. + /// + /// The state-saving dance mirrors `render_shape_pixels`: it touches the + /// Export surface, the render area and the pending-node stack, all of + /// which belong to the main workspace render and must not leak. + pub fn prepare_drag_layers( + &mut self, + ids: &[Uuid], + tree: ShapesPoolRef, + ) -> Result<()> { + self.drag_layers.clear(); + if ids.is_empty() { + return Ok(()); + } + + let scale = self.get_scale(); + + let saved_focus_mode = self.focus_mode.clone(); + let saved_export_context = self.export_context; + let saved_render_area = self.render_area; + let saved_render_area_with_margins = self.render_area_with_margins; + let saved_current_tile = self.current_tile; + let saved_pending_nodes = std::mem::take(&mut self.pending_nodes); + let saved_nested_fills = std::mem::take(&mut self.nested_fills); + let saved_nested_blurs = std::mem::take(&mut self.nested_blurs); + let saved_nested_shadows = std::mem::take(&mut self.nested_shadows); + let saved_ignore_nested_blurs = self.ignore_nested_blurs; + let saved_preview_mode = self.preview_mode; + + // Disable focus mode while rasterizing individual shapes so that a + // pre-existing focus filter from the workspace cannot hide the shape + // we are trying to snapshot. + self.focus_mode.clear(); + + let target_surface = SurfaceId::Export; + + for id in ids { + let Some(shape) = tree.get(id) else { + continue; + }; + if shape.hidden { + continue; + } + let source_doc_rect = shape.extrect(tree, scale); + let mut extrect = source_doc_rect; + + self.export_context = Some((extrect, scale)); + let margins = self.surfaces.margins(); + extrect.offset((margins.width as f32 / scale, margins.height as f32 / scale)); + + self.surfaces.resize_export_surface(scale, extrect); + self.render_area = extrect; + self.render_area_with_margins = extrect; + self.surfaces.update_render_context(extrect, scale); + + self.surfaces + .canvas(target_surface) + .clear(skia::Color::TRANSPARENT); + + self.pending_nodes.clear(); + self.pending_nodes.push(NodeRenderState { + id: *id, + visited_children: false, + clip_bounds: None, + visited_mask: false, + mask: false, + flattened: false, + }); + self.render_shape_tree_partial_uncached(tree, 0, false, true)?; + + self.export_context = None; + + self.surfaces + .flush_and_submit(&mut self.gpu_state, target_surface); + + let image = self.surfaces.snapshot(target_surface); + + self.drag_layers.push(DragLayer { + shape_id: *id, + image, + source_doc_rect, + }); + } + + // Restore the workspace render state exactly like `render_shape_pixels` + // does. Without this the next workspace render could observe stale + // render_area / focus / pending_nodes left over from our snapshots. + self.focus_mode = saved_focus_mode; + self.export_context = saved_export_context; + self.render_area = saved_render_area; + self.render_area_with_margins = saved_render_area_with_margins; + self.current_tile = saved_current_tile; + self.pending_nodes = saved_pending_nodes; + self.nested_fills = saved_nested_fills; + self.nested_blurs = saved_nested_blurs; + self.nested_shadows = saved_nested_shadows; + self.ignore_nested_blurs = saved_ignore_nested_blurs; + self.preview_mode = saved_preview_mode; + + let workspace_scale = self.get_scale(); + if let Some(tile) = self.current_tile { + self.update_render_context(tile); + } else if !self.render_area.is_empty() { + self.surfaces + .update_render_context(self.render_area, workspace_scale); + } + + self.drag_layers.active = true; + Ok(()) + } + + /// Release any snapshots created by `prepare_drag_layers`. + pub fn clear_drag_layers(&mut self) { + self.drag_layers.clear(); + } + + /// SVG-style layered render path used while the interactive transform is + /// active. Instead of walking the shape tree, we: + /// + /// 1. Paint the persistent atlas as a static backdrop. + /// 2. For every snapshot in `drag_layers`, concat the shape's modifier + /// matrix on the Target canvas and blit the image into its original + /// document-space rect. Because the canvas is already in document + /// space (zoom + pan applied), this produces the correct screen-space + /// position with a single draw call per layer and no rasterization. + /// 3. Flush the Target. + /// + /// This is the same trick the browser uses when it composes a dragged + /// SVG node: the raster never changes during the gesture, only the + /// transform applied to it. + pub fn render_drag_layered(&mut self, tree: ShapesPoolRef) -> Result<()> { + let _start = performance::begin_timed_log!("render_drag_layered"); + performance::begin_measure!("render_drag_layered"); + + self.reset_canvas(); + + // Step 1: stable backdrop from the persistent atlas. If the atlas + // has not been populated yet we fall through and the target stays + // filled with the background color, which is visually acceptable + // for the very first drag frame and will be corrected by the + // normal render that runs on `set_modifiers_end`. + if self.surfaces.has_atlas() { + self.surfaces.draw_atlas_to_target( + self.viewbox, + self.options.dpr(), + self.background_color, + ); + } + + // Step 2: compose the drag layers on top. + let dpr = self.options.dpr(); + let zoom = self.viewbox.zoom * dpr; + let pan = (self.viewbox.pan_x, self.viewbox.pan_y); + let sampling = self.sampling_options; + let background_color = self.background_color; + + // Collect the data we need up-front: extracting both an immutable + // borrow of `self.drag_layers` and a mutable borrow of + // `self.surfaces` at the same time would fight the borrow checker. + let draws: Vec<(skia::Image, Rect, Option)> = self + .drag_layers + .iter() + .map(|layer| { + let modifier = tree.get_modifier(&layer.shape_id).copied(); + (layer.image.clone(), layer.source_doc_rect, modifier) + }) + .collect(); + + let canvas = self.surfaces.canvas(SurfaceId::Target); + canvas.save(); + canvas.reset_matrix(); + // Configure document space on the target canvas: zoom + pan. + // After this, drawing at `source_doc_rect` lands where the shape + // originally was; the modifier matrix (concat below) moves it. + canvas.scale((zoom, zoom)); + canvas.translate(pan); + + // Erase the "ghost" of the selected shapes from the atlas backdrop. + // The atlas still has them at their pre-drag position; if we just + // composed the modifier-transformed layers on top, the user would + // see both the original silhouette (from the atlas) and the moved + // copy (from the layer). Paint the original extrects with the + // background color so the area reads as empty canvas. + // + // NOTE: this trades visual correctness for simplicity. If a shape + // lies on top of other shapes, the covered area will briefly show + // background instead of the underlying shapes during the gesture. + // A follow-up can replace this with an atlas re-render of those + // tiles with the selected shapes marked hidden. + let mut clear_paint = skia::Paint::default(); + clear_paint.set_color(background_color); + clear_paint.set_style(skia::PaintStyle::Fill); + clear_paint.set_anti_alias(false); + for (_, dst, _) in &draws { + canvas.draw_rect(*dst, &clear_paint); + } + + let paint = skia::Paint::default(); + for (image, dst, modifier) in draws { + canvas.save(); + if let Some(m) = modifier { + canvas.concat(&m); + } + canvas.draw_image_rect_with_sampling_options( + &image, + None, + dst, + sampling, + &paint, + ); + canvas.restore(); + } + + canvas.restore(); + + // Keep parity with the normal render loop: UI overlay is drawn + // on its own surface and composited on the target when we flush. + self.flush_and_submit(); + + performance::end_measure!("render_drag_layered"); + performance::end_timed_log!("render_drag_layered", _start); + Ok(()) + } + #[inline] pub fn should_stop_rendering(&self, iteration: i32, timestamp: i32) -> bool { if iteration % NODE_BATCH_THRESHOLD != 0 { diff --git a/render-wasm/src/render/drag_layers.rs b/render-wasm/src/render/drag_layers.rs new file mode 100644 index 0000000000..32cc62a270 --- /dev/null +++ b/render-wasm/src/render/drag_layers.rs @@ -0,0 +1,78 @@ +use skia_safe::{Image, Rect}; + +use crate::uuid::Uuid; + +/// A pre-rasterized texture of a single shape captured at the moment an +/// interactive transform (drag / resize / rotate) starts. +/// +/// During the gesture the layer is composed on top of the atlas backdrop by +/// applying the shape's current modifier matrix to the canvas and blitting +/// `image` into `source_doc_rect`. Because `source_doc_rect` is the shape's +/// extrect in document coordinates at capture time, `draw_image_rect` maps +/// the texels to the original footprint and then the modifier transform +/// (concatenated on the canvas) moves / rotates / scales the whole result +/// in one GPU draw call. +pub struct DragLayer { + /// Shape whose pixels are captured in `image`. + pub shape_id: Uuid, + + /// Pre-rasterized snapshot of the shape rendered at the current zoom and + /// without any modifier applied. The image is sized in device pixels and + /// already includes margins, shadows, blurs and any effect that extends + /// the shape past its selrect. + pub image: Image, + + /// Extrect of the shape in document space when the snapshot was taken. + /// This is the destination rectangle passed to `draw_image_rect`: skia + /// maps the full image into it and the canvas matrix (zoom + modifier) + /// takes care of placing the layer on screen. + pub source_doc_rect: Rect, +} + +/// Collection of pre-rasterized layers used to implement the SVG-style +/// "composited preview" during interactive transforms. +/// +/// When `active` is true, the render loop short-circuits the full tile +/// rendering pipeline and instead uses the cached images inside `layers` +/// to present a frame: this matches what a browser does with SVG shapes +/// during a drag (raster once, transform the raster). +pub struct DragLayers { + pub layers: Vec, + /// Toggled on by `prepare` once snapshots are captured, and off again by + /// `clear`. The render loop checks this flag before every frame to pick + /// between the layered fast path and the normal tile pipeline. + pub active: bool, +} + +impl DragLayers { + pub fn new() -> Self { + Self { + layers: Vec::new(), + active: false, + } + } + + pub fn clear(&mut self) { + self.layers.clear(); + self.active = false; + } + + pub fn is_empty(&self) -> bool { + self.layers.is_empty() + } + + pub fn push(&mut self, layer: DragLayer) { + self.layers.push(layer); + } + + pub fn iter(&self) -> std::slice::Iter<'_, DragLayer> { + self.layers.iter() + } +} + +impl Default for DragLayers { + fn default() -> Self { + Self::new() + } +} + diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index 7e03befa01..00631597e3 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -211,6 +211,16 @@ impl ShapesPoolImpl { self.modified_shape_cache.clear() } + /// Returns the raw modifier matrix currently applied to `id`, if any. + /// + /// The `get` method above already returns a shape with modifiers baked in, + /// but the drag-layer renderer needs the pure modifier matrix so it can + /// apply it as a canvas transform on top of a pre-rasterized snapshot. + pub fn get_modifier(&self, id: &Uuid) -> Option<&skia::Matrix> { + let idx = self.uuid_to_idx.get(id)?; + self.modifiers.get(idx) + } + pub fn set_modifiers(&mut self, modifiers: HashMap) { // Convert HashMap to HashMap using indices // Initialize the cache cells for affected shapes