🎉 Layered drag

This commit is contained in:
Alejandro Alonso 2026-04-21 09:50:24 +02:00
parent 0b49c1f3e9
commit 2047aa8cb5
4 changed files with 369 additions and 0 deletions

View File

@ -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)?;
});

View File

@ -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<Matrix>)> = 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 {

View File

@ -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<DragLayer>,
/// 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()
}
}

View File

@ -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<Uuid, skia::Matrix>) {
// Convert HashMap<Uuid, V> to HashMap<usize, V> using indices
// Initialize the cache cells for affected shapes