Present viewport before interest and clamp paint to atlas

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-11 13:57:52 +02:00
parent a67e177b43
commit 01e27edf74
5 changed files with 144 additions and 38 deletions

View File

@ -20,8 +20,14 @@
- Three device sizes (do not conflate): `paint_tile_size(dpr)` (raster/Current, capped),
`atlas_slot_size(paint, atlas)` (tile_atlas packing), `screen_tile_size(dpr)` (Target/
Backbuffer placement, uncapped `512×dpr`). Doc grid is zoom-only: `512/zoom`.
- Surfaces allocate with `effective_paint_tile_size` = `min(paint, atlas_slot max)` so
DPR 2 on a 4096² atlas paints 512 (not 1024→downscale-to-512). Overpainting the atlas
was pure GPU waste on zoom settle.
- Scales: `get_paint_scale()` matches tile CTM; `get_view_scale()` is `zoom×dpr` for
viewport/backbuffer mapping. `get_scale()` is an alias of paint scale (legacy name).
- 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

@ -366,6 +366,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
@ -375,7 +388,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
@ -472,13 +485,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
@ -1389,14 +1402,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 {
/// shadow. 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,
})
}
@ -2289,6 +2297,7 @@ impl RenderState {
// reorder by distance to the center.
self.current_tile = None;
self.viewport_presented = false;
}
pub fn start_render_loop(
@ -2461,9 +2470,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,
@ -2480,17 +2494,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");
}
}
@ -3952,8 +3981,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;
}
}
}
@ -3971,6 +4010,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

@ -477,7 +477,10 @@ impl Surfaces {
tile_dims: skia::ISize,
) -> Result<Self> {
let gpu_state = get_gpu_state();
let paint_size = tile_dims.width.max(1);
// Never allocate Current larger than atlas slots can store: overpainting
// is discarded on pack and spikes zoom-settle cost on large HiDPI views.
let max_texture_size = gpu_state.max_texture_size();
let paint_size = tiles::atlas_slot_size(tile_dims.width.max(1), max_texture_size);
let extra_tile_dims = skia::ISize::new(
paint_size * TILE_SIZE_MULTIPLIER,
@ -491,7 +494,6 @@ impl Surfaces {
let backbuffer =
gpu_state.create_surface_with_dimensions("backbuffer".to_string(), width, height)?;
let max_texture_size = gpu_state.max_texture_size();
let tile_atlas = gpu_state.create_surface_with_dimensions(
"tile_atlas".to_string(),
max_texture_size,
@ -565,10 +567,10 @@ impl Surfaces {
}
/// Recreate Current / effect / atlas-slot surfaces when paint size changes.
/// High DPRs that share the same capped paint size return `false`.
/// High DPRs that share the same atlas-clamped paint size return `false`.
pub fn set_dpr(&mut self, dpr: f32) -> Result<bool> {
self.dpr = dpr;
let new_paint = tiles::paint_tile_size(dpr);
let new_paint = tiles::effective_paint_tile_size(dpr, self.tile_atlas.width());
if new_paint == self.paint_size {
return Ok(false);
}

View File

@ -240,13 +240,22 @@ pub fn get_tile_size(zoom: f32) -> f32 {
}
/// GPU **paint** tile edge: `min(round(512 × dpr), TILE_PAINT_SIZE_CAP)`.
/// Sizes Current/effect surfaces and the paint CTM ([`tile_paint_scale`]).
/// Ideal raster size before atlas packing; prefer [`effective_paint_tile_size`]
/// when allocating Current/effect surfaces so we never paint larger than the
/// atlas can store (DPR 2 on a 4096² atlas would otherwise paint 1024 and
/// immediately downscale into 512 slots — pure GPU waste on zoom settle).
#[inline(always)]
pub fn paint_tile_size(dpr: f32) -> i32 {
let ideal = (TILE_SIZE * dpr).round().max(1.0) as i32;
ideal.min(TILE_PAINT_SIZE_CAP)
}
/// Paint size that fits the atlas: `min(paint_tile_size(dpr), atlas_slot max)`.
#[inline(always)]
pub fn effective_paint_tile_size(dpr: f32, atlas_texture_size: i32) -> i32 {
atlas_slot_size(paint_tile_size(dpr), atlas_texture_size)
}
/// Continuous **screen** size of one tile on Target/Backbuffer (`512 × dpr`,
/// uncapped). Atlas compose upscales from [`atlas_slot_size`] / paint when
/// the paint budget is below this.
@ -384,6 +393,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 {
@ -396,14 +407,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.
@ -455,10 +468,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> {
@ -484,6 +516,14 @@ mod tests {
assert_eq!(paint_tile_size(4.0), 1024);
}
#[test]
fn effective_paint_matches_atlas_slots_on_4096() {
// Ideal DPR-2 paint is 1024, but a 4096² atlas only keeps 512 slots.
assert_eq!(effective_paint_tile_size(2.0, 4096), 512);
assert_eq!(effective_paint_tile_size(1.0, 4096), 512);
assert_eq!(effective_paint_tile_size(2.0, 8192), 1024);
}
#[test]
fn screen_tile_size_stays_uncapped() {
assert_eq!(screen_tile_size(4.0), 2048.0);