diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index c7181abbaf..ed8e44e3ca 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -38,6 +38,23 @@ (def ^:private xf:without-uuid-zero (remove #(= % uuid/zero))) +;; Tracks whether the WASM renderer is currently in "interactive +;; transform" mode (a drag / resize / rotate gesture in progress). +;; Paired with `set-modifiers-start` / `set-modifiers-end` so the +;; native side only toggles once per gesture, regardless of how many +;; `set-wasm-modifiers` calls fire in between. +(defonce ^:private interactive-transform-active? (atom false)) + +(defn- ensure-interactive-transform-start! + [] + (when (compare-and-set! interactive-transform-active? false true) + (wasm.api/set-modifiers-start))) + +(defn- ensure-interactive-transform-end! + [] + (when (compare-and-set! interactive-transform-active? true false) + (wasm.api/set-modifiers-end))) + (def ^:private transform-attrs #{:selrect :points @@ -279,6 +296,11 @@ ptk/EffectEvent (effect [_ state _] (when (features/active-feature? state "render-wasm/v1") + ;; End interactive transform mode BEFORE cleaning modifiers so + ;; the final full-quality render triggered by subsequent shape + ;; updates is not still classified as "interactive" (which would + ;; skip shadows / blur). + (ensure-interactive-transform-end!) (wasm.api/clean-modifiers) (set-wasm-props! (dsh/lookup-page-objects state) (:wasm-props state) []))) @@ -624,6 +646,12 @@ ptk/WatchEvent (watch [_ state _] + ;; Entering an interactive transform (drag/resize/rotate). Flip + ;; the renderer into fast + atlas-backdrop mode so the live + ;; preview is cheap, tiles never appear sequentially and the main + ;; thread is not blocked. The pair is closed in + ;; `clear-local-transform`. + (ensure-interactive-transform-start!) (wasm.api/clean-modifiers) (let [prev-wasm-props (:prev-wasm-props state) wasm-props (:wasm-props state) @@ -764,6 +792,7 @@ (ptk/reify ::set-wasm-rotation-modifiers ptk/EffectEvent (effect [_ state _] + (ensure-interactive-transform-start!) (let [objects (dsh/lookup-page-objects state) ids (sequence xf-rotation-shape shapes) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index ec064493e3..0be80c8c8a 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1517,6 +1517,23 @@ [] (h/call wasm/internal-module "_clean_modifiers")) +(defn set-modifiers-start + "Enter interactive transform mode (drag / resize / rotate). Enables + fast-mode effect skipping in the renderer and activates an atlas + backdrop so tiles do not appear sequentially or flicker while the + gesture is in progress." + [] + (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (h/call wasm/internal-module "_set_modifiers_start"))) + +(defn set-modifiers-end + "Leave interactive transform mode. Cancels any pending async render + scheduled under it; the caller is expected to trigger a full-quality + render (via `request-render`) once the gesture is committed." + [] + (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (h/call wasm/internal-module "_set_modifiers_end"))) + (defn set-modifiers [modifiers] diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index ee3a7815f3..7a030e114d 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -401,6 +401,42 @@ pub extern "C" fn set_view_end() -> Result<()> { Ok(()) } +/// Enter interactive transform mode (drag / resize / rotate of a +/// shape). Activates the same expensive-effect skipping as pan/zoom +/// (`fast_mode`) but keeps per-frame flushing enabled so the Target is +/// presented every rAF, and triggers atlas-backed backdrops so +/// invalidated tiles do not appear sequentially or flicker. +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_modifiers_start() -> Result<()> { + with_state_mut!(state, { + performance::begin_measure!("set_modifiers_start"); + let opts = &mut state.render_state.options; + opts.set_fast_mode(true); + opts.set_interactive_transform(true); + performance::end_measure!("set_modifiers_start"); + }); + Ok(()) +} + +/// Leave interactive transform mode and cancel any pending async +/// render scheduled under it. The caller is responsible for triggering +/// a final full-quality render (typically via `_render`) once the +/// modifiers have been committed. +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_modifiers_end() -> Result<()> { + with_state_mut!(state, { + performance::begin_measure!("set_modifiers_end"); + let opts = &mut state.render_state.options; + opts.set_fast_mode(false); + opts.set_interactive_transform(false); + state.render_state.cancel_animation_frame(); + performance::end_measure!("set_modifiers_end"); + }); + Ok(()) +} + #[no_mangle] #[wasm_error] pub extern "C" fn clear_focus_mode() -> Result<()> { diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 5df0326ace..8684e0f112 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1666,6 +1666,24 @@ impl RenderState { self.cache_cleared_this_render = false; self.reset_canvas(); + + // During an interactive shape transform (drag/resize/rotate) the + // Target is repainted tile-by-tile. If only a subset of the + // invalidated tiles finishes in this rAF the remaining area + // would either show stale content from the previous frame or, + // on buffer swaps, show blank pixels — either way the user + // perceives tiles appearing sequentially. Paint the persistent + // 1:1 atlas as a stable backdrop so every flush presents a + // coherent picture: unchanged tiles come from the atlas and + // invalidated tiles are overwritten on top as they finish. + if self.options.is_interactive_transform() && self.surfaces.has_atlas() { + self.surfaces.draw_atlas_to_target( + self.viewbox, + self.options.dpr(), + self.background_color, + ); + } + let surface_ids = SurfaceId::Strokes as u32 | SurfaceId::Fills as u32 | SurfaceId::InnerShadows as u32 @@ -1744,12 +1762,16 @@ impl RenderState { self.render_shape_tree_partial(base_object, tree, timestamp, true)?; } - // 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() { + // In a pure viewport interaction (pan/zoom), 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. + // + // During interactive shape transforms (drag/resize/rotate) we + // still need to flush every rAF so the user sees the updated + // shape position — render_from_cache is not in the loop here. + if !self.options.is_viewport_interaction() { self.flush_and_submit(); } @@ -1887,8 +1909,26 @@ impl RenderState { #[inline] pub fn should_stop_rendering(&self, iteration: i32, timestamp: i32) -> bool { - iteration % NODE_BATCH_THRESHOLD == 0 - && performance::get_time() - timestamp > MAX_BLOCKING_TIME_MS + if iteration % NODE_BATCH_THRESHOLD != 0 { + return false; + } + if performance::get_time() - timestamp <= MAX_BLOCKING_TIME_MS { + return false; + } + + // During interactive shape transforms we must complete every + // visible tile in a single rAF so the user never sees tiles + // popping in sequentially. Only yield once all visible work is + // done and we are processing the interest-area pre-render. + if self.options.is_interactive_transform() { + if let Some(tile) = self.current_tile { + if self.tile_viewbox.is_visible(&tile) { + return false; + } + } + } + + true } #[inline] diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 27454ec90f..f6072f964f 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -9,6 +9,11 @@ pub struct RenderOptions { pub flags: u32, pub dpr: Option, fast_mode: bool, + /// Active while the user is interacting with a shape (drag, resize, + /// rotate). Implies `fast_mode` semantics for expensive effects but + /// keeps per-frame flushing enabled (unlike pan/zoom, where + /// `render_from_cache` drives target presentation). + interactive_transform: bool, /// Minimum on-screen size (CSS px at 1:1 zoom) above which vector antialiasing is enabled. pub antialias_threshold: f32, } @@ -19,6 +24,7 @@ impl Default for RenderOptions { flags: 0, dpr: None, fast_mode: false, + interactive_transform: false, antialias_threshold: 7.0, } } @@ -42,6 +48,26 @@ impl RenderOptions { self.fast_mode = enabled; } + /// Interactive transform is ON while the user is dragging, resizing + /// or rotating a shape. Callers use it to keep per-frame flushing + /// enabled and to render visible tiles in a single frame so tiles + /// never appear sequentially or flicker during the gesture. + pub fn is_interactive_transform(&self) -> bool { + self.interactive_transform + } + + pub fn set_interactive_transform(&mut self, enabled: bool) { + self.interactive_transform = enabled; + } + + /// True only when the viewport is the one being moved (pan/zoom) + /// and the dedicated `render_from_cache` path owns Target + /// presentation. In this mode `process_animation_frame` must not + /// flush to avoid presenting stale tile positions. + pub fn is_viewport_interaction(&self) -> bool { + self.fast_mode && !self.interactive_transform + } + pub fn dpr(&self) -> f32 { self.dpr.unwrap_or(1.0) }