🐛 Avoid sequential tile draws and flicker during shape transforms

This commit is contained in:
Alejandro Alonso 2026-04-21 07:45:27 +02:00
parent c42eb6ff86
commit 98c8bb1746
5 changed files with 156 additions and 8 deletions

View File

@ -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)

View File

@ -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]

View File

@ -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<()> {

View File

@ -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]

View File

@ -9,6 +9,11 @@ pub struct RenderOptions {
pub flags: u32,
pub dpr: Option<f32>,
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)
}