From ca1e1dd806e043497bfc4079c60e0077eaf2cf3f Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Mon, 27 Apr 2026 18:18:22 +0200 Subject: [PATCH] :zap: Improve drag performance --- render-wasm/DRAG_PERF_ANALYSIS.md | 221 +++++++++ render-wasm/src/main.rs | 93 +++- render-wasm/src/render.rs | 478 ++++++++++++------- render-wasm/src/render/drag_sprite.rs | 649 ++++++++++++++++++++++++++ render-wasm/src/render/options.rs | 23 + render-wasm/src/render/surfaces.rs | 45 +- render-wasm/src/shapes.rs | 7 + render-wasm/src/shapes/fills.rs | 20 +- 8 files changed, 1346 insertions(+), 190 deletions(-) create mode 100644 render-wasm/DRAG_PERF_ANALYSIS.md create mode 100644 render-wasm/src/render/drag_sprite.rs diff --git a/render-wasm/DRAG_PERF_ANALYSIS.md b/render-wasm/DRAG_PERF_ANALYSIS.md new file mode 100644 index 0000000000..2c74dc862f --- /dev/null +++ b/render-wasm/DRAG_PERF_ANALYSIS.md @@ -0,0 +1,221 @@ +# Drag performance analysis — branch `superalex-improve-modifiers-flickering` + +Date: 2026-04-24 +Scope: per-rAF cost of `set_modifiers` → `start_render_loop` when dragging +a shape inside a dense frame (~1400 shapes in one 1:1 atlas tile). + +## TL;DR + +- One change kept in this branch: in `rebuild_modifier_tiles`, skip + `all_with_ancestors` during `is_interactive_transform()`. This removes a + wide ancestor-driven tile eviction that was triggering full tile + re-renders on every rAF. +- This helped for dragged shapes that do **not** themselves cover the + dense tile. For the user's test case (dragged shape's own bbox covers + tile (0,0) with ~1400 siblings), frames are still 400–580 ms. +- Remaining cost is structural: ~0.3 ms × ~1400 Skia draws per tile = + ~400 ms to render the one tile that holds the dense cluster. The + anti-flicker guard (render.rs:2043) forces the whole tile to complete + in a single rAF during interactive transform, so the cost can't be + amortised across frames without a visible regression. +- Options A (relax the anti-flicker guard), B/C (clip-based dirty-rect + narrowing), and drag-sprite resurrection all fail for this shape. + Remaining candidates are scroll-blit and sprite-of-dragged-shape, + both carry risk. + +## The one change kept + +`render-wasm/src/render.rs` — `rebuild_modifier_tiles`: + +```rust +pub fn rebuild_modifier_tiles( + &mut self, + tree: ShapesPoolMutRef<'_>, + ids: Vec, +) -> Result<()> { + 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(()) +} +``` + +### Why this helps + +`all_with_ancestors` walks up from the dragged shape to the root. For a +shape inside a frame, that pulls in the frame itself. `update_shape_tiles` +on the frame computes the union of the frame's old and new extrects and +calls `remove_cached_tile_surface` on every atlas tile the frame covers — +which, for a frame that covers the whole viewport, is *every* visible +tile, including dense ones with hundreds of non-dragged siblings. + +The anti-flicker guard then forces those tiles to complete in the same +rAF, so a 1 ms per-tile cost balloons into hundreds of tiles × their +individual shape draw costs. + +Skipping the ancestor walk during interactive transform keeps eviction +bounded to the dragged shape's own tiles. Ancestor *extrect* caches are +still invalidated separately by `ShapesPool::set_modifiers` +(`state/shapes_pool.rs:214` — `all_with_ancestors(..., true)` on +`modified_shape_cache`), so layout metadata is correct. Tile-index +reconciliation happens post-gesture via `rebuild_touched_tiles` when the +commit path runs. + +### Where this is not enough + +The dragged shape's own bbox still evicts every tile it covers. If that +shape is, say, a 3000×1000 rectangle sitting on top of a cluster of 1400 +small siblings inside tile (0,0), the dragged shape's extrect is tile +(0,0), so `update_shape_tiles(dragged)` evicts (0,0) on every rAF. The +next render of (0,0) re-draws all ~1400 siblings from scratch. That's +the 400 ms. + +## Structural cost ceiling + +Per traces captured before cleanup: + +``` +SLOW_TILE (0,0) 376ms shapes=1319 +SLOW_TILE (0,0) 403ms shapes=1424 +SLOW_TILE (0,0) 583ms shapes=1426 +``` + +Arithmetic: ~0.3 ms per Skia draw × shape count in the tile ≈ the slow +frame time. Tile cost scales linearly with tile density. There is no +algorithmic reduction available as long as the full tile must be +re-rasterised from a cold atlas. + +### Anti-flicker guard + +`render.rs:2043` (commit `98c8bb1746`): during interactive transform, +`start_render_loop` iterates until all visible tiles complete in the +same rAF, bypassing the normal per-rAF budget. This exists because +`apply_render_to_final_canvas` is atomic — it only publishes to the +Target surface on full tile completion, not on EARLY_RETURN. A tile +left half-drawn is invisible; a tile evicted mid-gesture with no +replacement renders as background colour. + +Removing or loosening the guard makes the dragged shape appear frozen +at its old position for however many rAFs the evicted tile needs to +complete. At 28 ms per rAF chunk and 15 chunks that's ~400 ms of +visual freeze — the same total cost, just visible. + +## Why the obvious workarounds don't apply + +### Option A — relax the guard, spread cost across rAFs + +Would seem to amortise the tile re-render. Fails because the atomic +publish semantics mean Target doesn't update until the tile completes. +The dragged shape would visually freeze for the duration. Same total +cost, worse UX. + +### Option B/C — clip-based dirty-rect re-rendering of the tile + +The idea: instead of re-rasterising the whole tile, clip to the union +of `old_bbox ∪ new_bbox` and only re-render shapes intersecting the +dirty rect. Fails for translation of a wide shape: the union is +approximately the whole shape bbox, and in the user's test case the +shape bbox already covers the whole tile — so every sibling still +intersects and still draws. The clip doesn't narrow the work. + +Clip-based narrowing is only beneficial when the dragged shape is +small relative to the tile. In that case the task-17 change already +suffices (ancestor eviction skipped, only the shape's own tiles are +touched, and the shape's tiles are small enough that full re-render +is cheap). + +### Option D — resurrect the drag-sprite fast path + +Documented as abandoned on 2026-04-24 in +`memory/drag-sprite-abandoned.md`. Summary: capturing the dragged +subtree into a GPU Image + rewriting the atlas with "scene minus +shape" worked in principle but was structurally fragile — every tile +eviction path (`rebuild_touched_tiles`, `with_current_shape_mut!` → +`touch_current()`, any other `remove_cached_tile_surface` caller) +would need its own "is drag active?" guard. The sprite maintains a +parallel cache the rest of the renderer doesn't know about. + +## Remaining workaround candidates + +### 1. Scroll-blit for pure translation + +If the modifier is a pure translation (no rotation, no scale, no +layout reflow, no fill change), the tile's pre-drag content is still +valid for all pixels except: + +- the strip the shape is vacating (needs "scene minus shape" fill) +- the strip the shape is entering (needs old atlas + shape at new + position) + +Implementation sketch: snapshot the tile's atlas at drag start, blit +it translated by `−delta`, then redraw only the dragged shape at its +new position and redraw whatever siblings intersect the exposed +strips. + +Risk: identical correctness fragility to the drag-sprite approach — +any atlas-touching code path during the drag invalidates the +snapshot. Plus it only handles pure translation; the moment the user +triggers a rotation or a layout change the fast path falls off. + +### 2. Sprite-of-dragged-shape only (no atlas rewrite) + +Capture just the dragged shape into a GPU Image at drag start. +Per rAF: re-render the tile normally from the atlas (shape drawn +at old position — which is still correct in the atlas since we +didn't evict it), then paint the sprite on top at the +modifier-transformed position, with the shape's atlas contribution +somehow masked out. + +Problem: masking out the shape's own contribution to the atlas +without a separate "scene minus shape" capture is hard. If the +shape has transparency, strokes that extend beyond fills, blend +modes, or effects, there's no clean mask. You'd end up with the +shape painted twice — once at the old position (from the atlas) +and once at the new (sprite). + +Same fragility as option D for any path that evicts the shape's +tiles from the atlas mid-drag. + +### 3. Deferred render of non-dragged shapes + +Render the dragged shape every frame, but only re-render the +surrounding siblings every N-th frame or after pointer idle. The +dragged shape stays responsive; the backdrop updates at a lower +rate. Visual artifact: siblings that overlap the dragged shape +will appear stale (stacking-order glitches). + +Acceptable UX if the overlap is small, ugly if the shape is being +dragged across a dense cluster. + +## Measurement hooks preserved + +None in the current tree. The `performance::measure!("set_modifiers")` +wrapper referenced in `memory/drag-sprite-abandoned.md` was also +removed during cleanup. If re-adding instrumentation, the key stages +to time are: + +- `set_modifiers` whole-function (main.rs, inside `with_state_mut!`) +- `rebuild_modifier_tiles` (render.rs:3375) +- per-tile cost inside the `start_render_loop` tile walk (render.rs, + search for `render_shape_tree_partial` and the tile iteration) +- `apply_render_to_final_canvas` (only fires on full-tile completion) + +The dominant cost for this test case is the per-tile shape walk. +`rebuild_modifier_tiles` is sub-millisecond after task 17. + +## Files + +- `render-wasm/src/render.rs:3375` — the kept change + (`rebuild_modifier_tiles` branching on `is_interactive_transform()`) +- `render-wasm/src/render.rs:2043` — anti-flicker guard + (single-rAF completion during interactive transform) +- `render-wasm/src/render.rs:1716` — atlas backdrop composition during + interactive transform +- `render-wasm/src/state/shapes_pool.rs:214` — `ShapesPool::set_modifiers` + invalidates ancestor extrect caches (independent of the render-state + walk, so task 17 doesn't break layout metadata) +- `render-wasm/src/main.rs` — `set_modifiers` / `set_modifiers_end` (now + back to a clean minimum after drag-sprite removal) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index ee53f8a98c..a6f1f67608 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -223,6 +223,7 @@ pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn render(_: i32) -> Result<()> { + let dx_t = crate::get_now!(); with_state_mut!(state, { state.rebuild_touched_tiles(); // Drain the throttled modifier-tile invalidation accumulated @@ -230,7 +231,15 @@ pub extern "C" fn render(_: i32) -> Result<()> { // 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() { + // Skip mid-drag tile invalidation when the drag-sprite fast + // path is active: rebuild_modifier_tiles would clear atlas + // pixels under the dragged shape, wiping the captured backdrop. + // The fast-path render composites atlas + sprite directly; on + // commit, set_modifiers_end clears the sprite and the next + // full render rebuilds the affected tiles. + if state.render_state.options.is_interactive_transform() + && !state.render_state.drag_sprite_is_active() + { let ids = state.shapes.modifier_ids(); if !ids.is_empty() { state.rebuild_modifier_tiles(ids)?; @@ -240,6 +249,13 @@ pub extern "C" fn render(_: i32) -> Result<()> { .start_render_loop(performance::get_time()) .map_err(|_| Error::RecoverableError("Error rendering".to_string()))?; }); + let dx_dt = crate::get_now!() - dx_t; + if dx_dt > 16.0 { + crate::run_script!(format!( + "console.log('[wasm-entry] render took {:.1}ms')", + dx_dt + )); + } Ok(()) } @@ -354,7 +370,16 @@ pub extern "C" fn render_loading_overlay() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn process_animation_frame(timestamp: i32) -> Result<()> { + let dx_t = crate::get_now!(); let result = with_state_mut!(state, { state.process_animation_frame(timestamp) }); + let dx_dt = crate::get_now!() - dx_t; + if dx_dt > 16.0 { + crate::run_script!(format!( + "console.log('[wasm-entry] process_animation_frame took {:.1}ms')", + dx_dt + )); + } + if let Err(err) = result { eprintln!("process_animation_frame error: {}", err); } @@ -404,6 +429,9 @@ pub extern "C" fn set_view_start() -> Result<()> { } performance::begin_measure!("set_view_start"); state.render_state.options.set_fast_mode(true); + // If a previous two-pass rebuild was mid-flight, discard its + // intent — the new gesture supersedes it. + state.render_state.options.set_defer_effects(false); performance::end_measure!("set_view_start"); }); Ok(()) @@ -438,6 +466,11 @@ pub extern "C" fn set_view_end() -> Result<()> { // preview of the old content while new tiles render. state.render_state.rebuild_tile_index(&state.shapes); state.render_state.surfaces.invalidate_tile_cache(); + // Start the progressive two-pass rebuild. Pass 1 renders + // tiles without blur/shadow for fast feedback; when it + // completes, process_animation_frame flips this off and + // kicks pass 2 which adds the effects back in place. + state.render_state.options.set_defer_effects(true); } else { // Pure pan at the same zoom level: tile contents have not // changed — only the viewport position moved. Update the @@ -462,8 +495,22 @@ 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"); + // Drain pending touched_ids accumulated before the gesture + // started (typically from the click/selection that initiated + // the drag). Without this, the first rAF's rebuild_touched_tiles + // would evict 1-2 tiles between sprite capture and the + // try_render_frame check, tripping the eviction-seq mismatch + // and disabling the fast path for the entire gesture. + state.rebuild_touched_tiles(); state.render_state.options.set_fast_mode(true); state.render_state.options.set_interactive_transform(true); + state.render_state.drag_sprite_reset(); + // Keep the default interest area during drag. Reducing it to 1 + // caused ghost-shape artifacts: tiles invalidated mid-drag that + // moved outside the smaller interest area never re-rendered, so + // the atlas kept stale content. Default (3) keeps the queue + // larger but ensures everything that could be invalidated also + // gets re-rendered. performance::end_measure!("set_modifiers_start"); }); Ok(()) @@ -478,8 +525,15 @@ 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"); + state.render_state.drag_sprite_clear(); state.render_state.options.set_fast_mode(false); state.render_state.options.set_interactive_transform(false); + // Drop atlas regions for every tile invalidated during the + // gesture. We kept them populated during drag (to avoid + // disappearing-tile flicker), but stale shape silhouettes + // along the drag's path would otherwise linger in tiles that + // don't immediately re-render after drop. + state.render_state.flush_drag_atlas_cleanup(); state.render_state.cancel_animation_frame(); performance::end_measure!("set_modifiers_end"); }); @@ -952,11 +1006,13 @@ 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(); - // 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() { + // Skip the tile-cache cleanup when drag-sprite is active: the + // sprite fast path doesn't read the dragged shape's tile cache + // and the eviction would wipe the captured atlas backdrop. The + // next `set_modifiers` will replace `state.shapes.modifiers` + // regardless, so the data layer stays consistent. Tiles are + // rebuilt at gesture commit (set_modifiers_end → next render). + if !prev_modifier_ids.is_empty() && !state.render_state.drag_sprite_is_active() { state .render_state .update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?; @@ -982,13 +1038,36 @@ pub extern "C" fn set_modifiers() -> Result<()> { ids.push(entry.id); } + let dx_t = crate::get_now!(); + let dx_n = ids.len(); with_state_mut!(state, { + // Phase 3: capture sprite + backdrop BEFORE applying the + // modifier, so the captured image is at the pre-drag position. + // The state machine ensures the capture only runs on the first + // event of each gesture; subsequent calls are no-ops. + if state.render_state.options.is_interactive_transform() { + let ts = performance::get_time(); + state + .render_state + .drag_sprite_try_capture(&ids, &state.shapes, ts)?; + } state.set_modifiers(modifiers); - // TO CHECK + // Throttle: skip per-pointer-move tile invalidation. The render + // entry (`render`) drains the current modifier set once per rAF + // and calls rebuild_modifier_tiles then. With ~3 pointer moves + // per rAF, this cuts tile invalidations by 3× and removes the + // PAF backlog. if !state.render_state.options.is_interactive_transform() { state.rebuild_modifier_tiles(ids)?; } }); + let dx_dt = crate::get_now!() - dx_t; + if dx_dt > 16.0 { + crate::run_script!(format!( + "console.log('[wasm-entry] set_modifiers ids={} took {:.1}ms')", + dx_n, dx_dt + )); + } Ok(()) } diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 897fb733af..a26074655b 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1,4 +1,5 @@ mod debug; +pub mod drag_sprite; mod fills; pub mod filters; mod fonts; @@ -372,6 +373,17 @@ 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, + /// One-shot flag consumed by `start_render_loop`. When set, the Cache + /// surface is NOT wiped for the upcoming pass — its current content + /// (typically a first-pass preview) stays visible while new tiles + /// overwrite it in place. Used by the progressive two-pass rebuild + /// after a zoom ends. + pub preserve_cache_this_render: bool, + // ---- Drag-perf diagnostic counters (read-only instrumentation) ---- + // Cumulative `remove_cached_tile` calls since last PAF. Reset at end + // of each `process_animation_frame`. Tells us how aggressively tiles + // are being invalidated between PAF ticks. + pub dx_inval_since_paf: u32, /// 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 @@ -379,15 +391,51 @@ 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, - /// GPU crops from `Backbuffer` keyed by shape id. Filled on full-frame completion; during - /// drag, entries for the moved top-level selection are ensured here + /// Dormant. GPU crops from `Backbuffer` keyed by shape id, populated + /// after full-quality renders by `rebuild_backbuffer_crop_cache`. The + /// active drag-sprite path uses the persistent atlas as backdrop + /// instead and doesn't consult this cache; the field stays as a + /// scaffolding hook for re-enabling Alex's caching layer. pub backbuffer_crop_cache: HashMap, + /// Diagnostic: count of `mark_touched` calls since the last + /// `rebuild_touched_tiles` drain. High counts during drag indicate + /// CLJS is firing many shape mutations per pointer move. + pub dx_touch_calls: u32, + /// Pre-drag tile coverage for each shape that has a modifier + /// applied during the current gesture. Snapshotted on the first + /// `rebuild_modifier_tiles` per shape, invalidated at gesture end + /// (`clean_modifiers`). Without this, the shape's pre-drag atlas + /// region is never cleared because the per-rAF tile-index updates + /// during drag overwrite the original position before drop ever + /// gets a chance to invalidate it. + pub dx_pre_drag_tiles: HashMap>, + /// Every tile invalidated during the current interactive_transform + /// gesture. We skip clearing the atlas region on invalidation + /// during drag (to avoid disappearing-tile flicker), but tiles + /// that move outside the reduced interest area before they + /// re-render leave stale atlas content. At gesture end we drain + /// this set and force-clear all those atlas regions. + pub dx_drag_dirty_tiles: HashSet, + /// Cumulative tile-eviction counter. Bumped by every + /// `remove_cached_tile` call. Survives across PAFs (unlike + /// `dx_inval_since_paf`), so the drag-sprite path can compare + /// "current seq" to "seq at capture time" to detect an unexpected + /// eviction mid-gesture. + pub tile_eviction_seq: u32, + /// Drag-sprite optimization state for the current gesture. See + /// `render/drag_sprite.rs`. + pub drag_sprite_state: drag_sprite::DragSpriteState, + /// Active during `capture_drag_backdrop`: the tile-render walker + /// consults this set at both root-level and child-recursion to + /// skip the dragged subtree, leaving the atlas with "scene minus + /// shape" pixels. `None` outside the brief backdrop-capture call. + pub drag_capture_exclude: Option>, } +/// Dormant scaffolding. Kept alongside `should_use_cached_top_level_during_interactive` +/// and `rebuild_backbuffer_crop_cache` for the alternative cached-crop drag +/// path. The active drag-sprite path doesn't consult these fields. +#[allow(dead_code)] pub struct InteractiveDragCrop { pub src_doc_bounds: Rect, pub src_selrect: Rect, @@ -420,6 +468,12 @@ impl RenderState { /// because other transforms would require resampling and can diverge from the live render. /// - **Other cached nodes**: if the moving bounds overlap this cached crop, invalidate it so /// we don't show stale content while something moves over/inside it. + /// + /// Currently dormant: kept as data-structure scaffolding alongside the + /// drag-sprite path, which uses the persistent atlas as backdrop and + /// doesn't consult this cache. Re-enable by re-adding the call site at + /// the top of the tile walker if the drag-sprite path is removed. + #[allow(dead_code)] fn should_use_cached_top_level_during_interactive( &mut self, node_id: Uuid, @@ -532,9 +586,16 @@ impl RenderState { preview_mode: false, export_context: None, cache_cleared_this_render: false, + preserve_cache_this_render: false, + dx_inval_since_paf: 0, current_tile_had_shapes: false, - interactive_target_seeded: false, backbuffer_crop_cache: HashMap::default(), + dx_touch_calls: 0, + dx_pre_drag_tiles: HashMap::new(), + dx_drag_dirty_tiles: HashSet::new(), + tile_eviction_seq: 0, + drag_sprite_state: drag_sprite::DragSpriteState::default(), + drag_capture_exclude: None, }) } @@ -592,7 +653,7 @@ impl RenderState { /// Must be called BEFORE any save_layer for the shape's own opacity/blend, /// so that the backdrop blur is independent of the shape's visual properties. fn render_background_blur(&mut self, shape: &Shape, target_surface: SurfaceId) { - if self.options.is_fast_mode() { + if self.options.should_skip_effects() { return; } if matches!(shape.shape_type, Type::Text(_)) || matches!(shape.shape_type, Type::SVGRaw(_)) @@ -844,17 +905,8 @@ 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(); + let skip_atlas = self.options.is_defer_effects(); // 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), // while still preventing stale pixels from surviving across full-quality renders. @@ -875,6 +927,7 @@ impl RenderState { ¤t_tile, &tile_rect, fast_mode, + skip_atlas, self.render_area, ); @@ -1009,13 +1062,10 @@ 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.should_skip_effects(); let has_nested_fills = self .nested_fills @@ -1616,6 +1666,7 @@ impl RenderState { } } + #[allow(dead_code)] fn rebuild_backbuffer_crop_cache(&mut self, tree: ShapesPoolRef) { self.backbuffer_crop_cache.clear(); @@ -1903,6 +1954,14 @@ impl RenderState { self.stats.clear(); let _start = performance::begin_timed_log!("start_render_loop"); + + // Phase 3: drag-sprite fast path. When the sprite is captured + // and validity checks pass, this composites atlas-backdrop + + // sprite at the modifier position and skips the slow tile walk. + if self.drag_sprite_try_render_frame(tree) { + return Ok(()); + } + let scale = self.get_scale(); self.tile_viewbox.update(self.viewbox, scale); @@ -1911,28 +1970,49 @@ impl RenderState { performance::begin_measure!("render"); performance::begin_measure!("start_render_loop"); + // When preserve_cache_this_render is set, pretend the Cache surface + // has already been cleared for this pass. The first-tile clear guards + // in apply_render_to_final_canvas keep their pass-1 content intact so + // pass-2 tiles overwrite in place (no flicker between passes). + self.cache_cleared_this_render = self.preserve_cache_this_render; + self.preserve_cache_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); - 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). + // 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. + // + // The same applies to the post-zoom pass 1 (`defer_effects`): + // tiles at the new scale are still being rebuilt, so paint the + // atlas as a scaled backdrop until they land. Unlike the + // interactive-transform case we do NOT clear Target first — + // during early-load gestures the atlas may only cover part of + // the viewport and clearing would flash bg-colored rectangles + // where pass-1 tiles haven't landed yet. Target already holds + // whatever `render_from_cache` drew during the gesture (or + // direct tile renders from a prior rAF), which is a strictly + // better backdrop than the raw background color. + if self.options.is_interactive_transform() && self.surfaces.has_atlas() { + self.surfaces.draw_atlas_to_target( + self.viewbox, + self.options.dpr(), + self.background_color, + ); + } else if self.options.is_defer_effects() && self.surfaces.has_atlas() { 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; + .draw_atlas_over_target(self.viewbox, self.options.dpr()); } let surface_ids = SurfaceId::Strokes as u32 @@ -2050,6 +2130,11 @@ impl RenderState { timestamp: i32, ) -> Result<()> { performance::begin_measure!("process_animation_frame"); + let dx_t0 = crate::get_now!(); + let dx_pending_in = self.pending_tiles.list.len(); + let dx_inval_at_start = self.dx_inval_since_paf; + let dx_interactive = self.options.is_interactive_transform(); + let dx_was_in_progress = self.render_in_progress; if self.render_in_progress { if tree.len() != 0 { self.render_shape_tree_partial(base_object, tree, timestamp, true)?; @@ -2071,18 +2156,57 @@ impl RenderState { if self.render_in_progress { self.cancel_animation_frame(); self.render_request_id = Some(wapi::request_animation_frame!()); + } else if self.options.is_defer_effects() { + // Pass 1 just finished — tiles are on screen without + // blur/shadow. Immediately launch pass 2 to upgrade them + // in place. The tile texture cache is invalidated so + // every tile re-renders; the Cache surface is preserved + // so pass-1 content stays visible until each pass-2 + // tile overwrites it. + self.options.set_defer_effects(false); + self.surfaces.invalidate_tile_cache(); + self.preserve_cache_this_render = true; + self.start_render_loop(base_object, tree, timestamp, false)?; } else { - // A full-quality frame is now complete. Refresh Backbuffer and regenerate - // the per-shape crop cache so interactive drags can reuse pixels. - if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { - self.surfaces.copy_target_to_backbuffer(); - self.rebuild_backbuffer_crop_cache(tree); - } + // A full-quality frame is now complete. + // + // Backbuffer-population call is intentionally omitted: the + // active drag path uses the persistent atlas as backdrop + // and doesn't read Backbuffer, so copying Target → + // Backbuffer on every full render would be a wasted + // viewport-sized GPU blit (very visible during pan, where + // full-quality renders fire continuously). Re-add + // `self.surfaces.copy_target_to_backbuffer()` here if any + // consumer is brought back. wapi::notify_tiles_render_complete!(); performance::end_measure!("render"); } } performance::end_measure!("process_animation_frame"); + + // Per-PAF diagnostic. Emit only on slow PAFs (> 16ms breaks 60fps + // budget) or large invalidation bursts. Per-rAF logging with + // DevTools console open costs more than the work it measures. + let dx_pending_out = self.pending_tiles.list.len(); + let dx_inval_in_paf = self.dx_inval_since_paf; + let dx_dt = crate::get_now!() - dx_t0; + if dx_dt > 16.0 || dx_inval_in_paf > 50 { + crate::run_script!(format!( + "console.log('[paf] dt={:.1}ms interactive={} render_in_progress={} pending_in={} pending_out={} inval_in_paf={}')", + dx_dt, + dx_interactive, + dx_was_in_progress, + dx_pending_in, + dx_pending_out, + dx_inval_in_paf + )); + } + // Clear the per-PAF invalidation counter for the next tick. + // Note: invalidations that arrive AFTER this reset (e.g., from + // a `set_modifiers` call between PAFs) accumulate against the + // next PAF — exactly what we want to measure. + let _ = dx_inval_at_start; // (silence unused warning) + self.dx_inval_since_paf = 0; Ok(()) } @@ -2273,8 +2397,9 @@ 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). - if !self.options.is_fast_mode() { + // Skip frame-level blur in fast mode (pan/zoom) or during the + // post-gesture first rebuild pass. + if !self.options.should_skip_effects() { if let Some(frame_blur) = Self::frame_clip_layer_blur(element) { let scale = self.get_scale(); let sigma = radius_to_sigma(frame_blur.value * scale); @@ -2878,29 +3003,6 @@ impl RenderState { target_surface = SurfaceId::Export; } - // During interactive transforms we compute the union of the current bounds of all - // modified shapes (doc-space @ 100% zoom, scale=1.0). This is used as a cheap overlap - // guard to decide when cached top-level crops are unsafe to reuse (something is moving - // over/inside them), without doing expensive ancestor walks per node. - let moved_bounds = - if self.options.is_interactive_transform() && !tree.modifier_ids().is_empty() { - let mut acc: Option = None; - for id in tree.modifier_ids().iter() { - let Some(s) = tree.get(id) else { continue }; - let r = self.get_cached_extrect(s, tree, 1.0); - acc = Some(match acc { - None => r, - Some(mut prev) => { - prev.join(r); - prev - } - }); - } - acc - } else { - None - }; - while let Some(node_render_state) = self.pending_nodes.pop() { let node_id = node_render_state.id; let visited_children = node_render_state.visited_children; @@ -2971,64 +3073,6 @@ impl RenderState { } } - // Interactive drag cache: if this node is cacheable during interactive transform, - // draw it directly from Backbuffer crop on the current tile surface and skip - // traversing/rendering the subtree. - if self.options.is_interactive_transform() { - let use_cached = self.should_use_cached_top_level_during_interactive( - node_id, - tree, - &tree.modifier_ids(), - moved_bounds, - ); - if use_cached { - if let Some(crop) = self.backbuffer_crop_cache.get(&node_id) { - let crop_image = &crop.image; - let crop_src_selrect = crop.src_selrect; - let crop_src_doc_bounds = crop.src_doc_bounds; - - let cur_selrect = tree.get(&node_id).map(|s| s.selrect()); - let (dx, dy) = match cur_selrect { - Some(cur) => ( - cur.left - crop_src_selrect.left, - cur.top - crop_src_selrect.top, - ), - None => (0.0, 0.0), - }; - - let dst_doc_rect = Rect::new( - crop_src_doc_bounds.left + dx, - crop_src_doc_bounds.top + dy, - crop_src_doc_bounds.right + dx, - crop_src_doc_bounds.bottom + dy, - ); - let scale = self.get_scale(); - let translation = self - .surfaces - .get_render_context_translation(self.render_area, scale); - let dst_tile_rect = skia::Rect::from_xywh( - (dst_doc_rect.left + translation.0) * scale, - (dst_doc_rect.top + translation.1) * scale, - dst_doc_rect.width() * scale, - dst_doc_rect.height() * scale, - ); - - // let canvas = self.surfaces.canvas_and_mark_dirty(target_surface); - let canvas = self.surfaces.canvas(target_surface); - canvas.save(); - canvas.reset_matrix(); - canvas.draw_image_rect( - crop_image, - None, - dst_tile_rect, - &skia::Paint::default(), - ); - canvas.restore(); - } - continue; - } - } - let can_flatten = element.can_flatten() && !self.focus_mode.should_focus(&element.id); // Skip render_shape_enter/exit for flattened containers @@ -3042,7 +3086,7 @@ impl RenderState { // the layer blur (which would make it more diffused than without clipping) let shadow_before_layer = !node_render_state.is_root() && self.focus_mode.is_active() - && !self.options.is_fast_mode() + && !self.options.should_skip_effects() && !matches!(element.shape_type, Type::Text(_)) && Self::frame_clip_layer_blur(element).is_some() && element.drop_shadows_visible().next().is_some(); @@ -3078,8 +3122,9 @@ impl RenderState { .surfaces .get_render_context_translation(self.render_area, scale); - // Skip expensive drop shadow rendering in fast mode (during pan/zoom). - let skip_shadows = self.options.is_fast_mode(); + // Skip expensive drop shadow rendering in fast mode (during + // pan/zoom) or during the post-gesture first rebuild pass. + let skip_shadows = self.options.should_skip_effects(); // Skip shadow block when already rendered before the layer (frame_clip_layer_blur) let shadows_already_rendered = Self::frame_clip_layer_blur(element).is_some(); @@ -3175,6 +3220,16 @@ impl RenderState { let children_ids = sort_z_index(tree, element, children_ids); for child_id in children_ids.iter() { + // During drag-sprite backdrop capture, skip the + // dragged subtree so the atlas receives "scene + // minus shape" pixels. + if self + .drag_capture_exclude + .as_ref() + .is_some_and(|s| s.contains(child_id)) + { + continue; + } self.pending_nodes.push(NodeRenderState { id: *child_id, visited_children: false, @@ -3218,17 +3273,12 @@ 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()?; - if !self.options.is_interactive_transform() { - self.surfaces.draw_cached_tile_surface( - current_tile, - tile_rect, - self.background_color, - ); - } + 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. @@ -3266,19 +3316,18 @@ 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). + // 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 { - 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)?; - } + self.apply_render_to_final_canvas(tile_rect)?; if self.options.is_debug_visible() { debug::render_workspace_current_tile( @@ -3294,7 +3343,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); @@ -3339,28 +3387,26 @@ impl RenderState { }) }); - // 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. + // We only need first level shapes, in the same order as the parent node let mut valid_ids = Vec::with_capacity(ids.len()); - 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); - } + for root_id in root_ids.iter() { + // During drag-sprite backdrop capture, skip + // root-level shapes in the excluded subtree. + if self + .drag_capture_exclude + .as_ref() + .is_some_and(|s| s.contains(root_id)) + { + continue; + } + if tile_has_bg_blur || 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, @@ -3550,10 +3596,39 @@ impl RenderState { } pub fn remove_cached_tile(&mut self, tile: tiles::Tile) { + self.dx_inval_since_paf += 1; + self.drag_sprite_notify_eviction(); + // Diagnostic: log every eviction that fires while a sprite is + // captured. These are the ones that trip the eviction-seq + // mismatch and disable the drag-sprite fast path. The tile + // coords plus surrounding context tell us which call site is + // sneaking in mid-gesture. + if matches!( + self.drag_sprite_state, + drag_sprite::DragSpriteState::Captured(_) + ) { + crate::run_script!(format!( + "console.log('[evict_during_sprite] tile=({},{}) seq={}')", + tile.x(), + tile.y(), + self.tile_eviction_seq + )); + } self.surfaces .remove_cached_tile_surface(&mut self.gpu_state, tile); } + /// No-op shim: kept so main.rs::set_modifiers_end still compiles. + /// Atlas clearing is now done unconditionally in + /// `remove_cached_tile_surface`, the original behavior. The + /// interactive-transform-aware atlas-keep optimization caused + /// ghost-rect artifacts on heavy files; reverting to the simple + /// model. + pub fn flush_drag_atlas_cleanup(&mut self) { + let _ = self.dx_drag_dirty_tiles.drain(); + let _ = self.dx_pre_drag_tiles.drain(); + } + /// Rebuild the tile index (shape→tile mapping) for all top-level shapes. /// This does NOT invalidate the tile texture cache — cached tile images /// survive so that fast-mode renders during pan still show shadows/blur. @@ -3641,6 +3716,26 @@ impl RenderState { let mut all_tiles = HashSet::::new(); let ids = std::mem::take(&mut self.touched_ids); + let dx_n_shapes = ids.len(); + let dx_n_touch_calls = std::mem::take(&mut self.dx_touch_calls); + + // Diagnostic: log when this fires with non-empty touched_ids + // while a sprite is captured. These touches sneak in between + // capture and the first frame check, tripping the eviction-seq + // mismatch and disabling the fast path. + if dx_n_shapes > 0 + && matches!( + self.drag_sprite_state, + drag_sprite::DragSpriteState::Captured(_) + ) + { + let sample: Vec = ids.iter().take(3).map(|u| u.to_string()).collect(); + crate::run_script!(format!( + "console.log('[touched_during_sprite] n_shapes={} sample_ids={}')", + dx_n_shapes, + sample.join(",") + )); + } for shape_id in ids.iter() { if let Some(shape) = tree.get(shape_id) { @@ -3650,11 +3745,19 @@ impl RenderState { } } + let dx_n_tiles = all_tiles.len(); // Update the changed tiles for tile in all_tiles { self.remove_cached_tile(tile); } + if dx_n_tiles > 20 || dx_n_touch_calls > 20 { + crate::run_script!(format!( + "console.log('[touched] mark_calls={} unique_shapes={} tiles_invalidated={}')", + dx_n_touch_calls, dx_n_shapes, dx_n_tiles + )); + } + performance::end_measure!("rebuild_touched_tiles"); } @@ -3696,16 +3799,38 @@ 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 - // 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() { + // 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). + let interactive = self.options.is_interactive_transform(); + let inval_before = self.dx_inval_since_paf; + + let processed_count = if interactive { self.update_tiles_shapes(&ids, tree)?; + ids.len() } else { let ancestors = all_with_ancestors(&ids, tree, false); + let n = ancestors.len(); self.update_tiles_shapes(&ancestors, tree)?; + n + }; + let inval_added = self.dx_inval_since_paf - inval_before; + // Only log when something unusual happens — per-rAF logging itself + // costs 0.5-2ms with DevTools console open, polluting the very + // measurement we care about. Spike threshold catches accidental + // ancestor-walk (interactive=false) or many tiles invalidated. + if !interactive || inval_added > 8 { + crate::run_script!(format!( + "console.log('[rebuild] interactive={} input_ids={} processed={} tiles_invalidated={}')", + interactive, + ids.len(), + processed_count, + inval_added + )); } Ok(()) } @@ -3728,6 +3853,7 @@ impl RenderState { pub fn mark_touched(&mut self, uuid: Uuid) { self.touched_ids.insert(uuid); + self.dx_touch_calls += 1; } #[allow(dead_code)] diff --git a/render-wasm/src/render/drag_sprite.rs b/render-wasm/src/render/drag_sprite.rs new file mode 100644 index 0000000000..84f7a1983a --- /dev/null +++ b/render-wasm/src/render/drag_sprite.rs @@ -0,0 +1,649 @@ +//! Drag-sprite optimization (Phase 3: backdrop + sprite + fast-path render). +//! +//! At gesture start (first `set_modifiers` of an interactive transform): +//! 1. Capture the affected tiles WITHOUT the dragged shape into the +//! persistent atlas — this is the "scene minus shape" backdrop. +//! 2. Capture the shape itself into a GPU image — the sprite. +//! 3. Snapshot the cumulative `tile_eviction_seq` AFTER both captures. +//! +//! Per rAF during drag: +//! - Check sprite is still valid: `tile_eviction_seq` matches snapshot, +//! modifier is translation-only, zoom hasn't changed. Mismatch → flip +//! to `Disabled` for the rest of the gesture (slow path resumes). +//! - Draw the atlas (which holds the backdrop) to the target. +//! - Blit the sprite at the modifier-transformed position. +//! +//! On `set_modifiers_end`: discard sprite and let the normal post-gesture +//! render rebuild the affected tiles with the shape at its committed +//! position. + +use std::collections::HashSet; + +use skia_safe::{self as skia, Rect}; + +use super::{ui, NodeRenderState, RenderState, SurfaceId}; +use crate::error::Result; +use crate::performance; +use crate::state::ShapesPoolRef; +use crate::tiles::{self, TileRect}; +use crate::uuid::Uuid; + +/// State of the drag-sprite optimization for the current gesture. +#[derive(Default)] +pub enum DragSpriteState { + /// No active gesture, or capture not yet attempted. + #[default] + Idle, + /// Sprite captured at gesture start. Per-rAF compositing path is + /// active subject to per-frame validity checks. + Captured(DragSprite), + /// Capture was attempted and is invalid for the rest of this + /// gesture (preconditions not met, unexpected eviction, non- + /// translation modifier, zoom change, etc.). Slow path resumes. + Disabled, +} + +/// Pre-rendered raster of the dragged shape, plus metadata used to +/// validate that the cached state is still safe to use each frame. +#[derive(Clone)] +pub struct DragSprite { + /// GPU-backed snapshot of the shape rendered into Export at the + /// pre-modifier position, with all fills/strokes/effects baked in. + pub image: skia::Image, + /// Shape's pre-drag extrect in doc space. The per-rAF blit uses + /// `modifier.map_rect(base_doc_rect)` to place the sprite. + pub base_doc_rect: Rect, + /// Render scale used when the sprite was captured. Mismatch with + /// the current zoom invalidates the sprite (would alias). + pub sprite_scale: f32, + /// Id of the shape this sprite represents. + pub shape_id: Uuid, + /// `RenderState::tile_eviction_seq` AFTER backdrop capture + /// completes. Any subsequent eviction bumps the live counter, + /// signalling the backdrop atlas is no longer trustworthy. + pub captured_eviction_seq: u32, + /// Viewport-sized capture of the shapes that render *after* the + /// dragged shape in document order (its z-order successors at every + /// ancestor level). Drawn on top of the sprite each rAF so shapes + /// that should overlap the dragged shape do, instead of the sprite + /// always sitting on top. `None` when the dragged shape is on top + /// at every level (no above-shapes). + pub above_image: Option, +} + +/// Returns true iff the matrix is a pure translation (within a small +/// epsilon to absorb floating-point noise from modifier composition). +/// Drag-sprite's per-rAF blit assumes the captured pixels are still +/// correct at the new position — so any rotate/scale/skew/perspective +/// requires falling back to the slow path. +/// +/// We can't use `Matrix::is_translate` directly: it consults Skia's +/// type mask which is strict (any scale != exactly 1.0 sets the SCALE +/// bit). In practice modifier matrices arrive with sub-microscale +/// noise like `sx = 0.99999964` from upstream matrix composition, +/// which is visually pure translation but trips the strict check. +fn is_translation_only(m: &skia::Matrix) -> bool { + let eps = 1e-4_f32; + (m.scale_x() - 1.0).abs() < eps + && (m.scale_y() - 1.0).abs() < eps + && m.skew_x().abs() < eps + && m.skew_y().abs() < eps + && m.persp_x().abs() < eps + && m.persp_y().abs() < eps +} + +impl RenderState { + /// Idempotent. Called from `set_modifiers_start`. + pub fn drag_sprite_reset(&mut self) { + self.drag_sprite_state = DragSpriteState::Idle; + } + + /// Drop any captured sprite. Called from `set_modifiers_end`. The + /// subsequent commit-time render will rebuild the affected tiles + /// with the shape at its final position. + pub fn drag_sprite_clear(&mut self) { + self.drag_sprite_state = DragSpriteState::Idle; + } + + /// True iff the drag-sprite fast path is currently in `Captured` + /// state. Callers in `main.rs` use this to suppress mid-drag + /// `rebuild_modifier_tiles` (which would wipe the backdrop). + pub fn drag_sprite_is_active(&self) -> bool { + matches!(self.drag_sprite_state, DragSpriteState::Captured(_)) + } + + /// Try to capture the dragged shape's sprite + scene-minus-shape + /// backdrop. No-op if state is not `Idle` (already captured or + /// disabled). On any error, transitions to `Disabled`. + /// + /// Must be called BEFORE `state.set_modifiers(modifiers)` on the + /// first event of a gesture so the shape is rendered at its + /// pre-drag position. + pub fn drag_sprite_try_capture( + &mut self, + ids: &[Uuid], + tree: ShapesPoolRef, + timestamp: i32, + ) -> Result<()> { + if !matches!(self.drag_sprite_state, DragSpriteState::Idle) { + return Ok(()); + } + if ids.len() != 1 { + // Multi-shape drag: out of Phase 3 scope. + self.drag_sprite_state = DragSpriteState::Disabled; + return Ok(()); + } + let shape_id = ids[0]; + + match self.capture_drag_sprite_internal(&shape_id, tree, timestamp) { + Ok(sprite) => { + self.drag_sprite_state = DragSpriteState::Captured(sprite); + } + Err(_) => { + self.drag_sprite_state = DragSpriteState::Disabled; + } + } + Ok(()) + } + + /// Try the fast-path render. Returns true if the frame was rendered + /// (caller should skip the normal render loop). Returns false if + /// preconditions weren't met (state not Captured, eviction mismatch, + /// non-translation modifier, zoom change), in which case the slow + /// path runs. + /// + /// Side effect: on validity failure, transitions state to `Disabled` + /// so the slow path runs for the rest of the gesture. + pub fn drag_sprite_try_render_frame(&mut self, tree: ShapesPoolRef) -> bool { + // Borrow-check dance: extract sprite by clone (skia::Image is + // Arc-like, so this is cheap), then we're free to mutate self. + let sprite = match &self.drag_sprite_state { + DragSpriteState::Captured(s) => s.clone(), + _ => return false, + }; + + // Validity check 1: eviction-sequence must match. + if sprite.captured_eviction_seq != self.tile_eviction_seq { + crate::run_script!(format!( + "console.log('[drag_sprite] disabled: eviction_seq mismatch (captured={} current={})')", + sprite.captured_eviction_seq, self.tile_eviction_seq + )); + self.drag_sprite_state = DragSpriteState::Disabled; + return false; + } + + // Validity check 2: zoom must match. + if (sprite.sprite_scale - self.get_scale()).abs() > 1e-5 { + crate::run_script!(format!( + "console.log('[drag_sprite] disabled: scale changed ({} -> {})')", + sprite.sprite_scale, + self.get_scale() + )); + self.drag_sprite_state = DragSpriteState::Disabled; + return false; + } + + // Validity check 3: modifier must be translation-only. + let modifier = tree.get_modifier(&sprite.shape_id); + if let Some(m) = modifier { + if !is_translation_only(m) { + crate::run_script!(format!( + "console.log('[drag_sprite] disabled: modifier not translation-only: sx={} sy={} kx={} ky={} tx={} ty={} px={} py={}')", + m.scale_x(), + m.scale_y(), + m.skew_x(), + m.skew_y(), + m.translate_x(), + m.translate_y(), + m.persp_x(), + m.persp_y() + )); + self.drag_sprite_state = DragSpriteState::Disabled; + return false; + } + } + + self.render_drag_sprite_frame_inner(&sprite, tree); + true + } + + /// Bump the cumulative eviction counter. Called from + /// `remove_cached_tile`. The drag-sprite fast path uses this to + /// detect mid-gesture invalidation. + pub fn drag_sprite_notify_eviction(&mut self) { + self.tile_eviction_seq = self.tile_eviction_seq.wrapping_add(1); + } + + /// Capture phase: backdrop + sprite. Mirrors commit `667b503505`'s + /// `capture_drag_sprite` but with the safety-net `eviction_seq` + /// snapshot taken AFTER both phases complete. + fn capture_drag_sprite_internal( + &mut self, + id: &Uuid, + tree: ShapesPoolRef, + timestamp: i32, + ) -> Result { + performance::begin_measure!("drag_sprite_capture"); + let dx_t = crate::get_now!(); + + let scale = self.get_scale(); + let Some(shape) = tree.get(id) else { + performance::end_measure!("drag_sprite_capture"); + return Err(crate::error::Error::CriticalError(format!( + "drag_sprite: shape {} not found", + id + ))); + }; + let base_doc_rect = shape.extrect(tree, scale); + if base_doc_rect.is_empty() { + performance::end_measure!("drag_sprite_capture"); + return Err(crate::error::Error::CriticalError( + "drag_sprite: empty extrect".to_string(), + )); + } + + // Phase 1: backdrop. Re-renders affected tiles with the dragged + // subtree excluded; result lands in the persistent atlas. + let backdrop_result = self.capture_drag_backdrop(id, tree, timestamp, base_doc_rect); + + // Phase 2: sprite. Mirrors `render_shape_pixels` save/restore + // but returns a Skia Image instead of PNG bytes. + let target_surface = SurfaceId::Export; + + 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; + + self.focus_mode.clear(); + self.surfaces + .canvas(target_surface) + .clear(skia::Color::TRANSPARENT); + + let mut render_rect = base_doc_rect; + self.export_context = Some((render_rect, scale)); + let margins = self.surfaces.margins; + render_rect.offset((margins.width as f32 / scale, margins.height as f32 / scale)); + + self.surfaces.resize_export_surface(scale, render_rect); + self.render_area = render_rect; + self.render_area_with_margins = render_rect; + self.surfaces.update_render_context(render_rect, scale); + + self.pending_nodes.push(NodeRenderState { + id: *id, + visited_children: false, + clip_bounds: None, + visited_mask: false, + mask: false, + flattened: false, + }); + let sprite_result = self.render_shape_tree_partial_uncached(tree, timestamp, false, true); + + self.export_context = None; + self.surfaces + .flush_and_submit(&mut self.gpu_state, target_surface); + let image = self.surfaces.snapshot(target_surface); + + // Restore workspace state. + 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); + } + + // Surface errors AFTER the state restore above so a mid-render + // failure doesn't strand the renderer. + backdrop_result?; + sprite_result?; + + // Phase 3: above-shapes overlay. Render the shapes that come + // AFTER the dragged shape in document order (z-order successors + // at every ancestor level) into a viewport-sized image. Per-rAF + // we blit it on top of the sprite so shapes meant to be above + // the dragged shape actually appear above it. `None` when the + // dragged shape is on top at every level. + let above_image = self.capture_drag_above(id, tree, timestamp).unwrap_or(None); + + // Snapshot eviction seq AFTER all captures (each capture phase + // can evict tiles; we want to detect *additional* evictions + // that fire mid-gesture). + let captured_eviction_seq = self.tile_eviction_seq; + + let dx_dt = crate::get_now!() - dx_t; + crate::run_script!(format!( + "console.log('[drag_sprite] captured shape={} extrect_w={:.0} extrect_h={:.0} scale={:.2} above={} took={:.1}ms')", + id, + base_doc_rect.width(), + base_doc_rect.height(), + scale, + above_image.is_some(), + dx_dt + )); + + performance::end_measure!("drag_sprite_capture"); + + Ok(DragSprite { + image, + base_doc_rect, + sprite_scale: scale, + shape_id: *id, + captured_eviction_seq, + above_image, + }) + } + + /// Walk from the dragged shape up to the root, collecting siblings + /// that come *before* the current node in `children_ids` at each + /// level — those are the shapes drawn ON TOP of it (z-order + /// successors). The returned list is the push-order for the + /// renderer's pending_nodes stack: outermost ancestor's above- + /// siblings come first so they pop last and render last (on top). + fn collect_above_shapes_push_order(&self, dragged_id: &Uuid, tree: ShapesPoolRef) -> Vec { + let mut levels: Vec> = Vec::new(); + let mut current = *dragged_id; + loop { + let parent_id = tree + .get(¤t) + .and_then(|s| s.parent_id) + .unwrap_or(Uuid::nil()); + let Some(parent) = tree.get(&parent_id) else { + break; + }; + let siblings = parent.children_ids(false); + if let Some(idx) = siblings.iter().position(|id| *id == current) { + // children_ids[..idx] are the siblings rendered AFTER + // `current` in document order. children_ids[0] is the + // top-most of those. + levels.push(siblings[..idx].to_vec()); + } + if parent_id == Uuid::nil() { + break; + } + current = parent_id; + } + // Outer levels (highest z) first → push them first → they pop + // last → render last → on top. + let mut result = Vec::new(); + for level in levels.iter().rev() { + result.extend_from_slice(level); + } + result + } + + /// Render the above-set onto a viewport-sized image. Mirrors the + /// `render_shape_pixels` save/restore pattern but with multiple + /// shapes pushed onto `pending_nodes` and a viewport-scoped render + /// area. Returns `None` when there are no above-shapes (drag is on + /// top of the stack everywhere). + fn capture_drag_above( + &mut self, + dragged_id: &Uuid, + tree: ShapesPoolRef, + timestamp: i32, + ) -> Result> { + let above_ids = self.collect_above_shapes_push_order(dragged_id, tree); + if above_ids.is_empty() { + return Ok(None); + } + + let target_surface = SurfaceId::Export; + let scale = self.get_scale(); + + // Save state we're about to mutate. + 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; + + self.focus_mode.clear(); + self.surfaces + .canvas(target_surface) + .clear(skia::Color::TRANSPARENT); + + // Size Export to the workspace viewport (in doc space) plus the + // standard margin band — same convention as the sprite capture, + // so the per-rAF blit reuses the same translation math. + let viewport_doc = self.viewbox.area; + let mut render_rect = viewport_doc; + self.export_context = Some((render_rect, scale)); + let margins = self.surfaces.margins; + render_rect.offset((margins.width as f32 / scale, margins.height as f32 / scale)); + self.surfaces.resize_export_surface(scale, render_rect); + self.render_area = render_rect; + self.render_area_with_margins = render_rect; + self.surfaces.update_render_context(render_rect, scale); + + for id in &above_ids { + self.pending_nodes.push(NodeRenderState { + id: *id, + visited_children: false, + clip_bounds: None, + visited_mask: false, + mask: false, + flattened: false, + }); + } + let render_result = self.render_shape_tree_partial_uncached(tree, timestamp, false, true); + + self.export_context = None; + self.surfaces + .flush_and_submit(&mut self.gpu_state, target_surface); + let image = self.surfaces.snapshot(target_surface); + + // Restore workspace state. + 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_result?; + Ok(Some(image)) + } + + /// Compute the set of interest-area tiles covering a doc-space rect. + fn tiles_covering_doc_rect(&self, doc_rect: skia::Rect) -> HashSet { + let scale = self.get_scale(); + let tile_size = tiles::get_tile_size(scale); + let TileRect(x1, y1, x2, y2) = tiles::get_tiles_for_rect(doc_rect, tile_size); + let mut out = HashSet::new(); + for tx in x1..=x2 { + for ty in y1..=y2 { + let tile = tiles::Tile::from(tx, ty); + if self.tile_viewbox.interest_rect.contains(&tile) { + out.insert(tile); + } + } + } + out + } + + /// Re-render the tiles covering the dragged shape's pre-drag bounds + /// with that subtree excluded, leaving the atlas with pixel-correct + /// "scene minus shape" content. Texture cache for the affected tiles + /// is invalidated so the post-gesture render recomputes them with + /// the shape at its final position. + fn capture_drag_backdrop( + &mut self, + shape_id: &Uuid, + tree: ShapesPoolRef, + timestamp: i32, + base_doc_rect: skia::Rect, + ) -> Result<()> { + performance::begin_measure!("drag_sprite_backdrop"); + + let Some(shape) = tree.get(shape_id) else { + performance::end_measure!("drag_sprite_backdrop"); + return Ok(()); + }; + let mut exclude: HashSet = HashSet::new(); + exclude.insert(*shape_id); + for child_id in shape.all_children_iter(tree, true, true) { + exclude.insert(child_id); + } + + let capture_tiles = self.tiles_covering_doc_rect(base_doc_rect); + if capture_tiles.is_empty() { + performance::end_measure!("drag_sprite_backdrop"); + return Ok(()); + } + + // Clear the atlas region under the dragged shape so the nested + // render's tile blits write into a blank area. Without this, + // SrcOver compositing could leak old shape pixels through the + // transparent gaps. + let _ = self + .surfaces + .clear_doc_rect_in_atlas(&mut self.gpu_state, base_doc_rect); + + // Evict any cached tile textures for the affected tiles so the + // nested render is not short-circuited by the cache fast path. + for tile in &capture_tiles { + self.surfaces + .remove_cached_tile_surface(&mut self.gpu_state, *tile); + } + + // Save state the nested render mutates. + let saved_pending_tiles = std::mem::take(&mut self.pending_tiles.list); + let saved_pending_nodes = std::mem::take(&mut self.pending_nodes); + let saved_current_tile = self.current_tile; + let saved_render_in_progress = self.render_in_progress; + + self.pending_tiles.list = capture_tiles.iter().copied().collect(); + self.current_tile = None; + self.render_in_progress = true; + self.drag_capture_exclude = Some(exclude); + self.surfaces + .canvas(SurfaceId::Current) + .clear(self.background_color); + + let result = self.render_shape_tree_partial(None, tree, timestamp, false); + + // Restore. + self.drag_capture_exclude = None; + self.pending_tiles.list = saved_pending_tiles; + self.pending_nodes = saved_pending_nodes; + self.current_tile = saved_current_tile; + self.render_in_progress = saved_render_in_progress; + self.surfaces + .canvas(SurfaceId::Current) + .clear(skia::Color::TRANSPARENT); + + result?; + + // Atlas now holds scene-minus-shape pixels for `capture_tiles`. + // Drop the tile textures so the post-gesture render recomputes + // them (the cached textures also lack the shape — same wrong + // content, just baked into the texture cache). + for tile in &capture_tiles { + self.surfaces.invalidate_tile_texture_only(*tile); + } + + performance::end_measure!("drag_sprite_backdrop"); + Ok(()) + } + + /// Per-rAF fast path: draw atlas as scene backdrop, then blit the + /// pre-rendered sprite at the modifier-transformed position. + fn render_drag_sprite_frame_inner(&mut self, sprite: &DragSprite, tree: ShapesPoolRef) { + performance::begin_measure!("drag_sprite_render_frame"); + + // Apply the (validated translation-only) modifier to the + // sprite's pre-drag rect. + let dst_doc_rect = if let Some(m) = tree.get_modifier(&sprite.shape_id) { + m.map_rect(sprite.base_doc_rect).0 + } else { + sprite.base_doc_rect + }; + + // Draw the atlas (which holds the backdrop) to Target. + if self.surfaces.has_atlas() { + self.surfaces.draw_atlas_to_target( + self.viewbox, + self.options.dpr(), + self.background_color, + ); + } else { + self.surfaces + .canvas(SurfaceId::Target) + .clear(self.background_color); + } + + // Blit the sprite at the new position, in target pixel space. + let s = self.viewbox.zoom * self.options.dpr(); + let dst = skia::Rect::from_xywh( + (dst_doc_rect.left + self.viewbox.pan_x) * s, + (dst_doc_rect.top + self.viewbox.pan_y) * s, + dst_doc_rect.width() * s, + dst_doc_rect.height() * s, + ); + + let canvas = self.surfaces.canvas(SurfaceId::Target); + canvas.save(); + canvas.reset_matrix(); + canvas.draw_image_rect(&sprite.image, None, dst, &skia::Paint::default()); + canvas.restore(); + + // Above-shapes overlay: shapes drawn AFTER the dragged shape in + // document order go on top of the sprite, restoring correct + // z-order when they overlap. The above_image was rendered with + // canvas pixel (0,0) mapped to the viewport's top-left in doc + // space — exactly the same mapping as Target — so blit at (0,0). + if let Some(above) = sprite.above_image.as_ref() { + let canvas = self.surfaces.canvas(SurfaceId::Target); + canvas.save(); + canvas.reset_matrix(); + canvas.draw_image(above, (0, 0), Some(&skia::Paint::default())); + canvas.restore(); + } + + // Selection handles, snap guides, etc. ride on top. + ui::render(self, tree); + + self.flush_and_submit(); + performance::end_measure!("drag_sprite_render_frame"); + } +} diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 40a3125ccd..ce88fdc0a0 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -22,6 +22,11 @@ pub struct RenderOptions { /// keeps per-frame flushing enabled (unlike pan/zoom, where /// `render_from_cache` drives target presentation). interactive_transform: bool, + /// Active during the first rebuild pass after a zoom ends. Skips + /// blur/shadow (like `fast_mode`) but renders tiles normally rather + /// than using the atlas backdrop, so the user sees a fast full-fidelity + /// shape preview before effects come in on a second pass. + defer_effects: bool, /// Minimum on-screen size (CSS px at 1:1 zoom) above which vector antialiasing is enabled. pub antialias_threshold: f32, pub viewport_interest_area_threshold: i32, @@ -37,6 +42,7 @@ impl Default for RenderOptions { dpr: None, fast_mode: false, interactive_transform: false, + defer_effects: false, antialias_threshold: ANTIALIAS_THRESHOLD, viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD, max_blocking_time_ms: MAX_BLOCKING_TIME_MS, @@ -76,6 +82,23 @@ impl RenderOptions { self.interactive_transform = enabled; } + pub fn is_defer_effects(&self) -> bool { + self.defer_effects + } + + pub fn set_defer_effects(&mut self, enabled: bool) { + self.defer_effects = enabled; + } + + /// True when expensive per-shape effects (blur, shadow) should be + /// skipped. Covers both the active viewport gesture (`fast_mode`) + /// and the post-gesture first rebuild pass (`defer_effects`). + /// Do NOT use this to gate atlas-backdrop / cache-presentation logic + /// — those must key off `is_fast_mode()` specifically. + pub fn should_skip_effects(&self) -> bool { + self.fast_mode || self.defer_effects + } + /// True only when the viewport is the one being moved (pan/zoom) /// and the dedicated `render_from_cache` path owns Target /// presentation. In this mode `process_animation_frame` must not diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 688428f5eb..a980cba809 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -408,6 +408,26 @@ impl Surfaces { /// 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) { + self.draw_atlas_to_target_inner(viewbox, dpr, Some(background)); + } + + /// Same as `draw_atlas_to_target` but preserves whatever is already on + /// Target instead of clearing it to the background color first. Used + /// by the progressive pass-1 rebuild so that, when the atlas only + /// partially covers the current viewport, uncovered regions keep + /// their previous content (e.g. tiles rendered directly during an + /// earlier render) instead of flashing to the background color until + /// pass 1 catches up. + pub fn draw_atlas_over_target(&mut self, viewbox: Viewbox, dpr: f32) { + self.draw_atlas_to_target_inner(viewbox, dpr, None); + } + + fn draw_atlas_to_target_inner( + &mut self, + viewbox: Viewbox, + dpr: f32, + background: Option, + ) { if !self.has_atlas() { return; } @@ -421,7 +441,9 @@ impl Surfaces { None, true, ); - canvas.clear(background); + if let Some(bg) = background { + canvas.clear(bg); + } let s = viewbox.zoom * dpr; let atlas_scale = self.atlas_scale.max(0.01); @@ -944,6 +966,7 @@ impl Surfaces { canvas.restore(); } + #[allow(clippy::too_many_arguments)] pub fn cache_current_tile_texture( &mut self, gpu_state: &mut GpuState, @@ -951,6 +974,7 @@ impl Surfaces { tile: &Tile, tile_rect: &skia::Rect, skip_cache_surface: bool, + skip_atlas: bool, tile_doc_rect: skia::Rect, ) { let rect = IRect::from_xywh( @@ -975,8 +999,13 @@ impl Surfaces { // Incrementally update persistent 1:1 atlas in document space. // `tile_doc_rect` is in world/document coordinates (1 unit == 1 px at 100%). - let _ = self.blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect); - self.atlas_tile_doc_rects.insert(*tile, tile_doc_rect); + // Skipped during the progressive pass 1 (defer_effects) so we do + // not contaminate the atlas with shape previews that lack blur + // or shadows — pass 2 will write the final full-quality tiles. + if !skip_atlas { + let _ = self.blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect); + self.atlas_tile_doc_rects.insert(*tile, tile_doc_rect); + } self.tiles.add(tile_viewbox, tile, tile_image); } } @@ -995,6 +1024,16 @@ impl Surfaces { let _ = self.clear_tile_in_atlas(gpu_state, tile); } + /// Evict a tile from the texture cache WITHOUT touching the atlas. + /// Used by the drag-sprite backdrop capture: after re-rendering + /// affected tiles with the dragged subtree excluded, the atlas + /// holds the "scene minus shape" pixels we want; we just need + /// subsequent renders to recompute the tile textures rather than + /// reuse the cached ones (which also lack the shape). + pub fn invalidate_tile_texture_only(&mut self, tile: Tile) { + self.tiles.remove(tile); + } + pub fn draw_cached_tile_surface(&mut self, tile: Tile, rect: skia::Rect, color: skia::Color) { if let Some(image) = self.tiles.get(tile) { let mut paint = skia::Paint::default(); diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index a122b1d8f6..4edb9043e4 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1015,6 +1015,13 @@ impl Shape { } pub fn calculate_extrect(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect { + // Extrect is a pure function of shape geometry (selrect, transform, + // path, strokes, shadows, blur, children) — it does not depend on + // `scale`. The parameter is threaded through for downstream callers + // that need it (e.g. visibility checks that multiply by scale), but + // the cache key must NOT include scale: different callers request + // the same extrect at different scales (render at viewbox.zoom*dpr, + // compute_document_bounds at 1.0) and should share cache entries. if let Some(cached_extrect) = *self.extrect_cache.borrow() { return cached_extrect; } diff --git a/render-wasm/src/shapes/fills.rs b/render-wasm/src/shapes/fills.rs index 5b61b3ee2a..6519b44a10 100644 --- a/render-wasm/src/shapes/fills.rs +++ b/render-wasm/src/shapes/fills.rs @@ -259,15 +259,27 @@ pub fn get_fill_shader(fill: &Fill, bounding_box: &Rect) -> Option } pub fn merge_fills(fills: &[Fill], bounding_box: Rect) -> skia::Paint { - let mut combined_shader: Option = None; let mut fills_paint = skia::Paint::default(); if fills.is_empty() { - combined_shader = Some(skia::shaders::color(skia::Color::TRANSPARENT)); - fills_paint.set_shader(combined_shader); + fills_paint.set_color(skia::Color::TRANSPARENT); return fills_paint; } + // Fast path: a single solid color fill is the overwhelmingly common + // case. Setting the paint's color directly uses Skia's optimized + // solid-fill GPU path (uniform color, no shader). The general + // shader path below builds a `shaders::color` shader and runs the + // fragment-shader pipeline for every pixel — that costs ~10 ms for + // a 595x435 rect at 2x DPR (≈1M pixels) when the rect is dragged. + if fills.len() == 1 { + if let Fill::Solid(SolidColor(color)) = &fills[0] { + fills_paint.set_color(*color); + return fills_paint; + } + } + + let mut combined_shader: Option = None; for fill in fills { let shader = get_fill_shader(fill, &bounding_box); @@ -287,7 +299,7 @@ pub fn merge_fills(fills: &[Fill], bounding_box: Rect) -> skia::Paint { } } - fills_paint.set_shader(combined_shader.clone()); + fills_paint.set_shader(combined_shader); fills_paint }