diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index c24bcf0460..4a6fa5c2b8 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -362,6 +362,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 @@ -371,7 +384,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 @@ -468,13 +481,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 @@ -1376,14 +1389,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 f49ffa49ba..ed59bc77cd 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)] @@ -417,6 +421,9 @@ pub(crate) struct RenderState { /// are skipped. A full skip made flush_and_submit very slow (Skia ops-task /// ordering); doing it per shape was wasted GPU work. pub drop_shadows_ops_warmed: bool, + /// 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 { @@ -601,6 +608,7 @@ impl RenderState { backbuffer_crop_cache: HashMap::default(), tile_atlas_flushed: false, drop_shadows_ops_warmed: false, + viewport_presented: false, }) } @@ -2195,6 +2203,7 @@ impl RenderState { // reorder by distance to the center. self.current_tile = None; + self.viewport_presented = false; } pub fn start_render_loop( @@ -2366,9 +2375,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, @@ -2382,23 +2396,33 @@ impl RenderState { } FrameType::Partial => { // Drain tile GPU work (Current / tile atlas / cache) without - // presenting Target and without re-snapshotting the tile atlas — - // composition stays deferred to Full (`01fc3c3e7d`). A Backbuffer - // flush alone left commands queued until present_frame's - // flush_and_submit, which stalled the browser on large files. + // presenting Target and without re-snapshotting the tile atlas. + // Composition stays deferred until ViewportReady/Full. crate::get_gpu_state().context.flush_and_submit(); } - 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. + FrameType::ViewportReady => { + // Visible tiles are done: present now so the user sees the + // viewport without waiting for interest-ring pre-render. if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { 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); + self.viewport_presented = true; wapi::notify_tiles_render_complete!(); + crate::get_gpu_state().context.flush_and_submit(); + } + FrameType::Full => { + 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!(); + } + // If we already presented at ViewportReady, interest fill is + // done; Target already shows the viewport. performance::end_measure!("render"); } } @@ -3776,8 +3800,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; + } } } @@ -3795,6 +3829,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 d00dc25bdf..bd6fd8c8f6 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -323,6 +323,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 { @@ -335,14 +337,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. @@ -394,10 +398,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 {