Merge pull request #8959 from penpot/elenatorro-fix-render-wasm-loading

🔧 Improve loading times
This commit is contained in:
Alejandro Alonso 2026-04-13 16:50:30 +02:00 committed by GitHub
commit 367262f5a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 471 additions and 154 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,120 @@
(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 (loop [index start-index
t-acc thumbnails-acc t-acc (transient thumbnails-acc)
f-acc full-acc] f-acc (transient full-acc)]
(if (< index end-index) (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) (let [shape (nth shapes index)
{:keys [thumbnails full]} (set-object shape)] {:keys [thumbnails full]} (set-object shape)]
(recur (inc index) (recur (inc index)
(into t-acc thumbnails) (reduce conj! t-acc thumbnails)
(into f-acc full))) (reduce conj! f-acc full)))
{:thumbnails t-acc {:thumbnails (persistent! t-acc)
:full f-acc :full (persistent! f-acc)
:next-index end-index})))) :next-index index}))))
(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))))))] ;; Rebuild the tile index so _render knows which shapes
(process-next-chunk 0 [] [] 0)))))) ;; 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.
(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))
;; 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 [] (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 +1292,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 +1456,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

@ -220,7 +220,13 @@ pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()
#[wasm_error] #[wasm_error]
pub extern "C" fn render_from_cache(_: i32) -> Result<()> { pub extern "C" fn render_from_cache(_: i32) -> Result<()> {
with_state_mut!(state, { 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(); state.render_from_cache();
}); });
Ok(()) Ok(())
@ -244,6 +250,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 +347,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 +367,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

@ -40,6 +40,7 @@ pub use images::*;
// This is the extra area used for tile rendering (tiles beyond viewport). // 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. // Higher values pre-render more tiles, reducing empty squares during pan but using more memory.
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3; const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3;
const MAX_BLOCKING_TIME_MS: i32 = 32; const MAX_BLOCKING_TIME_MS: i32 = 32;
const NODE_BATCH_THRESHOLD: i32 = 3; const NODE_BATCH_THRESHOLD: i32 = 3;
const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0; 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 { pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize {
// First we retrieve the extended area of the viewport that we could render. // 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( let interest = VIEWPORT_INTEREST_AREA_THRESHOLD;
viewbox, let TileRect(isx, isy, iex, iey) =
VIEWPORT_INTEREST_AREA_THRESHOLD, tiles::get_tiles_for_viewbox_with_interest(viewbox, interest, scale);
scale,
);
let dx = if isx.signum() != iex.signum() { 1 } else { 0 }; let dx = if isx.signum() != iex.signum() { 1 } else { 0 };
let dy = if isy.signum() != iey.signum() { 1 } else { 0 }; let dy = if isy.signum() != iey.signum() { 1 } else { 0 };
@ -658,6 +657,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)
@ -669,20 +703,24 @@ impl RenderState {
} }
pub fn apply_render_to_final_canvas(&mut self, rect: skia::Rect) -> Result<()> { 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. // 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), // 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. // 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.surfaces.clear_cache(self.background_color);
self.cache_cleared_this_render = true; self.cache_cleared_this_render = true;
} }
let tile_rect = self.get_current_aligned_tile_bounds()?; 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.surfaces.cache_current_tile_texture(
&self.tile_viewbox, &self.tile_viewbox,
&self &self
.current_tile .current_tile
.ok_or(Error::CriticalError("Current tile not found".to_string()))?, .ok_or(Error::CriticalError("Current tile not found".to_string()))?,
&tile_rect, &tile_rect,
fast_mode,
); );
self.surfaces.draw_cached_tile_surface( self.surfaces.draw_cached_tile_surface(
@ -1419,18 +1457,19 @@ impl RenderState {
pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) { pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) {
let _start = performance::begin_timed_log!("render_from_cache"); let _start = performance::begin_timed_log!("render_from_cache");
performance::begin_measure!("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) // Check if we have a valid cached viewbox (non-zero dimensions indicate valid cache)
if self.cached_viewbox.area.width() > 0.0 { if self.cached_viewbox.area.width() > 0.0 {
// Scale and translate the target according to the cached data // Scale and translate the target according to the cached data
let navigate_zoom = self.viewbox.zoom / self.cached_viewbox.zoom; let navigate_zoom = self.viewbox.zoom / self.cached_viewbox.zoom;
let interest = VIEWPORT_INTEREST_AREA_THRESHOLD;
let TileRect(start_tile_x, start_tile_y, _, _) = let TileRect(start_tile_x, start_tile_y, _, _) =
tiles::get_tiles_for_viewbox_with_interest( tiles::get_tiles_for_viewbox_with_interest(
self.cached_viewbox, self.cached_viewbox,
VIEWPORT_INTEREST_AREA_THRESHOLD, interest,
scale, cached_scale,
); );
let offset_x = self.viewbox.area.left * self.cached_viewbox.zoom * self.options.dpr(); 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(); let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr();
@ -1453,6 +1492,36 @@ impl RenderState {
// Restore canvas state // Restore canvas state
self.surfaces.canvas(SurfaceId::Target).restore(); 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() { if self.options.is_debug_visible() {
debug::render(self); debug::render(self);
} }
@ -1559,6 +1628,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");
@ -1577,7 +1656,15 @@ impl RenderState {
if tree.len() != 0 { if tree.len() != 0 {
self.render_shape_tree_partial(base_object, tree, timestamp, true)?; 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 { if self.render_in_progress {
self.cancel_animation_frame(); self.cancel_animation_frame();
@ -1623,7 +1710,10 @@ impl RenderState {
.clear(skia::Color::TRANSPARENT); .clear(skia::Color::TRANSPARENT);
if tree.len() != 0 { 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); let mut extrect = shape.extrect(tree, scale);
self.export_context = Some((extrect, scale)); self.export_context = Some((extrect, scale));
let margins = self.surfaces.margins; let margins = self.surfaces.margins;
@ -2580,6 +2670,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() {
@ -2678,8 +2785,17 @@ impl RenderState {
self.surfaces.gc(); self.surfaces.gc();
// Mark cache as valid for render_from_cache // Mark cache as valid for render_from_cache.
self.cached_viewbox = self.viewbox; // 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() { if self.options.is_debug_visible() {
debug::render(self); debug::render(self);

View File

@ -506,6 +506,7 @@ impl Surfaces {
self.canvas(SurfaceId::Fills).restore_to_count(1); self.canvas(SurfaceId::Fills).restore_to_count(1);
self.canvas(SurfaceId::InnerShadows).restore_to_count(1); self.canvas(SurfaceId::InnerShadows).restore_to_count(1);
self.canvas(SurfaceId::TextDropShadows).restore_to_count(1); self.canvas(SurfaceId::TextDropShadows).restore_to_count(1);
self.canvas(SurfaceId::DropShadows).restore_to_count(1);
self.canvas(SurfaceId::Strokes).restore_to_count(1); self.canvas(SurfaceId::Strokes).restore_to_count(1);
self.canvas(SurfaceId::Current).restore_to_count(1); self.canvas(SurfaceId::Current).restore_to_count(1);
self.canvas(SurfaceId::Export).restore_to_count(1); self.canvas(SurfaceId::Export).restore_to_count(1);
@ -515,6 +516,7 @@ impl Surfaces {
| SurfaceId::Current as u32 | SurfaceId::Current as u32
| SurfaceId::InnerShadows as u32 | SurfaceId::InnerShadows as u32
| SurfaceId::TextDropShadows as u32 | SurfaceId::TextDropShadows as u32
| SurfaceId::DropShadows as u32
| SurfaceId::Export as u32, | SurfaceId::Export as u32,
|s| { |s| {
s.canvas().clear(color).reset_matrix(); s.canvas().clear(color).reset_matrix();
@ -547,6 +549,7 @@ impl Surfaces {
tile_viewbox: &TileViewbox, tile_viewbox: &TileViewbox,
tile: &Tile, tile: &Tile,
tile_rect: &skia::Rect, tile_rect: &skia::Rect,
skip_cache_surface: bool,
) { ) {
let rect = IRect::from_xywh( let rect = IRect::from_xywh(
self.margins.width, self.margins.width,
@ -558,13 +561,15 @@ impl Surfaces {
let tile_image_opt = self.current.image_snapshot_with_bounds(rect); let tile_image_opt = self.current.image_snapshot_with_bounds(rect);
if let Some(tile_image) = tile_image_opt { if let Some(tile_image) = tile_image_opt {
// Draw to cache first (takes reference), then move to tile cache if !skip_cache_surface {
self.cache.canvas().draw_image_rect( // Draw to cache surface for render_from_cache
&tile_image, self.cache.canvas().draw_image_rect(
None, &tile_image,
tile_rect, None,
&skia::Paint::default(), tile_rect,
); &skia::Paint::default(),
);
}
self.tiles.add(tile_viewbox, tile, tile_image); self.tiles.add(tile_viewbox, tile, tile_image);
} }
@ -594,6 +599,29 @@ 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);
}
} }
} }