Reuse atlas backdrop across drag rAFs and defer post-zoom pass 2

This commit is contained in:
Elena Torro 2026-05-05 14:15:28 +02:00
parent c3743930c2
commit 790dfb04f0
4 changed files with 211 additions and 370 deletions

View File

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

View File

@ -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<Uuid, InteractiveDragCrop>,
}
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<Rect>,
) -> 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<RenderState> {
// 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 {
&current_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<Uuid> = 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<Rect> = 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<Uuid>,
) -> 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 {

View File

@ -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

View File

@ -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<skia::Color>,
) {
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<skia::Image> {
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);
}
}