From 2a7c8dea4200033079b039a490a3c64ddaf071da Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 21 Apr 2026 10:24:00 +0200 Subject: [PATCH] WIP --- render-wasm/src/main.rs | 2 +- render-wasm/src/render.rs | 345 +++++++++++++++++++------- render-wasm/src/render/drag_layers.rs | 37 ++- render-wasm/src/state/shapes_pool.rs | 58 ++++- 4 files changed, 354 insertions(+), 88 deletions(-) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 2ca201da1a..6dd996c877 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -946,7 +946,7 @@ pub extern "C" fn set_modifiers() -> Result<()> { performance::begin_measure!("prepare_drag_layers"); state .render_state - .prepare_drag_layers(&ids, &state.shapes)?; + .prepare_drag_layers(&ids, &mut state.shapes)?; performance::end_measure!("prepare_drag_layers"); } diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 52642afc46..48cee6b4bd 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -66,6 +66,41 @@ pub struct NodeRenderState { flattened: bool, } +/// Mark every shape that satisfies `predicate` as hidden and return the +/// list of uuids actually toggled so the caller can restore their previous +/// state with `restore_hidden`. Shapes that were already hidden are left +/// alone and not included in the returned list. +fn hide_shapes_with( + tree: ShapesPoolMutRef, + predicate: impl Fn(&Shape) -> bool, +) -> Vec { + let candidates: Vec = tree.all_ids(); + let mut toggled = Vec::new(); + for id in candidates { + let Some(shape) = tree.get_mut(&id) else { + continue; + }; + if shape.hidden { + continue; + } + if predicate(&*shape) { + shape.hidden = true; + toggled.push(id); + } + } + toggled +} + +/// Reverses a previous `hide_shapes_with` by clearing the `hidden` flag on +/// exactly the shapes that call returned. +fn restore_hidden(tree: ShapesPoolMutRef, toggled: &[Uuid]) { + for id in toggled { + if let Some(shape) = tree.get_mut(id) { + shape.hidden = false; + } + } +} + /// Get simplified children of a container, flattening nested flattened containers fn get_simplified_children<'a>(tree: ShapesPoolRef<'a>, shape: &'a Shape) -> Vec { let mut result = Vec::new(); @@ -1937,22 +1972,30 @@ 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. + /// Rasterize the snapshots used by the SVG-style layered drag path: /// - /// 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. + /// * one `DragLayer` image per selected shape, + /// * a `backdrop` image of the viewport with the selected shapes + /// hidden, + /// * an `overlay` image that only contains the shapes that sit above + /// the selection in the z-order (captured on transparent). /// - /// 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. + /// 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 tree is borrowed mutably because capturing the backdrop / overlay + /// requires toggling `Shape::hidden` on a subset of shapes. The flags + /// are restored before this function returns. pub fn prepare_drag_layers( &mut self, ids: &[Uuid], - tree: ShapesPoolRef, + tree: ShapesPoolMutRef, ) -> Result<()> { self.drag_layers.clear(); if ids.is_empty() { @@ -1973,13 +2016,100 @@ impl RenderState { 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. + // Disable focus mode while rasterizing so that a pre-existing focus + // filter from the workspace cannot hide the shapes we are trying to + // snapshot. self.focus_mode.clear(); - let target_surface = SurfaceId::Export; + // Per-shape isolated snapshots. Use an immutable reborrow so the + // tree is only accessed through `get` for this block; bulk hidden + // toggles come later and require the mutable borrow back. + { + let tree_ref: ShapesPoolRef = tree; + self.capture_drag_layer_images(ids, tree_ref, scale)?; + } + // Backdrop + overlay are viewport-sized snapshots. They share the + // same destination rect — the viewbox area in document space — and + // are sampled 1:1 at `scale`. + let viewport_rect = self.viewbox.area; + let selected_set: HashSet = ids.iter().copied().collect(); + + // --- Backdrop: whole scene with the selected shapes hidden -------- + let hidden_backdrop = hide_shapes_with(tree, |shape| selected_set.contains(&shape.id)); + { + let tree_ref: ShapesPoolRef = tree; + let backdrop = self.capture_viewport_snapshot( + tree_ref, + viewport_rect, + scale, + skia::Color::TRANSPARENT, + )?; + self.drag_layers.backdrop = Some(backdrop); + } + restore_hidden(tree, &hidden_backdrop); + + // --- Overlay: only the shapes above the selection ---------------- + // The frontend always sees at least the selection itself above the + // backdrop, so we only bother capturing an overlay when there is + // something *else* stacked on top. + let above_ids = tree.collect_above_of(&selected_set); + if !above_ids.is_empty() { + let hidden_overlay = + hide_shapes_with(tree, |shape| !above_ids.contains(&shape.id)); + { + let tree_ref: ShapesPoolRef = tree; + let overlay = self.capture_viewport_snapshot( + tree_ref, + viewport_rect, + scale, + skia::Color::TRANSPARENT, + )?; + self.drag_layers.overlay = Some(overlay); + } + restore_hidden(tree, &hidden_overlay); + } + + self.drag_layers.viewport_rect = viewport_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(()) + } + + /// Rasterize every id in `ids` into its own `DragLayer` image, sized + /// tightly around the shape's extrect. Extracted from + /// `prepare_drag_layers` so the borrow of `tree` stays immutable for + /// the duration of the loop. + fn capture_drag_layer_images( + &mut self, + ids: &[Uuid], + tree: ShapesPoolRef, + scale: f32, + ) -> Result<()> { + let target_surface = SurfaceId::Export; for id in ids { let Some(shape) = tree.get(id) else { continue; @@ -2027,32 +2157,68 @@ impl RenderState { source_doc_rect, }); } + Ok(()) + } - // 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); + /// Render the workspace viewport to the Export surface and return its + /// snapshot. Assumes `tree` already has the intended `hidden` flags set + /// by the caller; this method only drives the render pipeline and does + /// not mutate shape state. + fn capture_viewport_snapshot( + &mut self, + tree: ShapesPoolRef, + viewport_rect: Rect, + scale: f32, + clear_color: skia::Color, + ) -> Result { + if viewport_rect.is_empty() { + return Err(Error::CriticalError( + "Cannot capture viewport snapshot on empty rect".to_string(), + )); } - self.drag_layers.active = true; - Ok(()) + let target_surface = SurfaceId::Export; + let mut extrect = viewport_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(clear_color); + + // Seed the traversal with every direct child of the invisible root + // shape. Mirrors what `render_shape_tree_partial` does for the + // workspace render (it pushes root children, not the root itself). + self.pending_nodes.clear(); + let Some(root) = tree.get(&Uuid::nil()) else { + return Err(Error::CriticalError( + "Root shape not found while capturing viewport snapshot".to_string(), + )); + }; + let root_children = root.children_ids(false); + self.pending_nodes.extend(root_children.into_iter().map(|id| { + NodeRenderState { + 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); + + Ok(self.surfaces.snapshot(target_surface)) } /// Release any snapshots created by `prepare_drag_layers`. @@ -2061,48 +2227,41 @@ impl RenderState { } /// SVG-style layered render path used while the interactive transform is - /// active. Instead of walking the shape tree, we: + /// active. Three pre-captured images are composited on the Target each + /// frame: /// - /// 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. + /// 1. `backdrop` — the scene without the selected shapes. Replaces + /// the "atlas + ghost killer" trick: no ghost silhouette, and the + /// shapes that were originally below the selection are preserved. + /// 2. `layers` — one image per selected shape, drawn with its current + /// modifier matrix concatenated on the canvas. Because + /// `source_doc_rect` is in document space, a single + /// `draw_image_rect` per layer is enough. + /// 3. `overlay` — the shapes that sit above the selection, captured + /// on a transparent background. Drawn last so they keep appearing + /// in front of the dragged shape, matching the original z-order. /// - /// 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. + /// This is the same trick the browser uses when it composites a dragged + /// SVG node: the rasters never change during the gesture, only the + /// transform applied to them. 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; + let viewport_rect = self.drag_layers.viewport_rect; // 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 backdrop_image = self.drag_layers.backdrop.clone(); + let overlay_image = self.drag_layers.overlay.clone(); let draws: Vec<(skia::Image, Rect, Option)> = self .drag_layers .iter() @@ -2116,32 +2275,39 @@ impl RenderState { 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. + // After this, drawing at `viewport_rect` / `source_doc_rect` lands + // where the original pixels were; the modifier matrix (concat + // below) moves the selection on top of that. 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); + // Background fill: if the backdrop snapshot fails or the scene was + // empty, the viewport still reads as the workspace color instead + // of a flash of the previous frame. + let mut bg_paint = skia::Paint::default(); + bg_paint.set_color(background_color); + bg_paint.set_style(skia::PaintStyle::Fill); + bg_paint.set_anti_alias(false); + if !viewport_rect.is_empty() { + canvas.draw_rect(viewport_rect, &bg_paint); } let paint = skia::Paint::default(); + + // Step 1: backdrop. + if let Some(image) = backdrop_image { + if !viewport_rect.is_empty() { + canvas.draw_image_rect_with_sampling_options( + &image, + None, + viewport_rect, + sampling, + &paint, + ); + } + } + + // Step 2: transformed selection layers. for (image, dst, modifier) in draws { canvas.save(); if let Some(m) = modifier { @@ -2157,6 +2323,19 @@ impl RenderState { canvas.restore(); } + // Step 3: overlay (shapes that were above the selection). + if let Some(image) = overlay_image { + if !viewport_rect.is_empty() { + canvas.draw_image_rect_with_sampling_options( + &image, + None, + viewport_rect, + sampling, + &paint, + ); + } + } + canvas.restore(); // Keep parity with the normal render loop: UI overlay is drawn diff --git a/render-wasm/src/render/drag_layers.rs b/render-wasm/src/render/drag_layers.rs index 32cc62a270..f9a522a2bc 100644 --- a/render-wasm/src/render/drag_layers.rs +++ b/render-wasm/src/render/drag_layers.rs @@ -33,11 +33,36 @@ pub struct DragLayer { /// "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). +/// rendering pipeline and instead uses three pre-captured images to compose +/// every frame: +/// +/// * `backdrop` — the workspace rendered with the selected shapes hidden. +/// Sits behind everything and already has the correct pixels for the +/// "hole" left by the dragged shapes, so we don't need to erase any +/// ghost silhouette. +/// * `layers` — per-shape snapshots of the selected shapes in isolation. +/// Each one is drawn with its current modifier matrix on top of the +/// backdrop. +/// * `overlay` — the shapes that sit above the (topmost) selected shape +/// in the z-order, captured on a transparent background. Drawing it +/// last restores the original stacking: elements that were in front of +/// the dragged shape keep being in front during the gesture. +/// +/// All three images are in document space and share the viewbox rect, so +/// compositing is `canvas.draw_image_rect(_, viewbox.area)` after setting +/// the canvas to document coordinates. pub struct DragLayers { pub layers: Vec, + /// Scene snapshot without the selected shapes. Drawn first each frame. + pub backdrop: Option, + /// Snapshot of the shapes that are above the selection on a transparent + /// background. Drawn last to preserve z-order. `None` when there is + /// nothing above the selection, in which case the overlay step is + /// skipped. + pub overlay: Option, + /// Document-space rectangle both `backdrop` and `overlay` cover. Matches + /// the viewbox area at the moment `prepare` was called. + pub viewport_rect: Rect, /// 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. @@ -48,12 +73,18 @@ impl DragLayers { pub fn new() -> Self { Self { layers: Vec::new(), + backdrop: None, + overlay: None, + viewport_rect: Rect::new_empty(), active: false, } } pub fn clear(&mut self) { self.layers.clear(); + self.backdrop = None; + self.overlay = None; + self.viewport_rect = Rect::new_empty(); self.active = false; } diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index 00631597e3..db01a5df85 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::iter; use crate::performance; @@ -211,6 +211,62 @@ impl ShapesPoolImpl { self.modified_shape_cache.clear() } + /// Returns every uuid registered in the pool, in arbitrary order. + /// + /// Used by the drag-layer pipeline to toggle `Shape::hidden` in bulk + /// when rendering the backdrop / overlay snapshots. + pub fn all_ids(&self) -> Vec { + self.uuid_to_idx.keys().copied().collect() + } + + /// Collect every shape that sits visually above any of `selected` in + /// the tree-render order. Currently we approximate "above" as + /// "siblings that come after a selected shape in its parent's child + /// list, plus all of their descendants". This matches the common case + /// (dragging a shape inside a frame) and, because the overlay is just + /// an extra compositing pass, anything we miss simply degrades back to + /// the current visual — it never corrupts the atlas. + pub fn collect_above_of(&self, selected: &HashSet) -> HashSet { + let mut above: HashSet = HashSet::new(); + + for sel_id in selected { + let Some(shape) = self.get(sel_id) else { + continue; + }; + let Some(parent_id) = shape.parent_id else { + continue; + }; + let Some(parent) = self.get(&parent_id) else { + continue; + }; + + // `children` stores siblings in paint order (first = behind, + // last = in front), so anything after `sel_id` is above it. + let Some(pos) = parent.children.iter().position(|c| c == sel_id) else { + continue; + }; + + for sibling_id in parent.children.iter().skip(pos + 1) { + if selected.contains(sibling_id) { + // Another selection also participates in the drag. Its + // own layer will be composed on top, so treat it as + // "part of the selected set" rather than overlay. + continue; + } + above.insert(*sibling_id); + if let Some(sibling) = self.get(sibling_id) { + for descendant in sibling.all_children_iter(self, false, false) { + if !selected.contains(&descendant) { + above.insert(descendant); + } + } + } + } + } + + above + } + /// Returns the raw modifier matrix currently applied to `id`, if any. /// /// The `get` method above already returns a shape with modifiers baked in,