diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs index a58e1512ba..71bdf0b7d4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs @@ -30,6 +30,7 @@ [app.util.timers :as timers] [cuerdas.core :as str] [okulary.core :as l] + [promesa.core :as p] [rumext.v2 :as mf])) ;; FIXME: can we unify this two refs in one? @@ -72,18 +73,21 @@ (mf/use-fn (mf/deps id current-page-id) (fn [] - ;; For the wasm renderer, apply a blur effect to the viewport canvas - ;; when we navigate to a different page. + ;; WASM page transitions: + ;; - Capture the current page (A) once + ;; - Show a blurred snapshot while the target page (B/C/...) renders + ;; - If the user clicks again during the transition, keep showing the original (A) snapshot (if (and (features/active-feature? @st/state "render-wasm/v1") (not= id current-page-id)) (do - (wasm.api/capture-canvas-pixels) - (wasm.api/apply-canvas-blur) - ;; NOTE: it seems we need two RAF so the blur is actually applied and visible - ;; in the canvas :( - (timers/raf - (fn [] - (timers/raf navigate-fn)))) + (-> (wasm.api/apply-canvas-blur) + (p/finally + (fn [] + ;; NOTE: it seems we need two RAF so the blur is actually applied and visible + ;; in the canvas :( + (timers/raf + (fn [] + (timers/raf navigate-fn))))))) (navigate-fn)))) on-delete diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 724c165c21..e90a584baa 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -132,21 +132,21 @@ (apply-modifiers-to-objects base-objects wasm-modifiers)) ;; STATE - alt? (mf/use-state false) - shift? (mf/use-state false) - mod? (mf/use-state false) - space? (mf/use-state false) - z? (mf/use-state false) - cursor (mf/use-state (utils/get-cursor :pointer-inner)) - hover-ids (mf/use-state nil) - hover (mf/use-state nil) - measure-hover (mf/use-state nil) - hover-disabled? (mf/use-state false) - hover-top-frame-id (mf/use-state nil) - frame-hover (mf/use-state nil) - active-frames (mf/use-state #{}) - canvas-init? (mf/use-state false) - initialized? (mf/use-state false) + alt? (mf/use-state false) + shift? (mf/use-state false) + mod? (mf/use-state false) + space? (mf/use-state false) + z? (mf/use-state false) + cursor (mf/use-state (utils/get-cursor :pointer-inner)) + hover-ids (mf/use-state nil) + hover (mf/use-state nil) + measure-hover (mf/use-state nil) + hover-disabled? (mf/use-state false) + hover-top-frame-id (mf/use-state nil) + frame-hover (mf/use-state nil) + active-frames (mf/use-state #{}) + canvas-init? (mf/use-state false) + initialized? (mf/use-state false) ;; REFS [viewport-ref @@ -205,6 +205,9 @@ mode-inspect? (= options-mode :inspect) + ;; True when we are opening a new file or switching to a new page + page-transition? (mf/deref wasm.api/page-transition?) + on-click (actions/on-click hover selected edition path-drawing? drawing-tool space? selrect z?) on-context-menu (actions/on-context-menu hover hover-ids read-only?) on-double-click (actions/on-double-click hover hover-ids hover-top-frame-id path-drawing? base-objects edition drawing-tool z? read-only?) @@ -234,41 +237,46 @@ show-cursor-tooltip? tooltip show-draw-area? drawing-obj show-gradient-handlers? (= (count selected) 1) - show-grids? (contains? layout :display-guides) + show-grids? (and (contains? layout :display-guides) (not page-transition?)) - show-frame-outline? (and (= transform :move) (not panning)) + show-frame-outline? (and (= transform :move) (not panning) (not page-transition?)) show-outlines? (and (nil? transform) (not panning) (not edition) (not drawing-obj) - (not (#{:comments :path :curve} drawing-tool))) + (not (#{:comments :path :curve} drawing-tool)) + (not page-transition?)) show-pixel-grid? (and (contains? layout :show-pixel-grid) - (>= zoom 8)) - show-text-editor? (and editing-shape (= :text (:type editing-shape))) + (>= zoom 8) + (not page-transition?)) + show-text-editor? (and editing-shape (= :text (:type editing-shape)) (not page-transition?)) hover-grid? (and (some? @hover-top-frame-id) - (ctl/grid-layout? objects @hover-top-frame-id)) + (ctl/grid-layout? objects @hover-top-frame-id) + (not page-transition?)) - show-grid-editor? (and editing-shape (ctl/grid-layout? editing-shape)) - show-presence? page-id - show-prototypes? (= options-mode :prototype) - show-selection-handlers? (and (seq selected) (not show-text-editor?)) + show-grid-editor? (and editing-shape (ctl/grid-layout? editing-shape) (not page-transition?)) + show-presence? (and page-id (not page-transition?)) + show-prototypes? (and (= options-mode :prototype) (not page-transition?)) + show-selection-handlers? (and (seq selected) (not show-text-editor?) (not page-transition?)) show-snap-distance? (and (contains? layout :dynamic-alignment) (= transform :move) - (seq selected)) + (seq selected) + (not page-transition?)) show-snap-points? (and (or (contains? layout :dynamic-alignment) (contains? layout :snap-guides)) - (or drawing-obj transform)) - show-selrect? (and selrect (empty? drawing) (not text-editing?)) + (or drawing-obj transform) + (not page-transition?)) + show-selrect? (and selrect (empty? drawing) (not text-editing?) (not page-transition?)) show-measures? (and (not transform) (not path-editing?) - (or show-distances? mode-inspect?)) - show-artboard-names? (contains? layout :display-artboard-names) + (or show-distances? mode-inspect?) + (not page-transition?)) + show-artboard-names? (and (contains? layout :display-artboard-names) (not page-transition?)) hide-ui? (contains? layout :hide-ui) show-rulers? (and (contains? layout :rulers) (not hide-ui?)) - disabled-guides? (or drawing-tool transform path-drawing? path-editing?) single-select? (= (count selected-shapes) 1) @@ -279,6 +287,8 @@ (or (ctk/is-variant-container? first-shape) (ctk/is-variant? first-shape))) + show-scrollbar? (not page-transition?) + add-variant (mf/use-fn (mf/deps first-shape) @@ -311,7 +321,8 @@ rule-area-size (/ rulers/ruler-area-size zoom) preview-blend (-> refs/workspace-preview-blend (mf/deref)) - shapes-loading? (mf/deref wasm.api/shapes-loading?)] + shapes-loading? (mf/deref wasm.api/shapes-loading?) + transition-image-url (mf/deref wasm.api/transition-image-url*)] ;; 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,15 +352,7 @@ (cond init? (do - (reset! canvas-init? true) - (wasm.api/apply-canvas-blur) - (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))))) + (reset! canvas-init? true)) (pos? retries) (vreset! timeout-id-ref @@ -392,14 +395,15 @@ (when @canvas-init? (if (not @initialized?) (do + ;; Initial file open uses the same transition workflow as page switches, + ;; but with a solid background-color blurred placeholder. + (wasm.api/start-initial-load-transition! 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))) + base-objects zoom vbox background) (reset! initialized? true) (mf/set-ref-val! last-file-version-id-ref file-version-id)) (when (and (some? file-version-id) @@ -476,6 +480,21 @@ :style {:background-color background :pointer-events "none"}}] + ;; Show the transition image when we are opening a new file or switching to a new page + (when (and page-transition? (some? transition-image-url)) + (let [src transition-image-url] + [:img {:data-testid "canvas-wasm-transition" + :src src + :draggable false + :style {:position "absolute" + :inset 0 + :width "100%" + :height "100%" + :object-fit "cover" + :pointer-events "none" + :filter "blur(4px)"}}])) + + [:svg.viewport-controls {:xmlns "http://www.w3.org/2000/svg" :xmlnsXlink "http://www.w3.org/1999/xlink" @@ -776,9 +795,10 @@ (get objects-modified @hover-top-frame-id)) :view-only (not show-grid-editor?)}])] - [:g.scrollbar-wrapper {:clipPath "url(#clip-handlers)"} - [:> scroll-bars/viewport-scrollbars* - {:objects base-objects - :zoom zoom - :vbox vbox - :bottom-padding (when palete-size (+ palete-size 8))}]]]]])) + (when show-scrollbar? + [:g.scrollbar-wrapper {:clipPath "url(#clip-handlers)"} + [:> scroll-bars/viewport-scrollbars* + {:objects base-objects + :zoom zoom + :vbox vbox + :bottom-padding (when palete-size (+ palete-size 8))}]])]]])) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 07672c8910..4ed44bb67b 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -55,6 +55,109 @@ (def use-dpr? (contains? cf/flags :render-wasm-dpr)) +;; --- Page transition state (WASM viewport) +;; +;; Goal: avoid showing tile-by-tile rendering during page switches (and initial load), +;; by keeping a blurred snapshot overlay visible until WASM dispatches +;; `penpot:wasm:tiles-complete`. +;; +;; - `page-transition?`: true while the overlay should be considered active. +;; - `transition-image-url*`: URL used by the UI overlay (usually `blob:` from the +;; current WebGL canvas snapshot; on initial load it may be a tiny SVG data-url +;; derived from the page background color). +;; - `transition-epoch*`: monotonic counter used to ignore stale async work/events +;; when the user clicks pages rapidly (A -> B -> C). +;; - `transition-tiles-handler*`: the currently installed DOM event handler for +;; `penpot:wasm:tiles-complete`, so we can remove/replace it safely. +(defonce page-transition? (atom false)) +(defonce transition-image-url* (atom nil)) +(defonce transition-epoch* (atom 0)) +(defonce transition-tiles-handler* (atom nil)) + +(def ^:private transition-blur-css "blur(4px)") + +(defn- set-transition-blur! + [] + (when-let [canvas ^js wasm/canvas] + (dom/set-style! canvas "filter" transition-blur-css)) + (when-let [nodes (.querySelectorAll ^js ug/document ".blurrable")] + (doseq [^js node (array-seq nodes)] + (dom/set-style! node "filter" transition-blur-css)))) + +(defn- clear-transition-blur! + [] + (when-let [canvas ^js wasm/canvas] + (dom/set-style! canvas "filter" "")) + (when-let [nodes (.querySelectorAll ^js ug/document ".blurrable")] + (doseq [^js node (array-seq nodes)] + (dom/set-style! node "filter" "")))) + +(defn set-transition-image-from-background! + "Sets `transition-image-url*` to a data URL representing a solid background color." + [background] + (when (string? background) + (let [svg (str "" + "" + "")] + (reset! transition-image-url* + (str "data:image/svg+xml;charset=utf-8," (js/encodeURIComponent svg)))))) + +(defn begin-page-transition! + [] + (reset! page-transition? true) + (swap! transition-epoch* inc)) + +(defn end-page-transition! + [] + (reset! page-transition? false) + (when-let [prev @transition-tiles-handler*] + (.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev)) + (reset! transition-tiles-handler* nil) + (reset! transition-image-url* nil) + (clear-transition-blur!) + ;; Clear captured pixels so future transitions must explicitly capture again. + (set! wasm/canvas-snapshot-url nil)) + +(defn- set-transition-tiles-complete-handler! + "Installs a tiles-complete handler bound to the current transition epoch. + Replaces any previous handler so rapid page switching doesn't end the wrong transition." + [epoch f] + (when-let [prev @transition-tiles-handler*] + (.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev)) + (letfn [(handler [_] + (when (= epoch @transition-epoch*) + (.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" handler) + (reset! transition-tiles-handler* nil) + (f)))] + (reset! transition-tiles-handler* handler) + (.addEventListener ^js ug/document "penpot:wasm:tiles-complete" handler))) + +(defn start-initial-load-transition! + "Starts a page-transition workflow for initial file open. + + - Sets `page-transition?` to true + - Installs a tiles-complete handler to end the transition + - Uses a solid background-color placeholder as the transition image" + [background] + ;; If something already toggled `page-transition?` (e.g. legacy init code paths), + ;; ensure we still have a deterministic placeholder on initial load. + (when (or (not @page-transition?) (nil? @transition-image-url*)) + (set-transition-image-from-background! background)) + (when-not @page-transition? + ;; Start transition + bind the tiles-complete handler to this epoch. + (let [epoch (begin-page-transition!)] + (set-transition-tiles-complete-handler! epoch end-page-transition!)))) + +(defn listen-tiles-render-complete-once! + "Registers a one-shot listener for `penpot:wasm:tiles-complete`, dispatched from WASM + when a full tile pass finishes." + [f] + (.addEventListener ^js ug/document + "penpot:wasm:tiles-complete" + (fn [_] + (f)) + #js {:once true})) + (defn text-editor-wasm? [] (or (contains? cf/flags :feature-text-editor-wasm) @@ -94,16 +197,9 @@ (def ^:const TEXT_EDITOR_EVENT_NEEDS_LAYOUT 4) ;; Re-export public WebGL functions -(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 capture-canvas-snapshot-url webgl/capture-canvas-snapshot-url) (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) (def text-editor-blur text-editor/text-editor-blur) @@ -1539,6 +1635,8 @@ (h/call wasm/internal-module "_set_render_options" flags dpr) (when-let [t (wasm-aa-threshold-from-route-params)] (h/call wasm/internal-module "_set_antialias_threshold" t)) + (when-let [max-tex (webgl/max-texture-size context)] + (h/call wasm/internal-module "_set_max_atlas_texture_size" max-tex)) ;; Set browser and canvas size only after initialization (h/call wasm/internal-module "_set_browser" browser) @@ -1776,9 +1874,36 @@ (defn apply-canvas-blur [] - (when wasm/canvas (dom/set-style! wasm/canvas "filter" "blur(4px)")) - (let [controls-to-blur (dom/query-all (dom/get-element "viewport-controls") ".blurrable")] - (run! #(dom/set-style! % "filter" "blur(4px)") controls-to-blur))) + (let [already? @page-transition? + epoch (begin-page-transition!)] + (set-transition-tiles-complete-handler! epoch end-page-transition!) + ;; Two-phase transition: + ;; - Apply CSS blur to the live canvas immediately (no async wait), so the user + ;; sees the transition right away. + ;; - In parallel, capture a `blob:` snapshot URL; once ready, switch the overlay + ;; to that fixed image (and guard with `epoch` to avoid stale async updates). + (set-transition-blur!) + ;; Lock the snapshot for the whole transition: if the user clicks to another page + ;; while the transition is active, keep showing the original page snapshot until + ;; the final target page finishes rendering. + (if already? + (p/resolved nil) + (do + ;; If we already have a snapshot URL, use it immediately. + (when-let [url wasm/canvas-snapshot-url] + (when (string? url) + (reset! transition-image-url* url))) + + ;; Capture a fresh snapshot asynchronously and update the overlay as soon + ;; as it is ready (guarded by `epoch` to avoid stale async updates). + (-> (capture-canvas-snapshot-url) + (p/then (fn [url] + (when (and (string? url) + @page-transition? + (= epoch @transition-epoch*)) + (reset! transition-image-url* url)) + url)) + (p/catch (fn [_] nil))))))) (defn render-shape-pixels [shape-id scale] diff --git a/frontend/src/app/render_wasm/api/webgl.cljs b/frontend/src/app/render_wasm/api/webgl.cljs index c6741944a2..7442947953 100644 --- a/frontend/src/app/render_wasm/api/webgl.cljs +++ b/frontend/src/app/render_wasm/api/webgl.cljs @@ -9,9 +9,17 @@ (:require [app.common.logging :as log] [app.render-wasm.wasm :as wasm] - [app.util.dom :as dom] [promesa.core :as p])) +(defn max-texture-size + "Returns `gl.MAX_TEXTURE_SIZE` (max dimension of a 2D texture), or nil if + unavailable." + [gl] + (when gl + (let [n (.getParameter ^js gl (.-MAX_TEXTURE_SIZE ^js gl))] + (when (and (number? n) (pos? n) (js/isFinite n)) + (js/Math.floor n))))) + (defn get-webgl-context "Gets the WebGL context from the WASM module" [] @@ -135,38 +143,29 @@ void main() { (.bindTexture ^js gl (.-TEXTURE_2D ^js gl) nil) (.deleteTexture ^js gl texture)))) -(defn restore-previous-canvas-pixels - "Restores previous canvas pixels into the new canvas" - [] - (when-let [previous-canvas-pixels wasm/canvas-pixels] - (when-let [gl wasm/gl-context] - (draw-imagedata-to-webgl gl previous-canvas-pixels) - (set! wasm/canvas-pixels nil)))) +(defn capture-canvas-snapshot-url + "Captures the current viewport canvas as a PNG `blob:` URL and stores it in + `wasm/canvas-snapshot-url`. -(defn clear-canvas-pixels + Returns a promise resolving to the URL string (or nil)." [] - (when wasm/canvas - (let [context wasm/gl-context] - (.clearColor ^js context 0 0 0 0.0) - (.clear ^js context (.-COLOR_BUFFER_BIT ^js context)) - (.clear ^js context (.-DEPTH_BUFFER_BIT ^js context)) - (.clear ^js context (.-STENCIL_BUFFER_BIT ^js context))) - (dom/set-style! wasm/canvas "filter" "none") - (let [controls-to-unblur (dom/query-all (dom/get-element "viewport-controls") ".blurrable")] - (run! #(dom/set-style! % "filter" "none") controls-to-unblur)) - (set! wasm/canvas-pixels nil))) - -(defn capture-canvas-pixels - "Captures the pixels of the viewport canvas" - [] - (when wasm/canvas - (let [context wasm/gl-context - width (.-width wasm/canvas) - height (.-height wasm/canvas) - buffer (js/Uint8ClampedArray. (* width height 4)) - _ (.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)))) + (if-let [^js canvas wasm/canvas] + (p/create + (fn [resolve _reject] + ;; Revoke previous snapshot to avoid leaking blob URLs. + (when-let [prev wasm/canvas-snapshot-url] + (when (and (string? prev) (.startsWith ^js prev "blob:")) + (js/URL.revokeObjectURL prev))) + (set! wasm/canvas-snapshot-url nil) + (.toBlob canvas + (fn [^js blob] + (if blob + (let [url (js/URL.createObjectURL blob)] + (set! wasm/canvas-snapshot-url url) + (resolve url)) + (resolve nil))) + "image/png"))) + (p/resolved nil))) (defn draw-thumbnail-to-canvas "Loads an image from `uri` and draws it stretched to fill the WebGL canvas. diff --git a/frontend/src/app/render_wasm/wasm.cljs b/frontend/src/app/render_wasm/wasm.cljs index c54091d5e2..5c43ba4899 100644 --- a/frontend/src/app/render_wasm/wasm.cljs +++ b/frontend/src/app/render_wasm/wasm.cljs @@ -12,8 +12,9 @@ ;; Reference to the HTML canvas element. (defonce canvas nil) -;; Reference to the captured pixels of the canvas (for page switching effect) -(defonce canvas-pixels nil) +;; Snapshot of the current canvas suitable for `` overlays. +;; This is typically a `blob:` URL created via `canvas.toBlob`. +(defonce canvas-snapshot-url nil) ;; Reference to the Emscripten GL context wrapper. (defonce gl-context-handle nil) diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index ee3f58b74c..65ac296895 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -135,6 +135,28 @@ (wasm.mem/free) text))) +(defn ^:export wasmAtlasConsole + "Logs the current render-wasm atlas as an image in the JS console (if present)." + [] + (let [module wasm/internal-module + f (when module (unchecked-get module "_debug_atlas_console"))] + (if (fn? f) + (wasm.h/call module "_debug_atlas_console") + (js/console.warn "[debug] render-wasm module not ready or missing _debug_atlas_console")))) + +(defn ^:export wasmAtlasBase64 + "Returns the atlas PNG base64 (empty string if missing/empty)." + [] + (let [module wasm/internal-module + f (when module (unchecked-get module "_debug_atlas_base64"))] + (if (fn? f) + (let [ptr (wasm.h/call module "_debug_atlas_base64") + s (or (wasm-read-len-prefixed-utf8 ptr) "")] + s) + (do + (js/console.warn "[debug] render-wasm module not ready or missing _debug_atlas_base64") + "")))) + (defn ^:export wasmCacheConsole "Logs the current render-wasm cache surface as an image in the JS console." [] diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 747b6018f8..740fae8104 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -154,6 +154,18 @@ pub extern "C" fn set_antialias_threshold(threshold: f32) -> Result<()> { Ok(()) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_max_atlas_texture_size(max_px: i32) -> Result<()> { + with_state_mut!(state, { + state + .render_state_mut() + .surfaces + .set_max_atlas_texture_size(max_px); + }); + Ok(()) +} + #[no_mangle] #[wasm_error] pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> { diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 14ad2b8848..e1897ae09b 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -43,6 +43,13 @@ const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3; const MAX_BLOCKING_TIME_MS: i32 = 32; const NODE_BATCH_THRESHOLD: i32 = 3; + +/// Dispatches `penpot:wasm:tiles-complete` on `document` so the UI can react when a full +/// tile pass has finished (e.g. remove page-transition blur). +fn notify_tiles_render_complete() { + #[cfg(target_arch = "wasm32")] + crate::run_script!("document.dispatchEvent(new CustomEvent('penpot:wasm:tiles-complete'))"); +} const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0; type ClipStack = Vec<(Rect, Option, Matrix)>; @@ -715,12 +722,14 @@ impl RenderState { // 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( + &mut self.gpu_state, &self.tile_viewbox, &self .current_tile .ok_or(Error::CriticalError("Current tile not found".to_string()))?, &tile_rect, fast_mode, + self.render_area, ); self.surfaces.draw_cached_tile_surface( @@ -1464,6 +1473,28 @@ impl RenderState { performance::begin_measure!("render_from_cache"); let cached_scale = self.get_cached_scale(); + let bg_color = self.background_color; + + // During fast mode (pan/zoom), if a previous full-quality render still has pending tiles, + // always prefer the persistent atlas. The atlas is incrementally updated as tiles finish, + // and drawing from it avoids mixing a partially-updated Cache surface with missing tiles. + if self.options.is_fast_mode() && self.render_in_progress && self.surfaces.has_atlas() { + self.surfaces + .draw_atlas_to_target(self.viewbox, self.options.dpr(), bg_color); + + if self.options.is_debug_visible() { + debug::render(self); + } + + ui::render(self, shapes); + debug::render_wasm_label(self); + + self.flush_and_submit(); + performance::end_measure!("render_from_cache"); + performance::end_timed_log!("render_from_cache", _start); + return; + } + // Check if we have a valid cached viewbox (non-zero dimensions indicate valid cache) if self.cached_viewbox.area.width() > 0.0 { // Scale and translate the target according to the cached data @@ -1480,7 +1511,62 @@ impl RenderState { let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr(); let translate_x = (start_tile_x as f32 * tiles::TILE_SIZE) - offset_x; let translate_y = (start_tile_y as f32 * tiles::TILE_SIZE) - offset_y; - let bg_color = self.background_color; + + // For zoom-out, prefer cache only if it fully covers the viewport. + // Otherwise, atlas will provide a more correct full-viewport preview. + let zooming_out = self.viewbox.zoom < self.cached_viewbox.zoom; + if zooming_out { + let cache_dim = self.surfaces.cache_dimensions(); + let cache_w = cache_dim.width as f32; + let cache_h = cache_dim.height as f32; + + // Viewport in target pixels. + let vw = (self.viewbox.width * self.options.dpr()).max(1.0); + let vh = (self.viewbox.height * self.options.dpr()).max(1.0); + + // Inverse-map viewport corners into cache coordinates. + // target = (cache * navigate_zoom) translated by (translate_x, translate_y) (in cache coords). + // => cache = (target / navigate_zoom) - translate + let inv = if navigate_zoom.abs() > f32::EPSILON { + 1.0 / navigate_zoom + } else { + 0.0 + }; + + let cx0 = (0.0 * inv) - translate_x; + let cy0 = (0.0 * inv) - translate_y; + let cx1 = (vw * inv) - translate_x; + let cy1 = (vh * inv) - translate_y; + + let min_x = cx0.min(cx1); + let min_y = cy0.min(cy1); + let max_x = cx0.max(cx1); + let max_y = cy0.max(cy1); + + let cache_covers = + min_x >= 0.0 && min_y >= 0.0 && max_x <= cache_w && max_y <= cache_h; + if !cache_covers { + // Early return only if atlas exists; otherwise keep cache path. + if self.surfaces.has_atlas() { + self.surfaces.draw_atlas_to_target( + self.viewbox, + self.options.dpr(), + bg_color, + ); + + if self.options.is_debug_visible() { + debug::render(self); + } + + ui::render(self, shapes); + debug::render_wasm_label(self); + self.flush_and_submit(); + performance::end_measure!("render_from_cache"); + performance::end_timed_log!("render_from_cache", _start); + return; + } + } + } // Setup canvas transform { @@ -1536,6 +1622,7 @@ impl RenderState { self.flush_and_submit(); } + performance::end_measure!("render_from_cache"); performance::end_timed_log!("render_from_cache", _start); } @@ -1675,6 +1762,7 @@ impl RenderState { self.cancel_animation_frame(); self.render_request_id = Some(wapi::request_animation_frame!()); } else { + notify_tiles_render_complete(); performance::end_measure!("render"); } } @@ -1692,6 +1780,7 @@ impl RenderState { self.render_shape_tree_partial(base_object, tree, timestamp, false)?; } self.flush_and_submit(); + notify_tiles_render_complete(); Ok(()) } @@ -2705,13 +2794,8 @@ impl RenderState { } } else { performance::begin_measure!("render_shape_tree::uncached"); - // Only allow stopping (yielding) if the current tile is NOT visible. - // This ensures all visible tiles render synchronously before showing, - // eliminating empty squares during zoom. Interest-area tiles can still yield. - let tile_is_visible = self.tile_viewbox.is_visible(¤t_tile); - let can_stop = allow_stop && !tile_is_visible; - let (is_empty, early_return) = - self.render_shape_tree_partial_uncached(tree, timestamp, can_stop, false)?; + let (is_empty, early_return) = self + .render_shape_tree_partial_uncached(tree, timestamp, allow_stop, false)?; if early_return { return Ok(()); diff --git a/render-wasm/src/render/debug.rs b/render-wasm/src/render/debug.rs index 47b739b484..f374e32af3 100644 --- a/render-wasm/src/render/debug.rs +++ b/render-wasm/src/render/debug.rs @@ -194,6 +194,15 @@ pub fn console_debug_surface(render_state: &mut RenderState, id: SurfaceId) { run_script!(format!("console.log('%c ', 'font-size: 1px; background: url(data:image/png;base64,{base64_image}) no-repeat; padding: 100px; background-size: contain;')")); } +pub fn console_debug_surface_base64(render_state: &mut RenderState, id: SurfaceId) { + let base64_image = render_state + .surfaces + .base64_snapshot(id) + .expect("Failed to get base64 image"); + + println!("{}", base64_image); +} + #[allow(dead_code)] #[cfg(target_arch = "wasm32")] pub fn console_debug_surface_rect(render_state: &mut RenderState, id: SurfaceId, rect: skia::Rect) { @@ -223,3 +232,33 @@ pub extern "C" fn debug_cache_console() -> Result<()> { }); Ok(()) } + +#[no_mangle] +#[wasm_error] +#[cfg(target_arch = "wasm32")] +pub extern "C" fn debug_cache_base64() -> Result<()> { + with_state_mut!(state, { + console_debug_surface_base64(state.render_state_mut(), SurfaceId::Cache); + }); + Ok(()) +} + +#[no_mangle] +#[wasm_error] +#[cfg(target_arch = "wasm32")] +pub extern "C" fn debug_atlas_console() -> Result<()> { + with_state_mut!(state, { + console_debug_surface(state.render_state_mut(), SurfaceId::Atlas); + }); + Ok(()) +} + +#[no_mangle] +#[wasm_error] +#[cfg(target_arch = "wasm32")] +pub extern "C" fn debug_atlas_base64() -> Result<()> { + with_state_mut!(state, { + console_debug_surface_base64(state.render_state_mut(), SurfaceId::Atlas); + }); + Ok(()) +} diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 1c5a77c72c..3a1d20d900 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -1,6 +1,7 @@ use crate::error::{Error, Result}; use crate::performance; use crate::shapes::Shape; +use crate::view::Viewbox; use skia_safe::{self as skia, IRect, Paint, RRect}; @@ -15,6 +16,16 @@ const TEXTURES_BATCH_DELETE: usize = 256; // If it's too big it could affect performance. const TILE_SIZE_MULTIPLIER: i32 = 2; +/// Atlas texture size limits (px per side). +/// +/// - `DEFAULT_MAX_ATLAS_TEXTURE_SIZE` is the startup fallback used until the +/// frontend reads the real `gl.MAX_TEXTURE_SIZE` and sends it via +/// [`Surfaces::set_max_atlas_texture_size`]. +/// - `MAX_ATLAS_TEXTURE_SIZE` is a hard upper bound to clamp the runtime value +/// (defensive cap to avoid accidentally creating oversized GPU textures). +const MAX_ATLAS_TEXTURE_SIZE: i32 = 4096; +const DEFAULT_MAX_ATLAS_TEXTURE_SIZE: i32 = 1024; + #[repr(u32)] #[derive(Debug, PartialEq, Clone, Copy)] pub enum SurfaceId { @@ -30,6 +41,7 @@ pub enum SurfaceId { Export = 0b010_0000_0000, UI = 0b100_0000_0000, Debug = 0b100_0000_0001, + Atlas = 0b100_0000_0010, } pub struct Surfaces { @@ -57,6 +69,18 @@ pub struct Surfaces { export: skia::Surface, tiles: TileTextureCache, + // Persistent 1:1 document-space atlas that gets incrementally updated as tiles render. + // It grows dynamically to include any rendered document rect. + atlas: skia::Surface, + atlas_origin: skia::Point, + atlas_size: skia::ISize, + /// Atlas pixel density relative to document pixels (1.0 == 1:1 doc px). + /// When the atlas would exceed `max_atlas_texture_size`, this value is + /// reduced so the atlas stays within the fixed texture cap. + atlas_scale: f32, + /// Max width/height in pixels for the atlas surface (typically browser + /// `MAX_TEXTURE_SIZE`). Set from ClojureScript after WebGL context creation. + max_atlas_texture_size: i32, sampling_options: skia::SamplingOptions, pub margins: skia::ISize, // Tracks which surfaces have content (dirty flag bitmask) @@ -99,6 +123,10 @@ impl Surfaces { let ui = gpu_state.create_surface_with_dimensions("ui".to_string(), width, height)?; let debug = gpu_state.create_surface_with_dimensions("debug".to_string(), width, height)?; + // Keep atlas as a regular surface like the rest. Start with a tiny + // transparent surface and grow it on demand. + let mut atlas = gpu_state.create_surface_with_dimensions("atlas".to_string(), 1, 1)?; + atlas.canvas().clear(skia::Color::TRANSPARENT); let tiles = TileTextureCache::new(); Ok(Surfaces { @@ -115,6 +143,11 @@ impl Surfaces { debug, export, tiles, + atlas, + atlas_origin: skia::Point::new(0.0, 0.0), + atlas_size: skia::ISize::new(0, 0), + atlas_scale: 1.0, + max_atlas_texture_size: DEFAULT_MAX_ATLAS_TEXTURE_SIZE, sampling_options, margins, dirty_surfaces: 0, @@ -122,10 +155,185 @@ impl Surfaces { }) } + /// Sets the maximum atlas texture dimension (one side). Should match the + /// WebGL `MAX_TEXTURE_SIZE` reported by the browser. Values are clamped to + /// a small minimum so the atlas logic stays well-defined. + pub fn set_max_atlas_texture_size(&mut self, max_px: i32) { + self.max_atlas_texture_size = max_px.clamp(TILE_SIZE as i32, MAX_ATLAS_TEXTURE_SIZE); + } + + fn ensure_atlas_contains( + &mut self, + gpu_state: &mut GpuState, + doc_rect: skia::Rect, + ) -> Result<()> { + if doc_rect.is_empty() { + return Ok(()); + } + + // Current atlas bounds in document space (1 unit == 1 px). + let current_left = self.atlas_origin.x; + let current_top = self.atlas_origin.y; + let atlas_scale = self.atlas_scale.max(0.01); + let current_right = current_left + (self.atlas_size.width as f32) / atlas_scale; + let current_bottom = current_top + (self.atlas_size.height as f32) / atlas_scale; + + let mut new_left = current_left; + let mut new_top = current_top; + let mut new_right = current_right; + let mut new_bottom = current_bottom; + + // If atlas is empty/uninitialized, seed to rect (expanded to tile boundaries for fewer reallocs). + let needs_init = self.atlas_size.width <= 0 || self.atlas_size.height <= 0; + if needs_init { + new_left = doc_rect.left.floor(); + new_top = doc_rect.top.floor(); + new_right = doc_rect.right.ceil(); + new_bottom = doc_rect.bottom.ceil(); + } else { + new_left = new_left.min(doc_rect.left.floor()); + new_top = new_top.min(doc_rect.top.floor()); + new_right = new_right.max(doc_rect.right.ceil()); + new_bottom = new_bottom.max(doc_rect.bottom.ceil()); + } + + // Add padding to reduce realloc frequency. + let pad = TILE_SIZE; + new_left -= pad; + new_top -= pad; + new_right += pad; + new_bottom += pad; + + let doc_w = (new_right - new_left).max(1.0); + let doc_h = (new_bottom - new_top).max(1.0); + + // Compute atlas scale needed to fit within the fixed texture cap. + // Keep the highest possible scale (closest to 1.0) that still fits. + let cap = self.max_atlas_texture_size.max(TILE_SIZE as i32) as f32; + let required_scale = (cap / doc_w).min(cap / doc_h).clamp(0.01, 1.0); + + // Never upscale the atlas (it would add blur and churn). + let new_scale = self.atlas_scale.min(required_scale).max(0.01); + + let new_w = (doc_w * new_scale).ceil().clamp(1.0, cap) as i32; + let new_h = (doc_h * new_scale).ceil().clamp(1.0, cap) as i32; + + // Fast path: existing atlas already contains the rect. + if !needs_init + && doc_rect.left >= current_left + && doc_rect.top >= current_top + && doc_rect.right <= current_right + && doc_rect.bottom <= current_bottom + { + return Ok(()); + } + + let mut new_atlas = + gpu_state.create_surface_with_dimensions("atlas".to_string(), new_w, new_h)?; + new_atlas.canvas().clear(skia::Color::TRANSPARENT); + + // Copy old atlas into the new one with offset. + if !needs_init { + let old_scale = self.atlas_scale.max(0.01); + let scale_ratio = new_scale / old_scale; + let dx = (current_left - new_left) * new_scale; + let dy = (current_top - new_top) * new_scale; + + let image = self.atlas.image_snapshot(); + let src = skia::Rect::from_xywh( + 0.0, + 0.0, + self.atlas_size.width as f32, + self.atlas_size.height as f32, + ); + let dst = skia::Rect::from_xywh( + dx, + dy, + (self.atlas_size.width as f32) * scale_ratio, + (self.atlas_size.height as f32) * scale_ratio, + ); + new_atlas.canvas().draw_image_rect( + &image, + Some((&src, skia::canvas::SrcRectConstraint::Fast)), + dst, + &skia::Paint::default(), + ); + } + + self.atlas_origin = skia::Point::new(new_left, new_top); + self.atlas_size = skia::ISize::new(new_w, new_h); + self.atlas_scale = new_scale; + self.atlas = new_atlas; + Ok(()) + } + + fn blit_tile_image_into_atlas( + &mut self, + gpu_state: &mut GpuState, + tile_image: &skia::Image, + doc_rect: skia::Rect, + ) -> Result<()> { + self.ensure_atlas_contains(gpu_state, doc_rect)?; + + // Destination is document-space rect mapped into atlas pixel coords. + let dst = skia::Rect::from_xywh( + (doc_rect.left - self.atlas_origin.x) * self.atlas_scale, + (doc_rect.top - self.atlas_origin.y) * self.atlas_scale, + doc_rect.width() * self.atlas_scale, + doc_rect.height() * self.atlas_scale, + ); + + self.atlas + .canvas() + .draw_image_rect(tile_image, None, dst, &skia::Paint::default()); + Ok(()) + } + pub fn clear_tiles(&mut self) { self.tiles.clear(); } + pub fn has_atlas(&self) -> bool { + self.atlas_size.width > 0 && self.atlas_size.height > 0 + } + + /// Draw the persistent atlas onto the target using the current viewbox transform. + /// Intended for fast pan/zoom-out previews (avoids per-tile composition). + pub fn draw_atlas_to_target(&mut self, viewbox: Viewbox, dpr: f32, background: skia::Color) { + if !self.has_atlas() { + return; + }; + + let canvas = self.target.canvas(); + canvas.save(); + canvas.reset_matrix(); + let size = canvas.base_layer_size(); + canvas.clip_rect( + skia::Rect::from_xywh(0.0, 0.0, size.width as f32, size.height as f32), + None, + true, + ); + + let s = viewbox.zoom * dpr; + let atlas_scale = self.atlas_scale.max(0.01); + + canvas.clear(background); + canvas.translate(( + (self.atlas_origin.x + viewbox.pan_x) * s, + (self.atlas_origin.y + viewbox.pan_y) * s, + )); + canvas.scale((s / atlas_scale, s / atlas_scale)); + + self.atlas.draw( + canvas, + (0.0, 0.0), + self.sampling_options, + Some(&skia::Paint::default()), + ); + + canvas.restore(); + } + pub fn margins(&self) -> skia::ISize { self.margins } @@ -255,6 +463,10 @@ impl Surfaces { ); } + pub fn cache_dimensions(&self) -> skia::ISize { + skia::ISize::new(self.cache.width(), self.cache.height()) + } + pub fn apply_mut(&mut self, ids: u32, mut f: impl FnMut(&mut skia::Surface)) { performance::begin_measure!("apply_mut::flags"); if ids & SurfaceId::Target as u32 != 0 { @@ -352,6 +564,7 @@ impl Surfaces { SurfaceId::Debug => &mut self.debug, SurfaceId::UI => &mut self.ui, SurfaceId::Export => &mut self.export, + SurfaceId::Atlas => &mut self.atlas, } } @@ -369,6 +582,7 @@ impl Surfaces { SurfaceId::Debug => &self.debug, SurfaceId::UI => &self.ui, SurfaceId::Export => &self.export, + SurfaceId::Atlas => &self.atlas, } } @@ -546,10 +760,12 @@ impl Surfaces { pub fn cache_current_tile_texture( &mut self, + gpu_state: &mut GpuState, tile_viewbox: &TileViewbox, tile: &Tile, tile_rect: &skia::Rect, skip_cache_surface: bool, + tile_doc_rect: skia::Rect, ) { let rect = IRect::from_xywh( self.margins.width, @@ -571,6 +787,9 @@ impl Surfaces { ); } + // Incrementally update persistent 1:1 atlas in document space. + // `tile_doc_rect` is in world/document coordinates (1 unit == 1 px at 100%). + let _ = self.blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect); self.tiles.add(tile_viewbox, tile, tile_image); } }