From d85d63ef3cf3c243d93e47ab848257ebde46c422 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Thu, 9 Apr 2026 11:44:08 +0200 Subject: [PATCH] :wrench: Improve page loading --- frontend/src/app/main/data/workspace.cljs | 1 - .../app/main/ui/workspace/viewport_wasm.cljs | 44 ++- frontend/src/app/render_wasm/api.cljs | 285 +++++++++++------- frontend/src/app/render_wasm/api/webgl.cljs | 32 +- render-wasm/src/main.rs | 60 +++- render-wasm/src/render.rs | 62 ++++ render-wasm/src/render/surfaces.rs | 20 ++ render-wasm/src/state.rs | 14 +- 8 files changed, 381 insertions(+), 137 deletions(-) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 71a709ec57..32dd2ae5e9 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -289,7 +289,6 @@ ;; This prevents errors when processing changes from other pages (when shape (wasm.api/process-object shape)))))) - (defn initialize-workspace ([team-id file-id] (initialize-workspace team-id file-id nil)) diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 973a3cf35e..091a00a3e7 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -310,7 +310,8 @@ (:y selected-frame)) rule-area-size (/ rulers/ruler-area-size zoom) preview-blend (-> refs/workspace-preview-blend - (mf/deref))] + (mf/deref)) + shapes-loading? (mf/deref wasm.api/shapes-loading?)] ;; NOTE: We need this page-id dependency to react to it and reset the ;; canvas, even though we are not using `page-id` inside the hook. @@ -341,10 +342,14 @@ init? (do (reset! canvas-init? true) - ;; Restore previous canvas pixels immediately after context initialization - ;; This happens before initialize-viewport is called (wasm.api/apply-canvas-blur) - (wasm.api/restore-previous-canvas-pixels)) + (if (wasm.api/has-captured-pixels?) + ;; Page switch: restore previously captured pixels (blurred) + (wasm.api/restore-previous-canvas-pixels) + ;; First load: try to draw a blurred page thumbnail + (when-let [frame-id (get page :thumbnail-frame-id)] + (when-let [uri (dm/get-in @st/state [:thumbnails frame-id])] + (wasm.api/draw-thumbnail-to-canvas uri))))) (pos? retries) (vreset! timeout-id-ref @@ -387,8 +392,14 @@ (when @canvas-init? (if (not @initialized?) (do - (wasm.api/clear-canvas-pixels) - (wasm.api/initialize-viewport base-objects zoom vbox background) + ;; Keep the blurred previous-page preview (page switch) or + ;; blank canvas (first load) visible while shapes load. + ;; The loading overlay is suppressed because on-shapes-ready + ;; is set. + (wasm.api/initialize-viewport + base-objects zoom vbox background 1 nil + (fn [] + (wasm.api/clear-canvas-pixels))) (reset! initialized? true) (mf/set-ref-val! last-file-version-id-ref file-version-id)) (when (and (some? file-version-id) @@ -497,7 +508,7 @@ :width (max 0 (- (:width vbox) rule-area-size)) :height (max 0 (- (:height vbox) rule-area-size))}]]] - [:g {:style {:pointer-events (if disable-events? "none" "auto")}} + [:g {:style {:pointer-events (if (or disable-events? shapes-loading?) "none" "auto")}} ;; Text editor handling: ;; - When text-editor-wasm/v1 is active, contenteditable is rendered in viewport-overlays (HTML DOM) (when show-text-editor? @@ -596,15 +607,16 @@ :alt? @alt? :shift? @shift?}]) - [:> widgets/frame-titles* - {:objects objects-modified - :selected selected - :zoom zoom - :is-show-artboard-names show-artboard-names? - :on-frame-enter on-frame-enter - :on-frame-leave on-frame-leave - :on-frame-select on-frame-select - :focus focus}] + (when-not shapes-loading? + [:> widgets/frame-titles* + {:objects objects-modified + :selected selected + :zoom zoom + :is-show-artboard-names show-artboard-names? + :on-frame-enter on-frame-enter + :on-frame-leave on-frame-leave + :on-frame-select on-frame-select + :focus focus}]) (when show-prototypes? [:> widgets/frame-flows* diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index c3a783040b..2fd7f17106 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -80,10 +80,9 @@ (def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024)) (def ^:const DEBOUNCE_DELAY_MS 100) -(def ^:const THROTTLE_DELAY_MS 10) -;; Number of shapes to process before yielding to browser -(def ^:const SHAPES_CHUNK_SIZE 100) +;; Time budget (ms) per chunk of shape processing before yielding to browser +(def ^:private ^:const CHUNK_TIME_BUDGET_MS 8) ;; Threshold below which we use synchronous processing (no chunking overhead) (def ^:const ASYNC_THRESHOLD 100) @@ -98,6 +97,12 @@ (def capture-canvas-pixels webgl/capture-canvas-pixels) (def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels) (def clear-canvas-pixels webgl/clear-canvas-pixels) +(def draw-thumbnail-to-canvas webgl/draw-thumbnail-to-canvas) + +(defn has-captured-pixels? + "Returns true if there are saved canvas pixels from a previous page." + [] + (some? wasm/canvas-pixels)) ;; Re-export public text editor functions (def text-editor-focus text-editor/text-editor-focus) @@ -123,13 +128,17 @@ ;; (def shape-wrapper-factory nil) -(defn- yield-to-browser - "Returns a promise that resolves after yielding to the browser's event loop. - Uses requestAnimationFrame for smooth visual updates during loading." - [] - (p/create - (fn [resolve _reject] - (js/requestAnimationFrame (fn [_] (resolve nil)))))) +(let [^js ch (js/MessageChannel.)] + (defn- yield-to-browser + "Returns a promise that resolves after yielding to the browser's event loop. + Uses MessageChannel for near-zero delay (avoids setTimeout's 4ms minimum + after nesting depth > 5). Same technique used by React's scheduler." + [] + (p/create + (fn [resolve _reject] + (set! (.-onmessage (.-port1 ch)) + (fn [_] (resolve nil))) + (.postMessage (.-port2 ch) nil))))) ;; Based on app.main.render/object-svg (mf/defc object-svg @@ -982,11 +991,18 @@ (letfn [(do-render [] ;; Check if context is still initialized before executing ;; to prevent errors when navigating quickly - (when wasm/context-initialized? + (when (and wasm/context-initialized? (not @wasm/context-lost?)) (perf/begin-measure "render-finish") (h/call wasm/internal-module "_set_view_end") (perf/end-measure "render-finish") - (render (js/performance.now))))] + ;; Use async _render: visible tiles render synchronously + ;; (no yield), interest-area tiles render progressively + ;; via rAF. _set_view_end already rebuilt the tile + ;; index. For pan, most tiles are cached so the render + ;; completes in the first frame. For zoom, interest- + ;; area tiles (~3 tile margin) don't block the main + ;; thread. + (h/call wasm/internal-module "_render" 0)))] (fns/debounce do-render DEBOUNCE_DELAY_MS))) (defn set-view-box @@ -1064,54 +1080,62 @@ (set-shape-layout shape) (set-layout-data shape) - (let [pending_thumbnails (into [] (concat - (set-shape-text-content id content) - (set-shape-text-images id content true) + (let [is-text? (= type :text) + pending_thumbnails (into [] (concat + (when is-text? (set-shape-text-content id content)) + (when is-text? (set-shape-text-images id content true)) (set-shape-fills id fills true) (set-shape-strokes id strokes true))) pending_full (into [] (concat - (set-shape-text-images id content false) + (when is-text? (set-shape-text-images id content false)) (set-shape-fills id fills false) (set-shape-strokes id strokes false)))] (perf/end-measure "set-object") {:thumbnails pending_thumbnails :full pending_full})))) -(defn update-text-layouts - [shapes] - (->> shapes - (filter cfh/text-shape?) - (map :id) - (run! - (fn [id] +(defn- update-text-layouts + "Synchronously update text layouts for all shapes and send rect updates + to the worker index." + [text-ids] + (run! (fn [id] (f/update-text-layout id) - (update-text-rect! id))))) + (update-text-rect! id)) + text-ids)) (defn process-pending - ([shapes thumbnails full on-complete] - (process-pending shapes thumbnails full nil on-complete)) - ([shapes thumbnails full on-render on-complete] - (let [pending-thumbnails - (d/index-by :key :callback thumbnails) + [shapes thumbnails full on-complete] + (let [pending-thumbnails + (d/index-by :key :callback thumbnails) - pending-full - (d/index-by :key :callback full)] + pending-full + (d/index-by :key :callback full)] - (->> (rx/concat - (->> (rx/from (vals pending-thumbnails)) - (rx/merge-map (fn [callback] (callback))) - (rx/reduce conj [])) - (->> (rx/from (vals pending-full)) - (rx/mapcat (fn [callback] (callback))) - (rx/reduce conj []))) - (rx/subs! - (fn [_] - (update-text-layouts shapes) - (if on-render - (on-render) - (request-render "pending-finished"))) - noop-fn - on-complete))))) + ;; Run text layouts synchronously so shapes are immediately correct. + (let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)] + (when (seq text-ids) + (update-text-layouts text-ids))) + + (if (or (seq pending-thumbnails) (seq pending-full)) + (->> (rx/concat + (->> (rx/from (vals pending-thumbnails)) + (rx/merge-map (fn [callback] (callback))) + (rx/reduce conj [])) + (->> (rx/from (vals pending-full)) + (rx/mapcat (fn [callback] (callback))) + (rx/reduce conj []))) + (rx/subs! + (fn [_] + ;; Fonts are now loaded — recompute text layouts so Skia + ;; uses the real metrics instead of fallback-font estimates. + (let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)] + (when (seq text-ids) + (update-text-layouts text-ids))) + (request-render "images-loaded")) + noop-fn + (fn [] (when on-complete (on-complete))))) + ;; No pending images — complete immediately. + (when on-complete (on-complete))))) (defn process-object [shape] @@ -1119,86 +1143,115 @@ (process-pending [shape] thumbnails full noop-fn))) (defn- process-shapes-chunk - "Process a chunk of shapes synchronously, returning accumulated pending operations. + "Process shapes starting at `start-index` until the time budget is exhausted. Returns {:thumbnails [...] :full [...] :next-index n}" - [shapes start-index chunk-size thumbnails-acc full-acc] - (let [total (count shapes) - end-index (min total (+ start-index chunk-size))] - (loop [index start-index - t-acc thumbnails-acc - f-acc full-acc] - (if (< index end-index) - (let [shape (nth shapes index) - {:keys [thumbnails full]} (set-object shape)] - (recur (inc index) - (into t-acc thumbnails) - (into f-acc full))) - {:thumbnails t-acc - :full f-acc - :next-index end-index})))) + [shapes start-index thumbnails-acc full-acc] + (let [total (count shapes) + deadline (+ (js/performance.now) CHUNK_TIME_BUDGET_MS)] + (let [result + (loop [index start-index + t-acc (transient thumbnails-acc) + f-acc (transient full-acc)] + (if (and (< index total) + ;; Check performance.now every 8 shapes to reduce overhead + (or (pos? (bit-and (- index start-index) 7)) + (<= (js/performance.now) deadline))) + (let [shape (nth shapes index) + {:keys [thumbnails full]} (set-object shape)] + (recur (inc index) + (reduce conj! t-acc thumbnails) + (reduce conj! f-acc full))) + {:thumbnails (persistent! t-acc) + :full (persistent! f-acc) + :next-index index}))] + result))) (defn- set-objects-async - "Asynchronously process shapes in chunks, yielding to the browser between chunks. - Returns a promise that resolves when all shapes are processed. - - Renders a preview only periodically during loading to show progress, - then does a full tile-based render at the end." - [shapes render-callback] - (let [total-shapes (count shapes) - total-chunks (mth/ceil (/ total-shapes SHAPES_CHUNK_SIZE)) - ;; Render at 25%, 50%, 75% of loading - render-at-chunks (set [(mth/floor (* total-chunks 0.25)) - (mth/floor (* total-chunks 0.5)) - (mth/floor (* total-chunks 0.75))])] + "Asynchronously process shapes in time-budgeted chunks, yielding to the + browser between chunks so the UI stays responsive. + Returns a promise that resolves when all shapes are processed." + [shapes render-callback on-shapes-ready] + (let [total-shapes (count shapes)] (p/create (fn [resolve _reject] - (letfn [(process-next-chunk [index thumbnails-acc full-acc chunk-count] + (letfn [(process-next-chunk [index thumbnails-acc full-acc] (if (< index total-shapes) - ;; Process one chunk + ;; Process one time-budgeted chunk (let [{:keys [thumbnails full next-index]} - (process-shapes-chunk shapes index SHAPES_CHUNK_SIZE - thumbnails-acc full-acc) - new-chunk-count (inc chunk-count)] - ;; Only render at specific progress milestones - (when (contains? render-at-chunks new-chunk-count) - (render-preview!)) - + (process-shapes-chunk shapes index + thumbnails-acc full-acc)] ;; Yield to browser, then continue with next chunk (-> (yield-to-browser) (p/then (fn [_] - (process-next-chunk next-index thumbnails full new-chunk-count))))) + (process-next-chunk next-index thumbnails full))))) ;; All chunks done - finalize (do (perf/end-measure "set-objects") - (process-pending shapes thumbnails-acc full-acc noop-fn - (fn [] - (end-shapes-loading!) - (if render-callback - (render-callback) - (render-finish)) - (ug/dispatch! (ug/event "penpot:wasm:set-objects")) - (resolve nil))))))] - (process-next-chunk 0 [] [] 0)))))) + + ;; Notify that shapes are loaded and tiles rebuilt + (when on-shapes-ready (on-shapes-ready)) + ;; Show shapes immediately: end loading overlay + unblock rendering + (h/call wasm/internal-module "_end_loading") + (end-shapes-loading!) + + ;; Text layouts must run after _end_loading (they + ;; depend on state that is only correct when loading + ;; is false). Each call touch_shape → touched_ids. + (let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)] + (when (seq text-ids) + (update-text-layouts text-ids))) + (if render-callback + (render-callback) + (request-render "set-objects-complete")) + (ug/dispatch! (ug/event "penpot:wasm:set-objects")) + (resolve nil) + + ;; Kick off image fetches in the background. + ;; The promise is already resolved so these don't + ;; block the caller. + (let [pending-thumbnails (d/index-by :key :callback thumbnails-acc) + pending-full (d/index-by :key :callback full-acc)] + (when (or (seq pending-thumbnails) (seq pending-full)) + (->> (rx/concat + (->> (rx/from (vals pending-thumbnails)) + (rx/merge-map (fn [callback] (callback))) + (rx/reduce conj [])) + (->> (rx/from (vals pending-full)) + (rx/mapcat (fn [callback] (callback))) + (rx/reduce conj []))) + (rx/subs! + (fn [_] + ;; Fonts are now loaded — recompute text + ;; layouts so Skia uses the real metrics + ;; instead of fallback-font estimates. + (let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)] + (when (seq text-ids) + (update-text-layouts text-ids))) + (request-render "images-loaded")) + noop-fn + noop-fn)))))))] + (process-next-chunk 0 [] [])))))) (defn- set-objects-sync "Synchronously process all shapes (for small shape counts)." - [shapes render-callback] + [shapes render-callback on-shapes-ready] (let [total-shapes (count shapes) {:keys [thumbnails full]} - (loop [index 0 thumbnails-acc [] full-acc []] + (loop [index 0 thumbnails-acc (transient []) full-acc (transient [])] (if (< index total-shapes) (let [shape (nth shapes index) {:keys [thumbnails full]} (set-object shape)] (recur (inc index) - (into thumbnails-acc thumbnails) - (into full-acc full))) - {:thumbnails thumbnails-acc :full full-acc}))] + (reduce conj! thumbnails-acc thumbnails) + (reduce conj! full-acc full))) + {:thumbnails (persistent! thumbnails-acc) :full (persistent! full-acc)}))] (perf/end-measure "set-objects") - (process-pending shapes thumbnails full noop-fn + (when on-shapes-ready (on-shapes-ready)) + (process-pending shapes thumbnails full (fn [] (if render-callback (render-callback) - (render-finish)) + (request-render "set-objects-sync-complete")) (ug/dispatch! (ug/event "penpot:wasm:set-objects")))))) (defn- shapes-in-tree-order @@ -1234,23 +1287,36 @@ "Set all shape objects for rendering. Shapes are processed in tree order (parents before children) - to maintain proper shape reference consistency in WASM." + to maintain proper shape reference consistency in WASM. + + on-shapes-ready is an optional callback invoked right after shapes are + loaded into WASM (and tiles rebuilt for async). It fires before image + loading begins, allowing callers to reveal the page content during + transitions." ([objects] - (set-objects objects nil)) + (set-objects objects nil nil)) ([objects render-callback] + (set-objects objects render-callback nil)) + ([objects render-callback on-shapes-ready] (perf/begin-measure "set-objects") (let [shapes (shapes-in-tree-order objects) total-shapes (count shapes)] (if (< total-shapes ASYNC_THRESHOLD) - (set-objects-sync shapes render-callback) + (set-objects-sync shapes render-callback on-shapes-ready) (do (begin-shapes-loading!) + (h/call wasm/internal-module "_begin_loading") + ;; NOTE: to render a loading overlay in the future + ;; (when-not on-shapes-ready + ;; (h/call wasm/internal-module "_render_loading_overlay")) (try - (-> (set-objects-async shapes render-callback) + (-> (set-objects-async shapes render-callback on-shapes-ready) (p/catch (fn [error] + (h/call wasm/internal-module "_end_loading") (end-shapes-loading!) (js/console.error "Async WASM shape loading failed" error)))) (catch :default error + (h/call wasm/internal-module "_end_loading") (end-shapes-loading!) (js/console.error "Async WASM shape loading failed" error) (throw error))) @@ -1385,17 +1451,18 @@ (defn initialize-viewport ([base-objects zoom vbox background] - (initialize-viewport base-objects zoom vbox background 1 nil)) + (initialize-viewport base-objects zoom vbox background 1 nil nil)) ([base-objects zoom vbox background callback] - (initialize-viewport base-objects zoom vbox background 1 callback)) + (initialize-viewport base-objects zoom vbox background 1 callback nil)) ([base-objects zoom vbox background background-opacity callback] + (initialize-viewport base-objects zoom vbox background background-opacity callback nil)) + ([base-objects zoom vbox background background-opacity callback on-shapes-ready] (let [rgba (sr-clr/hex->u32argb background background-opacity) - shapes (into [] (vals base-objects)) - total-shapes (count shapes)] + total-shapes (count (vals base-objects))] (h/call wasm/internal-module "_set_canvas_background" rgba) (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) (h/call wasm/internal-module "_init_shapes_pool" total-shapes) - (set-objects base-objects callback)))) + (set-objects base-objects callback on-shapes-ready)))) (def ^:private default-context-options #js {:antialias false diff --git a/frontend/src/app/render_wasm/api/webgl.cljs b/frontend/src/app/render_wasm/api/webgl.cljs index b97f977a0d..c6741944a2 100644 --- a/frontend/src/app/render_wasm/api/webgl.cljs +++ b/frontend/src/app/render_wasm/api/webgl.cljs @@ -9,7 +9,8 @@ (:require [app.common.logging :as log] [app.render-wasm.wasm :as wasm] - [app.util.dom :as dom])) + [app.util.dom :as dom] + [promesa.core :as p])) (defn get-webgl-context "Gets the WebGL context from the WASM module" @@ -166,3 +167,32 @@ void main() { _ (.readPixels ^js context 0 0 width height (.-RGBA ^js context) (.-UNSIGNED_BYTE ^js context) buffer) image-data (js/ImageData. buffer width height)] (set! wasm/canvas-pixels image-data)))) + +(defn draw-thumbnail-to-canvas + "Loads an image from `uri` and draws it stretched to fill the WebGL canvas. + Returns a promise that resolves to true if drawn, false otherwise." + [uri] + (if (and uri wasm/canvas wasm/gl-context) + (p/create + (fn [resolve _reject] + (let [img (js/Image.)] + (set! (.-crossOrigin img) "anonymous") + (set! (.-onload img) + (fn [] + (if wasm/gl-context + (let [gl wasm/gl-context + width (.-width wasm/canvas) + height (.-height wasm/canvas) + ;; Draw image to an offscreen 2D canvas, scaled to fill + canvas2d (js/OffscreenCanvas. width height) + ctx2d (.getContext canvas2d "2d")] + (.drawImage ctx2d img 0 0 width height) + (let [image-data (.getImageData ctx2d 0 0 width height)] + (draw-imagedata-to-webgl gl image-data)) + (resolve true)) + (resolve false)))) + (set! (.-onerror img) + (fn [_] + (resolve false))) + (set! (.-src img) uri)))) + (p/resolved false))) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 42a8c46671..89659afcf7 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -244,6 +244,42 @@ pub extern "C" fn render_preview() -> Result<()> { Ok(()) } +/// Enter bulk-loading mode. While active, `state.loading` is `true`. +#[no_mangle] +#[wasm_error] +pub extern "C" fn begin_loading() -> Result<()> { + with_state_mut!(state, { + state.loading = true; + }); + Ok(()) +} + +/// Leave bulk-loading mode. Should be called after the first +/// render so the loading flag is available during that render. +#[no_mangle] +#[wasm_error] +pub extern "C" fn end_loading() -> Result<()> { + with_state_mut!(state, { + state.loading = false; + }); + Ok(()) +} + +/// Draw a full-screen loading overlay (background + "Loading…" text). +/// Called from CLJS right after begin_loading so the user sees +/// immediate feedback while shapes are being processed. +/// NOTE: +/// This is currently not being used, but it's set there for testing purposes on +/// upcoming tasks +#[no_mangle] +#[wasm_error] +pub extern "C" fn render_loading_overlay() -> Result<()> { + with_state_mut!(state, { + state.render_state.render_loading_overlay(); + }); + Ok(()) +} + #[no_mangle] #[wasm_error] pub extern "C" fn process_animation_frame(timestamp: i32) -> Result<()> { @@ -305,8 +341,10 @@ pub extern "C" fn set_view_start() -> Result<()> { } /// Finishes a view interaction (zoom or pan). Rebuilds the tile index -/// and invalidates the tile texture cache so the subsequent render -/// re-draws all tiles at full quality (fast_mode is off at this point). +/// and, for zoom changes, invalidates the tile texture cache so the +/// subsequent render re-draws tiles at full quality. +/// For pure pan (same zoom), cached tiles are preserved so only +/// newly-visible tiles need rendering. #[no_mangle] #[wasm_error] pub extern "C" fn set_view_end() -> Result<()> { @@ -323,11 +361,21 @@ pub extern "C" fn set_view_end() -> Result<()> { if state.render_state.options.is_profile_rebuild_tiles() { state.rebuild_tiles(); + } else if state.render_state.zoom_changed() { + // Zoom changed: tile sizes differ so all cached tile + // textures are invalid (wrong scale). Rebuild the tile + // index and clear the tile texture cache, but *preserve* + // the cache canvas so render_from_cache can show a scaled + // preview of the old content while new tiles render. + state.render_state.rebuild_tile_index(&state.shapes); + state.render_state.surfaces.invalidate_tile_cache(); } else { - // Rebuild tile index + invalidate tile texture cache. - // Cache canvas is preserved so render_from_cache can still - // show a scaled preview during zoom. - state.rebuild_tiles_shallow(); + // Pure pan at the same zoom level: tile contents have not + // changed — only the viewport position moved. Update the + // tile index (which tiles are in the interest area) but + // keep cached tile textures so the render can blit them + // instead of re-drawing every visible tile from scratch. + state.render_state.rebuild_tile_index(&state.shapes); } performance::end_measure!("set_view_end"); diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index acdd436e7c..535a47e174 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -658,6 +658,41 @@ impl RenderState { self.surfaces.reset(self.background_color); } + /// NOTE: + /// This is currently not being used, but it's set there for testing purposes on + /// upcoming tasks + pub fn render_loading_overlay(&mut self) { + let canvas = self.surfaces.canvas(SurfaceId::Target); + let skia::ISize { width, height } = canvas.base_layer_size(); + + canvas.save(); + + // Full-screen background rect + let rect = skia::Rect::from_wh(width as f32, height as f32); + let mut bg_paint = skia::Paint::default(); + bg_paint.set_color(self.background_color); + bg_paint.set_style(skia::PaintStyle::Fill); + canvas.draw_rect(rect, &bg_paint); + + // Centered "Loading…" text + let mut text_paint = skia::Paint::default(); + text_paint.set_color(skia::Color::GRAY); + text_paint.set_anti_alias(true); + + let font = self.fonts.debug_font(); + // FIXME + let text = "Loading…"; + let (text_width, _) = font.measure_str(text, None); + let metrics = font.metrics(); + let text_height = metrics.1.cap_height; + let x = (width as f32 - text_width) / 2.0; + let y = (height as f32 + text_height) / 2.0; + canvas.draw_str(text, skia::Point::new(x, y), font, &text_paint); + + canvas.restore(); + self.flush_and_submit(); + } + #[allow(dead_code)] pub fn get_canvas_at(&mut self, surface_id: SurfaceId) -> &skia::Canvas { self.surfaces.canvas(surface_id) @@ -1559,6 +1594,16 @@ impl RenderState { self.render_shape_tree_sync(base_object, tree, timestamp)?; } else { self.process_animation_frame(base_object, tree, timestamp)?; + // Update cached_viewbox after visible tiles render + // synchronously so that render_from_cache uses the correct + // zoom ratio even if interest-area tiles are still rendering + // asynchronously. Without this, panning right after a zoom + // would keep scaling the Cache surface by the old zoom ratio + // (pixelated/wrong-scale tiles) because the async render + // never completes — each pan frame cancels it. + if self.cache_cleared_this_render { + self.cached_viewbox = self.viewbox; + } } performance::end_measure!("start_render_loop"); @@ -2576,6 +2621,23 @@ impl RenderState { tile_rect, self.background_color, ); + + // Also draw the cached tile to the Cache surface so + // render_from_cache (used during pan) has the full scene. + // apply_render_to_final_canvas clears Cache on the first + // uncached tile, but cached tiles must also be present. + if !self.options.is_fast_mode() { + if !self.cache_cleared_this_render { + self.surfaces.clear_cache(self.background_color); + self.cache_cleared_this_render = true; + } + let aligned_rect = self.get_aligned_tile_bounds(current_tile); + self.surfaces.draw_cached_tile_to_cache( + current_tile, + &aligned_rect, + self.background_color, + ); + } performance::end_measure!("render_shape_tree::cached"); if self.options.is_debug_visible() { diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 3b7d4bcbbc..308d354186 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -594,6 +594,26 @@ impl Surfaces { } } + /// Draws a cached tile texture to the Cache surface at the given + /// cache-aligned rect. This keeps the Cache surface in sync with + /// Target so that `render_from_cache` (used during pan) has the + /// full scene including tiles served from the texture cache. + pub fn draw_cached_tile_to_cache( + &mut self, + tile: Tile, + aligned_rect: &skia::Rect, + color: skia::Color, + ) { + if let Some(image) = self.tiles.get(tile) { + let mut bg = skia::Paint::default(); + bg.set_color(color); + self.cache.canvas().draw_rect(aligned_rect, &bg); + self.cache + .canvas() + .draw_image_rect(&image, None, aligned_rect, &skia::Paint::default()); + } + } + /// Draws the current tile directly to the target and cache surfaces without /// creating a snapshot. This avoids GPU stalls from ReadPixels but doesn't /// populate the tile texture cache (suitable for one-shot renders like tests). diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 7b6ba8a65e..74b81d6336 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -26,6 +26,8 @@ pub(crate) struct State { pub current_browser: u8, pub shapes: ShapesPool, pub saved_shapes: Option, + /// True while the first bulk load of shapes is in progress. + pub loading: bool, } impl State { @@ -36,8 +38,8 @@ impl State { current_id: None, current_browser: 0, shapes: ShapesPool::new(), - // TODO: Maybe this can be moved to a different object saved_shapes: None, + loading: false, }) } @@ -285,12 +287,16 @@ impl State { } pub fn touch_current(&mut self) { - if let Some(current_id) = self.current_id { - self.render_state.mark_touched(current_id); + if !self.loading { + if let Some(current_id) = self.current_id { + self.render_state.mark_touched(current_id); + } } } pub fn touch_shape(&mut self, id: Uuid) { - self.render_state.mark_touched(id); + if !self.loading { + self.render_state.mark_touched(id); + } } }