🐛 Evict multi-scale tile cache on shape edits (#11337)

those textures across zoom for progressive previews, and invalidate
by old∪new document coverage so rotate/move edits do not leave
stale fragments on zoom-out.
This commit is contained in:
Alejandro Alonso 2026-08-26 08:25:23 +02:00 committed by GitHub
parent d655aa9c63
commit 7419bc7007
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 371 additions and 104 deletions

View File

@ -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. - 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. - `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. - 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 oldnew 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. - 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 + - 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. blur saveLayer on `DropShadows`) when `uses_direct_container_drop_shadow` is true.

View File

@ -383,6 +383,8 @@ pub(crate) struct RenderState {
/// Frame id passed as `base_object` for viewer renders; always traversed. /// Frame id passed as `base_object` for viewer renders; always traversed.
pub viewer_render_root: Option<Uuid>, pub viewer_render_root: Option<Uuid>,
pub touched_ids: HashSet<Uuid>, pub touched_ids: HashSet<Uuid>,
/// Pre-edit extrects for oldnew tile eviction (captured on first touch).
touched_prev_extrects: HashMap<Uuid, Rect>,
/// Temporary flag used for off-screen passes (drop-shadow masks, filter surfaces, etc.) /// 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 /// where we must render shapes without inheriting ancestor layer blurs. Toggle it through
/// `with_nested_blurs_suppressed` to ensure it's always restored. /// `with_nested_blurs_suppressed` to ensure it's always restored.
@ -603,6 +605,7 @@ impl RenderState {
include_filter: None, include_filter: None,
viewer_render_root: None, viewer_render_root: None,
touched_ids: HashSet::default(), touched_ids: HashSet::default(),
touched_prev_extrects: HashMap::default(),
ignore_nested_blurs: false, ignore_nested_blurs: false,
preview_mode: false, preview_mode: false,
export_context: None, export_context: None,
@ -1148,6 +1151,8 @@ impl RenderState {
&tile_rect, &tile_rect,
false, false,
self.render_area, self.render_area,
self.get_scale(),
self.viewbox.area,
); );
Ok(()) Ok(())
@ -2406,7 +2411,7 @@ impl RenderState {
performance::begin_measure!("tile_cache"); performance::begin_measure!("tile_cache");
let only_visible = self.options.is_interactive_transform(); let only_visible = self.options.is_interactive_transform();
self.pending_tiles 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_measure!("tile_cache");
performance::end_timed_log!("tile_cache_update", _tile_start); performance::end_timed_log!("tile_cache_update", _tile_start);
@ -2498,11 +2503,21 @@ impl RenderState {
&& !self.viewport_presented; && !self.viewport_presented;
if should_compose { if should_compose {
self.surfaces.draw_tile_atlas_to_backbuffer( // Fast mode skips the tile atlas; use the same doc-atlas + scale
&self.viewbox, // overlays as render_from_cache instead of composing empty slots.
&self.tile_viewbox, if self.options.is_fast_mode() {
self.background_color, 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 { match frame_type {
@ -3923,7 +3938,10 @@ impl RenderState {
// is not cached because everything will be handled from draw_atlas. // is not cached because everything will be handled from draw_atlas.
// Viewer masked passes (include_filter) must not reuse cached tiles from // Viewer masked passes (include_filter) must not reuse cached tiles from
// a previous pass; otherwise pass-1 pixels can leak into pass 2. // 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"); performance::begin_measure!("render_shape_tree::uncached");
let (is_empty, early_return) = self let (is_empty, early_return) = self
@ -3968,7 +3986,9 @@ impl RenderState {
} }
} }
} else if self.tiles.is_empty_at(current_tile) { } 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; self.drop_shadows_ops_warmed = false;
let viewer_masked_pass = self.viewer_masked_pass(); 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 { let Some(ids) = self.tiles.get_shapes_at(next_tile) else {
// If the tile is empty we do not need to render it. // 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. // 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 // If the tile is cached, then we do not need to
// render it. // render it.
continue; continue;
@ -4333,9 +4358,8 @@ impl RenderState {
pub fn rebuild_touched_tiles(&mut self, tree: ShapesPoolRef) { pub fn rebuild_touched_tiles(&mut self, tree: ShapesPoolRef) {
performance::begin_measure!("rebuild_touched_tiles"); performance::begin_measure!("rebuild_touched_tiles");
let mut all_tiles = HashSet::<tiles::Tile>::new();
let ids = std::mem::take(&mut self.touched_ids); 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 // 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. // here when no shapes changed, or the next render clears the canvas.
if !ids.is_empty() { if !ids.is_empty() {
@ -4345,16 +4369,15 @@ impl RenderState {
for shape_id in ids.iter() { for shape_id in ids.iter() {
if let Some(shape) = tree.get(shape_id) { if let Some(shape) = tree.get(shape_id) {
if shape_id != &Uuid::nil() { 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"); performance::end_measure!("rebuild_touched_tiles");
} }
@ -4372,19 +4395,50 @@ impl RenderState {
tree: ShapesPoolMutRef<'_>, tree: ShapesPoolMutRef<'_>,
) -> Result<()> { ) -> Result<()> {
performance::begin_measure!("invalidate_and_update_tiles"); performance::begin_measure!("invalidate_and_update_tiles");
let mut all_tiles = HashSet::<tiles::Tile>::new();
for shape_id in shape_ids { for shape_id in shape_ids {
if let Some(shape) = tree.get(shape_id) { 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"); performance::end_measure!("invalidate_and_update_tiles");
Ok(()) Ok(())
} }
/// oldnewindexed 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>,
) -> 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<skia::Rect>,
) {
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 /// Rebuilds tiles for shapes with modifiers and processes their ancestors
/// ///
/// This function applies transformation modifiers to shapes and updates their tiles. /// 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) { 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<Rect>) {
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)] #[allow(dead_code)]
pub fn clean_touched(&mut self) { pub fn clean_touched(&mut self) {
self.touched_ids.clear(); self.touched_ids.clear();
self.touched_prev_extrects.clear();
} }
pub fn get_cached_extrect(&mut self, shape: &Shape, tree: ShapesPoolRef, scale: f32) -> Rect { pub fn get_cached_extrect(&mut self, shape: &Shape, tree: ShapesPoolRef, scale: f32) -> Rect {

View File

@ -92,6 +92,29 @@ pub enum SurfaceId {
TileAtlas = 0b100_0000_1000, 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 { pub struct DocAtlas {
// 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.
// It grows dynamically to include any rendered document rect. // 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 /// 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. /// clamp atlas writes/clears so the atlas doesn't grow due to outlier tile rects.
pub doc_bounds: Option<skia::Rect>, pub doc_bounds: Option<skia::Rect>,
/// Tracks the last document-space rect written to the atlas per tile. /// Last atlas write per (tile, scale). Scale is part of the key so zoom
/// Used to clear old content without clearing the whole (potentially huge) tile rect. /// levels do not overwrite each other's placement metadata.
pub tile_doc_rects: HashMap<Tile, skia::Rect>, pub tile_doc_rects: HashMap<TileCacheKey, skia::Rect>,
} }
impl DocAtlas { impl DocAtlas {
@ -389,13 +412,17 @@ impl DocAtlas {
Ok(()) 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 /// This avoids clearing the entire logical tile rect which, at very low
/// zoom levels, can be enormous in document space and would unnecessarily /// zoom levels, can be enormous in document space and would unnecessarily
/// grow / rescale the atlas. /// grow / rescale the atlas.
pub fn clear_tile_in_atlas(&mut self, gpu_state: &mut GpuState, tile: Tile) -> Result<()> { pub fn clear_tile_in_atlas(
if let Some(doc_rect) = self.tile_doc_rects.remove(&tile) { &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)?; self.clear_doc_rect_in_atlas(gpu_state, doc_rect)?;
} }
Ok(()) Ok(())
@ -1227,6 +1254,7 @@ impl Surfaces {
canvas.restore(); canvas.restore();
} }
#[allow(clippy::too_many_arguments)]
pub fn draw_current_tile_into_tile_atlas( pub fn draw_current_tile_into_tile_atlas(
&mut self, &mut self,
tile_viewbox: &TileViewbox, tile_viewbox: &TileViewbox,
@ -1234,6 +1262,8 @@ impl Surfaces {
tile_rect: &skia::Rect, tile_rect: &skia::Rect,
skip_cache_surface: bool, skip_cache_surface: bool,
tile_doc_rect: skia::Rect, tile_doc_rect: skia::Rect,
scale: f32,
view_doc: skia::Rect,
) { ) {
let gpu_state = get_gpu_state(); let gpu_state = get_gpu_state();
let src = skia::Rect::from(TILE_DRAWABLE_RECT); let src = skia::Rect::from(TILE_DRAWABLE_RECT);
@ -1247,9 +1277,14 @@ impl Surfaces {
tile_doc_rect, tile_doc_rect,
sampling, 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 dst = tile_ref.rect;
let mut current = self.current.clone(); let mut current = self.current.clone();
draw_surface_src_rect_to_dst(&mut current, self.tile_atlas.canvas(), src, dst, sampling); 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 { pub fn has_cached_tile_surface(&self, tile: Tile, scale: f32) -> bool {
self.tiles.has(tile) self.tiles.has(tile, scale)
} }
/// Builds a 1:1 workspace-pixel snapshot for `src_doc_bounds` / `src_irect` into /// 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, (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( let bounds = skia::IRect::from_ltrb(
tile_ref.rect.left as i32, tile_ref.rect.left as i32,
tile_ref.rect.top as i32, tile_ref.rect.top as i32,
@ -1387,7 +1422,47 @@ impl Surfaces {
self.tiles.remove(tile); self.tiles.remove(tile);
// Also clear the corresponding region in the persistent atlas to avoid // Also clear the corresponding region in the persistent atlas to avoid
// leaving stale pixels when shapes move/delete. // 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<TileCacheKey> = 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 /// Draws the current tile directly to the backbuffer and cache surfaces without
@ -1594,8 +1669,8 @@ pub struct TileTextureCache {
provider: TileAtlasTextureProvider, provider: TileAtlasTextureProvider,
transforms: Vec<skia::RSXform>, transforms: Vec<skia::RSXform>,
textures: Vec<skia::Rect>, textures: Vec<skia::Rect>,
grid: HashMap<Tile, TileAtlasTextureRef>, grid: HashMap<TileCacheKey, TileAtlasTextureRef>,
removed: HashSet<Tile>, removed: HashSet<TileCacheKey>,
} }
pub struct AtlasDrawBatch { pub struct AtlasDrawBatch {
@ -1658,9 +1733,10 @@ impl TileTextureCache {
} }
fn gc(&mut self) { fn gc(&mut self) {
// Make a real remove // Drain so soft-deleted keys cannot accumulate forever (scale bits make
for tile in self.removed.iter() { // TileCacheKey rarely repeat across zoom levels).
if let Some(tile_ref) = self.grid.remove(tile) { for key in self.removed.drain() {
if let Some(tile_ref) = self.grid.remove(&key) {
self.provider.deallocate(tile_ref); self.provider.deallocate(tile_ref);
} }
} }
@ -1674,29 +1750,57 @@ impl TileTextureCache {
self.is_updated = false; self.is_updated = false;
} }
fn gc_non_visible(&mut self, tile_viewbox: &TileViewbox) { fn gc_non_visible(
let marked: Vec<_> = self &mut self,
.grid tile_viewbox: &TileViewbox,
.iter_mut() scale: f32,
.filter_map(|(tile, _)| { view_doc: skia::Rect,
if !tile_viewbox.is_visible(tile) { tile_doc_rects: &mut HashMap<TileCacheKey, skia::Rect>,
Some(*tile) ) {
} else { // Evict by document coverage, not grid index: other-scale tiles can
None // still cover the viewport even when their index is outside visible_rect.
} let mut offscreen = Vec::new();
}) let mut other_scale_onscreen = Vec::new();
.take(TEXTURES_BATCH_DELETE)
.collect();
for tile in marked.iter() { for key in self.grid.keys() {
if let Some(tile_ref) = self.grid.remove(tile) { 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); self.provider.deallocate(tile_ref);
} }
tile_doc_rects.remove(key);
} }
} }
pub fn update(&mut self, viewbox: &Viewbox, tile_viewbox: &TileViewbox) { pub fn update(&mut self, viewbox: &Viewbox, tile_viewbox: &TileViewbox) {
let dest_scale = self.dest_scale(); let dest_scale = self.dest_scale();
let scale = viewbox.get_scale();
if self.transforms.len() != tile_viewbox.visible_rect.len() as usize { if self.transforms.len() != tile_viewbox.visible_rect.len() as usize {
self.transforms.resize( self.transforms.resize(
tile_viewbox.visible_rect.len() as usize, tile_viewbox.visible_rect.len() as usize,
@ -1719,13 +1823,13 @@ impl TileTextureCache {
let mut index = 0; let mut index = 0;
for y in tile_viewbox.visible_rect.top()..=tile_viewbox.visible_rect.bottom() { 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() { 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; continue;
}; };
if self.removed.contains(&tile) { if self.removed.contains(&key) {
continue; continue;
} }
@ -1750,7 +1854,7 @@ impl TileTextureCache {
&self, &self,
viewbox: &Viewbox, viewbox: &Viewbox,
tile_viewbox: &TileViewbox, tile_viewbox: &TileViewbox,
tile_doc_rects: &HashMap<Tile, skia::Rect>, tile_doc_rects: &HashMap<TileCacheKey, skia::Rect>,
) -> AtlasDrawBatch { ) -> AtlasDrawBatch {
let mut transforms = Vec::new(); let mut transforms = Vec::new();
let mut textures = 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 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() { 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; continue;
}; };
if self.removed.contains(&tile) { if self.removed.contains(&key) {
continue; continue;
} }
let doc_rect = tile_doc_rects let doc_rect = tile_doc_rects
.get(&tile) .get(&key)
.copied() .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) { if doc_rect.is_empty() || !doc_rect.intersects(view_doc) {
continue; continue;
} }
@ -1788,17 +1892,22 @@ impl TileTextureCache {
} }
} }
// Cached tiles from a previous zoom level use indices outside visible_rect; // Other-scale / off-grid tiles: place via stored doc rect (not current scale).
// place them via their stored document rect, not the current grid walk above. for (&key, tile_ref) in &self.grid {
for (&tile, tile_ref) in &self.grid { if self.removed.contains(&key) {
if tile_viewbox.is_visible(&tile) || self.removed.contains(&tile) { 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; continue;
} }
let doc_rect = tile_doc_rects let Some(doc_rect) = tile_doc_rects.get(&key).copied() else {
.get(&tile) continue;
.copied() };
.unwrap_or_else(|| tiles::get_tile_rect(tile, s));
if doc_rect.is_empty() || !doc_rect.intersects(view_doc) { if doc_rect.is_empty() || !doc_rect.intersects(view_doc) {
continue; continue;
} }
@ -1818,11 +1927,19 @@ impl TileTextureCache {
} }
} }
pub fn has(&self, tile: Tile) -> bool { pub fn has(&self, tile: Tile, scale: f32) -> bool {
self.grid.contains_key(&tile) && !self.removed.contains(&tile) 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<TileCacheKey, skia::Rect>,
) -> TileAtlasTextureRef {
// Evict against the real slot count (`provider.length`), not the // Evict against the real slot count (`provider.length`), not the
// hardcoded capacity — otherwise the guard never fires and the atlas // hardcoded capacity — otherwise the guard never fires and the atlas
// fills up until `allocate()` has no slot left. // fills up until `allocate()` has no slot left.
@ -1830,41 +1947,67 @@ impl TileTextureCache {
if self.grid.len() >= capacity { if self.grid.len() >= capacity {
self.gc(); 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 { let Some(tile_ref) = self.provider.allocate() else {
panic!("Tile texture allocation failed {}:{}", tile.0, tile.1); 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 { fn insert(&mut self, key: TileCacheKey, tile_ref: TileAtlasTextureRef) -> TileAtlasTextureRef {
self.grid.insert(*tile, tile_ref.clone()); if let Some(old_ref) = self.grid.insert(key, tile_ref.clone()) {
self.provider.deallocate(old_ref);
}
if self.removed.contains(tile) { if self.removed.contains(&key) {
self.removed.remove(tile); self.removed.remove(&key);
} }
self.is_updated = true; self.is_updated = true;
tile_ref tile_ref
} }
pub fn get(&mut self, tile: Tile) -> Option<&TileAtlasTextureRef> { pub fn get(&mut self, tile: Tile, scale: f32) -> Option<&TileAtlasTextureRef> {
if self.removed.contains(&tile) { let key = TileCacheKey::new(tile, scale);
if self.removed.contains(&key) {
return None; return None;
} }
self.grid.get(&tile) self.grid.get(&key)
} }
pub fn remove(&mut self, tile: Tile) { pub fn remove(&mut self, tile: Tile) {
if let Some(tile_ref) = self.grid.get(&tile) { let keys: Vec<_> = self
if tile_ref.index < self.textures.len() { .grid
self.textures[tile_ref.index].set_empty(); .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.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) { pub fn clear(&mut self) {

View File

@ -198,22 +198,24 @@ impl State {
// headless export path has none, so skip it there. // headless export path has none, so skip it there.
if has_render_state() { if has_render_state() {
let render_state = get_render_state(); let render_state = get_render_state();
// IMPORTANT: // Do NOT use `get_tiles_for_shape` (interest-clipped). Evict by
// Do NOT use `get_tiles_for_shape` here. That method intersects the shape // document coverage so cached tiles outside the interest area
// tiles with the current interest area, which means we'd only invalidate // cannot keep pixels of the deleted shape.
// 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.
let indexed_tiles: Vec<tiles::Tile> = render_state let indexed_tiles: Vec<tiles::Tile> = render_state
.tiles .tiles
.get_tiles_of(shape.id) .get_tiles_of(shape.id)
.map(|t| t.iter().copied().collect()) .map(|t| t.iter().copied().collect())
.unwrap_or_default(); .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 { for tile in indexed_tiles {
render_state.remove_cached_tile(tile);
render_state.tiles.remove_shape_at(tile, shape.id); render_state.tiles.remove_shape_at(tile, shape.id);
} }
} }
@ -345,7 +347,11 @@ impl State {
return; return;
} }
if let Some(current_id) = self.current_id { if let Some(current_id) = self.current_id {
get_render_state().mark_touched(current_id); let prev = self
.shapes
.get(&current_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() { if self.loading || !has_render_state() {
return; 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);
} }
} }

View File

@ -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.list.clear();
self.deferred_interest.clear(); self.deferred_interest.clear();
@ -417,7 +423,7 @@ impl PendingTiles {
for (_, tile) in self.tile_order.iter() { for (_, tile) in self.tile_order.iter() {
let tile = *tile; let tile = *tile;
let is_visible = tile_viewbox.visible_rect.contains(&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) { match (is_visible, is_cached) {
(true, true) => self.visible_cached.push(tile), (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<skia::Rect>,
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use skia_safe as skia;
#[test] #[test]
fn atlas_slot_is_full_size_when_tiles_fit() { fn atlas_slot_is_full_size_when_tiles_fit() {
@ -487,4 +518,22 @@ mod tests {
assert!((scale * src - TILE_SIZE).abs() < 1e-4); assert!((scale * src - TILE_SIZE).abs() < 1e-4);
assert!(src < slot as f32); 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));
}
} }