🔧 Improve zoom and pan performance

This commit is contained in:
Elena Torro 2026-04-13 16:08:28 +02:00
parent 47eadab82e
commit 6b3d5d930f
4 changed files with 112 additions and 41 deletions

View File

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

View File

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

View File

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

View File

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