diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 68b0b96453..67de08bd28 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -401,6 +401,11 @@ pub(crate) struct RenderState { /// 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, + /// When true, the next `start_render_loop` keeps the last presented `Target` + /// pixels instead of clearing the canvas. Set after incremental shape updates + /// (e.g. adding a rect) so the workspace stays visible while only affected + /// tiles are re-rendered asynchronously. + pub preserve_target_during_render: bool, /// GPU crops from `Backbuffer` or tile atlas 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, @@ -584,6 +589,7 @@ impl RenderState { cache_cleared_this_render: false, current_tile_had_shapes: false, interactive_target_seeded: false, + preserve_target_during_render: false, backbuffer_crop_cache: HashMap::default(), }) } @@ -1994,6 +2000,24 @@ impl RenderState { self.surfaces .draw_atlas_to_backbuffer(self.viewbox, bg_color); + // For pure pan (same zoom), overlay any cached per-tile textures on top of the atlas. + // This reduces the "rubbery"/distorted look of atlas-only previews while keeping + // fast-mode responsive. For zoom, the tile grid changes so cached tiles would be + // mispositioned — skip them. + if !self.zoom_changed() { + let visible_rect = tiles::get_tiles_for_viewbox(&self.viewbox); + let offset = self.viewbox.get_offset(); + for tx in visible_rect.x1()..=visible_rect.x2() { + for ty in visible_rect.y1()..=visible_rect.y2() { + let tile = tiles::Tile::from(tx, ty); + if self.surfaces.has_cached_tile_surface(tile) { + let rect = tile.get_rect_with_offset(&offset); + self.surfaces.draw_cached_tile_into_backbuffer(tile, &rect); + } + } + } + } + self.present_frame(shapes); performance::end_measure!("render_from_cache"); performance::end_timed_log!("render_from_cache", _start); @@ -2169,6 +2193,9 @@ impl RenderState { self.surfaces.atlas.set_doc_bounds(doc_bounds); self.cache_cleared_this_render = false; + let preserve_target = self.preserve_target_during_render; + self.preserve_target_during_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 @@ -2180,6 +2207,15 @@ impl RenderState { // fast_mode skips cache updates and regardless of atlas coverage. self.interactive_target_seeded = true; } + } else if preserve_target || self.zoom_changed() { + // Shape updates or zoom-end: keep the last presented frame on screen + // while tiles are re-rendered asynchronously. During zoom the + // preview from render_from_cache stays visible until the full- + // quality pass completes. + self.surfaces + .reset_interactive_transform(self.background_color); + self.surfaces.seed_backbuffer_from_target(); + self.interactive_target_seeded = false; } else { self.reset_canvas(); self.interactive_target_seeded = false; @@ -3941,6 +3977,7 @@ impl RenderState { let mut all_tiles = HashSet::::new(); let ids = std::mem::take(&mut self.touched_ids); + self.preserve_target_during_render = !ids.is_empty(); for shape_id in ids.iter() { if let Some(shape) = tree.get(shape_id) { diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 3f663bcd16..7a6a3a3d39 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -9,7 +9,7 @@ const SHOW_WASM_INFO: u32 = 0x08; // Higher values pre-render more tiles, reducing empty squares during pan but using more memory. const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 1; const MIN_DPR_VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 2; -const MAX_BLOCKING_TIME_MS: i32 = 8; +const MAX_BLOCKING_TIME_MS: i32 = 32; const NODE_BATCH_THRESHOLD: i32 = 3; const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0; const ANTIALIAS_THRESHOLD: f32 = 7.0; diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index ed9182c1f6..3937976cdf 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -424,6 +424,7 @@ pub struct Surfaces { backbuffer: skia::Surface, // Atlas used to keep tiles. tile_atlas: skia::Surface, + tile_atlas_image: Option, tiles: TileTextureCache, pub atlas: DocAtlas, @@ -500,6 +501,7 @@ impl Surfaces { export, backbuffer, tile_atlas, + tile_atlas_image: None, tiles, atlas, sampling_options, @@ -529,11 +531,17 @@ impl Surfaces { background: skia::Color, ) { self.tiles.update(viewbox, tile_viewbox); - let atlas_image = self.tile_atlas.image_snapshot(); + if self.tiles.needs_snapshot() || self.tile_atlas_image.is_none() { + self.tile_atlas_image = Some(self.tile_atlas.image_snapshot()); + self.tiles.snapshot(); + } + let Some(atlas_image) = self.tile_atlas_image.as_ref() else { + return; + }; let canvas = self.backbuffer.canvas(); canvas.clear(background); canvas.draw_atlas( - &atlas_image, + atlas_image, &self.tiles.transforms, &self.tiles.textures, None, @@ -1403,6 +1411,7 @@ impl Surfaces { pub fn invalidate_tile_cache(&mut self) { self.tiles.clear(); self.atlas.tile_doc_rects.clear(); + self.tile_atlas_image = None; } pub fn gc(&mut self) { @@ -1532,6 +1541,7 @@ impl TileAtlasTextureProvider { pub struct TileTextureCache { tile_size: f32, + is_updated: bool, provider: TileAtlasTextureProvider, transforms: Vec, textures: Vec, @@ -1543,6 +1553,7 @@ impl TileTextureCache { pub fn new(texture_size: i32, capacity: usize) -> Self { Self { tile_size: tiles::TILE_SIZE, + is_updated: false, provider: TileAtlasTextureProvider::new(texture_size, TILE_SIZE), transforms: Vec::with_capacity(capacity), textures: Vec::with_capacity(capacity), @@ -1560,6 +1571,14 @@ impl TileTextureCache { } } + pub fn needs_snapshot(&self) -> bool { + self.is_updated + } + + pub fn snapshot(&mut self) { + self.is_updated = false; + } + fn gc_non_visible(&mut self, tile_viewbox: &TileViewbox) { let marked: Vec<_> = self .grid @@ -1610,6 +1629,10 @@ impl TileTextureCache { continue; }; + if self.removed.contains(&tile) { + continue; + } + self.transforms[index].tx = x as f32 * self.tile_size - offset.x; self.transforms[index].ty = y as f32 * self.tile_size - offset.y; @@ -1652,6 +1675,7 @@ impl TileTextureCache { self.removed.remove(tile); } + self.is_updated = true; tile_ref.clone() } @@ -1668,6 +1692,7 @@ impl TileTextureCache { self.textures[tile_ref.index].set_empty(); } } + self.is_updated = true; self.removed.insert(tile); } @@ -1675,5 +1700,6 @@ impl TileTextureCache { for k in self.grid.keys() { self.removed.insert(*k); } + self.is_updated = true; } } diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index aa2fbd8d78..6e1a283110 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -214,6 +214,11 @@ impl State { /// invalidated and recalculated to include the new child. This ensures that frames /// and groups properly encompass their children. pub fn set_parent_for_current_shape(&mut self, id: Uuid) { + // Reparent preview during drag is handled by structure modifiers only. + if get_render_state().options.is_interactive_transform() { + return; + } + let Some(shape) = self.current_shape_mut() else { panic!("Invalid current shape") };