mirror of
https://github.com/penpot/penpot.git
synced 2026-09-14 07:59:36 +00:00
Merge pull request #8959 from penpot/elenatorro-fix-render-wasm-loading
🔧 Improve loading times
This commit is contained in:
commit
367262f5a0
@ -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))
|
||||||
|
|||||||
@ -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*
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)))
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user