mirror of
https://github.com/penpot/penpot.git
synced 2026-08-27 15:19:49 +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.
|
- 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 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.
|
- 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.
|
||||||
|
|||||||
@ -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 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.)
|
/// 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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// 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 {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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(¤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() {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user