🐛 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.
- `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 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.
- 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.
pub viewer_render_root: Option<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.)
/// 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::<tiles::Tile>::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::<tiles::Tile>::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(())
}
/// 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
///
/// 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<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)]
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 {

View File

@ -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<skia::Rect>,
/// 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<Tile, skia::Rect>,
/// 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<TileCacheKey, skia::Rect>,
}
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<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
@ -1594,8 +1669,8 @@ pub struct TileTextureCache {
provider: TileAtlasTextureProvider,
transforms: Vec<skia::RSXform>,
textures: Vec<skia::Rect>,
grid: HashMap<Tile, TileAtlasTextureRef>,
removed: HashSet<Tile>,
grid: HashMap<TileCacheKey, TileAtlasTextureRef>,
removed: HashSet<TileCacheKey>,
}
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<TileCacheKey, skia::Rect>,
) {
// 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, skia::Rect>,
tile_doc_rects: &HashMap<TileCacheKey, skia::Rect>,
) -> 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<TileCacheKey, skia::Rect>,
) -> 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) {

View File

@ -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<tiles::Tile> = 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(&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() {
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.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<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)]
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));
}
}