diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 2fd7f17106..3d8fe0d0df 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1148,23 +1148,21 @@ [shapes start-index thumbnails-acc full-acc] (let [total (count shapes) deadline (+ (js/performance.now) CHUNK_TIME_BUDGET_MS)] - (let [result - (loop [index start-index - t-acc (transient thumbnails-acc) - f-acc (transient full-acc)] - (if (and (< index total) - ;; Check performance.now every 8 shapes to reduce overhead - (or (pos? (bit-and (- index start-index) 7)) - (<= (js/performance.now) deadline))) - (let [shape (nth shapes index) - {:keys [thumbnails full]} (set-object shape)] - (recur (inc index) - (reduce conj! t-acc thumbnails) - (reduce conj! f-acc full))) - {:thumbnails (persistent! t-acc) - :full (persistent! f-acc) - :next-index index}))] - result))) + (loop [index start-index + t-acc (transient thumbnails-acc) + f-acc (transient full-acc)] + (if (and (< index total) + ;; Check performance.now every 8 shapes to reduce overhead + (or (pos? (bit-and (- index start-index) 7)) + (<= (js/performance.now) deadline))) + (let [shape (nth shapes index) + {:keys [thumbnails full]} (set-object shape)] + (recur (inc index) + (reduce conj! t-acc thumbnails) + (reduce conj! f-acc full))) + {:thumbnails (persistent! t-acc) + :full (persistent! f-acc) + :next-index index})))) (defn- set-objects-async "Asynchronously process shapes in time-budgeted chunks, yielding to the @@ -1194,6 +1192,10 @@ (h/call wasm/internal-module "_end_loading") (end-shapes-loading!) + ;; Rebuild the tile index so _render knows which shapes + ;; map to which tiles after a page switch. + (h/call wasm/internal-module "_set_view_end") + ;; Text layouts must run after _end_loading (they ;; depend on state that is only correct when loading ;; is false). Each call touch_shape → touched_ids. @@ -1247,6 +1249,9 @@ {:thumbnails (persistent! thumbnails-acc) :full (persistent! full-acc)}))] (perf/end-measure "set-objects") (when on-shapes-ready (on-shapes-ready)) + ;; Rebuild the tile index so _render knows which shapes + ;; map to which tiles after a page switch. + (h/call wasm/internal-module "_set_view_end") (process-pending shapes thumbnails full (fn [] (if render-callback diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 89659afcf7..747b6018f8 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -220,7 +220,13 @@ pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) -> Result<() #[wasm_error] pub extern "C" fn render_from_cache(_: i32) -> Result<()> { with_state_mut!(state, { - state.render_state.cancel_animation_frame(); + // Don't cancel the animation frame — let the async render + // continue populating the tile HashMap in the background. + // process_animation_frame skips flush_and_submit in fast + // mode so it won't present stale Target content. The + // tile HashMap is position-independent, so tiles rendered + // for the old viewport can be reused by the next full + // render at the new viewport position. state.render_from_cache(); }); Ok(()) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 535a47e174..7ed2e0f209 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -40,6 +40,7 @@ pub use images::*; // This is the extra area used for tile rendering (tiles beyond viewport). // Higher values pre-render more tiles, reducing empty squares during pan but using more memory. const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3; + const MAX_BLOCKING_TIME_MS: i32 = 32; const NODE_BATCH_THRESHOLD: i32 = 3; const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0; @@ -344,11 +345,9 @@ pub(crate) struct RenderState { pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize { // First we retrieve the extended area of the viewport that we could render. - let TileRect(isx, isy, iex, iey) = tiles::get_tiles_for_viewbox_with_interest( - viewbox, - VIEWPORT_INTEREST_AREA_THRESHOLD, - scale, - ); + let interest = VIEWPORT_INTEREST_AREA_THRESHOLD; + let TileRect(isx, isy, iex, iey) = + tiles::get_tiles_for_viewbox_with_interest(viewbox, interest, scale); let dx = if isx.signum() != iex.signum() { 1 } else { 0 }; let dy = if isy.signum() != iey.signum() { 1 } else { 0 }; @@ -704,20 +703,24 @@ impl RenderState { } pub fn apply_render_to_final_canvas(&mut self, rect: skia::Rect) -> Result<()> { + let fast_mode = self.options.is_fast_mode(); // Decide *now* (at the first real cache blit) whether we need to clear Cache. // This avoids clearing Cache on renders that don't actually paint tiles (e.g. hover/UI), // while still preventing stale pixels from surviving across full-quality renders. - if !self.options.is_fast_mode() && !self.cache_cleared_this_render { + if !fast_mode && !self.cache_cleared_this_render { self.surfaces.clear_cache(self.background_color); self.cache_cleared_this_render = true; } let tile_rect = self.get_current_aligned_tile_bounds()?; + // In fast mode the viewport is moving (pan/zoom) so Cache surface + // positions would be wrong — only save to the tile HashMap. self.surfaces.cache_current_tile_texture( &self.tile_viewbox, &self .current_tile .ok_or(Error::CriticalError("Current tile not found".to_string()))?, &tile_rect, + fast_mode, ); self.surfaces.draw_cached_tile_surface( @@ -1454,18 +1457,19 @@ 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 scale = self.get_cached_scale(); + let cached_scale = self.get_cached_scale(); // 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 = VIEWPORT_INTEREST_AREA_THRESHOLD; let TileRect(start_tile_x, start_tile_y, _, _) = tiles::get_tiles_for_viewbox_with_interest( self.cached_viewbox, - VIEWPORT_INTEREST_AREA_THRESHOLD, - scale, + interest, + cached_scale, ); 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(); @@ -1488,6 +1492,36 @@ impl RenderState { // Restore canvas state self.surfaces.canvas(SurfaceId::Target).restore(); + // During pure pan (same zoom), draw tiles from the HashMap + // on top of the scaled Cache surface. Cached tile textures + // include full-quality effects (shadows, blur) from the last + // render, so blitting them avoids re-rendering and keeps pan + // smooth. During zoom the tile grid changes so HashMap tiles + // would be at wrong positions — skip them and let the full + // render after set_view_end handle it. + if !self.zoom_changed() { + let current_scale = self.get_scale(); + let visible_rect = tiles::get_tiles_for_viewbox(self.viewbox, current_scale); + let vb_offset_x = self.viewbox.area.left * current_scale; + let vb_offset_y = self.viewbox.area.top * current_scale; + + for tx in visible_rect.x1()..=visible_rect.x2() { + for ty in visible_rect.y1()..=visible_rect.y2() { + let tile = tiles::Tile::from(tx, ty); + if self.surfaces.has_cached_tile_surface(tile) { + let tile_rect = skia::Rect::from_xywh( + tx as f32 * tiles::TILE_SIZE - vb_offset_x, + ty as f32 * tiles::TILE_SIZE - vb_offset_y, + tiles::TILE_SIZE, + tiles::TILE_SIZE, + ); + self.surfaces + .draw_cached_tile_surface(tile, tile_rect, bg_color); + } + } + } + } + if self.options.is_debug_visible() { debug::render(self); } @@ -1622,7 +1656,15 @@ impl RenderState { if tree.len() != 0 { self.render_shape_tree_partial(base_object, tree, timestamp, true)?; } - self.flush_and_submit(); + + // In fast mode (pan/zoom in progress), render_from_cache owns + // the Target surface — skip flush so we don't present stale + // tile positions. The rAF still populates the Cache surface + // and tile HashMap so render_from_cache progressively shows + // more complete content. + if !self.options.is_fast_mode() { + self.flush_and_submit(); + } if self.render_in_progress { self.cancel_animation_frame(); @@ -1668,7 +1710,10 @@ impl RenderState { .clear(skia::Color::TRANSPARENT); if tree.len() != 0 { - let shape = tree.get(id).unwrap(); + let Some(shape) = tree.get(id) else { + // FIXME + return Ok((Vec::new(), 0, 0)); + }; let mut extrect = shape.extrect(tree, scale); self.export_context = Some((extrect, scale)); let margins = self.surfaces.margins; @@ -2736,8 +2781,17 @@ impl RenderState { self.surfaces.gc(); - // Mark cache as valid for render_from_cache - self.cached_viewbox = self.viewbox; + // Mark cache as valid for render_from_cache. + // Only update for full-quality renders (non-fast mode). + // An async render can complete while fast mode is active + // (e.g. interest-area tiles finish during a pan gesture). + // Those tiles lack effects (shadows, blur). Updating + // cached_viewbox here would make zoom_changed() return false, + // so set_view_end would skip tile invalidation and the next + // full render would reuse the low-quality tiles. + if !self.options.is_fast_mode() { + self.cached_viewbox = self.viewbox; + } 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 79ee064f37..1c5a77c72c 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -549,6 +549,7 @@ impl Surfaces { tile_viewbox: &TileViewbox, tile: &Tile, tile_rect: &skia::Rect, + skip_cache_surface: bool, ) { let rect = IRect::from_xywh( self.margins.width, @@ -560,13 +561,15 @@ impl Surfaces { let tile_image_opt = self.current.image_snapshot_with_bounds(rect); if let Some(tile_image) = tile_image_opt { - // Draw to cache first (takes reference), then move to tile cache - self.cache.canvas().draw_image_rect( - &tile_image, - None, - tile_rect, - &skia::Paint::default(), - ); + if !skip_cache_surface { + // Draw to cache surface for render_from_cache + self.cache.canvas().draw_image_rect( + &tile_image, + None, + tile_rect, + &skia::Paint::default(), + ); + } self.tiles.add(tile_viewbox, tile, tile_image); } @@ -610,9 +613,12 @@ impl Surfaces { let mut bg = skia::Paint::default(); bg.set_color(color); self.cache.canvas().draw_rect(aligned_rect, &bg); - self.cache - .canvas() - .draw_image_rect(&image, None, aligned_rect, &skia::Paint::default()); + self.cache.canvas().draw_image_rect( + &image, + None, + aligned_rect, + &skia::Paint::default(), + ); } }