Merge pull request #9112 from penpot/superalex-improve-atlas-growth

🎉 Improve atlas growth
This commit is contained in:
Elena Torró 2026-04-23 09:22:39 +02:00 committed by GitHub
commit d43d1f431f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 136 additions and 26 deletions

View File

@ -720,26 +720,24 @@ impl RenderState {
self.surfaces.clear_cache(self.background_color); self.surfaces.clear_cache(self.background_color);
self.cache_cleared_this_render = true; self.cache_cleared_this_render = true;
} }
let tile_rect = self.get_current_aligned_tile_bounds()?;
// In fast mode the viewport is moving (pan/zoom) so Cache surface // In fast mode the viewport is moving (pan/zoom) so Cache surface
// positions would be wrong — only save to the tile HashMap. // positions would be wrong — only save to the tile HashMap.
let tile_rect = self.get_current_aligned_tile_bounds()?;
let current_tile = *self
.current_tile
.as_ref()
.ok_or(Error::CriticalError("Current tile not found".to_string()))?;
self.surfaces.cache_current_tile_texture( self.surfaces.cache_current_tile_texture(
&mut self.gpu_state, &mut self.gpu_state,
&self.tile_viewbox, &self.tile_viewbox,
&self &current_tile,
.current_tile
.ok_or(Error::CriticalError("Current tile not found".to_string()))?,
&tile_rect, &tile_rect,
fast_mode, fast_mode,
self.render_area, self.render_area,
); );
self.surfaces.draw_cached_tile_surface( self.surfaces
self.current_tile .draw_cached_tile_surface(current_tile, rect, self.background_color);
.ok_or(Error::CriticalError("Current tile not found".to_string()))?,
rect,
self.background_color,
);
Ok(()) Ok(())
} }
@ -1674,6 +1672,12 @@ impl RenderState {
self.cache_cleared_this_render = false; self.cache_cleared_this_render = false;
self.reset_canvas(); self.reset_canvas();
// Compute and set document-space bounds (1 unit == 1 doc px @ 100% zoom)
// to clamp atlas updates. This prevents zoom-out tiles from forcing atlas
// growth far beyond real content.
let doc_bounds = self.compute_document_bounds(base_object, tree);
self.surfaces.set_atlas_doc_bounds(doc_bounds);
// During an interactive shape transform (drag/resize/rotate) the // During an interactive shape transform (drag/resize/rotate) the
// Target is repainted tile-by-tile. If only a subset of the // Target is repainted tile-by-tile. If only a subset of the
// invalidated tiles finishes in this rAF the remaining area // invalidated tiles finishes in this rAF the remaining area
@ -1767,6 +1771,37 @@ impl RenderState {
Ok(()) Ok(())
} }
fn compute_document_bounds(
&mut self,
base_object: Option<&Uuid>,
tree: ShapesPoolRef,
) -> Option<skia::Rect> {
let ids: Vec<Uuid> = if let Some(id) = base_object {
vec![*id]
} else {
let root = tree.get(&Uuid::nil())?;
root.children_ids(false)
};
let mut acc: Option<skia::Rect> = None;
for id in ids.iter() {
let Some(shape) = tree.get(id) else {
continue;
};
let r = self.get_cached_extrect(shape, tree, 1.0);
if r.is_empty() {
continue;
}
acc = Some(if let Some(mut a) = acc {
a.join(r);
a
} else {
r
});
}
acc
}
pub fn process_animation_frame( pub fn process_animation_frame(
&mut self, &mut self,
base_object: Option<&Uuid>, base_object: Option<&Uuid>,
@ -2927,11 +2962,6 @@ impl RenderState {
s.canvas().draw_rect(aligned_rect, &paint); s.canvas().draw_rect(aligned_rect, &paint);
}); });
} }
// Clear atlas region to transparent so background shows through.
let _ = self
.surfaces
.clear_doc_rect_in_atlas(&mut self.gpu_state, self.render_area);
} }
} }
} }
@ -3156,7 +3186,8 @@ impl RenderState {
} }
pub fn remove_cached_tile(&mut self, tile: tiles::Tile) { pub fn remove_cached_tile(&mut self, tile: tiles::Tile) {
self.surfaces.remove_cached_tile_surface(tile); self.surfaces
.remove_cached_tile_surface(&mut self.gpu_state, tile);
} }
/// Rebuild the tile index (shape→tile mapping) for all top-level shapes. /// Rebuild the tile index (shape→tile mapping) for all top-level shapes.

View File

