From 8ba7b691733f4d7f054fcc5cd7cdc96605be70c6 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 6 Apr 2026 12:30:48 +0200 Subject: [PATCH] refresh 100% tiles after zoom --- render-wasm/src/render.rs | 153 +++++++++++++++++++++++++++-- render-wasm/src/render/surfaces.rs | 79 +++++++++++++++ 2 files changed, 226 insertions(+), 6 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 5ae705a7bd..3ca643fe7e 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -336,6 +336,12 @@ pub(crate) struct RenderState { pub show_grid: Option, pub focus_mode: FocusMode, pub touched_ids: HashSet, + /// When true, finishing a full-quality render at a zoom != 100% should schedule + /// a non-blocking refresh of the 100% tile cache from the rendered tiles. + base_cache_refresh_pending: bool, + /// Queue of tiles (at `base_cache_refresh_src_scale_bits`) to reproject into 100%. + base_cache_refresh_queue: Vec, + base_cache_refresh_src_scale_bits: u32, shape_last_extrect_by_scale: HashMap, /// 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 @@ -366,6 +372,89 @@ pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize { } impl RenderState { + fn schedule_base_cache_refresh_from_full_render(&mut self, src_scale_bits: u32) { + let base_bits = self.base_zoom_placeholder_scale_bits(); + if src_scale_bits == base_bits { + self.base_cache_refresh_pending = false; + self.base_cache_refresh_queue.clear(); + self.base_cache_refresh_src_scale_bits = 0; + println!("base-cache(100%) refresh skipped (already at 100%)"); + return; + } + // Schedule only tiles we actually have at src scale (interest area). + let scale = f32::from_bits(src_scale_bits); + if !scale.is_finite() || scale <= 0.0 { + return; + } + let tiles::TileRect(sx, sy, ex, ey) = tiles::get_tiles_for_viewbox_with_interest( + self.viewbox, + VIEWPORT_INTEREST_AREA_THRESHOLD, + scale, + ); + self.base_cache_refresh_queue.clear(); + for x in sx..=ex { + for y in sy..=ey { + let t = tiles::Tile::from(x, y); + if self.surfaces.has_cached_tile_surface(t, src_scale_bits) { + self.base_cache_refresh_queue.push(t); + } + } + } + self.base_cache_refresh_src_scale_bits = src_scale_bits; + + if self.base_cache_refresh_queue.is_empty() { + // Nothing to reproject from this full render. + self.base_cache_refresh_pending = false; + // Mark as "scheduled" (so we don't treat it as never started). + // Keep src bits set so `process_base_cache_refresh_batch` can report completion consistently. + println!( + "base-cache(100%) refresh done (empty queue, src_scale_bits={})", + src_scale_bits + ); + } else { + println!( + "base-cache(100%) refresh scheduled: {} tiles (src_scale_bits={})", + self.base_cache_refresh_queue.len(), + src_scale_bits + ); + } + } + + fn process_base_cache_refresh_batch(&mut self, max_tiles: usize) { + // If we haven't scheduled yet (src bits = 0), do nothing: we are waiting for a full render + // at a non-100% zoom to populate the queue. + if self.base_cache_refresh_src_scale_bits == 0 { + return; + } + + if self.base_cache_refresh_queue.is_empty() { + // Queue completed (or was empty but we already reported in schedule()). + // Reset src bits to avoid repeatedly printing. + self.base_cache_refresh_pending = false; + self.base_cache_refresh_src_scale_bits = 0; + println!("base-cache(100%) refresh done (queue already empty)"); + return; + } + let base_bits = self.base_zoom_placeholder_scale_bits(); + let src_bits = self.base_cache_refresh_src_scale_bits; + for _ in 0..max_tiles { + let Some(tile) = self.base_cache_refresh_queue.pop() else { break }; + let Some(img) = self.surfaces.cached_tile_image(tile, src_bits) else { continue }; + self.surfaces.reproject_cached_tile_into_scale( + &self.tile_viewbox, + &img, + tile, + src_bits, + base_bits, + self.background_color, + ); + } + if self.base_cache_refresh_queue.is_empty() { + self.base_cache_refresh_pending = false; + self.base_cache_refresh_src_scale_bits = 0; + println!("base-cache(100%) refresh done (src_scale_bits={})", src_bits); + } + } pub fn try_new(width: i32, height: i32) -> Result { // This needs to be done once per WebGL context. let mut gpu_state = GpuState::try_new()?; @@ -415,6 +504,9 @@ impl RenderState { show_grid: None, focus_mode: FocusMode::new(), touched_ids: HashSet::default(), + base_cache_refresh_pending: false, + base_cache_refresh_queue: Vec::new(), + base_cache_refresh_src_scale_bits: 0, shape_last_extrect_by_scale: HashMap::new(), ignore_nested_blurs: false, preview_mode: false, @@ -475,6 +567,40 @@ impl RenderState { self.shape_last_extrect_by_scale.insert(key, new_extrect); } + + // Additionally invalidate the 100% zoom cache, but only for tiles that we already had. + // This is the fast_mode source cache; we want it refreshed after edits, without + // creating work for tiles that were never cached at 100%. + let base_bits = self.base_zoom_placeholder_scale_bits(); + let base_scale = f32::from_bits(base_bits); + if base_scale.is_finite() && base_scale > 0.0 { + let new_extrect = shape.extrect(tree, base_scale); + let key = ShapeScaleKey { + shape_id: shape.id, + scale_bits: base_bits, + }; + let rect = if let Some(old) = self.shape_last_extrect_by_scale.get(&key).copied() { + Self::rect_union(old, new_extrect) + } else { + new_extrect + }; + + let tile_size = tiles::get_tile_size(base_scale); + let TileRect(sx, sy, ex, ey) = tiles::get_tiles_for_rect(rect, tile_size); + for x in sx..=ex { + for y in sy..=ey { + let tile = tiles::Tile::from(x, y); + if self.surfaces.has_cached_tile_surface_stale_ok(tile, base_bits) { + self.surfaces.remove_cached_tile_surface(tile, base_bits); + } + } + } + + self.shape_last_extrect_by_scale.insert(key, new_extrect); + } + + // Any edit means the base cache may need refresh after next full render. + self.base_cache_refresh_pending = true; } /// Combines every visible layer blur currently active (ancestors + shape) @@ -1639,18 +1765,25 @@ impl RenderState { timestamp: i32, ) -> Result<()> { performance::begin_measure!("process_animation_frame"); + // Always advance the base-cache refresh job in small batches. + // This job may be scheduled at the end of a full-quality render, and must + // keep progressing even after the main render finishes (non-blocking). + self.process_base_cache_refresh_batch(4); + if self.render_in_progress { if tree.len() != 0 { self.render_shape_tree_partial(base_object, tree, timestamp, true)?; } self.flush_and_submit(); + } - if self.render_in_progress { - self.cancel_animation_frame(); - self.render_request_id = Some(wapi::request_animation_frame!()); - } else { - performance::end_measure!("render"); - } + // Keep the RAF loop alive while either rendering is in progress or the + // base-cache refresh job still has work to do. + if self.render_in_progress || !self.base_cache_refresh_queue.is_empty() { + self.cancel_animation_frame(); + self.render_request_id = Some(wapi::request_animation_frame!()); + } else if !self.render_in_progress { + performance::end_measure!("render"); } performance::end_measure!("process_animation_frame"); Ok(()) @@ -2763,6 +2896,14 @@ impl RenderState { // Mark cache as valid for render_from_cache self.cached_viewbox = self.viewbox; + // If there was an edit, once we finish a full-quality render at a zoom != 100%, + // schedule a non-blocking refresh of the 100% cache from those rendered tiles. + if self.base_cache_refresh_pending && !self.options.is_fast_mode() { + self.schedule_base_cache_refresh_from_full_render(self.get_scale().to_bits()); + } + // Always process a small batch so refresh progresses without blocking. + self.process_base_cache_refresh_batch(4); + if self.options.is_debug_visible() { debug::render(self); } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index dd0067edff..6507751633 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -566,6 +566,10 @@ impl Surfaces { self.tiles.has(tile, scale_bits) } + pub fn has_cached_tile_surface_stale_ok(&self, tile: Tile, scale_bits: u32) -> bool { + self.tiles.has_stale(tile, scale_bits) + } + pub fn world_rect_has_any_tile_at_scale_bits(&self, world_rect: Rect, scale_bits: u32) -> bool { let scale = f32::from_bits(scale_bits); if !scale.is_finite() || scale <= 0.0 { @@ -618,6 +622,81 @@ impl Surfaces { } } + pub fn cached_tile_image(&self, tile: Tile, scale_bits: u32) -> Option { + self.tiles.get(tile, scale_bits).cloned() + } + + pub fn reproject_cached_tile_into_scale( + &mut self, + tile_viewbox: &TileViewbox, + src_image: &skia::Image, + src_tile: Tile, + src_scale_bits: u32, + dst_scale_bits: u32, + background: skia::Color, + ) { + let src_scale = f32::from_bits(src_scale_bits); + let dst_scale = f32::from_bits(dst_scale_bits); + if !src_scale.is_finite() || src_scale <= 0.0 || !dst_scale.is_finite() || dst_scale <= 0.0 { + return; + } + + let src_world_rect = super::tiles::get_tile_rect(src_tile, src_scale); + + let dst_tile_size_world = super::tiles::get_tile_size(dst_scale); + let super::tiles::TileRect(sx, sy, ex, ey) = + super::tiles::get_tiles_for_rect(src_world_rect, dst_tile_size_world); + + for x in sx..=ex { + for y in sy..=ey { + let dst_tile = Tile::from(x, y); + let dst_world_rect = super::tiles::get_tile_rect(dst_tile, dst_scale); + let Some(overlap_world) = Self::rect_intersection(src_world_rect, dst_world_rect) else { + continue; + }; + + 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); + + let dst_px_l = (overlap_world.left() - dst_world_rect.left()) * dst_scale; + let dst_px_t = (overlap_world.top() - dst_world_rect.top()) * dst_scale; + let dst_px_r = (overlap_world.right() - dst_world_rect.left()) * dst_scale; + let dst_px_b = (overlap_world.bottom() - dst_world_rect.top()) * dst_scale; + let dst_rect = Rect::from_ltrb(dst_px_l, dst_px_t, dst_px_r, dst_px_b); + + let mut tile_surface = match self + .current + .new_surface_with_dimensions((TILE_SIZE as i32, TILE_SIZE as i32)) + { + Some(s) => s, + None => return, + }; + // IMPORTANT: compose over existing dst tile image to avoid erasing + // regions not covered by this reprojection patch. + tile_surface.canvas().clear(background); + if let Some(existing) = self.tiles.get_stale(dst_tile, dst_scale_bits) { + tile_surface.canvas().draw_image_rect( + existing, + None, + Rect::from_xywh(0.0, 0.0, TILE_SIZE, TILE_SIZE), + &skia::Paint::default(), + ); + } + tile_surface.canvas().draw_image_rect( + src_image, + Some((&src_rect, skia::canvas::SrcRectConstraint::Fast)), + dst_rect, + &skia::Paint::default(), + ); + let new_img = tile_surface.image_snapshot(); + self.tiles.add(tile_viewbox, &dst_tile, dst_scale_bits, new_img); + } + } + } + /// Draws the current tile directly to the target and cache surfaces without /// creating a snapshot. This avoids GPU stalls from ReadPixels but doesn't /// populate the tile texture cache (suitable for one-shot renders like tests).