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_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!

View File

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

View File

@ -323,6 +323,8 @@ pub struct PendingTiles {
pub visible_uncached: Vec<Tile>,
pub interest_cached: Vec<Tile>,
pub interest_uncached: Vec<Tile>,
/// Interest-ring tiles deferred until after the viewport has been presented.
deferred_interest: Vec<Tile>,
}
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<Tile> {