Merge pull request #10407 from penpot/superalex-render-from-cache-4

🐛 Fix render from cache
This commit is contained in:
Aitor Moreno 2026-06-24 16:32:26 +02:00 committed by GitHub
commit 888f6798cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 138 additions and 95 deletions

View File

@ -2003,89 +2003,12 @@ impl RenderState {
pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) {
let _start = performance::begin_timed_log!("render_from_cache");
performance::begin_measure!("render_from_cache");
let bg_color = self.background_color;
// During fast mode (pan/zoom), if a previous full-quality render still has pending tiles,
// always prefer the persistent atlas. The atlas is incrementally updated as tiles finish,
// and drawing from it avoids mixing a partially-updated Cache surface with missing tiles.
if self.options.is_fast_mode() && !self.surfaces.atlas.is_empty() {
self.surfaces
.draw_atlas_to_backbuffer(self.viewbox, bg_color);
self.present_frame(shapes);
performance::end_measure!("render_from_cache");
performance::end_timed_log!("render_from_cache", _start);
return;
}
// Check if we have a valid cached viewbox (non-zero dimensions indicate valid cache)
if self.cached_viewbox.area.width() > 0.0 {
// Scale and translate the target according to the cached data
let navigate_zoom = self.viewbox.zoom / self.cached_viewbox.zoom;
let interest = self.options.dpr_viewport_interest_area_threshold;
let TileRect(start_tile_x, start_tile_y, _, _) =
tiles::get_tiles_for_viewbox_with_interest(&self.cached_viewbox, interest);
let offset_x = self.viewbox.area.left * self.cached_viewbox.zoom * self.options.dpr;
let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr;
let translate_x = (start_tile_x as f32 * tiles::TILE_SIZE) - offset_x;
let translate_y = (start_tile_y as f32 * tiles::TILE_SIZE) - offset_y;
// For zoom-out, prefer cache only if it fully covers the viewport.
// Otherwise, atlas will provide a more correct full-viewport preview.
let zooming_out = self.viewbox.zoom < self.cached_viewbox.zoom;
if zooming_out {
let cache_dim = self.surfaces.cache_dimensions();
let cache_w = cache_dim.width as f32;
let cache_h = cache_dim.height as f32;
// Viewport in target pixels.
let vw = self.viewbox.dpr_width().max(1.0);
let vh = self.viewbox.dpr_height().max(1.0);
// Inverse-map viewport corners into cache coordinates.
// target = (cache * navigate_zoom) translated by (translate_x, translate_y) (in cache coords).
// => cache = (target / navigate_zoom) - translate
let inv = if navigate_zoom.abs() > f32::EPSILON {
1.0 / navigate_zoom
} else {
0.0
};
// let cx0 = (0.0 * inv) - translate_x;
// let cy0 = (0.0 * inv) - translate_y;
// NOTA: 0.0 * inv => siempre 0
let cx0 = -translate_x;
let cy0 = -translate_y;
let cx1 = (vw * inv) - translate_x;
let cy1 = (vh * inv) - translate_y;
let min_x = cx0.min(cx1);
let min_y = cy0.min(cy1);
let max_x = cx0.max(cx1);
let max_y = cy0.max(cy1);
let cache_covers =
min_x >= 0.0 && min_y >= 0.0 && max_x <= cache_w && max_y <= cache_h;
if !cache_covers {
// Early return only if atlas exists; otherwise keep cache path.
if !self.surfaces.atlas.is_empty() {
self.surfaces
.draw_atlas_to_backbuffer(self.viewbox, bg_color);
self.present_frame(shapes);
performance::end_measure!("render_from_cache");
performance::end_timed_log!("render_from_cache", _start);
return;
}
}
}
// Draw directly from cache surface, avoiding snapshot overhead
self.surfaces.draw_cache_to_backbuffer();
self.present_frame(shapes);
}
self.surfaces.draw_combined_atlas_to_backbuffer(
&self.viewbox,
&self.tile_viewbox,
self.background_color,
);
self.present_frame(shapes);
performance::end_measure!("render_from_cache");
performance::end_timed_log!("render_from_cache", _start);

View File

@ -590,6 +590,57 @@ impl Surfaces {
canvas.restore();
}
/// Fast pan/zoom preview: draw the doc atlas as backdrop, then overlay HQ
/// cached tile textures placed via their stored document rects (pan + scale).
pub fn draw_combined_atlas_to_backbuffer(
&mut self,
viewbox: &Viewbox,
tile_viewbox: &TileViewbox,
background: skia::Color,
) {
self.draw_atlas_to_backbuffer(*viewbox, background);
// Tile textures are keyed by grid index but positioned in document space.
// Without `tile_doc_rects` we cannot displace/scale them correctly (e.g.
// right after zoom invalidation); the atlas backdrop alone is enough.
if self.atlas.tile_doc_rects.is_empty() {
return;
}
let batch = self.tiles.build_atlas_draw_batch_for_doc_rects(
viewbox,
tile_viewbox,
&self.atlas.tile_doc_rects,
);
if batch.is_empty() {
return;
}
if self.tiles.needs_snapshot() || self.tile_atlas_image.is_none() {
self.tile_atlas_image = Some(self.tile_atlas.image_snapshot());
self.tiles.snapshot();
}
let Some(atlas_image) = self.tile_atlas_image.as_ref() else {
return;
};
let canvas = self.backbuffer.canvas();
canvas.save();
canvas.reset_matrix();
canvas.draw_atlas(
atlas_image,
&batch.transforms,
&batch.textures,
None,
skia::BlendMode::SrcOver,
self.atlas_sampling_options,
None,
None,
);
canvas.restore();
}
pub fn margins(&self) -> skia::ISize {
self.margins
}
@ -716,18 +767,6 @@ impl Surfaces {
);
}
/// Draws the cache surface directly to the backbuffer canvas.
/// This avoids creating an intermediate snapshot, reducing GPU stalls.
pub fn draw_cache_to_backbuffer(&mut self) {
let sampling_options = self.sampling_options;
self.cache.draw(
self.backbuffer.canvas(),
(0.0, 0.0),
sampling_options,
Some(&skia::Paint::default()),
);
}
pub fn cache_dimensions(&self) -> skia::ISize {
skia::ISize::new(self.cache.width(), self.cache.height())
}
@ -1516,6 +1555,17 @@ pub struct TileTextureCache {
removed: HashSet<Tile>,
}
pub struct AtlasDrawBatch {
pub transforms: Vec<skia::RSXform>,
pub textures: Vec<skia::Rect>,
}
impl AtlasDrawBatch {
pub fn is_empty(&self) -> bool {
self.transforms.is_empty()
}
}
impl TileTextureCache {
pub fn new(texture_size: i32, capacity: usize) -> Self {
Self {
@ -1615,6 +1665,76 @@ impl TileTextureCache {
}
}
pub fn build_atlas_draw_batch_for_doc_rects(
&self,
viewbox: &Viewbox,
tile_viewbox: &TileViewbox,
tile_doc_rects: &HashMap<Tile, skia::Rect>,
) -> AtlasDrawBatch {
let mut transforms = Vec::new();
let mut textures = Vec::new();
let s = viewbox.get_scale();
let view_doc = viewbox.area;
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 Some(tile_ref) = self.grid.get(&tile) else {
continue;
};
if self.removed.contains(&tile) {
continue;
}
let doc_rect = tile_doc_rects
.get(&tile)
.copied()
.unwrap_or_else(|| tiles::get_tile_rect(tile, s));
if doc_rect.is_empty() || !doc_rect.intersects(view_doc) {
continue;
}
let scos = doc_rect.width() * s / self.tile_size;
let tx = (doc_rect.left + viewbox.pan.x) * s;
let ty = (doc_rect.top + viewbox.pan.y) * s;
transforms.push(skia::RSXform::new(scos, 0.0, (tx, ty)));
textures.push(tile_ref.rect);
}
}
// 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) {
continue;
}
let doc_rect = tile_doc_rects
.get(&tile)
.copied()
.unwrap_or_else(|| tiles::get_tile_rect(tile, s));
if doc_rect.is_empty() || !doc_rect.intersects(view_doc) {
continue;
}
let tx = (doc_rect.left + viewbox.pan.x) * s;
let ty = (doc_rect.top + viewbox.pan.y) * s;
let scos = doc_rect.width() * s / self.tile_size;
transforms.push(skia::RSXform::new(scos, 0.0, (tx, ty)));
textures.push(tile_ref.rect);
}
AtlasDrawBatch {
transforms,
textures,
}
}
pub fn has(&self, tile: Tile) -> bool {
self.grid.contains_key(&tile) && !self.removed.contains(&tile)
}