@ -78,10 +78,16 @@ pub struct Surfaces {
/// When the atlas would exceed `max_atlas_texture_size`, this value is /// When the atlas would exceed `max_atlas_texture_size`, this value is
/// reduced so the atlas stays within the fixed texture cap. /// reduced so the atlas stays within the fixed texture cap.
atlas_scale: f32, atlas_scale: f32,
/// 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.
atlas_doc_bounds: Option<skia::Rect>,
/// Max width/height in pixels for the atlas surface (typically browser /// Max width/height in pixels for the atlas surface (typically browser
/// `MAX_TEXTURE_SIZE`). Set from ClojureScript after WebGL context creation. /// `MAX_TEXTURE_SIZE`). Set from ClojureScript after WebGL context creation.
max_atlas_texture_size: i32, max_atlas_texture_size: i32,
sampling_options: skia::SamplingOptions, sampling_options: skia::SamplingOptions,
/// 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.
atlas_tile_doc_rects: HashMap<Tile, skia::Rect>,
pub margins: skia::ISize, pub margins: skia::ISize,
// Tracks which surfaces have content (dirty flag bitmask) // Tracks which surfaces have content (dirty flag bitmask)
dirty_surfaces: u32, dirty_surfaces: u32,
@ -147,8 +153,10 @@ impl Surfaces {
atlas_origin: skia::Point::new(0.0, 0.0), atlas_origin: skia::Point::new(0.0, 0.0),
atlas_size: skia::ISize::new(0, 0), atlas_size: skia::ISize::new(0, 0),
atlas_scale: 1.0, atlas_scale: 1.0,
atlas_doc_bounds: None,
max_atlas_texture_size: DEFAULT_MAX_ATLAS_TEXTURE_SIZE, max_atlas_texture_size: DEFAULT_MAX_ATLAS_TEXTURE_SIZE,
sampling_options, sampling_options,
atlas_tile_doc_rects: HashMap::default(),
margins, margins,
dirty_surfaces: 0, dirty_surfaces: 0,
extra_tile_dims, extra_tile_dims,
@ -162,6 +170,28 @@ impl Surfaces {
self.max_atlas_texture_size = max_px.clamp(TILE_SIZE as i32, MAX_ATLAS_TEXTURE_SIZE); self.max_atlas_texture_size = max_px.clamp(TILE_SIZE as i32, MAX_ATLAS_TEXTURE_SIZE);
} }
/// Sets the document-space bounds used to clamp atlas updates.
/// Pass `None` to disable clamping.
pub fn set_atlas_doc_bounds(&mut self, bounds: Option<skia::Rect>) {
self.atlas_doc_bounds = bounds.filter(|b| !b.is_empty());
}
fn clamp_doc_rect_to_bounds(&self, doc_rect: skia::Rect) -> skia::Rect {
if doc_rect.is_empty() {
return doc_rect;
}
if let Some(bounds) = self.atlas_doc_bounds {
let mut r = doc_rect;
if r.intersect(bounds) {
r
} else {
skia::Rect::new_empty()
}
} else {
doc_rect
}
}
fn ensure_atlas_contains( fn ensure_atlas_contains(
&mut self, &mut self,
gpu_state: &mut GpuState, gpu_state: &mut GpuState,
@ -271,21 +301,51 @@ impl Surfaces {
&mut self, &mut self,
gpu_state: &mut GpuState, gpu_state: &mut GpuState,
tile_image: &skia::Image, tile_image: &skia::Image,
doc_rect: skia::Rect, tile_doc_rect: skia::Rect,
) -> Result<()> { ) -> Result<()> {
self.ensure_atlas_contains(gpu_state, doc_rect)?; if tile_doc_rect.is_empty() {
return Ok(());
}
// Clamp to document bounds (if any) and compute a matching source-rect in tile pixels.
let mut clipped_doc_rect = tile_doc_rect;
if let Some(bounds) = self.atlas_doc_bounds {
if !clipped_doc_rect.intersect(bounds) {
return Ok(());
}
}
if clipped_doc_rect.is_empty() {
return Ok(());
}
self.ensure_atlas_contains(gpu_state, clipped_doc_rect)?;
// Destination is document-space rect mapped into atlas pixel coords. // Destination is document-space rect mapped into atlas pixel coords.
let dst = skia::Rect::from_xywh( let dst = skia::Rect::from_xywh(
(doc_rect.left - self.atlas_origin.x) * self.atlas_scale, (clipped_doc_rect.left - self.atlas_origin.x) * self.atlas_scale,
(doc_rect.top - self.atlas_origin.y) * self.atlas_scale, (clipped_doc_rect.top - self.atlas_origin.y) * self.atlas_scale,
doc_rect.width() * self.atlas_scale, clipped_doc_rect.width() * self.atlas_scale,
doc_rect.height() * self.atlas_scale, clipped_doc_rect.height() * self.atlas_scale,
); );
self.atlas // Compute source rect in tile_image pixel coordinates.
.canvas() let img_w = tile_image.width() as f32;
.draw_image_rect(tile_image, None, dst, &skia::Paint::default()); let img_h = tile_image.height() as f32;
let tw = tile_doc_rect.width().max(1.0);
let th = tile_doc_rect.height().max(1.0);
let sx = ((clipped_doc_rect.left - tile_doc_rect.left) / tw) * img_w;
let sy = ((clipped_doc_rect.top - tile_doc_rect.top) / th) * img_h;
let sw = (clipped_doc_rect.width() / tw) * img_w;
let sh = (clipped_doc_rect.height() / th) * img_h;
let src = skia::Rect::from_xywh(sx, sy, sw, sh);
self.atlas.canvas().draw_image_rect(
tile_image,
Some((&src, skia::canvas::SrcRectConstraint::Fast)),
dst,
&skia::Paint::default(),
);
Ok(()) Ok(())
} }
@ -294,6 +354,7 @@ impl Surfaces {
gpu_state: &mut GpuState, gpu_state: &mut GpuState,
doc_rect: skia::Rect, doc_rect: skia::Rect,
) -> Result<()> { ) -> Result<()> {
let doc_rect = self.clamp_doc_rect_to_bounds(doc_rect);
if doc_rect.is_empty() { if doc_rect.is_empty() {
return Ok(()); return Ok(());
} }
@ -316,6 +377,18 @@ impl Surfaces {
Ok(()) Ok(())
} }
/// Clears the last atlas region written by `tile` (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.atlas_tile_doc_rects.remove(&tile) {
self.clear_doc_rect_in_atlas(gpu_state, doc_rect)?;
}
Ok(())
}
pub fn clear_tiles(&mut self) { pub fn clear_tiles(&mut self) {
self.tiles.clear(); self.tiles.clear();
} }
@ -817,6 +890,7 @@ impl Surfaces {
// Incrementally update persistent 1:1 atlas in document space. // Incrementally update persistent 1:1 atlas in document space.
// `tile_doc_rect` is in world/document coordinates (1 unit == 1 px at 100%). // `tile_doc_rect` is in world/document coordinates (1 unit == 1 px at 100%).
let _ = self.blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect); let _ = self.blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect);
self.atlas_tile_doc_rects.insert(*tile, tile_doc_rect);
self.tiles.add(tile_viewbox, tile, tile_image); self.tiles.add(tile_viewbox, tile, tile_image);
} }
} }
@ -825,11 +899,14 @@ impl Surfaces {
self.tiles.has(tile) self.tiles.has(tile)
} }
pub fn remove_cached_tile_surface(&mut self, tile: Tile) { pub fn remove_cached_tile_surface(&mut self, gpu_state: &mut GpuState, tile: Tile) {
// Mark tile as invalid // Mark tile as invalid
// Old content stays visible until new tile overwrites it atomically, // Old content stays visible until new tile overwrites it atomically,
// preventing flickering during tile re-renders. // preventing flickering during tile re-renders.
self.tiles.remove(tile); self.tiles.remove(tile);
// Also clear the corresponding region in the persistent atlas to avoid
// leaving stale pixels when shapes move/delete.
let _ = self.clear_tile_in_atlas(gpu_state, tile);
} }
pub fn draw_cached_tile_surface(&mut self, tile: Tile, rect: skia::Rect, color: skia::Color) { pub fn draw_cached_tile_surface(&mut self, tile: Tile, rect: skia::Rect, color: skia::Color) {
@ -914,6 +991,7 @@ impl Surfaces {
/// the cache canvas for scaled previews, use `invalidate_tile_cache` instead. /// the cache canvas for scaled previews, use `invalidate_tile_cache` instead.
pub fn remove_cached_tiles(&mut self, color: skia::Color) { pub fn remove_cached_tiles(&mut self, color: skia::Color) {
self.tiles.clear(); self.tiles.clear();
self.atlas_tile_doc_rects.clear();
self.cache.canvas().clear(color); self.cache.canvas().clear(color);
} }
@ -923,6 +1001,7 @@ impl Surfaces {
/// content while new tiles are being rendered. /// content while new tiles are being rendered.
pub fn invalidate_tile_cache(&mut self) { pub fn invalidate_tile_cache(&mut self) {
self.tiles.clear(); self.tiles.clear();
self.atlas_tile_doc_rects.clear();
} }
pub fn gc(&mut self) { pub fn gc(&mut self) {