diff --git a/.serena/memories/render-wasm/ffi-rendering-subtleties.md b/.serena/memories/render-wasm/ffi-rendering-subtleties.md index 7cbf4e1d80..9f91d27311 100644 --- a/.serena/memories/render-wasm/ffi-rendering-subtleties.md +++ b/.serena/memories/render-wasm/ffi-rendering-subtleties.md @@ -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. diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index e7b0b03c0b..38f257813e 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -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! diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 0c8b9126b2..1a443ab32f 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 { /// 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) } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 9777c1ff08..dec055a792 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -477,7 +477,10 @@ impl Surfaces { tile_dims: skia::ISize, ) -> Result { 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 { 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); } diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index d9895c3769..f9b7595c8f 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -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, pub interest_cached: Vec, pub interest_uncached: Vec, + /// Interest-ring tiles deferred until after the viewport has been presented. + deferred_interest: Vec, } 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 { @@ -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);