diff --git a/.serena/memories/render-wasm/ffi-rendering-subtleties.md b/.serena/memories/render-wasm/ffi-rendering-subtleties.md index aac0201522..7d7d56d7fd 100644 --- a/.serena/memories/render-wasm/ffi-rendering-subtleties.md +++ b/.serena/memories/render-wasm/ffi-rendering-subtleties.md @@ -26,7 +26,9 @@ - During interactive transform, modifier tile invalidation is deferred to `render()` once per rAF. Outside interactive transform, `set_modifiers` rebuilds modifier tiles immediately. - `set_modifiers_end` disables fast/interactive state and cancels pending async render; the caller must request the final full-quality render. - Plain viewport fast mode (`options.is_viewport_interaction()`) renders from cache and does not flush target output inside `process_animation_frame`; interactive transforms do flush. -- Zoom changes rebuild the tile index while preserving cached tile textures. Avoid replacing that path with shallow rebuilds if blur/shadow cache preservation matters. +- Zoom settle wipes the tile texture cache in `set_view_end`. Mid-zoom overlays + key tiles by scale; shape edits must `invalidate_cached_tiles_intersecting` + the old∪new extrect so those overlays do not keep pre-edit pixels. - Pending tile priority is intentionally reversed by pop order; check the queue construction before changing tile scheduling. - Frames with a fill may use `render_frame_container_drop_shadow` (direct rrect + - blur saveLayer on `DropShadows`) when `uses_direct_container_drop_shadow` is true. \ No newline at end of file + blur saveLayer on `DropShadows`) when `uses_direct_container_drop_shadow` is true. diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index f435bf6697..985a2ca57a 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -383,6 +383,8 @@ pub(crate) struct RenderState { /// Frame id passed as `base_object` for viewer renders; always traversed. pub viewer_render_root: Option, pub touched_ids: HashSet, + /// Pre-edit extrects for old∪new tile eviction (captured on first touch). + touched_prev_extrects: HashMap, /// Temporary flag used for off-screen passes (drop-shadow masks, filter surfaces, etc.) /// where we must render shapes without inheriting ancestor layer blurs. Toggle it through /// `with_nested_blurs_suppressed` to ensure it's always restored. @@ -603,6 +605,7 @@ impl RenderState { include_filter: None, viewer_render_root: None, touched_ids: HashSet::default(), + touched_prev_extrects: HashMap::default(), ignore_nested_blurs: false, preview_mode: false, export_context: None, @@ -1148,6 +1151,8 @@ impl RenderState { &tile_rect, false, self.render_area, + self.get_scale(), + self.viewbox.area, ); Ok(()) @@ -2406,7 +2411,7 @@ impl RenderState { performance::begin_measure!("tile_cache"); let only_visible = self.options.is_interactive_transform(); self.pending_tiles - .update(&self.tile_viewbox, &self.surfaces, only_visible); + .update(&self.tile_viewbox, &self.surfaces, scale, only_visible); performance::end_measure!("tile_cache"); performance::end_timed_log!("tile_cache_update", _tile_start); @@ -2498,11 +2503,21 @@ impl RenderState { && !self.viewport_presented; if should_compose { - self.surfaces.draw_tile_atlas_to_backbuffer( - &self.viewbox, - &self.tile_viewbox, - self.background_color, - ); + // Fast mode skips the tile atlas; use the same doc-atlas + scale + // overlays as render_from_cache instead of composing empty slots. + if self.options.is_fast_mode() { + self.surfaces.draw_combined_atlas_to_backbuffer( + &self.viewbox, + &self.tile_viewbox, + self.background_color, + ); + } else { + self.surfaces.draw_tile_atlas_to_backbuffer( + &self.viewbox, + &self.tile_viewbox, + self.background_color, + ); + } } match frame_type { @@ -3923,7 +3938,10 @@ impl RenderState { // is not cached because everything will be handled from draw_atlas. // Viewer masked passes (include_filter) must not reuse cached tiles from // a previous pass; otherwise pass-1 pixels can leak into pass 2. - if self.viewer_masked_pass() || !self.surfaces.has_cached_tile_surface(current_tile) + if self.viewer_masked_pass() + || !self + .surfaces + .has_cached_tile_surface(current_tile, self.get_scale()) { performance::begin_measure!("render_shape_tree::uncached"); let (is_empty, early_return) = self @@ -3968,7 +3986,9 @@ impl RenderState { } } } else if self.tiles.is_empty_at(current_tile) { - self.surfaces.remove_cached_tile_surface(current_tile); + // Keep other-scale entries for mid-zoom overlays. + self.surfaces + .remove_cached_tile_surface_at(current_tile, self.get_scale()); } } @@ -3989,6 +4009,7 @@ impl RenderState { self.drop_shadows_ops_warmed = false; let viewer_masked_pass = self.viewer_masked_pass(); + let current_scale = self.get_scale(); let Some(ids) = self.tiles.get_shapes_at(next_tile) else { // If the tile is empty we do not need to render it. @@ -3996,7 +4017,11 @@ impl RenderState { }; // Never skip based on cached surfaces during viewer masked passes. - if !viewer_masked_pass && self.surfaces.has_cached_tile_surface(next_tile) { + if !viewer_masked_pass + && self + .surfaces + .has_cached_tile_surface(next_tile, current_scale) + { // If the tile is cached, then we do not need to // render it. continue; @@ -4333,9 +4358,8 @@ impl RenderState { pub fn rebuild_touched_tiles(&mut self, tree: ShapesPoolRef) { performance::begin_measure!("rebuild_touched_tiles"); - let mut all_tiles = HashSet::::new(); - let ids = std::mem::take(&mut self.touched_ids); + let prev_extrects = std::mem::take(&mut self.touched_prev_extrects); // Pan release sets `preserve_target` in `set_view_end`; don't reset it // here when no shapes changed, or the next render clears the canvas. if !ids.is_empty() { @@ -4345,16 +4369,15 @@ impl RenderState { for shape_id in ids.iter() { if let Some(shape) = tree.get(shape_id) { if shape_id != &Uuid::nil() { - all_tiles.extend(self.update_shape_tiles(shape, tree)); + self.invalidate_shape_and_update_tiles( + shape, + tree, + prev_extrects.get(shape_id).copied(), + ); } } } - // Update the changed tiles - for tile in all_tiles { - self.remove_cached_tile(tile); - } - performance::end_measure!("rebuild_touched_tiles"); } @@ -4372,19 +4395,50 @@ impl RenderState { tree: ShapesPoolMutRef<'_>, ) -> Result<()> { performance::begin_measure!("invalidate_and_update_tiles"); - let mut all_tiles = HashSet::::new(); for shape_id in shape_ids { if let Some(shape) = tree.get(shape_id) { - all_tiles.extend(self.update_shape_tiles(shape, tree)); + self.invalidate_shape_and_update_tiles(shape, tree, None); } } - for tile in all_tiles { - self.remove_cached_tile(tile); - } performance::end_measure!("invalidate_and_update_tiles"); Ok(()) } + /// old∪new∪indexed document coverage used to evict cached tiles after edits. + fn dirty_doc_rect_for_shape( + &mut self, + shape: &Shape, + tree: ShapesPoolRef, + prev_extrect: Option, + ) -> skia::Rect { + let scale = self.get_scale(); + let new_extrect = self.get_cached_extrect(shape, tree, 1.0); + let prev_extrect = prev_extrect.or_else(|| { + tree.get_modifier(&shape.id) + .and_then(|_| tree.get_raw(&shape.id).map(|raw| raw.extrect(tree, 1.0))) + }); + let indexed = self + .tiles + .get_tiles_of(shape.id) + .into_iter() + .flatten() + .fold(skia::Rect::new_empty(), |acc, tile| { + tiles::join_nonempty(acc, tiles::get_tile_rect(*tile, scale)) + }); + tiles::union_edit_dirty_rect(prev_extrect, new_extrect, indexed) + } + + fn invalidate_shape_and_update_tiles( + &mut self, + shape: &Shape, + tree: ShapesPoolRef, + prev_extrect: Option, + ) { + let dirty = self.dirty_doc_rect_for_shape(shape, tree, prev_extrect); + let _ = self.update_shape_tiles(shape, tree); + self.surfaces.invalidate_cached_tiles_intersecting(dirty); + } + /// Rebuilds tiles for shapes with modifiers and processes their ancestors /// /// This function applies transformation modifiers to shapes and updates their tiles. @@ -4423,12 +4477,21 @@ impl RenderState { } pub fn mark_touched(&mut self, uuid: Uuid) { - self.touched_ids.insert(uuid); + self.mark_touched_with_prev(uuid, None); + } + + pub fn mark_touched_with_prev(&mut self, uuid: Uuid, prev_extrect: Option) { + if self.touched_ids.insert(uuid) { + if let Some(rect) = prev_extrect.filter(|r| !r.is_empty()) { + self.touched_prev_extrects.insert(uuid, rect); + } + } } #[allow(dead_code)] pub fn clean_touched(&mut self) { self.touched_ids.clear(); + self.touched_prev_extrects.clear(); } pub fn get_cached_extrect(&mut self, shape: &Shape, tree: ShapesPoolRef, scale: f32) -> Rect { diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index f234c9f711..36e5c02008 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -92,6 +92,29 @@ pub enum SurfaceId { TileAtlas = 0b100_0000_1000, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TileCacheKey { + pub tile: Tile, + pub scale_bits: u32, +} + +impl TileCacheKey { + pub fn new(tile: Tile, scale: f32) -> Self { + Self { + tile, + scale_bits: scale.to_bits(), + } + } + + pub fn matches_scale(&self, scale: f32) -> bool { + self.scale_bits == scale.to_bits() + } + + pub fn scale(&self) -> f32 { + f32::from_bits(self.scale_bits) + } +} + pub struct DocAtlas { // Persistent 1:1 document-space atlas that gets incrementally updated as tiles render. // It grows dynamically to include any rendered document rect. @@ -105,9 +128,9 @@ pub struct DocAtlas { /// Optional document-space bounds (1 unit == 1 doc px @ 100% zoom) used to /// clamp atlas writes/clears so the atlas doesn't grow due to outlier tile rects. pub doc_bounds: Option, - /// Tracks the last document-space rect written to the atlas per tile. - /// Used to clear old content without clearing the whole (potentially huge) tile rect. - pub tile_doc_rects: HashMap, + /// Last atlas write per (tile, scale). Scale is part of the key so zoom + /// levels do not overwrite each other's placement metadata. + pub tile_doc_rects: HashMap, } impl DocAtlas { @@ -389,13 +412,17 @@ impl DocAtlas { Ok(()) } - /// Clears the last atlas region written by `tile` (if any). + /// Clears the last atlas region written by `key` (if any). /// /// This avoids clearing the entire logical tile rect which, at very low /// zoom levels, can be enormous in document space and would unnecessarily /// grow / rescale the atlas. - pub fn clear_tile_in_atlas(&mut self, gpu_state: &mut GpuState, tile: Tile) -> Result<()> { - if let Some(doc_rect) = self.tile_doc_rects.remove(&tile) { + pub fn clear_tile_in_atlas( + &mut self, + gpu_state: &mut GpuState, + key: TileCacheKey, + ) -> Result<()> { + if let Some(doc_rect) = self.tile_doc_rects.remove(&key) { self.clear_doc_rect_in_atlas(gpu_state, doc_rect)?; } Ok(()) @@ -1227,6 +1254,7 @@ impl Surfaces { canvas.restore(); } + #[allow(clippy::too_many_arguments)] pub fn draw_current_tile_into_tile_atlas( &mut self, tile_viewbox: &TileViewbox, @@ -1234,6 +1262,8 @@ impl Surfaces { tile_rect: &skia::Rect, skip_cache_surface: bool, tile_doc_rect: skia::Rect, + scale: f32, + view_doc: skia::Rect, ) { let gpu_state = get_gpu_state(); let src = skia::Rect::from(TILE_DRAWABLE_RECT); @@ -1247,9 +1277,14 @@ impl Surfaces { tile_doc_rect, sampling, ); - self.atlas.tile_doc_rects.insert(*tile, tile_doc_rect); + let key = TileCacheKey::new(*tile, scale); + self.atlas.tile_doc_rects.insert(key, tile_doc_rect); - let tile_ref = self.tiles.add(tile_viewbox, tile); + let mut tile_doc_rects = std::mem::take(&mut self.atlas.tile_doc_rects); + let tile_ref = self + .tiles + .add(tile_viewbox, tile, scale, view_doc, &mut tile_doc_rects); + self.atlas.tile_doc_rects = tile_doc_rects; let dst = tile_ref.rect; let mut current = self.current.clone(); draw_surface_src_rect_to_dst(&mut current, self.tile_atlas.canvas(), src, dst, sampling); @@ -1268,8 +1303,8 @@ impl Surfaces { } } - pub fn has_cached_tile_surface(&self, tile: Tile) -> bool { - self.tiles.has(tile) + pub fn has_cached_tile_surface(&self, tile: Tile, scale: f32) -> bool { + self.tiles.has(tile, scale) } /// Builds a 1:1 workspace-pixel snapshot for `src_doc_bounds` / `src_irect` into @@ -1326,7 +1361,7 @@ impl Surfaces { (clip_doc.bottom - vb_top) * scale - iy0, ); - if let Some(tile_ref) = self.tiles.get(tile) { + if let Some(tile_ref) = self.tiles.get(tile, scale) { let bounds = skia::IRect::from_ltrb( tile_ref.rect.left as i32, tile_ref.rect.top as i32, @@ -1387,7 +1422,47 @@ impl Surfaces { self.tiles.remove(tile); // Also clear the corresponding region in the persistent atlas to avoid // leaving stale pixels when shapes move/delete. - let _ = self.atlas.clear_tile_in_atlas(gpu_state, tile); + let keys: Vec<_> = self + .atlas + .tile_doc_rects + .keys() + .copied() + .filter(|key| key.tile == tile) + .collect(); + for key in keys { + let _ = self.atlas.clear_tile_in_atlas(gpu_state, key); + } + } + + /// Drop one (tile, scale) entry; leave other zoom levels alone. + pub fn remove_cached_tile_surface_at(&mut self, tile: Tile, scale: f32) { + let gpu_state = get_gpu_state(); + self.tiles.remove_at(tile, scale); + let key = TileCacheKey::new(tile, scale); + let _ = self.atlas.clear_tile_in_atlas(gpu_state, key); + } + + /// Evict every cached tile whose stored doc rect intersects `doc_rect`. + pub fn invalidate_cached_tiles_intersecting(&mut self, doc_rect: skia::Rect) { + if doc_rect.is_empty() { + return; + } + + let keys: Vec = self + .atlas + .tile_doc_rects + .iter() + .filter_map(|(key, rect)| { + (!rect.is_empty() && rect.intersects(doc_rect)).then_some(*key) + }) + .collect(); + + let gpu_state = get_gpu_state(); + for key in keys { + self.tiles.remove_at(key.tile, key.scale()); + let _ = self.atlas.clear_tile_in_atlas(gpu_state, key); + } + self.atlas.clear_doc_rect_in_atlas_clipped(doc_rect); } /// Draws the current tile directly to the backbuffer and cache surfaces without @@ -1594,8 +1669,8 @@ pub struct TileTextureCache { provider: TileAtlasTextureProvider, transforms: Vec, textures: Vec, - grid: HashMap, - removed: HashSet, + grid: HashMap, + removed: HashSet, } pub struct AtlasDrawBatch { @@ -1658,9 +1733,10 @@ impl TileTextureCache { } fn gc(&mut self) { - // Make a real remove - for tile in self.removed.iter() { - if let Some(tile_ref) = self.grid.remove(tile) { + // Drain so soft-deleted keys cannot accumulate forever (scale bits make + // TileCacheKey rarely repeat across zoom levels). + for key in self.removed.drain() { + if let Some(tile_ref) = self.grid.remove(&key) { self.provider.deallocate(tile_ref); } } @@ -1674,29 +1750,57 @@ impl TileTextureCache { self.is_updated = false; } - fn gc_non_visible(&mut self, tile_viewbox: &TileViewbox) { - let marked: Vec<_> = self - .grid - .iter_mut() - .filter_map(|(tile, _)| { - if !tile_viewbox.is_visible(tile) { - Some(*tile) - } else { - None - } - }) - .take(TEXTURES_BATCH_DELETE) - .collect(); + fn gc_non_visible( + &mut self, + tile_viewbox: &TileViewbox, + scale: f32, + view_doc: skia::Rect, + tile_doc_rects: &mut HashMap, + ) { + // Evict by document coverage, not grid index: other-scale tiles can + // still cover the viewport even when their index is outside visible_rect. + let mut offscreen = Vec::new(); + let mut other_scale_onscreen = Vec::new(); - for tile in marked.iter() { - if let Some(tile_ref) = self.grid.remove(tile) { + for key in self.grid.keys() { + if self.removed.contains(key) { + continue; + } + if key.matches_scale(scale) && tile_viewbox.is_visible(&key.tile) { + continue; + } + + let intersects = tile_doc_rects + .get(key) + .is_some_and(|doc_rect| !doc_rect.is_empty() && doc_rect.intersects(view_doc)); + + if intersects { + if !key.matches_scale(scale) { + other_scale_onscreen.push(*key); + } + } else { + offscreen.push(*key); + } + } + + let mut marked = Vec::with_capacity(TEXTURES_BATCH_DELETE); + marked.extend(offscreen.into_iter().take(TEXTURES_BATCH_DELETE)); + if marked.len() < TEXTURES_BATCH_DELETE { + let remaining = TEXTURES_BATCH_DELETE - marked.len(); + marked.extend(other_scale_onscreen.into_iter().take(remaining)); + } + + for key in marked.iter() { + if let Some(tile_ref) = self.grid.remove(key) { self.provider.deallocate(tile_ref); } + tile_doc_rects.remove(key); } } pub fn update(&mut self, viewbox: &Viewbox, tile_viewbox: &TileViewbox) { let dest_scale = self.dest_scale(); + let scale = viewbox.get_scale(); if self.transforms.len() != tile_viewbox.visible_rect.len() as usize { self.transforms.resize( tile_viewbox.visible_rect.len() as usize, @@ -1719,13 +1823,13 @@ impl TileTextureCache { let mut index = 0; for y in tile_viewbox.visible_rect.top()..=tile_viewbox.visible_rect.bottom() { for x in tile_viewbox.visible_rect.left()..=tile_viewbox.visible_rect.right() { - let tile = Tile(x, y); + let key = TileCacheKey::new(Tile(x, y), scale); - let Some(tile_ref) = self.grid.get(&tile) else { + let Some(tile_ref) = self.grid.get(&key) else { continue; }; - if self.removed.contains(&tile) { + if self.removed.contains(&key) { continue; } @@ -1750,7 +1854,7 @@ impl TileTextureCache { &self, viewbox: &Viewbox, tile_viewbox: &TileViewbox, - tile_doc_rects: &HashMap, + tile_doc_rects: &HashMap, ) -> AtlasDrawBatch { let mut transforms = Vec::new(); let mut textures = Vec::new(); @@ -1760,20 +1864,20 @@ impl TileTextureCache { for y in tile_viewbox.visible_rect.top()..=tile_viewbox.visible_rect.bottom() { for x in tile_viewbox.visible_rect.left()..=tile_viewbox.visible_rect.right() { - let tile = Tile(x, y); + let key = TileCacheKey::new(Tile(x, y), s); - let Some(tile_ref) = self.grid.get(&tile) else { + let Some(tile_ref) = self.grid.get(&key) else { continue; }; - if self.removed.contains(&tile) { + if self.removed.contains(&key) { continue; } let doc_rect = tile_doc_rects - .get(&tile) + .get(&key) .copied() - .unwrap_or_else(|| tiles::get_tile_rect(tile, s)); + .unwrap_or_else(|| tiles::get_tile_rect(key.tile, s)); if doc_rect.is_empty() || !doc_rect.intersects(view_doc) { continue; } @@ -1788,17 +1892,22 @@ impl TileTextureCache { } } - // Cached tiles from a previous zoom level use indices outside visible_rect; - // place them via their stored document rect, not the current grid walk above. - for (&tile, tile_ref) in &self.grid { - if tile_viewbox.is_visible(&tile) || self.removed.contains(&tile) { + // Other-scale / off-grid tiles: place via stored doc rect (not current scale). + for (&key, tile_ref) in &self.grid { + if self.removed.contains(&key) { + continue; + } + let visible = tile_viewbox.is_visible(&key.tile); + if key.matches_scale(s) && visible { + continue; + } + if !key.matches_scale(s) && visible && self.has(key.tile, s) { continue; } - let doc_rect = tile_doc_rects - .get(&tile) - .copied() - .unwrap_or_else(|| tiles::get_tile_rect(tile, s)); + let Some(doc_rect) = tile_doc_rects.get(&key).copied() else { + continue; + }; if doc_rect.is_empty() || !doc_rect.intersects(view_doc) { continue; } @@ -1818,11 +1927,19 @@ impl TileTextureCache { } } - pub fn has(&self, tile: Tile) -> bool { - self.grid.contains_key(&tile) && !self.removed.contains(&tile) + pub fn has(&self, tile: Tile, scale: f32) -> bool { + let key = TileCacheKey::new(tile, scale); + self.grid.contains_key(&key) && !self.removed.contains(&key) } - pub fn add(&mut self, tile_viewbox: &TileViewbox, tile: &Tile) -> TileAtlasTextureRef { + pub fn add( + &mut self, + tile_viewbox: &TileViewbox, + tile: &Tile, + scale: f32, + view_doc: skia::Rect, + tile_doc_rects: &mut HashMap, + ) -> TileAtlasTextureRef { // Evict against the real slot count (`provider.length`), not the // hardcoded capacity — otherwise the guard never fires and the atlas // fills up until `allocate()` has no slot left. @@ -1830,41 +1947,67 @@ impl TileTextureCache { if self.grid.len() >= capacity { self.gc(); - self.gc_non_visible(tile_viewbox); + self.gc_non_visible(tile_viewbox, scale, view_doc, tile_doc_rects); } let Some(tile_ref) = self.provider.allocate() else { panic!("Tile texture allocation failed {}:{}", tile.0, tile.1); }; - self.insert(tile, tile_ref) + self.insert(TileCacheKey::new(*tile, scale), tile_ref) } - fn insert(&mut self, tile: &Tile, tile_ref: TileAtlasTextureRef) -> TileAtlasTextureRef { - self.grid.insert(*tile, tile_ref.clone()); + fn insert(&mut self, key: TileCacheKey, tile_ref: TileAtlasTextureRef) -> TileAtlasTextureRef { + if let Some(old_ref) = self.grid.insert(key, tile_ref.clone()) { + self.provider.deallocate(old_ref); + } - if self.removed.contains(tile) { - self.removed.remove(tile); + if self.removed.contains(&key) { + self.removed.remove(&key); } self.is_updated = true; tile_ref } - pub fn get(&mut self, tile: Tile) -> Option<&TileAtlasTextureRef> { - if self.removed.contains(&tile) { + pub fn get(&mut self, tile: Tile, scale: f32) -> Option<&TileAtlasTextureRef> { + let key = TileCacheKey::new(tile, scale); + if self.removed.contains(&key) { return None; } - self.grid.get(&tile) + self.grid.get(&key) } pub fn remove(&mut self, tile: Tile) { - if let Some(tile_ref) = self.grid.get(&tile) { - if tile_ref.index < self.textures.len() { - self.textures[tile_ref.index].set_empty(); + let keys: Vec<_> = self + .grid + .keys() + .copied() + .filter(|key| key.tile == tile) + .collect(); + if keys.is_empty() { + return; + } + for key in keys { + if let Some(tile_ref) = self.grid.get(&key) { + if tile_ref.index < self.textures.len() { + self.textures[tile_ref.index].set_empty(); + } } + self.removed.insert(key); } self.is_updated = true; - self.removed.insert(tile); + } + + pub fn remove_at(&mut self, tile: Tile, scale: f32) { + let key = TileCacheKey::new(tile, scale); + let Some(tile_ref) = self.grid.get(&key) else { + return; + }; + if tile_ref.index < self.textures.len() { + self.textures[tile_ref.index].set_empty(); + } + self.removed.insert(key); + self.is_updated = true; } pub fn clear(&mut self) { diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 849d35131b..8e9be844d7 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -198,22 +198,24 @@ impl State { // headless export path has none, so skip it there. if has_render_state() { let render_state = get_render_state(); - // IMPORTANT: - // Do NOT use `get_tiles_for_shape` here. That method intersects the shape - // tiles with the current interest area, which means we'd only invalidate - // the subset currently near the viewport. When the user later pans/zooms - // to reveal previously cached tiles, stale pixels could reappear. - // - // Instead, remove the shape from *all* tiles where it was indexed, and - // drop cached tiles for those entries. + // Do NOT use `get_tiles_for_shape` (interest-clipped). Evict by + // document coverage so cached tiles outside the interest area + // cannot keep pixels of the deleted shape. let indexed_tiles: Vec = render_state .tiles .get_tiles_of(shape.id) .map(|t| t.iter().copied().collect()) .unwrap_or_default(); - + let scale = render_state.get_scale(); + let dirty = indexed_tiles + .iter() + .fold(shape.extrect(&self.shapes, 1.0), |acc, tile| { + tiles::join_nonempty(acc, tiles::get_tile_rect(*tile, scale)) + }); + render_state + .surfaces + .invalidate_cached_tiles_intersecting(dirty); for tile in indexed_tiles { - render_state.remove_cached_tile(tile); render_state.tiles.remove_shape_at(tile, shape.id); } } @@ -345,7 +347,11 @@ impl State { return; } if let Some(current_id) = self.current_id { - get_render_state().mark_touched(current_id); + let prev = self + .shapes + .get(¤t_id) + .map(|shape| shape.extrect(&self.shapes, 1.0)); + get_render_state().mark_touched_with_prev(current_id, prev); } } @@ -353,6 +359,10 @@ impl State { if self.loading || !has_render_state() { return; } - get_render_state().mark_touched(id); + let prev = self + .shapes + .get(&id) + .map(|shape| shape.extrect(&self.shapes, 1.0)); + get_render_state().mark_touched_with_prev(id, prev); } } diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index 71c7d91329..ea36363f6d 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -370,7 +370,13 @@ impl PendingTiles { } } - pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces, only_visible: bool) { + pub fn update( + &mut self, + tile_viewbox: &TileViewbox, + surfaces: &Surfaces, + scale: f32, + only_visible: bool, + ) { self.list.clear(); self.deferred_interest.clear(); @@ -417,7 +423,7 @@ impl PendingTiles { for (_, tile) in self.tile_order.iter() { let tile = *tile; let is_visible = tile_viewbox.visible_rect.contains(&tile); - let is_cached = surfaces.has_cached_tile_surface(tile); + let is_cached = surfaces.has_cached_tile_surface(tile, scale); match (is_visible, is_cached) { (true, true) => self.visible_cached.push(tile), @@ -456,9 +462,34 @@ impl PendingTiles { } } +pub fn join_nonempty(mut acc: skia::Rect, rect: skia::Rect) -> skia::Rect { + if rect.is_empty() { + return acc; + } + if acc.is_empty() { + rect + } else { + acc.join(rect); + acc + } +} + +/// old ∪ new ∪ indexed tile coverage for post-edit cache eviction. +pub fn union_edit_dirty_rect( + old: Option, + new: skia::Rect, + indexed: skia::Rect, +) -> skia::Rect { + [old, Some(new), Some(indexed)] + .into_iter() + .flatten() + .fold(skia::Rect::new_empty(), join_nonempty) +} + #[cfg(test)] mod tests { use super::*; + use skia_safe as skia; #[test] fn atlas_slot_is_full_size_when_tiles_fit() { @@ -487,4 +518,22 @@ mod tests { assert!((scale * src - TILE_SIZE).abs() < 1e-4); assert!(src < slot as f32); } + + #[test] + fn edit_dirty_rect_includes_pre_rotate_extent_outside_current_index() { + // Indexed tiles are interest-clipped; old AABB still covers wings. + let old = skia::Rect::from_ltrb(-1103.0, 1871.1, 4693.2, 3559.9); + let new = skia::Rect::from_ltrb(1445.0, -164.4, 2144.9, 5598.0); + let indexed = skia::Rect::from_ltrb(663.1, 1989.4, 2652.6, 3315.7); + let left_wing = skia::Rect::from_ltrb(-3926.0, 0.0, 0.0, 3926.0); + let right_wing = skia::Rect::from_ltrb(3926.0, 0.0, 7852.0, 3926.0); + + let without_old = union_edit_dirty_rect(None, new, indexed); + assert!(!without_old.intersects(left_wing)); + assert!(!without_old.intersects(right_wing)); + + let dirty = union_edit_dirty_rect(Some(old), new, indexed); + assert!(dirty.intersects(left_wing)); + assert!(dirty.intersects(right_wing)); + } }