🔧 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 ;; This prevents errors when processing changes from other pages
(when shape (when shape
(wasm.api/process-object shape)))))) (wasm.api/process-object shape))))))
(defn initialize-workspace (defn initialize-workspace
([team-id file-id] ([team-id file-id]
(initialize-workspace team-id file-id nil)) (initialize-workspace team-id file-id nil))

View File

@ -310,7 +310,8 @@
(:y selected-frame)) (:y selected-frame))
rule-area-size (/ rulers/ruler-area-size zoom) rule-area-size (/ rulers/ruler-area-size zoom)
preview-blend (-> refs/workspace-preview-blend 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 ;; 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. ;; canvas, even though we are not using `page-id` inside the hook.
@ -341,10 +342,14 @@
init? init?
(do (do
(reset! canvas-init? true) (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/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) (pos? retries)
(vreset! timeout-id-ref (vreset! timeout-id-ref
@ -387,8 +392,14 @@
(when @canvas-init? (when @canvas-init?
(if (not @initialized?) (if (not @initialized?)
(do (do
(wasm.api/clear-canvas-pixels) ;; Keep the blurred previous-page preview (page switch) or
(wasm.api/initialize-viewport base-objects zoom vbox background) ;; 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) (reset! initialized? true)
(mf/set-ref-val! last-file-version-id-ref file-version-id)) (mf/set-ref-val! last-file-version-id-ref file-version-id))
(when (and (some? file-version-id) (when (and (some? file-version-id)
@ -497,7 +508,7 @@
:width (max 0 (- (:width vbox) rule-area-size)) :width (max 0 (- (:width vbox) rule-area-size))
:height (max 0 (- (:height 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: ;; Text editor handling:
;; - When text-editor-wasm/v1 is active, contenteditable is rendered in viewport-overlays (HTML DOM) ;; - When text-editor-wasm/v1 is active, contenteditable is rendered in viewport-overlays (HTML DOM)
(when show-text-editor? (when show-text-editor?
@ -596,15 +607,16 @@
:alt? @alt? :alt? @alt?
:shift? @shift?}]) :shift? @shift?}])
[:> widgets/frame-titles* (when-not shapes-loading?
{:objects objects-modified [:> widgets/frame-titles*
:selected selected {:objects objects-modified
:zoom zoom :selected selected
:is-show-artboard-names show-artboard-names? :zoom zoom
:on-frame-enter on-frame-enter :is-show-artboard-names show-artboard-names?
:on-frame-leave on-frame-leave :on-frame-enter on-frame-enter
:on-frame-select on-frame-select :on-frame-leave on-frame-leave
:focus focus}] :on-frame-select on-frame-select
:focus focus}])
(when show-prototypes? (when show-prototypes?
[:> widgets/frame-flows* [:> widgets/frame-flows*

View File

@ -80,10 +80,9 @@
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024)) (def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))
(def ^:const DEBOUNCE_DELAY_MS 100) (def ^:const DEBOUNCE_DELAY_MS 100)
(def ^:const THROTTLE_DELAY_MS 10)
;; Number of shapes to process before yielding to browser ;; Time budget (ms) per chunk of shape processing before yielding to browser
(def ^:const SHAPES_CHUNK_SIZE 100) (def ^:private ^:const CHUNK_TIME_BUDGET_MS 8)
;; Threshold below which we use synchronous processing (no chunking overhead) ;; Threshold below which we use synchronous processing (no chunking overhead)
(def ^:const ASYNC_THRESHOLD 100) (def ^:const ASYNC_THRESHOLD 100)
@ -98,6 +97,12 @@
(def capture-canvas-pixels webgl/capture-canvas-pixels) (def capture-canvas-pixels webgl/capture-canvas-pixels)
(def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels) (def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels)
(def clear-canvas-pixels webgl/clear-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 ;; Re-export public text editor functions
(def text-editor-focus text-editor/text-editor-focus) (def text-editor-focus text-editor/text-editor-focus)
@ -123,13 +128,17 @@
;; ;;
(def shape-wrapper-factory nil) (def shape-wrapper-factory nil)
(defn- yield-to-browser (let [^js ch (js/MessageChannel.)]
"Returns a promise that resolves after yielding to the browser's event loop. (defn- yield-to-browser
Uses requestAnimationFrame for smooth visual updates during loading." "Returns a promise that resolves after yielding to the browser's event loop.
[] Uses MessageChannel for near-zero delay (avoids setTimeout's 4ms minimum
(p/create after nesting depth > 5). Same technique used by React's scheduler."
(fn [resolve _reject] []
(js/requestAnimationFrame (fn [_] (resolve nil)))))) (p/create
(fn [resolve _reject]
(set! (.-onmessage (.-port1 ch))
(fn [_] (resolve nil)))
(.postMessage (.-port2 ch) nil)))))
;; Based on app.main.render/object-svg ;; Based on app.main.render/object-svg
(mf/defc object-svg (mf/defc object-svg
@ -982,11 +991,18 @@
(letfn [(do-render [] (letfn [(do-render []
;; Check if context is still initialized before executing ;; Check if context is still initialized before executing
;; to prevent errors when navigating quickly ;; to prevent errors when navigating quickly
(when wasm/context-initialized? (when (and wasm/context-initialized? (not @wasm/context-lost?))
(perf/begin-measure "render-finish") (perf/begin-measure "render-finish")
(h/call wasm/internal-module "_set_view_end") (h/call wasm/internal-module "_set_view_end")
(perf/end-measure "render-finish") (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))) (fns/debounce do-render DEBOUNCE_DELAY_MS)))
(defn set-view-box (defn set-view-box
@ -1064,54 +1080,62 @@
(set-shape-layout shape) (set-shape-layout shape)
(set-layout-data shape) (set-layout-data shape)
(let [pending_thumbnails (into [] (concat (let [is-text? (= type :text)
(set-shape-text-content id content) pending_thumbnails (into [] (concat
(set-shape-text-images id content true) (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-fills id fills true)
(set-shape-strokes id strokes true))) (set-shape-strokes id strokes true)))
pending_full (into [] (concat 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-fills id fills false)
(set-shape-strokes id strokes false)))] (set-shape-strokes id strokes false)))]
(perf/end-measure "set-object") (perf/end-measure "set-object")
{:thumbnails pending_thumbnails {:thumbnails pending_thumbnails
:full pending_full})))) :full pending_full}))))
(defn update-text-layouts (defn- update-text-layouts
[shapes] "Synchronously update text layouts for all shapes and send rect updates
(->> shapes to the worker index."
(filter cfh/text-shape?) [text-ids]
(map :id) (run! (fn [id]
(run!
(fn [id]
(f/update-text-layout id) (f/update-text-layout id)
(update-text-rect! id))))) (update-text-rect! id))
text-ids))
(defn process-pending (defn process-pending
([shapes thumbnails full on-complete] [shapes thumbnails full on-complete]
(process-pending shapes thumbnails full nil on-complete)) (let [pending-thumbnails
([shapes thumbnails full on-render on-complete] (d/index-by :key :callback thumbnails)
(let [pending-thumbnails
(d/index-by :key :callback thumbnails)
pending-full pending-full
(d/index-by :key :callback full)] (d/index-by :key :callback full)]
(->> (rx/concat ;; Run text layouts synchronously so shapes are immediately correct.
(->> (rx/from (vals pending-thumbnails)) (let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)]
(rx/merge-map (fn [callback] (callback))) (when (seq text-ids)
(rx/reduce conj [])) (update-text-layouts text-ids)))
(->> (rx/from (vals pending-full))
(rx/mapcat (fn [callback] (callback))) (if (or (seq pending-thumbnails) (seq pending-full))
(rx/reduce conj []))) (->> (rx/concat
(rx/subs! (->> (rx/from (vals pending-thumbnails))
(fn [_] (rx/merge-map (fn [callback] (callback)))
(update-text-layouts shapes) (rx/reduce conj []))
(if on-render (->> (rx/from (vals pending-full))
(on-render) (rx/mapcat (fn [callback] (callback)))
(request-render "pending-finished"))) (rx/reduce conj [])))
noop-fn (rx/subs!
on-complete))))) (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 (defn process-object
[shape] [shape]
@ -1119,86 +1143,115 @@
(process-pending [shape] thumbnails full noop-fn))) (process-pending [shape] thumbnails full noop-fn)))
(defn- process-shapes-chunk (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}" Returns {:thumbnails [...] :full [...] :next-index n}"
[shapes start-index chunk-size thumbnails-acc full-acc] [shapes start-index thumbnails-acc full-acc]
(let [total (count shapes) (let [total (count shapes)
end-index (min total (+ start-index chunk-size))] deadline (+ (js/performance.now) CHUNK_TIME_BUDGET_MS)]
(loop [index start-index (let [result
t-acc thumbnails-acc (loop [index start-index
f-acc full-acc] t-acc (transient thumbnails-acc)
(if (< index end-index) f-acc (transient full-acc)]
(let [shape (nth shapes index) (if (and (< index total)
{:keys [thumbnails full]} (set-object shape)] ;; Check performance.now every 8 shapes to reduce overhead
(recur (inc index) (or (pos? (bit-and (- index start-index) 7))
(into t-acc thumbnails) (<= (js/performance.now) deadline)))
(into f-acc full))) (let [shape (nth shapes index)
{:thumbnails t-acc {:keys [thumbnails full]} (set-object shape)]
:full f-acc (recur (inc index)
:next-index end-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 (defn- set-objects-async
"Asynchronously process shapes in chunks, yielding to the browser between chunks. "Asynchronously process shapes in time-budgeted chunks, yielding to the
Returns a promise that resolves when all shapes are processed. browser between chunks so the UI stays responsive.
Returns a promise that resolves when all shapes are processed."
Renders a preview only periodically during loading to show progress, [shapes render-callback on-shapes-ready]
then does a full tile-based render at the end." (let [total-shapes (count shapes)]
[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))])]
(p/create (p/create
(fn [resolve _reject] (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) (if (< index total-shapes)
;; Process one chunk ;; Process one time-budgeted chunk
(let [{:keys [thumbnails full next-index]} (let [{:keys [thumbnails full next-index]}
(process-shapes-chunk shapes index SHAPES_CHUNK_SIZE (process-shapes-chunk shapes index
thumbnails-acc full-acc) 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!))
;; Yield to browser, then continue with next chunk ;; Yield to browser, then continue with next chunk
(-> (yield-to-browser) (-> (yield-to-browser)
(p/then (fn [_] (p/then (fn [_]
(process-next-chunk next-index thumbnails full new-chunk-count))))) (process-next-chunk next-index thumbnails full)))))
;; All chunks done - finalize ;; All chunks done - finalize
(do (do
(perf/end-measure "set-objects") (perf/end-measure "set-objects")
(process-pending shapes thumbnails-acc full-acc noop-fn
(fn [] ;; Notify that shapes are loaded and tiles rebuilt
(end-shapes-loading!) (when on-shapes-ready (on-shapes-ready))
(if render-callback ;; Show shapes immediately: end loading overlay + unblock rendering
(render-callback) (h/call wasm/internal-module "_end_loading")
(render-finish)) (end-shapes-loading!)
(ug/dispatch! (ug/event "penpot:wasm:set-objects"))
(resolve nil))))))] ;; Text layouts must run after _end_loading (they
(process-next-chunk 0 [] [] 0)))))) ;; 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 (defn- set-objects-sync
"Synchronously process all shapes (for small shape counts)." "Synchronously process all shapes (for small shape counts)."
[shapes render-callback] [shapes render-callback on-shapes-ready]
(let [total-shapes (count shapes) (let [total-shapes (count shapes)
{:keys [thumbnails full]} {:keys [thumbnails full]}
(loop [index 0 thumbnails-acc [] full-acc []] (loop [index 0 thumbnails-acc (transient []) full-acc (transient [])]
(if (< index total-shapes) (if (< index total-shapes)
(let [shape (nth shapes index) (let [shape (nth shapes index)
{:keys [thumbnails full]} (set-object shape)] {:keys [thumbnails full]} (set-object shape)]
(recur (inc index) (recur (inc index)
(into thumbnails-acc thumbnails) (reduce conj! thumbnails-acc thumbnails)
(into full-acc full))) (reduce conj! full-acc full)))
{:thumbnails thumbnails-acc :full full-acc}))] {:thumbnails (persistent! thumbnails-acc) :full (persistent! full-acc)}))]
(perf/end-measure "set-objects") (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 [] (fn []
(if render-callback (if render-callback
(render-callback) (render-callback)
(render-finish)) (request-render "set-objects-sync-complete"))
(ug/dispatch! (ug/event "penpot:wasm:set-objects")))))) (ug/dispatch! (ug/event "penpot:wasm:set-objects"))))))
(defn- shapes-in-tree-order (defn- shapes-in-tree-order
@ -1234,23 +1287,36 @@
"Set all shape objects for rendering. "Set all shape objects for rendering.
Shapes are processed in tree order (parents before children) 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] ([objects]
(set-objects objects nil)) (set-objects objects nil nil))
([objects render-callback] ([objects render-callback]
(set-objects objects render-callback nil))
([objects render-callback on-shapes-ready]
(perf/begin-measure "set-objects") (perf/begin-measure "set-objects")
(let [shapes (shapes-in-tree-order objects) (let [shapes (shapes-in-tree-order objects)
total-shapes (count shapes)] total-shapes (count shapes)]
(if (< total-shapes ASYNC_THRESHOLD) (if (< total-shapes ASYNC_THRESHOLD)
(set-objects-sync shapes render-callback) (set-objects-sync shapes render-callback on-shapes-ready)
(do (do
(begin-shapes-loading!) (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 (try
(-> (set-objects-async shapes render-callback) (-> (set-objects-async shapes render-callback on-shapes-ready)
(p/catch (fn [error] (p/catch (fn [error]
(h/call wasm/internal-module "_end_loading")
(end-shapes-loading!) (end-shapes-loading!)
(js/console.error "Async WASM shape loading failed" error)))) (js/console.error "Async WASM shape loading failed" error))))
(catch :default error (catch :default error
(h/call wasm/internal-module "_end_loading")
(end-shapes-loading!) (end-shapes-loading!)
(js/console.error "Async WASM shape loading failed" error) (js/console.error "Async WASM shape loading failed" error)
(throw error))) (throw error)))
@ -1385,17 +1451,18 @@
(defn initialize-viewport (defn initialize-viewport
([base-objects zoom vbox background] ([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] ([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] ([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) (let [rgba (sr-clr/hex->u32argb background background-opacity)
shapes (into [] (vals base-objects)) total-shapes (count (vals base-objects))]
total-shapes (count shapes)]
(h/call wasm/internal-module "_set_canvas_background" rgba) (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 "_set_view" zoom (- (:x vbox)) (- (:y vbox)))
(h/call wasm/internal-module "_init_shapes_pool" total-shapes) (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 (def ^:private default-context-options
#js {:antialias false #js {:antialias false

View File

@ -9,7 +9,8 @@
(:require (:require
[app.common.logging :as log] [app.common.logging :as log]
[app.render-wasm.wasm :as wasm] [app.render-wasm.wasm :as wasm]
[app.util.dom :as dom])) [app.util.dom :as dom]
[promesa.core :as p]))
(defn get-webgl-context (defn get-webgl-context
"Gets the WebGL context from the WASM module" "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) _ (.readPixels ^js context 0 0 width height (.-RGBA ^js context) (.-UNSIGNED_BYTE ^js context) buffer)
image-data (js/ImageData. buffer width height)] image-data (js/ImageData. buffer width height)]
(set! wasm/canvas-pixels image-data)))) (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(()) 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] #[no_mangle]
#[wasm_error] #[wasm_error]
pub extern "C" fn process_animation_frame(timestamp: i32) -> Result<()> { 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 /// Finishes a view interaction (zoom or pan). Rebuilds the tile index
/// and invalidates the tile texture cache so the subsequent render /// and, for zoom changes, invalidates the tile texture cache so the
/// re-draws all tiles at full quality (fast_mode is off at this point). /// 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] #[no_mangle]
#[wasm_error] #[wasm_error]
pub extern "C" fn set_view_end() -> Result<()> { 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() { if state.render_state.options.is_profile_rebuild_tiles() {
state.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 { } else {
// Rebuild tile index + invalidate tile texture cache. // Pure pan at the same zoom level: tile contents have not
// Cache canvas is preserved so render_from_cache can still // changed — only the viewport position moved. Update the
// show a scaled preview during zoom. // tile index (which tiles are in the interest area) but
state.rebuild_tiles_shallow(); // 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"); performance::end_measure!("set_view_end");

View File

@ -658,6 +658,41 @@ impl RenderState {
self.surfaces.reset(self.background_color); 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)] #[allow(dead_code)]
pub fn get_canvas_at(&mut self, surface_id: SurfaceId) -> &skia::Canvas { pub fn get_canvas_at(&mut self, surface_id: SurfaceId) -> &skia::Canvas {
self.surfaces.canvas(surface_id) self.surfaces.canvas(surface_id)
@ -1559,6 +1594,16 @@ impl RenderState {
self.render_shape_tree_sync(base_object, tree, timestamp)?; self.render_shape_tree_sync(base_object, tree, timestamp)?;
} else { } else {
self.process_animation_frame(base_object, tree, timestamp)?; 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"); performance::end_measure!("start_render_loop");
@ -2576,6 +2621,23 @@ impl RenderState {
tile_rect, tile_rect,
self.background_color, 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"); performance::end_measure!("render_shape_tree::cached");
if self.options.is_debug_visible() { 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 /// Draws the current tile directly to the target and cache surfaces without
/// creating a snapshot. This avoids GPU stalls from ReadPixels but doesn't /// creating a snapshot. This avoids GPU stalls from ReadPixels but doesn't
/// populate the tile texture cache (suitable for one-shot renders like tests). /// 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 current_browser: u8,
pub shapes: ShapesPool, pub shapes: ShapesPool,
pub saved_shapes: Option<ShapesPool>, pub saved_shapes: Option<ShapesPool>,
/// True while the first bulk load of shapes is in progress.
pub loading: bool,
} }
impl State { impl State {
@ -36,8 +38,8 @@ impl State {
current_id: None, current_id: None,
current_browser: 0, current_browser: 0,
shapes: ShapesPool::new(), shapes: ShapesPool::new(),
// TODO: Maybe this can be moved to a different object
saved_shapes: None, saved_shapes: None,
loading: false,
}) })
} }
@ -285,12 +287,16 @@ impl State {
} }
pub fn touch_current(&mut self) { pub fn touch_current(&mut self) {
if let Some(current_id) = self.current_id { if !self.loading {
self.render_state.mark_touched(current_id); if let Some(current_id) = self.current_id {
self.render_state.mark_touched(current_id);
}
} }
} }
pub fn touch_shape(&mut self, id: Uuid) { pub fn touch_shape(&mut self, id: Uuid) {
self.render_state.mark_touched(id); if !self.loading {
self.render_state.mark_touched(id);
}
} }
} }