🎉 Use backbuffer + direct-to-target tiles during drag

This commit is contained in:
Alejandro Alonso 2026-04-28 09:35:00 +02:00 committed by Elena Torro
parent 483ce8b1c9
commit e4af37a7ff
4 changed files with 210 additions and 62 deletions

View File

@ -464,6 +464,9 @@ pub extern "C" fn set_modifiers_start() -> Result<()> {
performance::begin_measure!("set_modifiers_start"); performance::begin_measure!("set_modifiers_start");
state.render_state.options.set_fast_mode(true); state.render_state.options.set_fast_mode(true);
state.render_state.options.set_interactive_transform(true); state.render_state.options.set_interactive_transform(true);
// Capture the last fully-rendered frame as a stable backdrop for the drag.
// This avoids relying on atlas/cache correctness during fast_mode.
state.render_state.surfaces.copy_target_to_backbuffer();
performance::end_measure!("set_modifiers_start"); performance::end_measure!("set_modifiers_start");
}); });
Ok(()) Ok(())

View File

@ -341,6 +341,10 @@ pub(crate) struct RenderState {
/// (must composite to present the work). Reset when current_tile /// (must composite to present the work). Reset when current_tile
/// changes. /// changes.
pub current_tile_had_shapes: bool, 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,
} }
pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize { pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize {
@ -415,6 +419,7 @@ impl RenderState {
export_context: None, export_context: None,
cache_cleared_this_render: false, cache_cleared_this_render: false,
current_tile_had_shapes: false, current_tile_had_shapes: false,
interactive_target_seeded: false,
}) })
} }
@ -724,6 +729,16 @@ impl RenderState {
} }
pub fn apply_render_to_final_canvas(&mut self, rect: skia::Rect) -> Result<()> { 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 fast_mode = self.options.is_fast_mode();
// Decide *now* (at the first real cache blit) whether we need to clear Cache. // 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), // This avoids clearing Cache on renders that don't actually paint tiles (e.g. hover/UI),
@ -876,10 +891,14 @@ impl RenderState {
s.canvas().save(); 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.is_fast_mode();
let has_nested_fills = self let has_nested_fills = self
.nested_fills .nested_fills
.last() .last()
@ -922,7 +941,6 @@ impl RenderState {
}); });
fills::render(self, shape, &shape.fills, antialias, target_surface, None)?; fills::render(self, shape, &shape.fills, antialias, target_surface, None)?;
// Pass strokes in natural order; stroke merging handles top-most ordering internally. // Pass strokes in natural order; stroke merging handles top-most ordering internally.
let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect(); let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect();
strokes::render( strokes::render(
@ -1681,30 +1699,28 @@ impl RenderState {
performance::begin_measure!("render"); performance::begin_measure!("render");
performance::begin_measure!("start_render_loop"); performance::begin_measure!("start_render_loop");
self.cache_cleared_this_render = false;
self.reset_canvas();
// Compute and set document-space bounds (1 unit == 1 doc px @ 100% zoom) // 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 // to clamp atlas updates. This prevents zoom-out tiles from forcing atlas
// growth far beyond real content. // growth far beyond real content.
let doc_bounds = self.compute_document_bounds(base_object, tree); let doc_bounds = self.compute_document_bounds(base_object, tree);
self.surfaces.set_atlas_doc_bounds(doc_bounds); self.surfaces.set_atlas_doc_bounds(doc_bounds);
// During an interactive shape transform (drag/resize/rotate) the self.cache_cleared_this_render = false;
// Target is repainted tile-by-tile. If only a subset of the if self.options.is_interactive_transform() {
// invalidated tiles finishes in this rAF the remaining area // Keep `Target` as the previous frame and overwrite only the tiles
// would either show stale content from the previous frame or, // that changed. This avoids clearing + redrawing an atlas backdrop
// on buffer swaps, show blank pixels — either way the user // every rAF during drag (a common source of GPU work/stalls).
// perceives tiles appearing sequentially. Paint the persistent self.surfaces
// 1:1 atlas as a stable backdrop so every flush presents a .reset_interactive_transform(self.background_color);
// coherent picture: unchanged tiles come from the atlas and if !self.interactive_target_seeded {
// invalidated tiles are overwritten on top as they finish. // Seed from the last presented frame; this is stable even when
if self.options.is_interactive_transform() && self.surfaces.has_atlas() { // fast_mode skips cache updates and regardless of atlas coverage.
self.surfaces.draw_atlas_to_target( self.surfaces.seed_target_from_backbuffer();
self.viewbox, self.interactive_target_seeded = true;
self.options.dpr(), }
self.background_color, } else {
); self.reset_canvas();
self.interactive_target_seeded = false;
} }
let surface_ids = SurfaceId::Strokes as u32 let surface_ids = SurfaceId::Strokes as u32
@ -1741,8 +1757,9 @@ impl RenderState {
let _tile_start = performance::begin_timed_log!("tile_cache_update"); let _tile_start = performance::begin_timed_log!("tile_cache_update");
performance::begin_measure!("tile_cache"); performance::begin_measure!("tile_cache");
let only_visible = self.options.is_interactive_transform();
self.pending_tiles self.pending_tiles
.update(&self.tile_viewbox, &self.surfaces); .update(&self.tile_viewbox, &self.surfaces, only_visible);
performance::end_measure!("tile_cache"); performance::end_measure!("tile_cache");
performance::end_timed_log!("tile_cache_update", _tile_start); performance::end_timed_log!("tile_cache_update", _tile_start);
@ -1827,20 +1844,16 @@ impl RenderState {
} }
// In a pure viewport interaction (pan/zoom), render_from_cache // In a pure viewport interaction (pan/zoom), render_from_cache
// owns the Target surface — don't flush Target so we don't // owns the Target surface — skip flush so we don't present
// present stale tile positions. We still drain the GPU command // stale tile positions. The rAF still populates the Cache
// queue with a non-Target `flush_and_submit` so the backlog // surface and tile HashMap so render_from_cache progressively
// of tile-render commands executes incrementally instead of // shows more complete content.
// piling up for hundreds of milliseconds and blowing up the
// next `render_from_cache` call into a multi-frame hitch.
// //
// During interactive shape transforms (drag/resize/rotate) we // During interactive shape transforms (drag/resize/rotate) we
// still need to flush every rAF so the user sees the updated // still need to flush every rAF so the user sees the updated
// shape position — render_from_cache is not in the loop here. // shape position — render_from_cache is not in the loop here.
if !self.options.is_viewport_interaction() { if !self.options.is_viewport_interaction() {
self.flush_and_submit(); self.flush_and_submit();
} else {
self.gpu_state.context.flush_and_submit();
} }
if self.render_in_progress { if self.render_in_progress {
@ -2561,7 +2574,8 @@ impl RenderState {
} }
if let Some(clips) = clip_bounds.as_ref() { if let Some(clips) = clip_bounds.as_ref() {
let antialias = element.should_use_antialias(scale, self.options.antialias_threshold); let antialias = !self.options.is_fast_mode()
&& element.should_use_antialias(scale, self.options.antialias_threshold);
self.surfaces.canvas(target_surface).save(); self.surfaces.canvas(target_surface).save();
for (bounds, corners, transform) in clips.iter() { for (bounds, corners, transform) in clips.iter() {
if target_surface == SurfaceId::Export { if target_surface == SurfaceId::Export {
@ -2905,12 +2919,17 @@ impl RenderState {
if let Some(current_tile) = self.current_tile { if let Some(current_tile) = self.current_tile {
if self.surfaces.has_cached_tile_surface(current_tile) { if self.surfaces.has_cached_tile_surface(current_tile) {
performance::begin_measure!("render_shape_tree::cached"); 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()?; let tile_rect = self.get_current_tile_bounds()?;
self.surfaces.draw_cached_tile_surface( if !self.options.is_interactive_transform() {
current_tile, self.surfaces.draw_cached_tile_surface(
tile_rect, current_tile,
self.background_color, tile_rect,
); self.background_color,
);
}
// Also draw the cached tile to the Cache surface so // Also draw the cached tile to the Cache surface so
// render_from_cache (used during pan) has the full scene. // render_from_cache (used during pan) has the full scene.
@ -2948,18 +2967,19 @@ impl RenderState {
} }
performance::end_measure!("render_shape_tree::uncached"); performance::end_measure!("render_shape_tree::uncached");
let tile_rect = self.get_current_tile_bounds()?; let tile_rect = self.get_current_tile_bounds()?;
// Composite if the walker did work in this PAF // Composite if the walker did work in this PAF (`!is_empty`) OR
// (`!is_empty`) OR the tile has unfinished work from // the tile has unfinished work from a previous PAF
// a previous PAF (`current_tile_had_shapes` was set // (`current_tile_had_shapes` was set when we populated pending_nodes
// when we populated pending_nodes for this tile). // 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 !is_empty || self.current_tile_had_shapes {
self.apply_render_to_final_canvas(tile_rect)?; 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)?;
}
if self.options.is_debug_visible() { if self.options.is_debug_visible() {
debug::render_workspace_current_tile( debug::render_workspace_current_tile(
@ -2975,6 +2995,7 @@ impl RenderState {
paint.set_color(self.background_color); paint.set_color(self.background_color);
s.canvas().draw_rect(tile_rect, &paint); s.canvas().draw_rect(tile_rect, &paint);
}); });
// Keep Cache surface coherent for render_from_cache.
if !self.options.is_fast_mode() { if !self.options.is_fast_mode() {
if !self.cache_cleared_this_render { if !self.cache_cleared_this_render {
self.surfaces.clear_cache(self.background_color); self.surfaces.clear_cache(self.background_color);
@ -3019,17 +3040,28 @@ impl RenderState {
}) })
}); });
// We only need first level shapes, in the same order as the parent node // 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.
let mut valid_ids = Vec::with_capacity(ids.len()); let mut valid_ids = Vec::with_capacity(ids.len());
for root_id in root_ids.iter() { if self.options.is_interactive_transform() || tile_has_bg_blur {
if tile_has_bg_blur || ids.contains(root_id) { valid_ids.extend(root_ids.iter().copied());
valid_ids.push(*root_id); } else {
for root_id in root_ids.iter() {
if ids.contains(root_id) {
valid_ids.push(*root_id);
}
} }
} }
if !valid_ids.is_empty() { if !valid_ids.is_empty() {
self.current_tile_had_shapes = true; self.current_tile_had_shapes = true;
} }
self.pending_nodes.extend(valid_ids.into_iter().map(|id| { self.pending_nodes.extend(valid_ids.into_iter().map(|id| {
NodeRenderState { NodeRenderState {
id, id,
@ -3365,13 +3397,11 @@ impl RenderState {
tree: ShapesPoolMutRef<'_>, tree: ShapesPoolMutRef<'_>,
ids: Vec<Uuid>, ids: Vec<Uuid>,
) -> Result<()> { ) -> Result<()> {
// During interactive transform, skip ancestor invalidation: walking // During interactive transform, skip ancestor invalidation: walking up to the
// up to the parent frame evicts every tile the frame covers, // parent frame evicts every tile the frame covers, including dense tiles with
// including dense tiles with hundreds of siblings. The anti-flicker // many siblings. Ancestor extrect caches are already invalidated by
// guard then forces all of them to re-render in a single frame. // `ShapesPool::set_modifiers`; the tile index is reconciled post-gesture by
// Ancestor extrect caches are already invalidated by // the committing code path (rebuild_touched_tiles).
// `ShapesPool::set_modifiers`; the tile index is reconciled
// post-gesture by the committing code path (rebuild_touched_tiles).
if self.options.is_interactive_transform() { if self.options.is_interactive_transform() {
self.update_tiles_shapes(&ids, tree)?; self.update_tiles_shapes(&ids, tree)?;
} else { } else {

View File

@ -42,6 +42,7 @@ pub enum SurfaceId {
UI = 0b100_0000_0000, UI = 0b100_0000_0000,
Debug = 0b100_0000_0001, Debug = 0b100_0000_0001,
Atlas = 0b100_0000_0010, Atlas = 0b100_0000_0010,
Backbuffer = 0b100_0000_0100,
} }
pub struct Surfaces { pub struct Surfaces {
@ -67,6 +68,8 @@ pub struct Surfaces {
debug: skia::Surface, debug: skia::Surface,
// for drawing tiles. // for drawing tiles.
export: skia::Surface, export: skia::Surface,
// Persistent viewport-sized surface used to keep the last presented frame.
backbuffer: skia::Surface,
tiles: TileTextureCache, tiles: TileTextureCache,
// Persistent 1:1 document-space atlas that gets incrementally updated as tiles render. // Persistent 1:1 document-space atlas that gets incrementally updated as tiles render.
@ -112,6 +115,8 @@ impl Surfaces {
let target = gpu_state.create_target_surface(width, height)?; let target = gpu_state.create_target_surface(width, height)?;
let filter = gpu_state.create_surface_with_isize("filter".to_string(), extra_tile_dims)?; let filter = gpu_state.create_surface_with_isize("filter".to_string(), extra_tile_dims)?;
let cache = gpu_state.create_surface_with_dimensions("cache".to_string(), width, height)?; let cache = gpu_state.create_surface_with_dimensions("cache".to_string(), width, height)?;
let backbuffer =
gpu_state.create_surface_with_dimensions("backbuffer".to_string(), width, height)?;
let current = let current =
gpu_state.create_surface_with_isize("current".to_string(), extra_tile_dims)?; gpu_state.create_surface_with_isize("current".to_string(), extra_tile_dims)?;
@ -148,6 +153,7 @@ impl Surfaces {
ui, ui,
debug, debug,
export, export,
backbuffer,
tiles, tiles,
atlas, atlas,
atlas_origin: skia::Point::new(0.0, 0.0), atlas_origin: skia::Point::new(0.0, 0.0),
@ -582,6 +588,9 @@ impl Surfaces {
if ids & SurfaceId::Cache as u32 != 0 { if ids & SurfaceId::Cache as u32 != 0 {
f(self.get_mut(SurfaceId::Cache)); f(self.get_mut(SurfaceId::Cache));
} }
if ids & SurfaceId::Backbuffer as u32 != 0 {
f(self.get_mut(SurfaceId::Backbuffer));
}
if ids & SurfaceId::Fills as u32 != 0 { if ids & SurfaceId::Fills as u32 != 0 {
f(self.get_mut(SurfaceId::Fills)); f(self.get_mut(SurfaceId::Fills));
} }
@ -656,6 +665,7 @@ impl Surfaces {
SurfaceId::Target => &mut self.target, SurfaceId::Target => &mut self.target,
SurfaceId::Filter => &mut self.filter, SurfaceId::Filter => &mut self.filter,
SurfaceId::Cache => &mut self.cache, SurfaceId::Cache => &mut self.cache,
SurfaceId::Backbuffer => &mut self.backbuffer,
SurfaceId::Current => &mut self.current, SurfaceId::Current => &mut self.current,
SurfaceId::DropShadows => &mut self.drop_shadows, SurfaceId::DropShadows => &mut self.drop_shadows,
SurfaceId::InnerShadows => &mut self.inner_shadows, SurfaceId::InnerShadows => &mut self.inner_shadows,
@ -674,6 +684,7 @@ impl Surfaces {
SurfaceId::Target => &self.target, SurfaceId::Target => &self.target,
SurfaceId::Filter => &self.filter, SurfaceId::Filter => &self.filter,
SurfaceId::Cache => &self.cache, SurfaceId::Cache => &self.cache,
SurfaceId::Backbuffer => &self.backbuffer,
SurfaceId::Current => &self.current, SurfaceId::Current => &self.current,
SurfaceId::DropShadows => &self.drop_shadows, SurfaceId::DropShadows => &self.drop_shadows,
SurfaceId::InnerShadows => &self.inner_shadows, SurfaceId::InnerShadows => &self.inner_shadows,
@ -687,6 +698,29 @@ impl Surfaces {
} }
} }
/// Copy the current `Target` contents into the persistent `Backbuffer`.
/// This is a GPU→GPU copy via Skia (no ReadPixels).
pub fn copy_target_to_backbuffer(&mut self) {
let sampling_options = self.sampling_options;
self.target.clone().draw(
self.backbuffer.canvas(),
(0.0, 0.0),
sampling_options,
Some(&skia::Paint::default()),
);
}
/// Seed `Target` from `Backbuffer` (last presented frame).
pub fn seed_target_from_backbuffer(&mut self) {
let sampling_options = self.sampling_options;
self.backbuffer.clone().draw(
self.target.canvas(),
(0.0, 0.0),
sampling_options,
Some(&skia::Paint::default()),
);
}
fn reset_from_target(&mut self, target: skia::Surface) -> Result<()> { fn reset_from_target(&mut self, target: skia::Surface) -> Result<()> {
let dim = (target.width(), target.height()); let dim = (target.width(), target.height());
self.target = target; self.target = target;
@ -694,6 +728,10 @@ impl Surfaces {
.target .target
.new_surface_with_dimensions(dim) .new_surface_with_dimensions(dim)
.ok_or(Error::CriticalError("Failed to create surface".to_string()))?; .ok_or(Error::CriticalError("Failed to create surface".to_string()))?;
self.backbuffer = self
.target
.new_surface_with_dimensions(dim)
.ok_or(Error::CriticalError("Failed to create surface".to_string()))?;
self.debug = self self.debug = self
.target .target
.new_surface_with_dimensions(dim) .new_surface_with_dimensions(dim)
@ -850,6 +888,43 @@ impl Surfaces {
self.clear_all_dirty(); self.clear_all_dirty();
} }
/// Reset render surfaces for interactive transforms without clearing `Target`.
/// Keeping `Target` avoids having to redraw an atlas backdrop each frame; we
/// then overwrite only the tiles that changed.
pub fn reset_interactive_transform(&mut self, color: skia::Color) {
self.canvas(SurfaceId::Fills).restore_to_count(1);
self.canvas(SurfaceId::InnerShadows).restore_to_count(1);
self.canvas(SurfaceId::TextDropShadows).restore_to_count(1);
self.canvas(SurfaceId::DropShadows).restore_to_count(1);
self.canvas(SurfaceId::Strokes).restore_to_count(1);
self.canvas(SurfaceId::Current).restore_to_count(1);
self.canvas(SurfaceId::Export).restore_to_count(1);
// Clear tile-sized/intermediate surfaces; leave Target intact.
self.apply_mut(
SurfaceId::Fills as u32
| SurfaceId::Strokes as u32
| SurfaceId::Current as u32
| SurfaceId::InnerShadows as u32
| SurfaceId::TextDropShadows as u32
| SurfaceId::DropShadows as u32
| SurfaceId::Export as u32,
|s| {
s.canvas().clear(color).reset_matrix();
},
);
// UI/debug can be redrawn; clearing them is fine.
self.canvas(SurfaceId::Debug)
.clear(skia::Color::TRANSPARENT)
.reset_matrix();
self.canvas(SurfaceId::UI)
.clear(skia::Color::TRANSPARENT)
.reset_matrix();
self.clear_all_dirty();
}
/// Clears the whole cache surface without disturbing its configured transform. /// Clears the whole cache surface without disturbing its configured transform.
pub fn clear_cache(&mut self, color: skia::Color) { pub fn clear_cache(&mut self, color: skia::Color) {
let canvas = self.cache.canvas(); let canvas = self.cache.canvas();
@ -987,6 +1062,37 @@ impl Surfaces {
); );
} }
/// Same as `draw_current_tile_direct` but draws only into Target.
/// Useful during interactive transforms to reduce extra GPU work.
pub fn draw_current_tile_direct_target_only(
&mut self,
tile_rect: &skia::Rect,
color: skia::Color,
) {
let sampling_options = self.sampling_options;
let src_rect = IRect::from_xywh(
self.margins.width,
self.margins.height,
self.current.width() - TILE_SIZE_MULTIPLIER * self.margins.width,
self.current.height() - TILE_SIZE_MULTIPLIER * self.margins.height,
);
let src_rect_f = skia::Rect::from(src_rect);
let mut paint = skia::Paint::default();
paint.set_color(color);
self.target.canvas().draw_rect(tile_rect, &paint);
self.current.clone().draw(
self.target.canvas(),
(
tile_rect.left - src_rect_f.left,
tile_rect.top - src_rect_f.top,
),
sampling_options,
None,
);
}
/// Full cache reset: clears both the tile texture cache and the cache canvas. /// Full cache reset: clears both the tile texture cache and the cache canvas.
/// Used by `rebuild_tiles` (full rebuild). For shallow rebuilds that preserve /// Used by `rebuild_tiles` (full rebuild). For shallow rebuilds that preserve
/// the cache canvas for scaled previews, use `invalidate_tile_cache` instead. /// the cache canvas for scaled previews, use `invalidate_tile_cache` instead.

View File

@ -265,11 +265,20 @@ impl PendingTiles {
result result
} }
pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces) { pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces, only_visible: bool) {
self.list.clear(); self.list.clear();
// Generate spiral for the interest area (viewport + margin) // During interactive transform, skip the interest-area ring
let spiral = Self::generate_spiral(&tile_viewbox.interest_rect); // entirely — the user is dragging, every rAF is on the critical
// path, and pre-rendering tiles outside the viewport is wasted
// work that just gets evicted on the next pointer move. The ring
// is repopulated naturally on gesture end / on idle rAFs.
let spiral_rect = if only_visible {
&tile_viewbox.visible_rect
} else {
&tile_viewbox.interest_rect
};
let spiral = Self::generate_spiral(spiral_rect);
// Partition tiles into 4 priority groups (highest priority = processed last due to pop()): // Partition tiles into 4 priority groups (highest priority = processed last due to pop()):
// 1. visible + cached (fastest - just blit from cache) // 1. visible + cached (fastest - just blit from cache)