🎉 Improving tile reutilization

This commit is contained in:
Alejandro Alonso 2026-04-01 10:30:00 +02:00
parent 0ad5baa5d9
commit f06b230f24
4 changed files with 335 additions and 56 deletions

View File

@ -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]

View File

@ -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");
}

View File

@ -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<Rect> {
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<Tile, skia::Image>,
removed: HashSet<Tile>,
grid: HashMap<TileCacheKey, skia::Image>,
removed: HashSet<TileCacheKey>,
scales: HashSet<u32>,
}
#[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<u32> {
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)
}
}

View File

@ -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),