From f06b230f246ebe99fef95ee9b917e884037de88c Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 1 Apr 2026 10:30:00 +0200 Subject: [PATCH] :tada: Improving tile reutilization --- frontend/src/app/render_wasm/api.cljs | 29 ++- render-wasm/src/render.rs | 107 +++++++++-- render-wasm/src/render/surfaces.rs | 251 ++++++++++++++++++++++---- render-wasm/src/tiles.rs | 4 +- 4 files changed, 335 insertions(+), 56 deletions(-) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 27dc63e5c6..23c8bb3331 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -982,17 +982,30 @@ (h/call wasm/internal-module "_set_view_start") (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) - (if is-pan - (do (perf/end-measure "set-view-box") - (perf/begin-measure "set-view-box::pan") - (render-pan) - (render-finish) - (perf/end-measure "set-view-box::pan")) - (do (perf/end-measure "set-view-box") + ;; (do (perf/end-measure "set-view-box") + ;; (perf/begin-measure "set-view-box::pan") + ;; (render-pan) + ;; (render-finish) + ;; (perf/end-measure "set-view-box::pan")))) + + (do (perf/end-measure "set-view-box") (perf/begin-measure "set-view-box::zoom") (h/call wasm/internal-module "_render_from_cache" 0) (render-finish) - (perf/end-measure "set-view-box::zoom"))))) + (perf/end-measure "set-view-box::zoom")))) + + ;; (< zoom prev-zoom) + ;; (if (or is-pan) + ;; (do (perf/end-measure "set-view-box") + ;; (perf/begin-measure "set-view-box::pan") + ;; (render-pan) + ;; (render-finish) + ;; (perf/end-measure "set-view-box::pan")) + ;; (do (perf/end-measure "set-view-box") + ;; (perf/begin-measure "set-view-box::zoom") + ;; (h/call wasm/internal-module "_render_from_cache" 0) + ;; (render-finish) + ;; (perf/end-measure "set-view-box::zoom"))))) (defn update-text-rect! [id] diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 1b8fcb87e3..34673cdf3b 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -666,17 +666,20 @@ impl RenderState { pub fn apply_render_to_final_canvas(&mut self, rect: skia::Rect) -> Result<()> { let tile_rect = self.get_current_aligned_tile_bounds()?; + let scale_bits = self.get_scale().to_bits(); self.surfaces.cache_current_tile_texture( &self.tile_viewbox, &self .current_tile .ok_or(Error::CriticalError("Current tile not found".to_string()))?, &tile_rect, + scale_bits, ); self.surfaces.draw_cached_tile_surface( self.current_tile .ok_or(Error::CriticalError("Current tile not found".to_string()))?, + scale_bits, rect, self.background_color, ); @@ -1397,6 +1400,68 @@ impl RenderState { // Restore canvas state self.surfaces.canvas(SurfaceId::Target).restore(); + // When zooming out, the cached surface (from a previous, more zoomed-in + // render) may not cover the newly visible world area. Fill those gaps + // with any cached tiles (exact zoom or cross-zoom fallback) so we don't + // show temporary empty squares. + if navigate_zoom < 1.0 { + let current_scale = self.get_scale(); + let current_scale_bits = current_scale.to_bits(); + let tiles::TileRect(vsx, vsy, vex, vey) = + tiles::get_tiles_for_viewbox(self.viewbox, current_scale); + + let offset_x = self.viewbox.area.left * current_scale; + let offset_y = self.viewbox.area.top * current_scale; + + let mut exact_hits: usize = 0; + let mut fallback_hits: usize = 0; + let mut fallback_blits: usize = 0; + let mut misses: usize = 0; + + for x in vsx..=vex { + for y in vsy..=vey { + let tile = tiles::Tile::from(x, y); + let rect = skia::Rect::from_xywh( + (x as f32 * tiles::TILE_SIZE) - offset_x, + (y as f32 * tiles::TILE_SIZE) - offset_y, + tiles::TILE_SIZE, + tiles::TILE_SIZE, + ); + if self.surfaces.has_cached_tile_surface(tile, current_scale_bits) { + self.surfaces.draw_cached_tile_surface( + tile, + current_scale_bits, + rect, + self.background_color, + ); + exact_hits += 1; + } else { + let target_world_rect = tiles::get_tile_rect(tile, current_scale); + let blits = self.surfaces.draw_tile_fallback_cross_zoom( + &self.tile_viewbox, + rect, + self.background_color, + target_world_rect, + current_scale, + current_scale_bits, + true, + ); + if blits > 0 { + fallback_hits += 1; + fallback_blits += blits; + } else { + misses += 1; + } + } + } + } + + eprintln!( + "render_from_cache zoom-out fill: exact_hits={} fallback_tiles={} fallback_blits={} misses={} scale={}", + exact_hits, fallback_hits, fallback_blits, misses, current_scale + ); + } + if self.options.is_debug_visible() { debug::render(self); } @@ -1477,8 +1542,9 @@ impl RenderState { let _tile_start = performance::begin_timed_log!("tile_cache_update"); performance::begin_measure!("tile_cache"); + let scale_bits = scale.to_bits(); self.pending_tiles - .update(&self.tile_viewbox, &self.surfaces); + .update(&self.tile_viewbox, &self.surfaces, scale_bits); performance::end_measure!("tile_cache"); performance::end_timed_log!("tile_cache_update", _tile_start); @@ -2511,11 +2577,14 @@ impl RenderState { while !should_stop { if let Some(current_tile) = self.current_tile { - if self.surfaces.has_cached_tile_surface(current_tile) { + let scale = self.get_scale(); + let scale_bits = scale.to_bits(); + if self.surfaces.has_cached_tile_surface(current_tile, scale_bits) { performance::begin_measure!("render_shape_tree::cached"); let tile_rect = self.get_current_tile_bounds()?; self.surfaces.draw_cached_tile_surface( current_tile, + scale_bits, tile_rect, self.background_color, ); @@ -2530,6 +2599,19 @@ impl RenderState { ); } } else { + // Cross-zoom fallback: draw any cached content from other scales so we + // never show empty tiles while the exact tile is being regenerated. + let tile_rect = self.get_current_tile_bounds()?; + let target_world_rect = tiles::get_tile_rect(current_tile, scale); + let _blits = self.surfaces.draw_tile_fallback_cross_zoom( + &self.tile_viewbox, + tile_rect, + self.background_color, + target_world_rect, + scale, + scale_bits, + true, + ); performance::begin_measure!("render_shape_tree::uncached"); // Only allow stopping (yielding) if the current tile is NOT visible. // This ensures all visible tiles render synchronously before showing, @@ -2574,7 +2656,9 @@ impl RenderState { if let Some(next_tile) = self.pending_tiles.pop() { self.update_render_context(next_tile); - if !self.surfaces.has_cached_tile_surface(next_tile) { + let scale = self.get_scale(); + let scale_bits = scale.to_bits(); + if !self.surfaces.has_cached_tile_surface(next_tile, scale_bits) { if let Some(ids) = self.tiles.get_shapes_at(next_tile) { // Check if any shape on this tile has a background blur. // If so, we need ALL root shapes rendered (not just those @@ -2615,7 +2699,7 @@ impl RenderState { self.render_in_progress = false; - self.surfaces.gc(); + // self.surfaces.gc(); // Mark cache as valid for render_from_cache self.cached_viewbox = self.viewbox; @@ -2776,7 +2860,9 @@ impl RenderState { } pub fn remove_cached_tile(&mut self, tile: tiles::Tile) { - self.surfaces.remove_cached_tile_surface(tile); + // Tile content changed: invalidate this tile across all cached scales so + // cross-zoom fallbacks never resurrect stale content. + self.surfaces.remove_cached_tile_surface_all_scales(tile); } /// Rebuild the tile index (shape→tile mapping) for all top-level shapes. @@ -2809,14 +2895,9 @@ impl RenderState { self.rebuild_tile_index(tree); - // Zoom changes world tile size: a partial cache update would mix scales in the - // mosaic and glitch. Same zoom as last finished render (typical pan): drop only - // tile textures and keep the cache canvas for render_from_cache. - if self.zoom_changed() { - self.surfaces.remove_cached_tiles(self.background_color); - } else { - self.surfaces.invalidate_tile_cache(); - } + // IMPORTANT: Do not invalidate cached tile images on zoom/pan. + // We intentionally keep them so cross-zoom/pan fallbacks can reuse + // already-rendered tiles while the current zoom level is re-rendered. performance::end_measure!("rebuild_tiles_shallow"); } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 2ab32d1a61..840337d41b 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -2,7 +2,7 @@ use crate::error::{Error, Result}; use crate::performance; use crate::shapes::Shape; -use skia_safe::{self as skia, IRect, Paint, RRect}; +use skia_safe::{self as skia, IRect, Paint, RRect, Rect}; use super::{gpu_state::GpuState, tiles::Tile, tiles::TileViewbox, tiles::TILE_SIZE}; @@ -538,6 +538,7 @@ impl Surfaces { tile_viewbox: &TileViewbox, tile: &Tile, tile_rect: &skia::Rect, + scale_bits: u32, ) { let rect = IRect::from_xywh( self.margins.width, @@ -557,23 +558,33 @@ impl Surfaces { &skia::Paint::default(), ); - self.tiles.add(tile_viewbox, tile, tile_image); + self.tiles.add(tile_viewbox, tile, scale_bits, tile_image); } } - pub fn has_cached_tile_surface(&self, tile: Tile) -> bool { - self.tiles.has(tile) + pub fn has_cached_tile_surface(&self, tile: Tile, scale_bits: u32) -> bool { + self.tiles.has(tile, scale_bits) } - pub fn remove_cached_tile_surface(&mut self, tile: Tile) { + pub fn remove_cached_tile_surface(&mut self, tile: Tile, scale_bits: u32) { // Mark tile as invalid // Old content stays visible until new tile overwrites it atomically, // preventing flickering during tile re-renders. - self.tiles.remove(tile); + self.tiles.remove(tile, scale_bits); } - pub fn draw_cached_tile_surface(&mut self, tile: Tile, rect: skia::Rect, color: skia::Color) { - if let Some(image) = self.tiles.get(tile) { + pub fn remove_cached_tile_surface_all_scales(&mut self, tile: Tile) { + self.tiles.remove_all_scales_for_tile(tile); + } + + pub fn draw_cached_tile_surface( + &mut self, + tile: Tile, + scale_bits: u32, + rect: skia::Rect, + color: skia::Color, + ) { + if let Some(image) = self.tiles.get(tile, scale_bits) { let mut paint = skia::Paint::default(); paint.set_color(color); @@ -581,7 +592,7 @@ impl Surfaces { self.target .canvas() - .draw_image_rect(&image, None, rect, &skia::Paint::default()); + .draw_image_rect(image, None, rect, &skia::Paint::default()); } } @@ -642,6 +653,127 @@ impl Surfaces { self.tiles.clear(); } + fn rect_intersection(a: Rect, b: Rect) -> Option { + let l = a.left().max(b.left()); + let t = a.top().max(b.top()); + let r = a.right().min(b.right()); + let btm = a.bottom().min(b.bottom()); + if r > l && btm > t { + Some(Rect::from_ltrb(l, t, r, btm)) + } else { + None + } + } + + /// Draw a placeholder for a missing tile using cached tiles from other zoom levels. + pub fn draw_tile_fallback_cross_zoom( + &mut self, + tile_viewbox: &TileViewbox, + rect: Rect, + color: skia::Color, + target_world_rect: Rect, + target_scale: f32, + target_scale_bits: u32, + debug_trace: bool, + ) -> usize { + let Some(candidate_scale_bits) = + self.tiles + .best_fallback_scale_bits(target_scale, target_scale_bits) + else { + if debug_trace { + println!( + "tile_fallback: no candidate scale (target_scale={})", + target_scale + ); + } + return 0; + }; + + let src_scale = f32::from_bits(candidate_scale_bits); + if !src_scale.is_finite() || src_scale <= 0.0 { + if debug_trace { + println!( + "tile_fallback: invalid candidate scale (bits={}, scale={})", + candidate_scale_bits, src_scale + ); + } + return 0; + } + + if debug_trace { + println!( + "tile_fallback: target_scale={} -> candidate_scale={}", + target_scale, src_scale + ); + } + + let tile_size_src_world = super::tiles::get_tile_size(src_scale); + let super::tiles::TileRect(sx, sy, ex, ey) = + super::tiles::get_tiles_for_rect(target_world_rect, tile_size_src_world); + + let mut blits: usize = 0; + + for x in sx..=ex { + for y in sy..=ey { + let src_tile = Tile::from(x, y); + let Some(src_image) = self.tiles.get(src_tile, candidate_scale_bits) else { + continue; + }; + + let src_world_rect = super::tiles::get_tile_rect(src_tile, src_scale); + let Some(overlap_world) = Self::rect_intersection(target_world_rect, src_world_rect) + else { + continue; + }; + + // Source pixel rect within the cached tile image. + let src_px_l = (overlap_world.left() - src_world_rect.left()) * src_scale; + let src_px_t = (overlap_world.top() - src_world_rect.top()) * src_scale; + let src_px_r = (overlap_world.right() - src_world_rect.left()) * src_scale; + let src_px_b = (overlap_world.bottom() - src_world_rect.top()) * src_scale; + let src_rect = Rect::from_ltrb(src_px_l, src_px_t, src_px_r, src_px_b); + + // Destination rect in target device space. + let dst_px_l = + rect.left() + (overlap_world.left() - target_world_rect.left()) * target_scale; + let dst_px_t = + rect.top() + (overlap_world.top() - target_world_rect.top()) * target_scale; + let dst_px_r = + rect.left() + (overlap_world.right() - target_world_rect.left()) * target_scale; + let dst_px_b = + rect.top() + (overlap_world.bottom() - target_world_rect.top()) * target_scale; + let dst_rect = Rect::from_ltrb(dst_px_l, dst_px_t, dst_px_r, dst_px_b); + let Some(dst_rect) = Self::rect_intersection(dst_rect, rect) else { + continue; + }; + + let mut paint = skia::Paint::default(); + paint.set_color(color); + self.target.canvas().draw_rect(dst_rect, &paint); + + self.target.canvas().draw_image_rect( + src_image, + Some((&src_rect, skia::canvas::SrcRectConstraint::Fast)), + dst_rect, + &skia::Paint::default(), + ); + + blits += 1; + } + } + + // Opportunistic cleanup in case we kept too much cross-zoom content. + if blits > 0 && self.tiles.grid_len() > TEXTURES_CACHE_CAPACITY { + self.tiles.free_tiles(tile_viewbox); + } + + if debug_trace { + println!("tile_fallback: blits={}", blits); + } + + blits + } + pub fn gc(&mut self) { self.tiles.gc(); } @@ -689,8 +821,15 @@ impl Surfaces { } pub struct TileTextureCache { - grid: HashMap, - removed: HashSet, + grid: HashMap, + removed: HashSet, + scales: HashSet, +} + +#[derive(PartialEq, Eq, Hash, Clone, Copy, Debug)] +struct TileCacheKey { + tile: Tile, + scale_bits: u32, } impl TileTextureCache { @@ -698,27 +837,32 @@ impl TileTextureCache { Self { grid: HashMap::default(), removed: HashSet::default(), + scales: HashSet::default(), } } - pub fn has(&self, tile: Tile) -> bool { - self.grid.contains_key(&tile) && !self.removed.contains(&tile) + pub fn has(&self, tile: Tile, scale_bits: u32) -> bool { + let key = TileCacheKey { tile, scale_bits }; + self.grid.contains_key(&key) && !self.removed.contains(&key) } fn gc(&mut self) { // Make a real remove - for tile in self.removed.iter() { - self.grid.remove(tile); + let removed = std::mem::take(&mut self.removed); + for key in removed.iter() { + self.grid.remove(key); } } fn free_tiles(&mut self, tile_viewbox: &TileViewbox) { + println!("free_tiles"); let marked: Vec<_> = self .grid .iter_mut() - .filter_map(|(tile, _)| { - if !tile_viewbox.is_visible(tile) { - Some(*tile) + .filter_map(|(key, _)| { + // Approximate visibility check: uses tile coords only. + if !tile_viewbox.is_visible(&key.tile) { + Some(*key) } else { None } @@ -726,12 +870,18 @@ impl TileTextureCache { .take(TEXTURES_BATCH_DELETE) .collect(); - for tile in marked.iter() { - self.grid.remove(tile); + for key in marked.iter() { + self.grid.remove(key); } } - pub fn add(&mut self, tile_viewbox: &TileViewbox, tile: &Tile, image: skia::Image) { + pub fn add( + &mut self, + tile_viewbox: &TileViewbox, + tile: &Tile, + scale_bits: u32, + image: skia::Image, + ) { if self.grid.len() > TEXTURES_CACHE_CAPACITY { // First we try to remove the obsolete tiles self.gc(); @@ -741,27 +891,62 @@ impl TileTextureCache { self.free_tiles(tile_viewbox); } - self.grid.insert(*tile, image); - - if self.removed.contains(tile) { - self.removed.remove(tile); - } + let key = TileCacheKey { + tile: *tile, + scale_bits, + }; + self.scales.insert(scale_bits); + self.grid.insert(key, image); + println!("add: {:?}", key); + self.removed.remove(&key); } - pub fn get(&mut self, tile: Tile) -> Option<&mut skia::Image> { - if self.removed.contains(&tile) { + pub fn get(&self, tile: Tile, scale_bits: u32) -> Option<&skia::Image> { + let key = TileCacheKey { tile, scale_bits }; + if self.removed.contains(&key) { return None; } - self.grid.get_mut(&tile) + self.grid.get(&key) } - pub fn remove(&mut self, tile: Tile) { - self.removed.insert(tile); + pub fn remove(&mut self, tile: Tile, scale_bits: u32) { + self.removed.insert(TileCacheKey { tile, scale_bits }); + } + + pub fn remove_all_scales_for_tile(&mut self, tile: Tile) { + for scale_bits in self.scales.iter().copied() { + self.removed.insert(TileCacheKey { tile, scale_bits }); + } } pub fn clear(&mut self) { - for k in self.grid.keys() { - self.removed.insert(*k); + self.removed.extend(self.grid.keys().copied()); + // After a full invalidation, we must also drop the list of known scales. + // Otherwise `best_fallback_scale_bits` may keep suggesting a scale that has + // no usable (non-removed) tiles, leading to confusing `blits=0` traces. + self.scales.clear(); + } + + pub fn grid_len(&self) -> usize { + self.grid.len() + } + + pub fn best_fallback_scale_bits(&self, target_scale: f32, target_scale_bits: u32) -> Option { + let mut best: Option<(f32, u32)> = None; + for bits in self.scales.iter().copied() { + if bits == target_scale_bits { + continue; + } + let s = f32::from_bits(bits); + if !s.is_finite() || s <= 0.0 { + continue; + } + let score = (s / target_scale).ln().abs(); + match best { + Some((best_score, _)) if best_score <= score => {} + _ => best = Some((score, bits)), + } } + best.map(|(_, bits)| bits) } } diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index 13ed4c1aeb..318b91b04c 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -261,7 +261,7 @@ impl PendingTiles { result } - pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces) { + pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces, scale_bits: u32) { self.list.clear(); // Generate spiral for the interest area (viewport + margin) @@ -279,7 +279,7 @@ impl PendingTiles { for tile in spiral { 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_bits); match (is_visible, is_cached) { (true, true) => visible_cached.push(tile),