From 790dfb04f0bdc3f20d04f0ef8825cf40782c3861 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Tue, 5 May 2026 14:15:28 +0200 Subject: [PATCH] :zap: Reuse atlas backdrop across drag rAFs and defer post-zoom pass 2 --- render-wasm/src/main.rs | 26 +- render-wasm/src/render.rs | 492 ++++++++--------------------- render-wasm/src/render/options.rs | 23 ++ render-wasm/src/render/surfaces.rs | 40 ++- 4 files changed, 211 insertions(+), 370 deletions(-) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 380e0e1f47..ad6ce8a3e6 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -415,6 +415,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(()) @@ -449,6 +452,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 @@ -474,6 +482,18 @@ pub extern "C" fn set_modifiers_start() -> Result<()> { performance::begin_measure!("set_modifiers_start"); state.render_state.options.set_fast_mode(true); state.render_state.options.set_interactive_transform(true); + // Force the next interactive-rAF to re-seed Target by blitting + // the persistent atlas as a backdrop. After that, Target is + // kept coherent tile-by-tile by the render walker for the rest + // of the gesture, so subsequent rAFs skip the full-viewport + // blit. + state.render_state.interactive_backdrop_drawn = false; + // 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(()) @@ -994,7 +1014,11 @@ pub extern "C" fn set_modifiers() -> Result<()> { with_state_mut!(state, { 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)?; } diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 97564b7ac8..9ab1436606 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -372,6 +372,23 @@ 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, + /// Set when pass 1 of the post-zoom two-pass rebuild has just + /// finished and pass 2 should start on the next animation frame. + /// Checked at the top of `process_animation_frame`. + pub pass_2_pending: bool, + /// One-shot flag for the interactive-transform Target seed. + /// `start_render_loop` blits the persistent atlas onto Target on + /// the first interactive-transform rAF and sets this to true; on + /// subsequent rAFs the blit is skipped because Target already + /// holds a coherent picture (per-tile renders during the gesture + /// keep it up to date in place). Reset in `set_modifiers_start`. + pub interactive_backdrop_drawn: bool, /// 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,28 +396,6 @@ 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 - pub backbuffer_crop_cache: HashMap, -} - -pub struct InteractiveDragCrop { - pub src_doc_bounds: Rect, - pub src_selrect: Rect, - /// True if the captured crop bounds were fully inside the viewport at capture time. - /// Used to avoid serving partial/offscreen crops during interactive drag. - pub fits_viewport_at_capture: bool, - /// Viewbox origin (doc-space) at capture time. - pub capture_vb_left: f32, - pub capture_vb_top: f32, - /// Backbuffer pixel origin used for `snapshot_rect` (so we can do 1:1 blits). - pub capture_src_left: i32, - pub capture_src_top: i32, - pub image: skia::Image, } pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize { @@ -420,76 +415,6 @@ pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISiz } impl RenderState { - /// Decide whether a top-level node can be served from `backbuffer_crop_cache` during an - /// interactive transform (drag/resize/rotate). - /// - /// We only reuse cached pixels when it is safe and visually correct: - /// - **Top-level only**: cache entries are built for direct children of the root. - /// - **Moved node**: only allow cache reuse for *pure translations* (no scale/rotate/skew), - /// 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. - fn should_use_cached_top_level_during_interactive( - &mut self, - node_id: Uuid, - tree: ShapesPoolRef, - moved_ids: &[Uuid], - moved_bounds: Option, - ) -> bool { - if !self.backbuffer_crop_cache.contains_key(&node_id) { - return false; - } - let Some(raw) = tree.get_raw(&node_id) else { - return false; - }; - if raw.parent_id != Some(Uuid::nil()) { - return false; - } - - // If this top-level shape itself is being moved, always allow using its cached pixels. - // BUT only for pure translations. For non-translation transforms (scale/rotate/skew), - // cached pixels won't match the live result (and may require resampling), so render live. - if moved_ids.contains(&node_id) { - let Some(m) = tree.get_modifier(&node_id) else { - return false; - }; - // Only allow using the cached pixels for pure translations. - // For non-translation transforms (scale/rotate/skew), cached pixels won't match. - if !crate::math::is_move_only_matrix(m) { - return false; - } - - let Some(crop) = self.backbuffer_crop_cache.get(&node_id) else { - return false; - }; - if !crop.fits_viewport_at_capture { - return false; - } - - // Additionally require this node to be safe to serve from a rectangular backbuffer - // crop while moving; otherwise it must be rendered live (e.g. text, overflow frames). - return tree - .get(&node_id) - .is_some_and(|s| s.is_safe_for_drag_crop_cache(tree)); - } - - // If the moving content overlaps this cached crop, do not use the cached pixels - // for this frame. We intentionally keep the cache entry: overlap is typically - // transient during drag, and once the moving content leaves the area the crop - // becomes valid again (stationary shape unchanged). - if let Some(moved) = moved_bounds { - let intersects = self - .backbuffer_crop_cache - .get(&node_id) - .is_some_and(|crop| moved.intersects(crop.src_doc_bounds)); - - if intersects { - return false; - } - } - true - } - pub fn try_new(width: i32, height: i32) -> Result { // This needs to be done once per WebGL context. let mut gpu_state = GpuState::try_new()?; @@ -545,9 +470,10 @@ impl RenderState { preview_mode: false, export_context: None, cache_cleared_this_render: false, + preserve_cache_this_render: false, + pass_2_pending: false, + interactive_backdrop_drawn: false, current_tile_had_shapes: false, - interactive_target_seeded: false, - backbuffer_crop_cache: HashMap::default(), }) } @@ -605,7 +531,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(_)) @@ -864,17 +790,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. @@ -895,6 +812,7 @@ impl RenderState { ¤t_tile, &tile_rect, fast_mode, + skip_atlas, self.render_area, ); @@ -1029,13 +947,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 @@ -1647,103 +1562,6 @@ impl RenderState { } } - fn rebuild_backbuffer_crop_cache(&mut self, tree: ShapesPoolRef) { - self.backbuffer_crop_cache.clear(); - - // Collect candidate shapes that are "recortable" and visible in the current viewport. - // This is intentionally conservative; we only cache shapes that do not overlap with - // ANY other candidate to guarantee the pixels under their bounds belong exclusively - // to that shape in Backbuffer. - let viewport = self.viewbox.area; - let mut candidates: Vec<(Uuid, Rect, Rect)> = Vec::new(); // (id, doc_bounds, selrect) - - let root_ids: Vec = match tree.get(&Uuid::nil()) { - Some(root) => root.children_ids(false), - None => Vec::new(), - }; - - for shape_id in root_ids { - let Some(shape) = tree.get(&shape_id) else { - continue; - }; - if shape.hidden { - continue; - } - - let doc_bounds = self.get_cached_extrect(shape, tree, 1.0); - if !doc_bounds.intersects(viewport) { - continue; - } - - // Also require selrect to be visible; used for drag delta placement. - let selrect = shape.selrect(); - if !selrect.intersects(viewport) { - continue; - } - - candidates.push((shape.id, doc_bounds, selrect)); - } - - // Filter out any candidate that overlaps with any other candidate. - let mut non_overlapping: Vec<(Uuid, Rect, Rect)> = Vec::new(); - 'outer: for (i, (id, bounds, selrect)) in candidates.iter().enumerate() { - for (j, (_id2, bounds2, _sel2)) in candidates.iter().enumerate() { - if i == j { - continue; - } - if bounds.intersects(*bounds2) { - continue 'outer; - } - } - non_overlapping.push((*id, *bounds, *selrect)); - } - - // Snapshot from Backbuffer for each accepted shape. - let scale = self.get_scale(); - let vb_left = self.viewbox.area.left; - let vb_top = self.viewbox.area.top; - for (id, doc_bounds, selrect) in non_overlapping { - let left = ((doc_bounds.left - vb_left) * scale).floor() as i32; - let top = ((doc_bounds.top - vb_top) * scale).floor() as i32; - let right = ((doc_bounds.right - vb_left) * scale).ceil() as i32; - let bottom = ((doc_bounds.bottom - vb_top) * scale).ceil() as i32; - if right <= left || bottom <= top { - continue; - } - let src_irect = skia::IRect::new(left, top, right, bottom); - let Some(image) = self - .surfaces - .snapshot_rect(SurfaceId::Backbuffer, src_irect) - else { - continue; - }; - - let src_doc_bounds = Rect::new( - src_irect.left as f32 / scale + vb_left, - src_irect.top as f32 / scale + vb_top, - src_irect.right as f32 / scale + vb_left, - src_irect.bottom as f32 / scale + vb_top, - ); - let fits_viewport_at_capture = doc_bounds.left >= viewport.left - && doc_bounds.top >= viewport.top - && doc_bounds.right <= viewport.right - && doc_bounds.bottom <= viewport.bottom; - self.backbuffer_crop_cache.insert( - id, - InteractiveDragCrop { - src_doc_bounds, - src_selrect: selrect, - fits_viewport_at_capture, - capture_vb_left: vb_left, - capture_vb_top: vb_top, - capture_src_left: src_irect.left, - capture_src_top: src_irect.top, - image, - }, - ); - } - } - pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) { let _start = performance::begin_timed_log!("render_from_cache"); performance::begin_measure!("render_from_cache"); @@ -1940,6 +1758,7 @@ impl RenderState { self.stats.clear(); let _start = performance::begin_timed_log!("start_render_loop"); + let scale = self.get_scale(); self.tile_viewbox.update(self.viewbox, scale); @@ -1948,28 +1767,57 @@ 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; + // 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. We seed Target by + // blitting the persistent 1:1 atlas as a stable backdrop on + // the FIRST rAF of the gesture only. Subsequent rAFs reuse + // Target as-is (it persists across rAFs in the WebGL context + // with `preserveDrawingBuffer = true`); the per-tile walker + // overwrites the changed tiles via `draw_cached_tile_surface` + // and untouched tiles keep their seed content. This avoids a + // viewport-sized GPU blit every rAF — the dominant fixed cost + // during drag on zoomed-out heavy files. + // + // 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() { - // 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). - 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; + if !self.interactive_backdrop_drawn && self.surfaces.has_atlas() { + self.surfaces.draw_atlas_to_target( + self.viewbox, + self.options.dpr, + self.background_color, + ); + self.interactive_backdrop_drawn = true; } - } else { - self.reset_canvas(); - self.interactive_target_seeded = false; + } else if self.options.is_defer_effects() && self.surfaces.has_atlas() { + self.surfaces + .draw_atlas_over_target(self.viewbox, self.options.dpr); } let surface_ids = SurfaceId::Strokes as u32 @@ -2087,6 +1935,18 @@ impl RenderState { timestamp: i32, ) -> Result<()> { performance::begin_measure!("process_animation_frame"); + + // Pass 2 of the post-zoom two-pass rebuild was scheduled on the + // previous frame. Run it now via a fresh `start_render_loop` + // and return — running pass 2 inline at the tail of pass 1 was + // doubling the per-PAF budget on heavy files, defeating the + // point of splitting passes. + if self.pass_2_pending { + self.pass_2_pending = false; + performance::end_measure!("process_animation_frame"); + return self.start_render_loop(base_object, tree, timestamp, false); + } + if self.render_in_progress { if tree.len() != 0 { self.render_shape_tree_partial(base_object, tree, timestamp, true)?; @@ -2108,13 +1968,21 @@ 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. Schedule pass 2 for the NEXT animation + // frame (instead of running it inline) so the post-zoom + // frame budget isn't doubled on heavy files. The tile + // texture cache is invalidated so every tile re-renders + // with effects; 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.pass_2_pending = true; + self.render_request_id = Some(wapi::request_animation_frame!()); } 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); - } wapi::notify_tiles_render_complete!(); performance::end_measure!("render"); } @@ -2310,8 +2178,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); @@ -2915,29 +2784,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; @@ -3008,67 +2854,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 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 scale = self.get_scale(); - let translation = self - .surfaces - .get_render_context_translation(self.render_area, scale); - - let canvas = self.surfaces.canvas(target_surface); - canvas.save(); - canvas.reset_matrix(); - // If the crop includes shadows/blur (extrect pixels outside the fill/stroke - // silhouette), do NOT apply the silhouette clip or we'd cut those pixels. - let should_clip_crop = element.shadows.is_empty() && element.blur.is_none(); - if should_clip_crop { - if let Some(clip_path) = element.drag_crop_clip_path() { - let mut doc_to_tile = Matrix::new_identity(); - // Map document-space coordinates into tile pixels. - // Rendering surfaces apply: scale(scale) then translate(translation) in doc units. - // Equivalent point mapping: (doc + translation) * scale. - doc_to_tile.post_translate((translation.0, translation.1)); - doc_to_tile.post_scale((scale, scale), None); - let clip_path = clip_path.make_transform(&doc_to_tile); - canvas.clip_path(&clip_path, skia::ClipOp::Intersect, true); - } - } - let doc_left = - crop.capture_vb_left + (crop.capture_src_left as f32 / scale) + dx; - let doc_top = - crop.capture_vb_top + (crop.capture_src_top as f32 / scale) + dy; - - let x = (doc_left + translation.0) * scale; - let y = (doc_top + translation.1) * scale; - canvas.draw_image(crop_image, (x, y), Some(&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 @@ -3082,7 +2867,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(); @@ -3118,8 +2903,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(); @@ -3258,17 +3044,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. @@ -3306,19 +3087,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( @@ -3334,7 +3114,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); @@ -3379,28 +3158,17 @@ 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() { + 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, @@ -3736,11 +3504,13 @@ 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). + // 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). if self.options.is_interactive_transform() { self.update_tiles_shapes(&ids, tree)?; } else { diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 0f80b28bcb..8181489fc6 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, @@ -38,6 +43,7 @@ impl Default for RenderOptions { dpr: 1.0, fast_mode: false, interactive_transform: false, + defer_effects: false, antialias_threshold: ANTIALIAS_THRESHOLD, viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD, dpr_viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD, @@ -96,6 +102,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..113c083397 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); @@ -493,11 +515,6 @@ impl Surfaces { } } - pub fn snapshot_rect(&mut self, id: SurfaceId, irect: skia::IRect) -> Option { - let surface = self.get_mut(id); - surface.image_snapshot_with_bounds(irect) - } - /// Returns a mutable reference to the canvas and automatically marks /// render surfaces as dirty when accessed. This tracks which surfaces /// have content for optimization purposes. @@ -944,6 +961,7 @@ impl Surfaces { canvas.restore(); } + #[allow(clippy::too_many_arguments)] pub fn cache_current_tile_texture( &mut self, gpu_state: &mut GpuState, @@ -951,6 +969,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 +994,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); } }