diff --git a/.serena/memories/render-wasm/ffi-rendering-subtleties.md b/.serena/memories/render-wasm/ffi-rendering-subtleties.md index bb09ea8d52..aac0201522 100644 --- a/.serena/memories/render-wasm/ffi-rendering-subtleties.md +++ b/.serena/memories/render-wasm/ffi-rendering-subtleties.md @@ -19,6 +19,9 @@ - Raster `Fill::Image`: skip `save_layer` unless the shape has an image filter; plain Rect/Frame (no corners) also skip the container clip (`draw_image_fill` in fills.rs). +- Zoom settle: visible tiles present via `FrameType::ViewportReady` before interest-ring + work; crop-cache rebuild is deferred to the later `Full` so the soft→sharp snap is + compose+present only. - Interactive transforms are distinct from viewport fast mode. `set_modifiers_start` enables fast mode and interactive transform; interactive transform still flushes each animation frame. - During interactive transform, modifier tile invalidation is deferred to `render()` once per rAF. Outside interactive transform, `set_modifiers` rebuilds modifier tiles immediately. - `set_modifiers_end` disables fast/interactive state and cancels pending async render; the caller must request the final full-quality render. diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 94d55480ee..e1769db813 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -379,6 +379,19 @@ (def ^:const FRAME_TYPE_NONE 0) ;; This type should never "leak". (def ^:const FRAME_TYPE_PARTIAL 1) ;; A frame needs more render calls to end. (def ^:const FRAME_TYPE_FULL 2) ;; A frame was full. +(def ^:const FRAME_TYPE_VIEWPORT_READY 3) ;; Viewport presented; interest tiles may still be pending. + +(defn- needs-more-render-frames? + "True when WASM still has progressive tile work (visible or interest ring)." + [] + (or (= wasm/internal-frame-type FRAME_TYPE_PARTIAL) + (= wasm/internal-frame-type FRAME_TYPE_VIEWPORT_READY))) + +(defn- frame-presented-target? + "True when this frame recomposited Target (full or early viewport present)." + [] + (not= wasm/internal-frame-type FRAME_TYPE_PARTIAL)) + (def ^:const RENDER-FLAG-SYNC-TILES 4) ;; Rebuild tile index without ending fast mode (pan/zoom pause). (defn- internal-render @@ -388,7 +401,7 @@ (internal-render timestamp wasm/internal-frame-type)) ([timestamp flags] (set! wasm/internal-frame-type (h/call wasm/internal-module "_render" timestamp flags)) - (when (= wasm/internal-frame-type FRAME_TYPE_PARTIAL) + (when (needs-more-render-frames?) (request-render "frame-type-partial")))) (defn- build-reload-payload @@ -485,13 +498,13 @@ (when (is-text-editor-wasm-enabled @st/state) (text-editor/text-editor-update-blink timestamp) ;; Only repaint the overlay when this frame recomposited Target (a full - ;; frame). A partial frame is flushed but not presented — Target still - ;; shows the last presented frame with the overlay already on it — so - ;; repainting the translucent selection over it stacks another layer - ;; every progressive frame: it darkens, then snaps back when the final - ;; frame presents from the clean Backbuffer (the blink at the end of a - ;; zoom over a selection, gh-10709). - (when (not= wasm/internal-frame-type FRAME_TYPE_PARTIAL) + ;; frame or early viewport present). A partial frame is flushed but not + ;; presented - Target still shows the last presented frame with the + ;; overlay already on it - so repainting the translucent selection over + ;; it stacks another layer every progressive frame: it darkens, then + ;; snaps back when the final frame presents from the clean Backbuffer + ;; (the blink at the end of a zoom over a selection, gh-10709). + (when (frame-presented-target?) (text-editor/text-editor-render-overlay)) ;; Drain editor events. Only content/layout changes need a full shape ;; re-render; selection/style changes are already reflected by the @@ -1413,14 +1426,14 @@ (defn- render-text-editor-overlay-after-frame! "Repaint the overlay after a direct `internal-render`, but only when that - render recomposited Target (a full frame). A partial frame is only flushed — - Target keeps the last presented frame with the overlay already on it — so - repainting the translucent selection then stacks another layer and it visibly - darkens across the progressive frames before snapping back on the final - present (the blink at the end of a zoom over a selection, gh-10709). The - final full frame's own repaint keeps the overlay in place." + render recomposited Target (a full frame or early viewport present). A partial + frame is only flushed - Target keeps the last presented frame with the overlay + already on it - so repainting the translucent selection then stacks another + layer and it visibly darkens across the progressive frames before snapping + back on the final present (the blink at the end of a zoom over a selection, + gh-10709). The final full frame's own repaint keeps the overlay in place." [] - (when (not= wasm/internal-frame-type FRAME_TYPE_PARTIAL) + (when (frame-presented-target?) (render-text-editor-overlay-if-active!))) (defn finalize-view-interaction! diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 1854a2d5ba..5c6e160f44 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -49,6 +49,10 @@ pub enum FrameType { None = 0, Partial = 1, Full = 2, + /// Viewport tiles are presented; interest-ring work may still be pending. + /// Frontend should keep requesting frames (like Partial) but may treat the + /// Target as freshly composited (like Full) for overlays. + ViewportReady = 3, } #[allow(dead_code)] @@ -419,6 +423,9 @@ pub(crate) struct RenderState { pub drop_shadows_ops_warmed: bool, /// Filter-surface snapshots for drop shadows, reused across tiles. drop_shadow_filter_cache: shadows::DropShadowFilterCache, + /// Visible tiles were already presented this pass; interest-ring fill may + /// still be running. Final Full should not re-present. + pub viewport_presented: bool, } pub struct InteractiveDragCrop { @@ -607,6 +614,7 @@ impl RenderState { tile_atlas_flushed: false, drop_shadows_ops_warmed: false, drop_shadow_filter_cache: shadows::DropShadowFilterCache::new(), + viewport_presented: false, }) } @@ -2309,6 +2317,7 @@ impl RenderState { // reorder by distance to the center. self.current_tile = None; self.drop_shadow_filter_cache.clear(); + self.viewport_presented = false; } pub fn start_render_loop( @@ -2481,9 +2490,14 @@ impl RenderState { self.render_shape_tree_partial(base_object, tree, timestamp, allow_stop)?; // `draw_atlas` needs a snapshot of the tile atlas. Partial frames are not - // presented (only flushed), so defer composition to the final frame and - // avoid re-snapshotting up to 4096² on every rAF during async tile work. - if !self.options.is_interactive_transform() && matches!(frame_type, FrameType::Full) { + // presented (only flushed), so defer composition until the viewport is + // ready and avoid re-snapshotting up to 4096² on every rAF during async + // tile work. + let should_compose = !self.options.is_interactive_transform() + && matches!(frame_type, FrameType::Full | FrameType::ViewportReady) + && !self.viewport_presented; + + if should_compose { self.surfaces.draw_tile_atlas_to_backbuffer( &self.viewbox, &self.tile_viewbox, @@ -2500,17 +2514,32 @@ impl RenderState { // `drain_partial_gpu_soft`). Full still submits via present_frame. Self::drain_partial_gpu_soft(); } + FrameType::ViewportReady => { + // Visible tiles are done: present now so the user sees the + // viewport without waiting for interest-ring pre-render. + // Defer crop-cache rebuild to Full — it is expensive on large + // HiDPI viewports and is not needed until the next drag. + self.present_frame(tree); + self.viewport_presented = true; + wapi::notify_tiles_render_complete!(); + Self::drain_partial_gpu_soft(); + } FrameType::Full => { - // A full-quality frame is now complete. Rebuild the per-shape crop - // cache from the clean Backbuffer (no UI overlay yet) so that - // interactive drag backgrounds don't include the grid overlay. - if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { + if !self.viewport_presented { + // A full-quality frame is now complete (no early viewport + // present). Rebuild crop cache and present. + if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { + self.rebuild_backbuffer_crop_cache(tree); + } + self.present_frame(tree); + wapi::notify_tiles_render_complete!(); + } else if !self.options.is_fast_mode() && !self.options.is_interactive_transform() + { + // Interest fill finished after ViewportReady. Backbuffer + // still holds the viewport compose; rebuild crop cache + // off the sharp-snap frame. self.rebuild_backbuffer_crop_cache(tree); } - // present_frame: copy clean Backbuffer → Target, draw UI/debug - // overlays on Target only, then flush. Backbuffer stays overlay-free. - self.present_frame(tree); - wapi::notify_tiles_render_complete!(); performance::end_measure!("render"); } } @@ -4015,8 +4044,18 @@ impl RenderState { flattened: false, })); } else { - // If there are no more pending tiles, stop. - should_stop = true; + // Visible tiles finished. Promote deferred interest-ring work + // so pan/zoom pre-render still happens, but yield first when + // allowed so continue_render_loop can present the viewport. + if self.pending_tiles.promote_deferred_interest() { + if allow_stop { + should_stop = true; + } + // Sync path (allow_stop=false): keep looping on interest + // tiles in the same call without an early present. + } else { + should_stop = true; + } } } @@ -4034,6 +4073,12 @@ impl RenderState { self.cached_viewbox = self.viewbox; } + // Visible done with interest still queued and we yielded: present + // viewport now, keep Partial-like rAFs for the ring. + if allow_stop && !self.pending_tiles.list.is_empty() { + return Ok(FrameType::ViewportReady); + } + Ok(FrameType::Full) } diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index 0a59bda8da..ac8143fca2 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -352,6 +352,8 @@ pub struct PendingTiles { pub visible_uncached: Vec, pub interest_cached: Vec, pub interest_uncached: Vec, + /// Interest-ring tiles deferred until after the viewport has been presented. + deferred_interest: Vec, } impl PendingTiles { @@ -364,14 +366,16 @@ impl PendingTiles { visible_uncached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), interest_cached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), interest_uncached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), + deferred_interest: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), } } pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces, only_visible: bool) { self.list.clear(); + self.deferred_interest.clear(); // During interactive transform, skip the interest-area ring - // entirely — the user is dragging, every rAF is on the critical + // entirely: the user is dragging, every rAF is on the critical // path, and pre-rendering tiles outside the viewport is wasted // work that just gets evicted on the next pointer move. The ring // is repopulated naturally on gesture end / on idle rAFs. @@ -423,10 +427,29 @@ impl PendingTiles { } } - self.list.extend(self.interest_uncached.iter()); - self.list.extend(self.interest_cached.iter()); - self.list.extend(self.visible_uncached.iter()); - self.list.extend(self.visible_cached.iter()); + // Visible tiles first. Interest-ring work is deferred so we can present + // as soon as the viewport is ready (see `promote_deferred_interest`). + // Interactive/`only_visible` already excludes the ring from `tile_rect`. + if only_visible { + self.list.extend(self.visible_uncached.iter()); + self.list.extend(self.visible_cached.iter()); + } else { + self.deferred_interest + .extend(self.interest_uncached.iter()); + self.deferred_interest.extend(self.interest_cached.iter()); + self.list.extend(self.visible_uncached.iter()); + self.list.extend(self.visible_cached.iter()); + } + } + + /// Move deferred interest-ring tiles onto the pending list. + /// Returns true when there is interest work left to do. + pub fn promote_deferred_interest(&mut self) -> bool { + if self.deferred_interest.is_empty() { + return false; + } + self.list.append(&mut self.deferred_interest); + true } pub fn pop(&mut self) -> Option {