🔧 Improve page loading

This commit is contained in:
Elena Torro 2026-04-09 11:44:08 +02:00
parent 83e9f85ccf
commit d85d63ef3c
8 changed files with 381 additions and 137 deletions

View File

@ -289,7 +289,6 @@
;; This prevents errors when processing changes from other pages
(when shape
(wasm.api/process-object shape))))))
(defn initialize-workspace
([team-id file-id]
(initialize-workspace team-id file-id nil))

View File

@ -310,7 +310,8 @@
(:y selected-frame))
rule-area-size (/ rulers/ruler-area-size zoom)
preview-blend (-> refs/workspace-preview-blend
(mf/deref))]
(mf/deref))
shapes-loading? (mf/deref wasm.api/shapes-loading?)]
;; NOTE: We need this page-id dependency to react to it and reset the
;; canvas, even though we are not using `page-id` inside the hook.
@ -341,10 +342,14 @@
init?
(do
(reset! canvas-init? true)
;; Restore previous canvas pixels immediately after context initialization
;; This happens before initialize-viewport is called
(wasm.api/apply-canvas-blur)
(wasm.api/restore-previous-canvas-pixels))
(if (wasm.api/has-captured-pixels?)
;; Page switch: restore previously captured pixels (blurred)
(wasm.api/restore-previous-canvas-pixels)
;; First load: try to draw a blurred page thumbnail
(when-let [frame-id (get page :thumbnail-frame-id)]
(when-let [uri (dm/get-in @st/state [:thumbnails frame-id])]
(wasm.api/draw-thumbnail-to-canvas uri)))))
(pos? retries)
(vreset! timeout-id-ref
@ -387,8 +392,14 @@
(when @canvas-init?
(if (not @initialized?)
(do
(wasm.api/clear-canvas-pixels)
(wasm.api/initialize-viewport base-objects zoom vbox background)
;; Keep the blurred previous-page preview (page switch) or
;; blank canvas (first load) visible while shapes load.
;; The loading overlay is suppressed because on-shapes-ready
;; is set.
(wasm.api/initialize-viewport
base-objects zoom vbox background 1 nil
(fn []
(wasm.api/clear-canvas-pixels)))
(reset! initialized? true)
(mf/set-ref-val! last-file-version-id-ref file-version-id))
(when (and (some? file-version-id)
@ -497,7 +508,7 @@
:width (max 0 (- (:width vbox) rule-area-size))
:height (max 0 (- (:height vbox) rule-area-size))}]]]
[:g {:style {:pointer-events (if disable-events? "none" "auto")}}
[:g {:style {:pointer-events (if (or disable-events? shapes-loading?) "none" "auto")}}
;; Text editor handling:
;; - When text-editor-wasm/v1 is active, contenteditable is rendered in viewport-overlays (HTML DOM)
(when show-text-editor?
@ -596,15 +607,16 @@
:alt? @alt?
:shift? @shift?}])
[:> widgets/frame-titles*
{:objects objects-modified
:selected selected
:zoom zoom
:is-show-artboard-names show-artboard-names?
:on-frame-enter on-frame-enter
:on-frame-leave on-frame-leave
:on-frame-select on-frame-select
:focus focus}]
(when-not shapes-loading?
[:> widgets/frame-titles*
{:objects objects-modified
:selected selected
:zoom zoom
:is-show-artboard-names show-artboard-names?
:on-frame-enter on-frame-enter
:on-frame-leave on-frame-leave
:on-frame-select on-frame-select
:focus focus}])
(when show-prototypes?
[:> widgets/frame-flows*

View File

@ -80,10 +80,9 @@
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))
(def ^:const DEBOUNCE_DELAY_MS 100)
(def ^:const THROTTLE_DELAY_MS 10)
;; Number of shapes to process before yielding to browser
(def ^:const SHAPES_CHUNK_SIZE 100)
;; Time budget (ms) per chunk of shape processing before yielding to browser
(def ^:private ^:const CHUNK_TIME_BUDGET_MS 8)
;; Threshold below which we use synchronous processing (no chunking overhead)
(def ^:const ASYNC_THRESHOLD 100)
@ -98,6 +97,12 @@
(def capture-canvas-pixels webgl/capture-canvas-pixels)
(def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels)
(def clear-canvas-pixels webgl/clear-canvas-pixels)
(def draw-thumbnail-to-canvas webgl/draw-thumbnail-to-canvas)
(defn has-captured-pixels?
"Returns true if there are saved canvas pixels from a previous page."
[]
(some? wasm/canvas-pixels))
;; Re-export public text editor functions
(def text-editor-focus text-editor/text-editor-focus)
@ -123,13 +128,17 @@
;;
(def shape-wrapper-factory nil)
(defn- yield-to-browser
"Returns a promise that resolves after yielding to the browser's event loop.
Uses requestAnimationFrame for smooth visual updates during loading."
[]
(p/create
(fn [resolve _reject]
(js/requestAnimationFrame (fn [_] (resolve nil))))))
(let [^js ch (js/MessageChannel.)]
(defn- yield-to-browser
"Returns a promise that resolves after yielding to the browser's event loop.
Uses MessageChannel for near-zero delay (avoids setTimeout's 4ms minimum
after nesting depth > 5). Same technique used by React's scheduler."
[]
(p/create
(fn [resolve _reject]
(set! (.-onmessage (.-port1 ch))
(fn [_] (resolve nil)))
(.postMessage (.-port2 ch) nil)))))
;; Based on app.main.render/object-svg
(mf/defc object-svg
@ -982,11 +991,18 @@
(letfn [(do-render []
;; Check if context is still initialized before executing
;; to prevent errors when navigating quickly
(when wasm/context-initialized?
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(perf/begin-measure "render-finish")
(h/call wasm/internal-module "_set_view_end")
(perf/end-measure "render-finish")
(render (js/performance.now))))]
;; Use async _render: visible tiles render synchronously
;; (no yield), interest-area tiles render progressively
;; via rAF. _set_view_end already rebuilt the tile
;; index. For pan, most tiles are cached so the render
;; completes in the first frame. For zoom, interest-
;; area tiles (~3 tile margin) don't block the main
;; thread.
(h/call wasm/internal-module "_render" 0)))]
(fns/debounce do-render DEBOUNCE_DELAY_MS)))
(defn set-view-box
@ -1064,54 +1080,62 @@
(set-shape-layout shape)
(set-layout-data shape)
(let [pending_thumbnails (into [] (concat
(set-shape-text-content id content)
(set-shape-text-images id content true)
(let [is-text? (= type :text)
pending_thumbnails (into [] (concat
(when is-text? (set-shape-text-content id content))
(when is-text? (set-shape-text-images id content true))
(set-shape-fills id fills true)
(set-shape-strokes id strokes true)))
pending_full (into [] (concat
(set-shape-text-images id content false)
(when is-text? (set-shape-text-images id content false))
(set-shape-fills id fills false)
(set-shape-strokes id strokes false)))]
(perf/end-measure "set-object")
{:thumbnails pending_thumbnails
:full pending_full}))))
(defn update-text-layouts
[shapes]
(->> shapes
(filter cfh/text-shape?)
(map :id)
(run!
(fn [id]
(defn- update-text-layouts
"Synchronously update text layouts for all shapes and send rect updates
to the worker index."
[text-ids]
(run! (fn [id]
(f/update-text-layout id)
(update-text-rect! id)))))
(update-text-rect! id))
text-ids))
(defn process-pending
([shapes thumbnails full on-complete]
(process-pending shapes thumbnails full nil on-complete))
([shapes thumbnails full on-render on-complete]
(let [pending-thumbnails
(d/index-by :key :callback thumbnails)
[shapes thumbnails full on-complete]
(let [pending-thumbnails
(d/index-by :key :callback thumbnails)
pending-full
(d/index-by :key :callback full)]
pending-full
(d/index-by :key :callback full)]
(->> (rx/concat
(->> (rx/from (vals pending-thumbnails))
(rx/merge-map (fn [callback] (callback)))
(rx/reduce conj []))
(->> (rx/from (vals pending-full))
(rx/mapcat (fn [callback] (callback)))
(rx/reduce conj [])))
(rx/subs!
(fn [_]
(update-text-layouts shapes)
(if on-render
(on-render)
(request-render "pending-finished")))
noop-fn
on-complete)))))
;; Run text layouts synchronously so shapes are immediately correct.
(let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)]
(when (seq text-ids)
(update-text-layouts text-ids)))
(if (or (seq pending-thumbnails) (seq pending-full))
(->> (rx/concat
(->> (rx/from (vals pending-thumbnails))
(rx/merge-map (fn [callback] (callback)))
(rx/reduce conj []))
(->> (rx/from (vals pending-full))
(rx/mapcat (fn [callback] (callback)))
(rx/reduce conj [])))
(rx/subs!
(fn [_]
;; Fonts are now loaded — recompute text layouts so Skia
;; uses the real metrics instead of fallback-font estimates.
(let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)]
(when (seq text-ids)
(update-text-layouts text-ids)))
(request-render "images-loaded"))
noop-fn
(fn [] (when on-complete (on-complete)))))
;; No pending images — complete immediately.
(when on-complete (on-complete)))))
(defn process-object
[shape]
@ -1119,86 +1143,115 @@
(process-pending [shape] thumbnails full noop-fn)))
(defn- process-shapes-chunk
"Process a chunk of shapes synchronously, returning accumulated pending operations.
"Process shapes starting at `start-index` until the time budget is exhausted.
Returns {:thumbnails [...] :full [...] :next-index n}"
[shapes start-index chunk-size thumbnails-acc full-acc]
(let [total (count shapes)
end-index (min total (+ start-index chunk-size))]
(loop [index start-index
t-acc thumbnails-acc
f-acc full-acc]
(if (< index end-index)
(let [shape (nth shapes index)
{:keys [thumbnails full]} (set-object shape)]
(recur (inc index)
(into t-acc thumbnails)
(into f-acc full)))
{:thumbnails t-acc
:full f-acc
:next-index end-index}))))
[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)))
(defn- set-objects-async
"Asynchronously process shapes in chunks, yielding to the browser between chunks.
Returns a promise that resolves when all shapes are processed.
Renders a preview only periodically during loading to show progress,
then does a full tile-based render at the end."
[shapes render-callback]
(let [total-shapes (count shapes)
total-chunks (mth/ceil (/ total-shapes SHAPES_CHUNK_SIZE))
;; Render at 25%, 50%, 75% of loading
render-at-chunks (set [(mth/floor (* total-chunks 0.25))
(mth/floor (* total-chunks 0.5))
(mth/floor (* total-chunks 0.75))])]
"Asynchronously process shapes in time-budgeted chunks, yielding to the
browser between chunks so the UI stays responsive.
Returns a promise that resolves when all shapes are processed."
[shapes render-callback on-shapes-ready]
(let [total-shapes (count shapes)]
(p/create
(fn [resolve _reject]
(letfn [(process-next-chunk [index thumbnails-acc full-acc chunk-count]
(letfn [(process-next-chunk [index thumbnails-acc full-acc]
(if (< index total-shapes)
;; Process one chunk
;; Process one time-budgeted chunk
(let [{:keys [thumbnails full next-index]}
(process-shapes-chunk shapes index SHAPES_CHUNK_SIZE
thumbnails-acc full-acc)
new-chunk-count (inc chunk-count)]
;; Only render at specific progress milestones
(when (contains? render-at-chunks new-chunk-count)
(render-preview!))
(process-shapes-chunk shapes index
thumbnails-acc full-acc)]
;; Yield to browser, then continue with next chunk
(-> (yield-to-browser)
(p/then (fn [_]
(process-next-chunk next-index thumbnails full new-chunk-count)))))
(process-next-chunk next-index thumbnails full)))))
;; All chunks done - finalize
(do
(perf/end-measure "set-objects")
(process-pending shapes thumbnails-acc full-acc noop-fn
(fn []
(end-shapes-loading!)
(if render-callback
(render-callback)
(render-finish))
(ug/dispatch! (ug/event "penpot:wasm:set-objects"))
(resolve nil))))))]
(process-next-chunk 0 [] [] 0))))))
;; Notify that shapes are loaded and tiles rebuilt
(when on-shapes-ready (on-shapes-ready))
;; Show shapes immediately: end loading overlay + unblock rendering
(h/call wasm/internal-module "_end_loading")
(end-shapes-loading!)
;; 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.
(let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)]
(when (seq text-ids)
(update-text-layouts text-ids)))
(if render-callback
(render-callback)
(request-render "set-objects-complete"))
(ug/dispatch! (ug/event "penpot:wasm:set-objects"))
(resolve nil)
;; Kick off image fetches in the background.
;; The promise is already resolved so these don't
;; block the caller.
(let [pending-thumbnails (d/index-by :key :callback thumbnails-acc)
pending-full (d/index-by :key :callback full-acc)]
(when (or (seq pending-thumbnails) (seq pending-full))
(->> (rx/concat
(->> (rx/from (vals pending-thumbnails))
(rx/merge-map (fn [callback] (callback)))
(rx/reduce conj []))
(->> (rx/from (vals pending-full))
(rx/mapcat (fn [callback] (callback)))
(rx/reduce conj [])))
(rx/subs!
(fn [_]
;; Fonts are now loaded — recompute text
;; layouts so Skia uses the real metrics
;; instead of fallback-font estimates.
(let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)]
(when (seq text-ids)
(update-text-layouts text-ids)))
(request-render "images-loaded"))
noop-fn
noop-fn)))))))]
(process-next-chunk 0 [] []))))))
(defn- set-objects-sync
"Synchronously process all shapes (for small shape counts)."
[shapes render-callback]
[shapes render-callback on-shapes-ready]
(let [total-shapes (count shapes)
{:keys [thumbnails full]}
(loop [index 0 thumbnails-acc [] full-acc []]
(loop [index 0 thumbnails-acc (transient []) full-acc (transient [])]
(if (< index total-shapes)
(let [shape (nth shapes index)
{:keys [thumbnails full]} (set-object shape)]
(recur (inc index)
(into thumbnails-acc thumbnails)
(into full-acc full)))
{:thumbnails thumbnails-acc :full full-acc}))]
(reduce conj! thumbnails-acc thumbnails)
(reduce conj! full-acc full)))
{:thumbnails (persistent! thumbnails-acc) :full (persistent! full-acc)}))]
(perf/end-measure "set-objects")
(process-pending shapes thumbnails full noop-fn
(when on-shapes-ready (on-shapes-ready))
(process-pending shapes thumbnails full
(fn []
(if render-callback
(render-callback)
(render-finish))
(request-render "set-objects-sync-complete"))
(ug/dispatch! (ug/event "penpot:wasm:set-objects"))))))
(defn- shapes-in-tree-order
@ -1234,23 +1287,36 @@
"Set all shape objects for rendering.
Shapes are processed in tree order (parents before children)
to maintain proper shape reference consistency in WASM."
to maintain proper shape reference consistency in WASM.
on-shapes-ready is an optional callback invoked right after shapes are
loaded into WASM (and tiles rebuilt for async). It fires before image
loading begins, allowing callers to reveal the page content during
transitions."
([objects]
(set-objects objects nil))
(set-objects objects nil nil))
([objects render-callback]
(set-objects objects render-callback nil))
([objects render-callback on-shapes-ready]
(perf/begin-measure "set-objects")
(let [shapes (shapes-in-tree-order objects)
total-shapes (count shapes)]
(if (< total-shapes ASYNC_THRESHOLD)
(set-objects-sync shapes render-callback)
(set-objects-sync shapes render-callback on-shapes-ready)
(do
(begin-shapes-loading!)
(h/call wasm/internal-module "_begin_loading")
;; NOTE: to render a loading overlay in the future
;; (when-not on-shapes-ready
;; (h/call wasm/internal-module "_render_loading_overlay"))
(try
(-> (set-objects-async shapes render-callback)
(-> (set-objects-async shapes render-callback on-shapes-ready)
(p/catch (fn [error]
(h/call wasm/internal-module "_end_loading")
(end-shapes-loading!)
(js/console.error "Async WASM shape loading failed" error))))
(catch :default error
(h/call wasm/internal-module "_end_loading")
(end-shapes-loading!)
(js/console.error "Async WASM shape loading failed" error)
(throw error)))
@ -1385,17 +1451,18 @@
(defn initialize-viewport
([base-objects zoom vbox background]
(initialize-viewport base-objects zoom vbox background 1 nil))
(initialize-viewport base-objects zoom vbox background 1 nil nil))
([base-objects zoom vbox background callback]
(initialize-viewport base-objects zoom vbox background 1 callback))
(initialize-viewport base-objects zoom vbox background 1 callback nil))
([base-objects zoom vbox background background-opacity callback]
(initialize-viewport base-objects zoom vbox background background-opacity callback nil))
([base-objects zoom vbox background background-opacity callback on-shapes-ready]
(let [rgba (sr-clr/hex->u32argb background background-opacity)
shapes (into [] (vals base-objects))
total-shapes (count shapes)]
total-shapes (count (vals base-objects))]
(h/call wasm/internal-module "_set_canvas_background" rgba)
(h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox)))
(h/call wasm/internal-module "_init_shapes_pool" total-shapes)
(set-objects base-objects callback))))
(set-objects base-objects callback on-shapes-ready))))
(def ^:private default-context-options
#js {:antialias false

View File

@ -9,7 +9,8 @@
(:require
[app.common.logging :as log]
[app.render-wasm.wasm :as wasm]
[app.util.dom :as dom]))
[app.util.dom :as dom]
[promesa.core :as p]))
(defn get-webgl-context
"Gets the WebGL context from the WASM module"
@ -166,3 +167,32 @@ void main() {
_ (.readPixels ^js context 0 0 width height (.-RGBA ^js context) (.-UNSIGNED_BYTE ^js context) buffer)
image-data (js/ImageData. buffer width height)]
(set! wasm/canvas-pixels image-data))))
(defn draw-thumbnail-to-canvas
"Loads an image from `uri` and draws it stretched to fill the WebGL canvas.
Returns a promise that resolves to true if drawn, false otherwise."
[uri]
(if (and uri wasm/canvas wasm/gl-context)
(p/create
(fn [resolve _reject]
(let [img (js/Image.)]
(set! (.-crossOrigin img) "anonymous")
(set! (.-onload img)
(fn []
(if wasm/gl-context
(let [gl wasm/gl-context
width (.-width wasm/canvas)
height (.-height wasm/canvas)
;; Draw image to an offscreen 2D canvas, scaled to fill
canvas2d (js/OffscreenCanvas. width height)
ctx2d (.getContext canvas2d "2d")]
(.drawImage ctx2d img 0 0 width height)
(let [image-data (.getImageData ctx2d 0 0 width height)]
(draw-imagedata-to-webgl gl image-data))
(resolve true))
(resolve false))))
(set! (.-onerror img)
(fn [_]
(resolve false)))
(set! (.-src img) uri))))
(p/resolved false)))

