Present viewport before interest and clamp paint to atlas (#11313)

Present visible tiles via ViewportReady so zoom settle turns sharp
without waiting on the interest ring, and paint at atlas slot size so
DPR 2 does not rasterize 1024 only to downscale into 512 slots.
This commit is contained in:
Alejandro Alonso 2026-08-25 12:56:07 +02:00 committed by GitHub
parent 81c3b3cd56
commit 57c0e81616
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 156 additions and 57 deletions

View File

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

View File

@ -58,6 +58,7 @@
WebGL is ready before re-init finishes."
[]
(set! internal-frame-id nil)
(set! internal-frame-type 0)
(set! canvas nil)
(set! canvas-snapshot nil)
(set! gl-context-handle nil)

View File

@ -389,6 +389,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
@ -398,7 +411,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
@ -495,13 +508,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
@ -610,12 +623,31 @@
(defonce shapes-loading? (atom false))
(defonce deferred-render? (atom false))
;; Each `request-render` captures this number. `stop-progressive-render!`
;; increments it so already-scheduled rAFs become no-ops.
;;
;; Why: ViewportReady asks for another frame for the interest ring. If the
;; page changes before that frame runs, `_init` leaves an empty shapes pool
;; and `_render` panics ("Root shape not found"). Cancel alone is not enough
;; when the current `_render` itself schedules the next rAF after we cancelled.
(defonce ^:private render-seq* (atom 0))
(defn render-pending?
"True while a render has been scheduled but not yet completed — including the
frames of an in-progress progressive render."
[]
@pending-render)
(defn- stop-progressive-render!
"Cancel the pending tile-pass rAF and invalidate any follow-ups it may schedule."
[]
(swap! render-seq* inc)
(when-let [frame-id wasm/internal-frame-id]
(timers/cancel-af! frame-id)
(set! wasm/internal-frame-id nil))
(reset! pending-render false)
(set! wasm/internal-frame-type FRAME_TYPE_NONE))
(defn- register-deferred-render!
[]
(reset! deferred-render? true))
@ -629,19 +661,21 @@
(register-deferred-render!)
(when-not @pending-render
(reset! pending-render true)
(let [frame-id
(timers/raf
(fn [ts]
(reset! pending-render false)
(set! wasm/internal-frame-id nil)
(try
(render ts)
(catch :default e
;; A failed render (e.g. a WASM panic) must not strand an
;; active page-transition. Force ending of it so the
;; workspace is shown without a blur.
(end-page-transition!)
(throw e)))))]
(let [seq-n @render-seq*
frame-id (timers/raf
(fn [ts]
;; Dropped if `stop-progressive-render!` ran since we scheduled.
(when (= seq-n @render-seq*)
(reset! pending-render false)
(set! wasm/internal-frame-id nil)
(try
(render ts)
(catch :default e
;; A failed render (e.g. a WASM panic) must not strand an
;; active page-transition. Force ending of it so the
;; workspace is shown without a blur.
(end-page-transition!)
(throw e))))))]
(set! wasm/internal-frame-id frame-id))))))
(defn request-render-preserving-target
@ -664,12 +698,8 @@
(defn- begin-shapes-loading!
[]
(reset! shapes-loading? true)
(let [frame-id wasm/internal-frame-id
was-pending @pending-render]
(when frame-id
(js/cancelAnimationFrame frame-id)
(set! wasm/internal-frame-id nil))
(reset! pending-render false)
(let [was-pending @pending-render]
(stop-progressive-render!)
(reset! deferred-render? was-pending)))
(defn- end-shapes-loading!
@ -1425,14 +1455,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!
@ -2096,6 +2126,8 @@
(let [rgba (when background (sr-clr/hex->u32argb background background-opacity))
total-shapes (count (vals base-objects))]
;; Stop Partial/ViewportReady follow-ups before we clear the shapes pool.
(stop-progressive-render!)
(when rgba (h/call wasm/internal-module "_set_canvas_background" rgba))
(h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox)))
(h/call wasm/internal-module "_init_shapes_pool" total-shapes)
@ -2378,12 +2410,9 @@
(set! wasm/context-initialized? false)
;; Cancel any pending animation frame to prevent race conditions.
(when wasm/internal-frame-id
(timers/cancel-af! wasm/internal-frame-id))
(stop-progressive-render!)
;; Reset render flags to prevent new renders from being scheduled.
(reset! pending-render false)
;; Reset remaining render flags so teardown cannot schedule work.
(reset! shapes-loading? false)
(reset! deferred-render? false)
(reset! view-interaction-active? false)

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)]
@ -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,31 @@ 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 +4043,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 +4072,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

@ -352,6 +352,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 {
@ -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,28 @@ 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> {