mirror of
https://github.com/penpot/penpot.git
synced 2026-08-26 06:39:12 +00:00
🐛 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:
parent
d655aa9c63
commit
7419bc7007
@ -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.
|
||||
blur saveLayer on `DropShadows`) when `uses_direct_container_drop_shadow` is true.
|
||||
|
||||
@ -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 old∪new 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(())
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
) -> 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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(¤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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user