diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index acdd436e7c..e4c5938804 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -361,6 +361,23 @@ pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize { .into() } +/// Origin (px) of the tile-aligned grid used when blitting into `SurfaceId::Cache`. +/// +/// The cache mosaic stores tiles on a fixed \(TILE_SIZE × TILE_SIZE\) grid in *scaled* pixel +/// space (i.e. after applying `scale`). When the viewbox is between tile boundaries (pan), +/// the on-screen tile bounds (`get_current_tile_bounds`) move continuously, but the cache mosaic +/// must remain snapped to the global grid so previously cached tiles keep landing in the same +/// slots. +/// +/// This function computes that snapped grid origin by rounding the viewbox top-left down to the +/// nearest multiple of `tiles::TILE_SIZE` in scaled space. Callers then position individual tiles +/// relative to this origin via `get_aligned_tile_bounds`. +fn cache_mosaic_snap_origin(viewbox: Viewbox, scale: f32) -> (f32, f32) { + let sx = (viewbox.area.left * scale / tiles::TILE_SIZE).floor() * tiles::TILE_SIZE; + let sy = (viewbox.area.top * scale / tiles::TILE_SIZE).floor() * tiles::TILE_SIZE; + (sx, sy) +} + impl RenderState { pub fn try_new(width: i32, height: i32) -> Result { // This needs to be done once per WebGL context. @@ -676,19 +693,23 @@ impl RenderState { self.surfaces.clear_cache(self.background_color); self.cache_cleared_this_render = true; } - let tile_rect = self.get_current_aligned_tile_bounds()?; + // Cache mosaic uses a tile grid snapped to TILE_SIZE in scaled space; `render_from_cache` + // relies on that (continuous view offset minus snapped tile origin). The target blit must + // still use `rect` from `get_current_tile_bounds` for smooth sub-tile pan on screen. + let cache_tile_rect = self.get_current_aligned_tile_bounds()?; self.surfaces.cache_current_tile_texture( &self.tile_viewbox, &self .current_tile .ok_or(Error::CriticalError("Current tile not found".to_string()))?, - &tile_rect, + &cache_tile_rect, ); self.surfaces.draw_cached_tile_surface( self.current_tile .ok_or(Error::CriticalError("Current tile not found".to_string()))?, rect, + None, self.background_color, ); Ok(()) @@ -1510,6 +1531,7 @@ impl RenderState { self.cache_cleared_this_render = false; self.reset_canvas(); + let surface_ids = SurfaceId::Strokes as u32 | SurfaceId::Fills as u32 | SurfaceId::InnerShadows as u32 @@ -1519,7 +1541,8 @@ impl RenderState { }); let viewbox_cache_size = get_cache_size(self.viewbox, scale); - let cached_viewbox_cache_size = get_cache_size(self.cached_viewbox, scale); + let cached_scale = self.get_cached_scale(); + let cached_viewbox_cache_size = get_cache_size(self.cached_viewbox, cached_scale); // Only resize cache if the new size is larger than the cached size // This avoids unnecessary surface recreations when the cache size decreases if viewbox_cache_size.width > cached_viewbox_cache_size.width @@ -1529,6 +1552,26 @@ impl RenderState { .resize_cache(viewbox_cache_size, VIEWPORT_INTEREST_AREA_THRESHOLD)?; } + // The cache mosaic uses `get_aligned_tile_bounds` (snapped origin). If we pan across a + // TILE_SIZE boundary in scaled space, new tiles would map to different slots while old + // pixels stay put — `render_from_cache` then shows a sheared / shifted composite. + if self.cached_viewbox.area.width() > 0.0 { + println!("1"); + let mosaic_stale = if self.zoom_changed() { + true + } else { + let snap_now = cache_mosaic_snap_origin(self.viewbox, scale); + let snap_cached = cache_mosaic_snap_origin(self.cached_viewbox, cached_scale); + snap_now != snap_cached + }; + println!("mosaic_stale: {}", mosaic_stale); + if mosaic_stale { + println!("clearing cache"); + self.surfaces.clear_cache(self.background_color); + self.cache_cleared_this_render = true; + } + } + // FIXME - review debug // debug::render_debug_tiles_for_viewbox(self); @@ -1879,14 +1922,12 @@ impl RenderState { ) } - // Returns the bounds of the current tile relative to the viewbox, - // aligned to the nearest tile grid origin. - // - // Unlike `get_current_tile_bounds`, which calculates bounds using the exact - // scaled offset of the viewbox, this method snaps the origin to the nearest - // lower multiple of `TILE_SIZE`. This ensures the tile bounds are aligned - // with the global tile grid, which is useful for rendering tiles in a - /// consistent and predictable layout. + /// Returns the bounds of the current tile relative to the viewbox, aligned to the nearest + /// tile grid origin. + /// + /// Unlike [`Self::get_current_tile_bounds`], which uses the exact scaled offset of the + /// viewbox, this snaps the origin to the nearest lower multiple of [`tiles::TILE_SIZE`], so + /// tile bounds line up with the global tile grid used for the cache mosaic. pub fn get_current_aligned_tile_bounds(&mut self) -> Result { Ok(self.get_aligned_tile_bounds( self.current_tile @@ -2571,9 +2612,15 @@ impl RenderState { if self.surfaces.has_cached_tile_surface(current_tile) { performance::begin_measure!("render_shape_tree::cached"); let tile_rect = self.get_current_tile_bounds()?; + let cache_rect = if self.zoom_changed() { + None + } else { + Some(self.get_aligned_tile_bounds(current_tile)) + }; self.surfaces.draw_cached_tile_surface( current_tile, tile_rect, + cache_rect, self.background_color, ); performance::end_measure!("render_shape_tree::cached"); @@ -2866,13 +2913,10 @@ 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. + // Typical pan at the same zoom: keep tile textures so we can blit from cache. + // Zoom changes invalidate tile textures (different scale); drop caches then. if self.zoom_changed() { self.surfaces.remove_cached_tiles(self.background_color); - } else { - self.surfaces.invalidate_tile_cache(); } performance::end_measure!("rebuild_tiles_shallow"); diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 3b7d4bcbbc..10d8a887d7 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -581,16 +581,34 @@ impl Surfaces { self.tiles.remove(tile); } - pub fn draw_cached_tile_surface(&mut self, tile: Tile, rect: skia::Rect, color: skia::Color) { + /// Draws a cached tile image to the target. When `cache_rect` is set, also blits into the + /// cache mosaic (`render_from_cache`). + pub fn draw_cached_tile_surface( + &mut self, + tile: Tile, + target_rect: skia::Rect, + cache_rect: Option, + color: skia::Color, + ) { if let Some(image) = self.tiles.get(tile) { + let img: &skia::Image = &*image; let mut paint = skia::Paint::default(); paint.set_color(color); - self.target.canvas().draw_rect(rect, &paint); + self.target.canvas().draw_rect(target_rect, &paint); - self.target - .canvas() - .draw_image_rect(&image, None, rect, &skia::Paint::default()); + self.target.canvas().draw_image_rect( + img, + None, + target_rect, + &skia::Paint::default(), + ); + + if let Some(cr) = cache_rect { + self.cache + .canvas() + .draw_image_rect(img, None, cr, &skia::Paint::default()); + } } } @@ -726,11 +744,7 @@ impl TileTextureCache { .grid .iter_mut() .filter_map(|(tile, _)| { - if !tile_viewbox.is_visible(tile) { - Some(*tile) - } else { - None - } + (!tile_viewbox.is_visible(tile)).then_some(*tile) }) .take(TEXTURES_BATCH_DELETE) .collect(); diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 7b6ba8a65e..041685e014 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -112,14 +112,16 @@ impl State { } pub fn start_render_loop(&mut self, timestamp: i32) -> Result<()> { - // If zoom changed (e.g. interrupted zoom render followed by pan), the - // tile index may be stale for the new viewport position. Rebuild the - // index so shapes are mapped to the correct tiles. We use - // rebuild_tile_index (NOT rebuild_tiles_shallow) to preserve the tile - // texture cache — otherwise cached tiles with shadows/blur would be - // cleared and re-rendered in fast mode without effects. + // During view interactions (wheel/pinch), the frontend paints intermediate frames via + // `render_from_cache` and may interrupt renders. If the zoom changes mid-flight, the + // tile index can become stale for the new viewbox and cached tile textures (rasterized + // at the previous scale) must not be blitted again. + // + // We therefore use `rebuild_tiles_shallow` on zoom changes: it rebuilds the tile index + // and drops cached tiles for zoom transitions, but keeps the cache intact for same-zoom + // pans (so panning stays fast). if self.render_state.zoom_changed() { - self.render_state.rebuild_tile_index(&self.shapes); + self.render_state.rebuild_tiles_shallow(&self.shapes); } self.render_state