diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 7e33fdc1ca..6df71e7e4f 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -69,12 +69,29 @@ (def ^:const DEBOUNCE_DELAY_MS 100) (def ^:const THROTTLE_DELAY_MS 10) +;; Async chunked processing constants +;; SHAPES_CHUNK_SIZE: Number of shapes to process before yielding to browser +;; Lower values = more responsive UI but slower total processing +;; Higher values = faster total processing but may cause UI jank +(def ^:const SHAPES_CHUNK_SIZE 100) + +;; Threshold below which we use synchronous processing (no chunking overhead) +(def ^:const ASYNC_THRESHOLD 100) + (def dpr (if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0)) (def noop-fn (constantly 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)))))) + ;; Based on app.main.render/object-svg (mf/defc object-svg {::mf/props :obj} @@ -121,17 +138,70 @@ (aget buffer 3)) (set! wasm/internal-frame-id nil)))) +(defn render-preview! + "Render a lightweight preview without tile caching. + Used during progressive loading for fast feedback." + [] + (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (h/call wasm/internal-module "_render_preview"))) + (defonce pending-render (atom false)) +(defonce shapes-loading? (atom false)) +(defonce deferred-render? (atom false)) + +(defn- register-deferred-render! + [] + (reset! deferred-render? true)) (defn request-render [_requester] - (when (and wasm/context-initialized? (not @pending-render) (not @wasm/context-lost?)) - (reset! pending-render true) - (js/requestAnimationFrame - (fn [ts] - (reset! pending-render false) - (render ts))))) + (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (if @shapes-loading? + (register-deferred-render!) + (when-not @pending-render + (reset! pending-render true) + (let [frame-id + (js/requestAnimationFrame + (fn [ts] + (reset! pending-render false) + (set! wasm/internal-frame-id nil) + (render ts)))] + (set! wasm/internal-frame-id frame-id)))))) + +(defn- begin-shapes-loading! + [] + (reset! shapes-loading? true) + (let [frame-id wasm/internal-frame-id + was-pending @pending-render] + (when frame-id + (js/cancelAnimationFrame frame-id) + (set! wasm/internal-frame-id nil)) + (reset! pending-render false) + (reset! deferred-render? was-pending))) + +(defn- end-shapes-loading! + [] + (let [was-loading (compare-and-set! shapes-loading? true false)] + (reset! deferred-render? false) + ;; Always trigger a render after loading completes + ;; This ensures shapes are displayed even if no deferred render was requested + (when was-loading + (request-render "set-objects:flush")))) + +(defn- request-progressive-render! + ([reason] + (let [was-loading? @shapes-loading?] + (if was-loading? + (do + (reset! shapes-loading? false) + (try + (request-render reason) + (finally + (reset! shapes-loading? was-loading?)))) + (request-render reason)))) + ([] + (request-progressive-render! "set-objects:chunk"))) (declare get-text-dimensions) @@ -993,30 +1063,131 @@ (let [{:keys [thumbnails full]} (set-object shape)] (process-pending [shape] thumbnails full noop-fn))) +(defn- process-shapes-chunk + "Process a chunk of shapes synchronously, returning accumulated pending operations. + 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})))) + +(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) + ;; Calculate how many chunks we'll process + total-chunks (js/Math.ceil (/ total-shapes SHAPES_CHUNK_SIZE)) + ;; Render at 25%, 50%, 75% of loading + render-at-chunks (set [(js/Math.floor (* total-chunks 0.25)) + (js/Math.floor (* total-chunks 0.5)) + (js/Math.floor (* total-chunks 0.75))])] + (p/create + (fn [resolve _reject] + (letfn [(process-next-chunk [index thumbnails-acc full-acc chunk-count] + (if (< index total-shapes) + ;; Process one 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!)) + + ;; Yield to browser, then continue with next chunk + (-> (yield-to-browser) + (p/then (fn [_] + (process-next-chunk next-index thumbnails full new-chunk-count))))) + ;; 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))))))] + ;; Start processing + (process-next-chunk 0 [] [] 0)))))) + +(defn- set-objects-sync + "Synchronously process all shapes (for small shape counts)." + [shapes render-callback] + (let [total-shapes (count shapes) + {:keys [thumbnails full]} + (loop [index 0 thumbnails-acc [] full-acc []] + (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}))] + (perf/end-measure "set-objects") + (process-pending shapes thumbnails full noop-fn + (fn [] + (if render-callback + (render-callback) + (render-finish)) + (ug/dispatch! (ug/event "penpot:wasm:set-objects")))))) + +(defn- shapes-in-tree-order + "Returns shapes sorted in tree order (parents before children). + This ensures parent shapes are processed before their children, + maintaining proper shape reference consistency in WASM." + [objects] + ;; Get IDs in tree order starting from root (uuid/zero) + ;; cfh/get-children-ids-with-self returns [root-id, child1-id, grandchild1-id, ...] + (let [ordered-ids (cfh/get-children-ids-with-self objects uuid/zero)] + (into [] + (keep #(get objects %)) + ordered-ids))) + (defn set-objects + "Set all shape objects for rendering. + + IMPORTANT: Shapes are processed in tree order (parents before children) + to maintain proper shape reference consistency in WASM. + + NOTE: Async processing uses render gating to avoid race conditions with + requestAnimationFrame renders during loading." ([objects] (set-objects objects nil)) ([objects render-callback] (perf/begin-measure "set-objects") - (let [shapes (into [] (vals objects)) - total-shapes (count shapes) - ;; Collect pending operations - set-object returns {:thumbnails [...] :full [...]} - {:keys [thumbnails full]} - (loop [index 0 thumbnails-acc [] full-acc []] - (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}))] - (perf/end-measure "set-objects") - (process-pending shapes thumbnails full noop-fn - (fn [] - (if render-callback - (render-callback) - (render-finish)) - (ug/dispatch! (ug/event "penpot:wasm:set-objects"))))))) + (let [shapes (shapes-in-tree-order objects) + total-shapes (count shapes)] + (if (< total-shapes ASYNC_THRESHOLD) + (set-objects-sync shapes render-callback) + (do + (begin-shapes-loading!) + (try + (-> (set-objects-async shapes render-callback) + (p/catch (fn [error] + (end-shapes-loading!) + (js/console.error "Async WASM shape loading failed" error)))) + (catch :default error + (end-shapes-loading!) + (js/console.error "Async WASM shape loading failed" error) + (throw error))) + nil))))) (defn clear-focus-mode [] diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 11b23309c7..c23ce7a07c 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -191,6 +191,20 @@ pub extern "C" fn render_from_cache(_: i32) { }); } +#[no_mangle] +pub extern "C" fn set_preview_mode(enabled: bool) { + with_state_mut!(state, { + state.render_state.set_preview_mode(enabled); + }); +} + +#[no_mangle] +pub extern "C" fn render_preview() { + with_state_mut!(state, { + state.render_preview(performance::get_time()); + }); +} + #[no_mangle] pub extern "C" fn process_animation_frame(timestamp: i32) { let result = std::panic::catch_unwind(|| { diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index f0619a9e37..47fafce5bf 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -294,6 +294,8 @@ pub(crate) struct RenderState { /// where we must render shapes without inheriting ancestor layer blurs. Toggle it through /// `with_nested_blurs_suppressed` to ensure it's always restored. pub ignore_nested_blurs: bool, + /// Preview render mode - when true, uses simplified rendering for progressive loading + pub preview_mode: bool, } pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize { @@ -366,6 +368,7 @@ impl RenderState { focus_mode: FocusMode::new(), touched_ids: HashSet::default(), ignore_nested_blurs: false, + preview_mode: false, } } @@ -486,6 +489,10 @@ impl RenderState { self.background_color = color; } + pub fn set_preview_mode(&mut self, enabled: bool) { + self.preview_mode = enabled; + } + pub fn resize(&mut self, width: i32, height: i32) { let dpr_width = (width as f32 * self.options.dpr()).floor() as i32; let dpr_height = (height as f32 * self.options.dpr()).floor() as i32; @@ -1127,6 +1134,25 @@ impl RenderState { performance::end_timed_log!("render_from_cache", _start); } + /// Render a preview of the shapes during loading. + /// This rebuilds tiles for touched shapes and renders synchronously. + pub fn render_preview(&mut self, tree: ShapesPoolRef, timestamp: i32) -> Result<(), String> { + let _start = performance::begin_timed_log!("render_preview"); + performance::begin_measure!("render_preview"); + + // Skip tile rebuilding during preview - we'll do it at the end + // Just rebuild tiles for touched shapes and render synchronously + self.rebuild_touched_tiles(tree); + + // Use the sync render path + self.start_render_loop(None, tree, timestamp, true)?; + + performance::end_measure!("render_preview"); + performance::end_timed_log!("render_preview", _start); + + Ok(()) + } + pub fn start_render_loop( &mut self, base_object: Option<&Uuid>, @@ -1622,10 +1648,11 @@ impl RenderState { is_empty = false; - let element = tree.get(&node_id).ok_or(format!( - "Error: Element with root_id {} not found in the tree.", - node_render_state.id - ))?; + let Some(element) = tree.get(&node_id) else { + // The shape isn't available yet (likely still streaming in from WASM). + // Skip it for this pass; a subsequent render will pick it up once present. + continue; + }; let scale = self.get_scale(); let mut extrect: Option = None; diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index b2950e781b..1f80e4a41f 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -223,6 +223,10 @@ impl State { self.render_state.rebuild_touched_tiles(&self.shapes); } + pub fn render_preview(&mut self, timestamp: i32) { + let _ = self.render_state.render_preview(&self.shapes, timestamp); + } + pub fn rebuild_modifier_tiles(&mut self, ids: Vec) { // No longer need unsafe lifetime extension - index-based storage is safe self.render_state