Present viewport before interest-area tile work

Defer the interest-ring until visible tiles are done, present via
FrameType::ViewportReady, then fill interest on later Partial frames
so large files show the viewport without waiting on off-screen work.
This commit is contained in:
Alejandro Alonso 2026-07-31 10:19:03 +02:00
parent 288193f3e7
commit 53e048bbae
3 changed files with 111 additions and 35 deletions

View File

@ -362,6 +362,19 @@
(def ^:const FRAME_TYPE_NONE 0) ;; This type should never "leak". (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_PARTIAL 1) ;; A frame needs more render calls to end.
(def ^:const FRAME_TYPE_FULL 2) ;; A frame was full. (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). (def ^:const RENDER-FLAG-SYNC-TILES 4) ;; Rebuild tile index without ending fast mode (pan/zoom pause).
(defn- internal-render (defn- internal-render
@ -371,7 +384,7 @@
(internal-render timestamp wasm/internal-frame-type)) (internal-render timestamp wasm/internal-frame-type))
([timestamp flags] ([timestamp flags]
(set! wasm/internal-frame-type (h/call wasm/internal-module "_render" 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")))) (request-render "frame-type-partial"))))
(defn- build-reload-payload (defn- build-reload-payload
@ -468,13 +481,13 @@
(when (is-text-editor-wasm-enabled @st/state) (when (is-text-editor-wasm-enabled @st/state)
(text-editor/text-editor-update-blink timestamp) (text-editor/text-editor-update-blink timestamp)
;; Only repaint the overlay when this frame recomposited Target (a full ;; Only repaint the overlay when this frame recomposited Target (a full
;; frame). A partial frame is flushed but not presented — Target still ;; frame or early viewport present). A partial frame is flushed but not
;; shows the last presented frame with the overlay already on it — so ;; presented - Target still shows the last presented frame with the
;; repainting the translucent selection over it stacks another layer ;; overlay already on it - so repainting the translucent selection over
;; every progressive frame: it darkens, then snaps back when the final ;; it stacks another layer every progressive frame: it darkens, then
;; frame presents from the clean Backbuffer (the blink at the end of a ;; snaps back when the final frame presents from the clean Backbuffer
;; zoom over a selection, gh-10709). ;; (the blink at the end of a zoom over a selection, gh-10709).
(when (not= wasm/internal-frame-type FRAME_TYPE_PARTIAL) (when (frame-presented-target?)
(text-editor/text-editor-render-overlay)) (text-editor/text-editor-render-overlay))
;; Drain editor events. Only content/layout changes need a full shape ;; Drain editor events. Only content/layout changes need a full shape
;; re-render; selection/style changes are already reflected by the ;; re-render; selection/style changes are already reflected by the
@ -1376,14 +1389,14 @@
(defn- render-text-editor-overlay-after-frame! (defn- render-text-editor-overlay-after-frame!
"Repaint the overlay after a direct `internal-render`, but only when that "Repaint the overlay after a direct `internal-render`, but only when that
render recomposited Target (a full frame). A partial frame is only flushed — render recomposited Target (a full frame or early viewport present). A partial
Target keeps the last presented frame with the overlay already on it — so frame is only flushed - Target keeps the last presented frame with the overlay
repainting the translucent selection then stacks another layer and it visibly already on it - so repainting the translucent selection then stacks another
darkens across the progressive frames before snapping back on the final layer and it visibly darkens across the progressive frames before snapping
present (the blink at the end of a zoom over a selection, gh-10709). The back on the final present (the blink at the end of a zoom over a selection,
final full frame's own repaint keeps the overlay in place." 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!))) (render-text-editor-overlay-if-active!)))
(defn finalize-view-interaction! (defn finalize-view-interaction!

View File

@ -49,6 +49,10 @@ pub enum FrameType {
None = 0, None = 0,
Partial = 1, Partial = 1,
Full = 2, 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)] #[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 /// are skipped. A full skip made flush_and_submit very slow (Skia ops-task
/// ordering); doing it per shape was wasted GPU work. /// ordering); doing it per shape was wasted GPU work.
pub drop_shadows_ops_warmed: bool, 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 { pub struct InteractiveDragCrop {
@ -601,6 +608,7 @@ impl RenderState {
backbuffer_crop_cache: HashMap::default(), backbuffer_crop_cache: HashMap::default(),
tile_atlas_flushed: false, tile_atlas_flushed: false,
drop_shadows_ops_warmed: false, drop_shadows_ops_warmed: false,
viewport_presented: false,
}) })
} }
@ -2195,6 +2203,7 @@ impl RenderState {
// reorder by distance to the center. // reorder by distance to the center.
self.current_tile = None; self.current_tile = None;
self.viewport_presented = false;
} }
pub fn start_render_loop( pub fn start_render_loop(
@ -2366,9 +2375,14 @@ impl RenderState {
self.render_shape_tree_partial(base_object, tree, timestamp, allow_stop)?; self.render_shape_tree_partial(base_object, tree, timestamp, allow_stop)?;
// `draw_atlas` needs a snapshot of the tile atlas. Partial frames are not // `draw_atlas` needs a snapshot of the tile atlas. Partial frames are not
// presented (only flushed), so defer composition to the final frame and // presented (only flushed), so defer composition until the viewport is
// avoid re-snapshotting up to 4096² on every rAF during async tile work. // ready and avoid re-snapshotting up to 4096² on every rAF during async
if !self.options.is_interactive_transform() && matches!(frame_type, FrameType::Full) { // 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.surfaces.draw_tile_atlas_to_backbuffer(
&self.viewbox, &self.viewbox,
&self.tile_viewbox, &self.tile_viewbox,
@ -2382,23 +2396,33 @@ impl RenderState {
} }
FrameType::Partial => { FrameType::Partial => {
// Drain tile GPU work (Current / tile atlas / cache) without // Drain tile GPU work (Current / tile atlas / cache) without
// presenting Target and without re-snapshotting the tile atlas — // presenting Target and without re-snapshotting the tile atlas.
// composition stays deferred to Full (`01fc3c3e7d`). A Backbuffer // Composition stays deferred until ViewportReady/Full.
// flush alone left commands queued until present_frame's
// flush_and_submit, which stalled the browser on large files.
crate::get_gpu_state().context.flush_and_submit(); crate::get_gpu_state().context.flush_and_submit();
} }
FrameType::Full => { FrameType::ViewportReady => {
// A full-quality frame is now complete. Rebuild the per-shape crop // Visible tiles are done: present now so the user sees the
// cache from the clean Backbuffer (no UI overlay yet) so that // viewport without waiting for interest-ring pre-render.
// interactive drag backgrounds don't include the grid overlay.
if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { if !self.options.is_fast_mode() && !self.options.is_interactive_transform() {
self.rebuild_backbuffer_crop_cache(tree); 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.present_frame(tree);
self.viewport_presented = true;
wapi::notify_tiles_render_complete!(); 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"); performance::end_measure!("render");
} }
} }
@ -3776,8 +3800,18 @@ impl RenderState {
flattened: false, flattened: false,
})); }));
} else { } else {
// If there are no more pending tiles, stop. // Visible tiles finished. Promote deferred interest-ring work
should_stop = true; // 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; 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) Ok(FrameType::Full)
} }

View File

@ -323,6 +323,8 @@ pub struct PendingTiles {
pub visible_uncached: Vec<Tile>, pub visible_uncached: Vec<Tile>,
pub interest_cached: Vec<Tile>, pub interest_cached: Vec<Tile>,
pub interest_uncached: Vec<Tile>, pub interest_uncached: Vec<Tile>,
/// Interest-ring tiles deferred until after the viewport has been presented.
deferred_interest: Vec<Tile>,
} }
impl PendingTiles { impl PendingTiles {
@ -335,14 +337,16 @@ impl PendingTiles {
visible_uncached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), visible_uncached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY),
interest_cached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), interest_cached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY),
interest_uncached: 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) { pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces, only_visible: bool) {
self.list.clear(); self.list.clear();
self.deferred_interest.clear();
// During interactive transform, skip the interest-area ring // 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 // path, and pre-rendering tiles outside the viewport is wasted
// work that just gets evicted on the next pointer move. The ring // work that just gets evicted on the next pointer move. The ring
// is repopulated naturally on gesture end / on idle rAFs. // is repopulated naturally on gesture end / on idle rAFs.
@ -394,10 +398,29 @@ impl PendingTiles {
} }
} }
self.list.extend(self.interest_uncached.iter()); // Visible tiles first. Interest-ring work is deferred so we can present
self.list.extend(self.interest_cached.iter()); // as soon as the viewport is ready (see `promote_deferred_interest`).
self.list.extend(self.visible_uncached.iter()); // Interactive/`only_visible` already excludes the ring from `tile_rect`.
self.list.extend(self.visible_cached.iter()); 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<Tile> { pub fn pop(&mut self) -> Option<Tile> {