View File

@ -244,6 +244,42 @@ pub extern "C" fn render_preview() -> Result<()> {
Ok(())
}
/// Enter bulk-loading mode. While active, `state.loading` is `true`.
#[no_mangle]
#[wasm_error]
pub extern "C" fn begin_loading() -> Result<()> {
with_state_mut!(state, {
state.loading = true;
});
Ok(())
}
/// Leave bulk-loading mode. Should be called after the first
/// render so the loading flag is available during that render.
#[no_mangle]
#[wasm_error]
pub extern "C" fn end_loading() -> Result<()> {
with_state_mut!(state, {
state.loading = false;
});
Ok(())
}
/// Draw a full-screen loading overlay (background + "Loading…" text).
/// Called from CLJS right after begin_loading so the user sees
/// immediate feedback while shapes are being processed.
/// NOTE:
/// This is currently not being used, but it's set there for testing purposes on
/// upcoming tasks
#[no_mangle]
#[wasm_error]
pub extern "C" fn render_loading_overlay() -> Result<()> {
with_state_mut!(state, {
state.render_state.render_loading_overlay();
});
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn process_animation_frame(timestamp: i32) -> Result<()> {
@ -305,8 +341,10 @@ pub extern "C" fn set_view_start() -> Result<()> {
}
/// Finishes a view interaction (zoom or pan). Rebuilds the tile index
/// and invalidates the tile texture cache so the subsequent render
/// re-draws all tiles at full quality (fast_mode is off at this point).
/// and, for zoom changes, invalidates the tile texture cache so the
/// subsequent render re-draws tiles at full quality.
/// For pure pan (same zoom), cached tiles are preserved so only
/// newly-visible tiles need rendering.
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_view_end() -> Result<()> {
@ -323,11 +361,21 @@ pub extern "C" fn set_view_end() -> Result<()> {
if state.render_state.options.is_profile_rebuild_tiles() {
state.rebuild_tiles();
} else if state.render_state.zoom_changed() {
// Zoom changed: tile sizes differ so all cached tile
// textures are invalid (wrong scale). Rebuild the tile
// index and clear the tile texture cache, but *preserve*
// the cache canvas so render_from_cache can show a scaled
// preview of the old content while new tiles render.
state.render_state.rebuild_tile_index(&state.shapes);
state.render_state.surfaces.invalidate_tile_cache();
} else {
// Rebuild tile index + invalidate tile texture cache.
// Cache canvas is preserved so render_from_cache can still
// show a scaled preview during zoom.
state.rebuild_tiles_shallow();
// Pure pan at the same zoom level: tile contents have not
// changed — only the viewport position moved. Update the
// tile index (which tiles are in the interest area) but
// keep cached tile textures so the render can blit them
// instead of re-drawing every visible tile from scratch.
state.render_state.rebuild_tile_index(&state.shapes);
}
performance::end_measure!("set_view_end");

View File

@ -658,6 +658,41 @@ impl RenderState {
self.surfaces.reset(self.background_color);
}
/// NOTE:
/// This is currently not being used, but it's set there for testing purposes on
/// upcoming tasks
pub fn render_loading_overlay(&mut self) {
let canvas = self.surfaces.canvas(SurfaceId::Target);
let skia::ISize { width, height } = canvas.base_layer_size();
canvas.save();
// Full-screen background rect
let rect = skia::Rect::from_wh(width as f32, height as f32);
let mut bg_paint = skia::Paint::default();
bg_paint.set_color(self.background_color);
bg_paint.set_style(skia::PaintStyle::Fill);
canvas.draw_rect(rect, &bg_paint);
// Centered "Loading…" text
let mut text_paint = skia::Paint::default();
text_paint.set_color(skia::Color::GRAY);
text_paint.set_anti_alias(true);
let font = self.fonts.debug_font();
// FIXME
let text = "Loading…";
let (text_width, _) = font.measure_str(text, None);
let metrics = font.metrics();
let text_height = metrics.1.cap_height;
let x = (width as f32 - text_width) / 2.0;
let y = (height as f32 + text_height) / 2.0;
canvas.draw_str(text, skia::Point::new(x, y), font, &text_paint);
canvas.restore();
self.flush_and_submit();
}
#[allow(dead_code)]
pub fn get_canvas_at(&mut self, surface_id: SurfaceId) -> &skia::Canvas {
self.surfaces.canvas(surface_id)
@ -1559,6 +1594,16 @@ impl RenderState {
self.render_shape_tree_sync(base_object, tree, timestamp)?;
} else {
self.process_animation_frame(base_object, tree, timestamp)?;
// Update cached_viewbox after visible tiles render
// synchronously so that render_from_cache uses the correct
// zoom ratio even if interest-area tiles are still rendering
// asynchronously. Without this, panning right after a zoom
// would keep scaling the Cache surface by the old zoom ratio
// (pixelated/wrong-scale tiles) because the async render
// never completes — each pan frame cancels it.
if self.cache_cleared_this_render {
self.cached_viewbox = self.viewbox;
}
}
performance::end_measure!("start_render_loop");
@ -2576,6 +2621,23 @@ impl RenderState {
tile_rect,
self.background_color,
);
// Also draw the cached tile to the Cache surface so
// render_from_cache (used during pan) has the full scene.
// apply_render_to_final_canvas clears Cache on the first
// uncached tile, but cached tiles must also be present.
if !self.options.is_fast_mode() {
if !self.cache_cleared_this_render {
self.surfaces.clear_cache(self.background_color);
self.cache_cleared_this_render = true;
}
let aligned_rect = self.get_aligned_tile_bounds(current_tile);
self.surfaces.draw_cached_tile_to_cache(
current_tile,
&aligned_rect,
self.background_color,
);
}
performance::end_measure!("render_shape_tree::cached");
if self.options.is_debug_visible() {

View File

@ -594,6 +594,26 @@ impl Surfaces {
}
}
/// Draws a cached tile texture to the Cache surface at the given
/// cache-aligned rect. This keeps the Cache surface in sync with
/// Target so that `render_from_cache` (used during pan) has the
/// full scene including tiles served from the texture cache.
pub fn draw_cached_tile_to_cache(
&mut self,
tile: Tile,
aligned_rect: &skia::Rect,
color: skia::Color,
) {
if let Some(image) = self.tiles.get(tile) {
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());
}
}
/// 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).

View File

@ -26,6 +26,8 @@ pub(crate) struct State {
pub current_browser: u8,
pub shapes: ShapesPool,
pub saved_shapes: Option<ShapesPool>,
/// True while the first bulk load of shapes is in progress.
pub loading: bool,
}
impl State {
@ -36,8 +38,8 @@ impl State {
current_id: None,
current_browser: 0,
shapes: ShapesPool::new(),
// TODO: Maybe this can be moved to a different object
saved_shapes: None,
loading: false,
})
}
@ -285,12 +287,16 @@ impl State {
}
pub fn touch_current(&mut self) {
if let Some(current_id) = self.current_id {
self.render_state.mark_touched(current_id);
if !self.loading {
if let Some(current_id) = self.current_id {
self.render_state.mark_touched(current_id);
}
}
}
pub fn touch_shape(&mut self, id: Uuid) {
self.render_state.mark_touched(id);
if !self.loading {
self.render_state.mark_touched(id);
}
}
}