From c12bc0eab45f41fe558f89f24986558d4893aaae Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 4 Aug 2026 10:57:42 +0200 Subject: [PATCH] :zap: Soft-then-sharp HiDPI zoom settle with paint-once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At DPR>1, refill visible tiles once at Interactive (tile_px=512, paint_scale≈zoom), present, then promote to Full (512*dpr + interest) on the next continue. Keep the world tile grid DPR-stable, resize Current to the paint-once region, skip GPU flush on Partial frames, and preview zoom via DocAtlas without resetting it on tile invalidation. --- .../render-wasm/ffi-rendering-subtleties.md | 9 + render-wasm/src/main.rs | 3 + render-wasm/src/render.rs | 401 ++++++++++++++---- render-wasm/src/render/debug.rs | 4 +- render-wasm/src/render/options.rs | 61 ++- render-wasm/src/render/surfaces.rs | 373 ++++++++++++---- render-wasm/src/tiles.rs | 83 ++-- 7 files changed, 712 insertions(+), 222 deletions(-) diff --git a/.serena/memories/render-wasm/ffi-rendering-subtleties.md b/.serena/memories/render-wasm/ffi-rendering-subtleties.md index f57856a17d..c468403eb2 100644 --- a/.serena/memories/render-wasm/ffi-rendering-subtleties.md +++ b/.serena/memories/render-wasm/ffi-rendering-subtleties.md @@ -22,4 +22,13 @@ - `set_modifiers_end` disables fast/interactive state and cancels pending async render; the caller must request the final full-quality render. - Plain viewport fast mode (`options.is_viewport_interaction()`) renders from cache and does not flush target output inside `process_animation_frame`; interactive transforms do flush. - Zoom changes rebuild the tile index while preserving cached tile textures. Avoid replacing that path with shallow rebuilds if blur/shadow cache preservation matters. +- Two-phase HiDPI zoom settle: (1) **Interactive** — one paint-once of **visible** tiles at DPR≤1 fill-rate (`tile_px=512`, `paint_scale≈zoom`); present + notify. (2) Deferred **Full** — `512*dpr` sprites, **visible+interest**, paint-once when the region fits (else per-tile). At DPR≤1 there is no Interactive phase. CTM must use `get_paint_scale()` / `get_raster_scale()`. +- Soft settle schedules `pending_sharp_promote` (next `continue`); do not stay Interactive forever. +- Partial frames **skip** GPU flush; hard sync in `present_frame` / ViewportReady. +- Paint-once **resizes Current** to the region, restores viewport pad after present. +- Zoom-gesture preview: **DocAtlas only** when `zoom_changed` (no tile-sprite overlay). `invalidate_tile_cache` clears sprites only — not `DocAtlas::reset()`. `continue_render_loop` no-ops while `fast_mode && zoom_changed`. +- Full-quality `tile_px=1024` → ~16 atlas slots; `TileTextureCache::add` must not panic (see harden rules below). +- `rebuild_backbuffer_crop_cache` caps crop windows to the backbuffer size (not `max_texture_size`) so crops use cheap Backbuffer `snapshot_rect`; doc-atlas snapshot + scratch are lazy and only for off-viewport fallback. Avoid eager 4096² scratch after pan/zoom Full frames. +- Cache surface growth must compare against the live `cache` width/height, not `get_cache_size(cached_viewbox)`. The latter re-created the same large cache on every progressive frame while `cached_viewbox` lagged. +- `start_render_loop` always sets `allow_stop=true` so tile work yields under `max_blocking_time_ms`. Do not gate yielding on `preserve_target && !zoom_changed`: that forced a full interest-area sync pass after pan and froze HiDPI deep-zoom sessions. `preserve_target` alone keeps the last frame visible during progressive fill-in. - Pending tile priority is intentionally reversed by pop order; check the queue construction before changing tile scheduling. \ No newline at end of file diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 2d007d18e4..f638fc02a3 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -366,6 +366,9 @@ pub extern "C" fn set_view_end() -> Result<()> { // index and clear the tile texture cache, but *preserve* // the cache canvas so render_from_cache can show a scaled // preview of the old content while new tiles render. + // At HiDPI, refill first at interactive (512 px) quality so + // progressive fill-rate matches DPR=1, then promote to sharp. + render_state.enter_interactive_content_quality()?; render_state.rebuild_tile_index(&state.shapes); render_state.surfaces.invalidate_tile_cache(); } else { diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 265063bd47..5dc4f570e1 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -22,7 +22,7 @@ use skia_safe::{self as skia, Matrix, RRect, Rect}; use std::borrow::Cow; use std::collections::{HashMap, HashSet}; -use options::RenderOptions; +use options::{ContentQuality, RenderOptions}; pub use surfaces::{SurfaceId, Surfaces}; use crate::error::{Error, Result}; @@ -416,6 +416,14 @@ pub(crate) struct RenderState { pub tile_atlas_flushed: bool, /// Multi-tile paint-once into Current, then crop to atlas slots. paint_region: Option, + /// After soft (DPR≤1 fill-rate) settle, refill sharp tiles one-by-one at + /// full view DPR. Paint-once is disabled until this pass finishes. + sharp_tile_refill: bool, + /// Soft settle presented; begin sharp refill on the *next* continue so the + /// browser gets a frame to stay responsive before per-tile HiDPI work. + pending_sharp_promote: bool, + /// True after soft settle presented Target; cleared when Full-quality pass starts. + viewport_presented: bool, } /// Active paint-once region (visible viewport tiles or interest ring). @@ -436,13 +444,18 @@ pub struct InteractiveDragCrop { pub image: skia::Image, } -/// Chooses a window inside the full workspace-pixel crop `[0, out_w) × [0, out_h)` with each side -/// at most `max_side_px` (**without scaling**): centered on the projection of -/// `viewport_doc ∩ src_doc_bounds`, or on the full crop if that intersection is empty. -/// `max_side_px` should match [`GpuState::max_texture_size`] (same budget as the atlas). +/// Chooses a window inside the full workspace-pixel crop `[0, out_w) × [0, out_h)` +/// with width/height at most `max_w_px` / `max_h_px` (**without scaling**): centered on +/// the projection of `viewport_doc ∩ src_doc_bounds`, or on the full crop if that +/// intersection is empty. +/// +/// Caps should match the backbuffer (viewport) size so the common path can +/// `snapshot_rect` from Backbuffer instead of allocating a max-texture scratch +/// and snapshotting the document atlas. #[allow(clippy::too_many_arguments)] fn drag_crop_snapshot_window_px( - max_side_px: i32, + max_w_px: i32, + max_h_px: i32, out_w: i32, out_h: i32, viewport_doc: Rect, @@ -453,12 +466,13 @@ fn drag_crop_snapshot_window_px( src_top_px: i32, src_doc_bounds: Rect, ) -> (i32, i32, i32, i32) { - let cap = max_side_px.max(1); - if out_w <= cap && out_h <= cap { + let cap_w = max_w_px.max(1); + let cap_h = max_h_px.max(1); + if out_w <= cap_w && out_h <= cap_h { return (0, 0, out_w, out_h); } - let win_w = out_w.min(cap); - let win_h = out_h.min(cap); + let win_w = out_w.min(cap_w); + let win_h = out_h.min(cap_h); let mut vis = viewport_doc; let has_vis = vis.intersect(src_doc_bounds); @@ -560,7 +574,7 @@ impl RenderState { let surfaces = Surfaces::try_new( (width, height), sampling_options, - tiles::get_tile_dimensions(), + tiles::get_tile_dimensions(1.0), RenderOptions::default().dpr_viewport_interest_area_threshold, )?; @@ -607,6 +621,9 @@ impl RenderState { backbuffer_crop_cache: HashMap::default(), tile_atlas_flushed: false, paint_region: None, + sharp_tile_refill: false, + pending_sharp_promote: false, + viewport_presented: false, }) } @@ -884,17 +901,83 @@ impl RenderState { if self.options.set_dpr(dpr) { self.tile_viewbox .set_interest(self.options.dpr_viewport_interest_area_threshold); + get_resources().fonts.set_scale_debug_font(dpr); + self.viewbox.set_dpr(dpr); + self.cached_viewbox.set_dpr(dpr); + self.surfaces.set_view_dpr(dpr)?; + self.surfaces + .set_raster_tile_size(self.options.raster_tile_size_px())?; self.resize( self.viewbox.width().floor() as i32, self.viewbox.height().floor() as i32, )?; - get_resources().fonts.set_scale_debug_font(dpr); - self.viewbox.set_dpr(dpr); - self.surfaces.set_dpr(dpr); + self.tiles.invalidate(); + self.tile_viewbox.update(&self.viewbox); + // First paint at DPR>1 should be the soft (DPR≤1 fill-rate) pass. + self.enter_interactive_content_quality()?; } Ok(()) } + /// Drop to interactive (512 px) tile raster when view DPR > 1 so zoom + /// refill fill-rate matches DPR=1. No-op when already interactive or at DPR≈1. + pub fn enter_interactive_content_quality(&mut self) -> Result<()> { + if self.options.dpr <= 1.05 { + return Ok(()); + } + if !self + .options + .set_content_quality(ContentQuality::Interactive) + { + return Ok(()); + } + self.surfaces + .set_raster_tile_size(self.options.raster_tile_size_px())?; + self.surfaces.invalidate_tile_cache(); + self.sharp_tile_refill = false; + self.pending_sharp_promote = false; + self.paint_region = None; + Ok(()) + } + + /// Promote to full-DPR tile textures after soft settle. Returns `true` when + /// a progressive pass is required. + /// + /// Soft settle already did one paint-once of the viewport at DPR≤1 fill-rate. + /// This pass requeues **visible + interest** at the real view DPR and allows + /// paint-once again when the region fits Current. + pub fn try_begin_full_quality_pass(&mut self, _tree: ShapesPoolRef) -> Result { + if !self.options.needs_full_quality_upgrade() { + return Ok(false); + } + self.options.set_content_quality(ContentQuality::Full); + self.surfaces + .set_raster_tile_size(self.options.raster_tile_size_px())?; + self.surfaces.invalidate_tile_cache(); + let scale = self.get_raster_scale(); + let surface_ids = SurfaceId::Strokes as u32 + | SurfaceId::Fills as u32 + | SurfaceId::InnerShadows as u32 + | SurfaceId::TextDropShadows as u32; + self.surfaces.apply_mut(surface_ids, |s| { + s.canvas().scale((scale, scale)); + }); + self.tile_viewbox.update(&self.viewbox); + // Visible + interest at full DPR (soft settle was visible-only). + self.pending_tiles + .update(&self.tile_viewbox, &self.surfaces, false); + self.current_tile = None; + self.pending_nodes.clear(); + self.paint_region = None; + self.cache_cleared_this_render = false; + self.preserve_target_during_render = true; + self.viewport_presented = false; + // Allow paint-once of the pending set when it fits; otherwise per-tile. + self.sharp_tile_refill = false; + self.pending_sharp_promote = false; + Ok(true) + } + pub fn set_antialias_threshold(&mut self, value: f32) { self.options.set_antialias_threshold(value); } @@ -1342,15 +1425,15 @@ impl RenderState { && target_surface != SurfaceId::Export; if can_render_directly { - let scale = self.get_scale(); + let paint_scale = self.get_paint_scale(); let translation = self .surfaces - .get_render_context_translation(self.render_area, scale); + .get_render_context_translation(self.render_area, paint_scale); self.surfaces.apply_mut(target_surface as u32, |s| { let canvas = s.canvas(); canvas.save(); - canvas.scale((scale, scale)); + canvas.scale((paint_scale, paint_scale)); canvas.translate(translation); }); @@ -1938,18 +2021,20 @@ impl RenderState { pub fn update_render_context(&mut self, tile: tiles::Tile) { self.current_tile = Some(tile); - let scale = self.get_scale(); - self.render_area = tiles::get_tile_rect(tile, scale); + let view_scale = self.get_scale(); + let raster_scale = self.get_raster_scale(); + self.render_area = tiles::get_tile_rect(tile, view_scale, self.viewbox.dpr); let margins = self.surfaces.margins(); - let margin_w = margins.width as f32 / scale; - let margin_h = margins.height as f32 / scale; + let margin_w = margins.width as f32 / raster_scale; + let margin_h = margins.height as f32 / raster_scale; self.render_area_with_margins = skia::Rect::from_ltrb( self.render_area.left - margin_w, self.render_area.top - margin_h, self.render_area.right + margin_w, self.render_area.bottom + margin_h, ); - self.surfaces.update_render_context(self.render_area, scale); + self.surfaces + .update_render_context(self.render_area, raster_scale); } fn rebuild_backbuffer_crop_cache(&mut self, tree: ShapesPoolRef) { @@ -2028,19 +2113,22 @@ impl RenderState { }) .collect(); + if non_overlapping.is_empty() { + return; + } + let vb_left = self.viewbox.area.left; let vb_top = self.viewbox.area.top; let (bb_w, bb_h) = self.surfaces.surface_size(SurfaceId::Backbuffer); - let max_snap_px = get_gpu_state().max_texture_size(); + let max_tex = get_gpu_state().max_texture_size(); + // Prefer viewport-sized crops so we can snapshot from Backbuffer (cheap) + // instead of allocating up to max_texture scratch + doc-atlas snapshot. + let max_w_px = bb_w.min(max_tex).max(1); + let max_h_px = bb_h.min(max_tex).max(1); - // Snapshot the atlas once for the whole pass so that all shapes sharing - // the tile/atlas fallback path reuse the same GPU image rather than each - // triggering a separate `image_snapshot` flush. - let atlas_snap = self.surfaces.atlas.snapshot_for_drag_crop(); - - // Scratch surface reused across all shapes that need the tile/atlas - // fallback — avoids one WebGL texture allocation per shape. - // Created lazily on first use and grown if a later shape needs more space. + // Lazily snapshot the doc atlas only if a shape falls outside the + // backbuffer and needs the tile/atlas fallback path. + let mut atlas_snap: Option<(skia::Image, f32, skia::Point)> = None; let mut scratch_surface: Option = None; for (id, doc_bounds, selrect) in non_overlapping { @@ -2063,7 +2151,8 @@ impl RenderState { let full_w = src_irect.width(); let full_h = src_irect.height(); let (win_ox, win_oy, win_w, win_h) = drag_crop_snapshot_window_px( - max_snap_px, + max_w_px, + max_h_px, full_w, full_h, viewport, @@ -2103,6 +2192,9 @@ impl RenderState { let image = if let Some(img) = backbuffer_snap { img } else { + if atlas_snap.is_none() { + atlas_snap = self.surfaces.atlas.snapshot_for_drag_crop(); + } // Ensure the scratch surface is large enough for this window. // Grow (reallocate) only when necessary so that the common case // of similarly-sized shapes pays zero extra allocation cost. @@ -2154,11 +2246,20 @@ impl RenderState { pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) { let _start = performance::begin_timed_log!("render_from_cache"); performance::begin_measure!("render_from_cache"); - self.surfaces.draw_combined_atlas_to_backbuffer( - &self.viewbox, - &self.tile_viewbox, - self.background_color, - ); + if self.zoom_changed() { + // DocAtlas is document-space and scales with the view. Tile sprites + // are keyed to the previous world-tile grid — overlaying them is what + // caused wrong-scale streaks. Drop sprites and preview DocAtlas only. + self.surfaces.clear_tile_sprites(); + self.surfaces + .draw_atlas_to_backbuffer(self.viewbox, self.background_color); + } else { + self.surfaces.draw_combined_atlas_to_backbuffer( + &self.viewbox, + &self.tile_viewbox, + self.background_color, + ); + } self.present_frame(shapes); performance::end_measure!("render_from_cache"); @@ -2227,7 +2328,7 @@ impl RenderState { let timestamp = self.render_budget_start(timestamp); let _start = performance::begin_timed_log!("start_render_loop"); - let scale = self.get_scale(); + let scale = self.get_raster_scale(); self.tile_viewbox.update(&self.viewbox); self.focus_mode.reset(); @@ -2238,7 +2339,6 @@ impl RenderState { // Compute and set document-space bounds (1 unit == 1 doc px @ 100% zoom) // to clamp atlas updates. This prevents zoom-out tiles from forcing atlas // growth far beyond real content. - let t_bounds = performance::get_time(); let doc_bounds = self.compute_document_bounds(base_object, tree); self.surfaces.atlas.set_doc_bounds(doc_bounds); @@ -2247,7 +2347,6 @@ impl RenderState { self.preserve_target_during_render = false; if preserve_target && self.options.is_fast_mode() { - let t_idx = performance::get_time(); self.rebuild_tile_index(tree); } @@ -2303,8 +2402,8 @@ impl RenderState { let _tile_start = performance::begin_timed_log!("tile_cache_update"); performance::begin_measure!("tile_cache"); - let t_pending = performance::get_time(); - let only_visible = self.options.is_interactive_transform(); + let only_visible = self.options.is_interactive_transform() + || self.options.content_quality() == ContentQuality::Interactive; self.pending_tiles .update(&self.tile_viewbox, &self.surfaces, only_visible); performance::end_measure!("tile_cache"); @@ -2318,11 +2417,10 @@ impl RenderState { if sync_render { frame_type = self.render_shape_tree_sync(base_object, tree, timestamp)?; } else { - // Keep progressive yielding, except for a localized shape edit on a - // stable viewbox (e.g. recoloring) which renders in one frame. - let allow_stop = - !preserve_target || self.zoom_changed() || self.options.is_interactive_transform(); - let t_cont = performance::get_time(); + // Always allow progressive yielding. `preserve_target` keeps the last + // frame visible while tiles fill in. Gating allow_stop on preserve_target + // forced a synchronous interest-area pass after pan and blocked HiDPI. + let allow_stop = true; frame_type = self.continue_render_loop(base_object, tree, timestamp, allow_stop)?; // This is an option to debug frames. @@ -2386,15 +2484,26 @@ impl RenderState { allow_stop: bool, ) -> Result { performance::begin_measure!("continue_render_loop"); + // Zoom gesture: `render_from_cache` owns presentation via DocAtlas. + // Skip so an in-flight settle cannot overlay old-grid tile sprites. + if self.options.is_fast_mode() && self.zoom_changed() { + performance::end_measure!("continue_render_loop"); + return Ok(FrameType::Partial); + } let timestamp = self.render_budget_start(timestamp); - let t0 = performance::get_time(); - let pending_before = self.pending_tiles.list.len(); + + // Soft already on screen: promote on a fresh rAF so atlas teardown / + // Full-quality setup does not share the soft present frame. + if self.pending_sharp_promote { + self.pending_sharp_promote = false; + self.try_begin_full_quality_pass(tree)?; + performance::end_measure!("continue_render_loop"); + return Ok(FrameType::Partial); + } let frame_type = self.render_shape_tree_partial(base_object, tree, timestamp, allow_stop)?; - let t_tree = performance::get_time(); - // `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. @@ -2406,28 +2515,45 @@ impl RenderState { ); } + // Soft settle (Interactive, viewport, DPR≤1 fill-rate): present now so + // the UI sees content, then defer Full+interest at real DPR to the next + // continue (browser gets a frame between passes). + if !self.options.is_interactive_transform() + && matches!(frame_type, FrameType::Full) + && self.options.needs_full_quality_upgrade() + { + self.present_frame(tree); + self.viewport_presented = true; + wapi::notify_tiles_render_complete!(); + let _ = self.restore_paint_surfaces_to_viewport(); + self.pending_sharp_promote = true; + performance::end_measure!("continue_render_loop"); + return Ok(FrameType::Partial); + } + match frame_type { FrameType::None => { panic!("FrameType::None"); } FrameType::Partial => { - // Partial frame: just flush GPU work. The display shows the last - // fully submitted frame; no need to copy or draw UI overlays here. - self.flush(); + // Do not flush here. On WASM, `context.flush(None)` still waited + // ~470ms after paint-once DONE (same as flush_and_submit). The + // intentional Partial yield after DONE only helps if we return + // immediately; hard sync belongs in present_frame. } FrameType::Full => { + self.sharp_tile_refill = false; // 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() { - let t_crop = performance::get_time(); 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!(); - + let _ = self.restore_paint_surfaces_to_viewport(); performance::end_measure!("render"); } } @@ -2564,10 +2690,10 @@ impl RenderState { // Restore render-surface transforms for the workspace context. // If we have a current tile, restore its tile render context; otherwise // fall back to restoring the previous render_area (may be empty). - let workspace_scale = self.get_scale(); if let Some(tile) = self.current_tile { self.update_render_context(tile); } else if !self.render_area.is_empty() { + let workspace_scale = self.get_paint_scale(); self.surfaces .update_render_context(self.render_area, workspace_scale); } @@ -2580,7 +2706,8 @@ impl RenderState { if iteration % self.options.node_batch_threshold != 0 { return false; } - if performance::get_time() - timestamp <= self.options.max_blocking_time_ms { + let budget = self.render_time_budget_ms(); + if performance::get_time() - timestamp <= budget { return false; } @@ -2599,6 +2726,17 @@ impl RenderState { true } + /// Per-frame CPU budget for progressive tile work. + #[inline] + fn render_time_budget_ms(&self) -> i32 { + // Sharp HiDPI tiles are expensive (1024² at DPR=2); keep slices short + // so the frontend stays responsive after zoom. + if self.sharp_tile_refill { + return self.options.max_blocking_time_ms.min(8).max(4); + } + self.options.max_blocking_time_ms + } + /// Push pending GPU work without waiting so Partial `flush_and_submit` /// does not absorb an entire paint-region's worth of ops in one spike. #[inline] @@ -2876,7 +3014,7 @@ impl RenderState { .current_tile .ok_or(Error::CriticalError("Current tile not found".to_string()))?; let offset = self.viewbox.get_offset(); - Ok(tile.get_rect_with_offset(&offset)) + Ok(tile.get_rect_with_offset(&offset, tiles::device_tile_size_px(self.viewbox.dpr))) } pub fn get_rect_bounds(&mut self, rect: skia::Rect) -> Rect { @@ -2904,15 +3042,16 @@ impl RenderState { pub fn get_aligned_tile_bounds(&mut self, tile: tiles::Tile) -> Rect { let scale = self.get_scale(); + let device_tile = tiles::device_tile_size_px(self.viewbox.dpr); let start_tile_x = - (self.viewbox.area.left * scale / tiles::TILE_SIZE).floor() * tiles::TILE_SIZE; + (self.viewbox.area.left * scale / device_tile).floor() * device_tile; let start_tile_y = - (self.viewbox.area.top * scale / tiles::TILE_SIZE).floor() * tiles::TILE_SIZE; + (self.viewbox.area.top * scale / device_tile).floor() * device_tile; Rect::from_xywh( - (tile.x() as f32 * tiles::TILE_SIZE) - start_tile_x, - (tile.y() as f32 * tiles::TILE_SIZE) - start_tile_y, - tiles::TILE_SIZE, - tiles::TILE_SIZE, + (tile.x() as f32 * device_tile) - start_tile_x, + (tile.y() as f32 * device_tile) - start_tile_y, + device_tile, + device_tile, ) } @@ -3589,7 +3728,7 @@ impl RenderState { Cow::Borrowed(element) }; - let is_text = matches!(element_for_inline.shape_type, Type::Text(_)); + let _is_text = matches!(element_for_inline.shape_type, Type::Text(_)); self.render_shape( &element_for_inline, clip_bounds.clone(), @@ -3674,13 +3813,14 @@ impl RenderState { } } - // We try to avoid doing too many calls to get_time - if allow_stop && iteration % self.options.node_batch_threshold == 0 { - // Kick GPU work while the walker continues so Partial - // flush_and_submit does not wait on a full paint-region backlog. - if self.paint_region.is_some() { - self.soft_flush_gpu(); - } + // Kick GPU work while the walker continues. Skip during paint-once: + // on WASM `flush(None)` still syncs and the final Partial paid ~470ms; + // mid-chunk waits also inflate walker time. Present flushes instead. + if allow_stop + && self.paint_region.is_none() + && iteration % self.options.node_batch_threshold == 0 + { + self.soft_flush_gpu(); } if allow_stop && self.should_stop_rendering(iteration, timestamp) { return Ok((is_empty, true)); @@ -3692,7 +3832,7 @@ impl RenderState { } fn update_render_context_for_area(&mut self, area: Rect) { - let scale = self.get_scale(); + let scale = self.get_paint_scale(); self.render_area = area; let margins = self.surfaces.margins(); let margin_w = margins.width as f32 / scale; @@ -3708,6 +3848,9 @@ impl RenderState { /// Drain pending uncached tiles into a paint-once region when safe. /// Returns true when `paint_region` was started and nodes were seeded. + /// + /// Used for the soft settle pass (DPR≤1 fill-rate, one tree walk). The + /// sharp HiDPI refill sets `sharp_tile_refill` and skips this path. fn try_begin_paint_region( &mut self, root_ids: &[Uuid], @@ -3716,11 +3859,19 @@ impl RenderState { if self.viewer_masked_pass() || self.options.is_interactive_transform() { return Ok(false); } + // Sharp HiDPI pass: walk/paint per tile at full view DPR. + if self.sharp_tile_refill { + return Ok(false); + } if self.paint_region.is_some() || !self.pending_nodes.is_empty() { return Ok(false); } - let label = "region"; + let label = if self.options.content_quality() == ContentQuality::Interactive { + "visible" + } else { + "region" + }; let mut region_tiles = Vec::new(); let mut remaining = Vec::new(); @@ -3740,10 +3891,11 @@ impl RenderState { return Ok(false); } - let scale = self.get_scale(); + let view_scale = self.get_scale(); + let paint_scale = self.get_paint_scale(); let mut area = Rect::new_empty(); for tile in ®ion_tiles { - let r = tiles::get_tile_rect(*tile, scale); + let r = tiles::get_tile_rect(*tile, view_scale, self.viewbox.dpr); if area.is_empty() { area = r; } else { @@ -3751,12 +3903,22 @@ impl RenderState { } } - if region_tiles.len() == 1 || !self.surfaces.region_fits_paint_surface(area, scale) { - // Restore for the single-tile path (pop from end). + // No banding: one paint-once of the whole pending set, or per-tile. + // Fit is against GPU max texture — Current is resized to the region below. + if region_tiles.len() == 1 + || !self + .surfaces + .region_fits_paint_surface(area, paint_scale) + { self.pending_tiles.list.extend(region_tiles); return Ok(false); } + // Shrink Current (+ layers) to the region so soft settle does not clear / + // flush a HiDPI viewport-sized surface. + let need = self.surfaces.paint_region_need_dims(area, paint_scale); + self.surfaces.resize_paint_surfaces_to(need)?; + self.update_render_context_for_area(area); self.current_tile = Some(region_tiles[0]); self.current_tile_had_shapes = true; @@ -3815,15 +3977,16 @@ impl RenderState { crate::get_gpu_state().context.flush_and_submit(); } self.cache_cleared_this_render = true; - let scale = self.get_scale(); + let view_scale = self.get_scale(); + let paint_scale = self.get_paint_scale(); let render_area = self.render_area; for tile in ®ion.tiles { - let tile_doc_rect = tiles::get_tile_rect(*tile, scale); + let tile_doc_rect = tiles::get_tile_rect(*tile, view_scale, self.viewbox.dpr); let src = self.surfaces.tile_drawable_src_in_region( tile_doc_rect, render_area, - scale, + paint_scale, ); let aligned = self.get_aligned_tile_bounds(*tile); self.surfaces.draw_current_src_into_tile_atlas( @@ -3835,9 +3998,22 @@ impl RenderState { src, ); } + // Do not restore Current here — recreating surfaces can sync the GPU + // and reintroduce the DONE-frame hitch. Restore after present. Ok(()) } + /// Grow Current back to viewport + interest pad after a region-sized paint-once. + fn restore_paint_surfaces_to_viewport(&mut self) -> Result<()> { + let dpr_width = (self.viewbox.width() * self.options.dpr).floor() as i32; + let dpr_height = (self.viewbox.height() * self.options.dpr).floor() as i32; + self.surfaces.resize_paint_surfaces( + dpr_width, + dpr_height, + self.options.dpr_viewport_interest_area_threshold, + ) + } + pub fn render_shape_tree_partial( &mut self, base_object: Option<&Uuid>, @@ -3885,6 +4061,12 @@ impl RenderState { if !is_empty || self.current_tile_had_shapes { self.apply_paint_region_to_atlas(®ion)?; } + // Soft paint-once can be large; yield so the UI can run. + if allow_stop { + self.current_tile = None; + self.viewer_render_root = None; + return Ok(FrameType::Partial); + } } else { let tile_rect = self.get_current_tile_bounds()?; // Composite if the walker did work in this PAF (`!is_empty`) OR @@ -3913,6 +4095,17 @@ impl RenderState { ); } } + // Sharp HiDPI: at most one tile per rAF. Also yield when + // the frame budget is already spent. + if allow_stop + && (self.sharp_tile_refill + || performance::get_time() - timestamp + > self.render_time_budget_ms()) + { + self.current_tile = None; + self.viewer_render_root = None; + return Ok(FrameType::Partial); + } } } else if self.tiles.is_empty_at(current_tile) { self.surfaces.remove_cached_tile_surface(current_tile); @@ -4033,7 +4226,7 @@ impl RenderState { pub fn get_tiles_for_shape(&mut self, shape: &Shape, tree: ShapesPoolRef) -> TileRect { let scale = self.get_scale(); let extrect = self.get_cached_extrect(shape, tree, scale); - let tile_size = tiles::get_tile_size(scale); + let tile_size = tiles::get_tile_size(scale, self.viewbox.dpr); let shape_tiles = tiles::get_tiles_for_rect(extrect, tile_size); let interest_rect = &self.tile_viewbox.interest_rect; // Calculate the intersection of shape_tiles with interest_rect @@ -4190,14 +4383,13 @@ impl RenderState { pub fn rebuild_tile_index(&mut self, tree: ShapesPoolRef) { let zoom_changed = self.zoom_changed(); performance::begin_measure!("rebuild_tile_index"); - let t0 = performance::get_time(); - let mut shapes_visited = 0u32; + let mut _shapes_visited = 0u32; let mut nodes = Vec::::with_capacity(64); nodes.push(Uuid::nil()); while let Some(shape_id) = nodes.pop() { if let Some(shape) = tree.get(&shape_id) { if shape_id != Uuid::nil() { - shapes_visited += 1; + _shapes_visited += 1; if zoom_changed { let _ = self.update_shape_tiles(shape, tree); } else { @@ -4219,14 +4411,9 @@ impl RenderState { self.rebuild_tile_index(tree); - // Zoom changes world tile size: a partial cache update would mix scales in the - // mosaic and glitch. Same zoom as last finished render (typical pan): drop only - // tile textures and keep the cache canvas for render_from_cache. - if self.zoom_changed() { - self.surfaces.remove_cached_tiles(self.background_color); - } else { - self.surfaces.invalidate_tile_cache(); - } + // Drop tile sprites (wrong world-tile grid after zoom). Keep DocAtlas + // pixels so zoom-out preview can scale document coverage. + self.surfaces.invalidate_tile_cache(); performance::end_measure!("rebuild_tiles_shallow"); } @@ -4358,6 +4545,32 @@ impl RenderState { self.viewbox.get_scale() } + /// Doc→pixel scale for *tile raster* surfaces (Current/Fills/… in single-tile mode). + /// + /// At full quality this equals [`Self::get_scale`] (`zoom * dpr`). During + /// interactive HiDPI LOD, atlas sprites are only `BASE` px wide so the CTM + /// must be `zoom` — otherwise shapes are drawn 2× too large into the tile. + pub fn get_raster_scale(&self) -> f32 { + if let Some((_, export_scale)) = self.export_context { + return export_scale; + } + let raster = self.surfaces.tile_size_px() as f32; + let world_tile = + tiles::get_tile_size(self.viewbox.get_scale(), self.viewbox.dpr).max(1e-6); + raster / world_tile + } + + /// Scale matching the active paint CTM on Current/Fills/…. + /// + /// Always [`Self::get_raster_scale`]: at full quality that equals view + /// scale (`zoom * dpr`); during interactive HiDPI LOD it is `zoom` so + /// paint-once and single-tile stamps both match DPR=1 fill-rate. Using + /// view scale for paint-once during soft settle re-introduced the DPR=2 + /// zoom hitch (full-res walk, then another walk on sharp promote). + pub fn get_paint_scale(&self) -> f32 { + self.get_raster_scale() + } + pub fn zoom_changed(&self) -> bool { (self.viewbox.zoom - self.cached_viewbox.zoom).abs() > f32::EPSILON } diff --git a/render-wasm/src/render/debug.rs b/render-wasm/src/render/debug.rs index d661fd2bd1..2e9d54129d 100644 --- a/render-wasm/src/render/debug.rs +++ b/render-wasm/src/render/debug.rs @@ -99,7 +99,7 @@ pub fn render_debug_viewbox_tiles(render_state: &mut RenderState) { paint.set_color(skia::Color::MAGENTA); paint.set_stroke_width(1.); - let tile_size = tiles::get_tile_size(scale); + let tile_size = tiles::get_tile_size(scale, render_state.viewbox.dpr); let tile_rect = tiles::get_tiles_for_rect(render_state.viewbox.area, tile_size); let tiles::TileRect(sx, sy, ex, ey) = tile_rect; @@ -108,7 +108,7 @@ pub fn render_debug_viewbox_tiles(render_state: &mut RenderState) { let debug_font = get_resources().fonts.debug_font(); canvas.draw_str(str_rect, skia::Point::new(100.0, 100.0), debug_font, &paint); - let tile_size = tiles::get_tile_size(scale); + let tile_size = tiles::get_tile_size(scale, render_state.viewbox.dpr); for tile in tile_rect.iter(true) { let tiles::Tile(x, y) = tile; let rect = tile.get_rect_with_size(tile_size); diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index fed66505fe..ce21c379a4 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -4,15 +4,25 @@ const PROFILE_REBUILD_TILES: u32 = 0x02; const TEXT_EDITOR_V3: u32 = 0x04; const SHOW_WASM_INFO: u32 = 0x08; -// Render performance options -// This is the extra area used for tile rendering (tiles beyond viewport). -// Higher values pre-render more tiles, reducing empty squares during pan but using more memory. +// Extra tiles beyond the viewport for pre-render. Kept in *tile* units (not +// scaled by DPR): world tile count already matches DPR=1 (`BASE/zoom`). const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 1; -const MIN_DPR_VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 2; const MAX_BLOCKING_TIME_MS: i32 = 32; const NODE_BATCH_THRESHOLD: i32 = 3; const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0; const ANTIALIAS_THRESHOLD: f32 = 7.0; + +/// Raster resolution for tile textures relative to the view DPR. +/// +/// Interactive keeps raster tiles at 512 px (effective paint DPR = min(view_dpr, 1)) +/// for the soft settle pass; Full uses `512 * dpr` after promote. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)] +pub enum ContentQuality { + Interactive, + #[default] + Full, +} + #[derive(Debug, Copy, Clone, PartialEq)] pub struct RenderOptions { pub flags: u32, @@ -23,6 +33,7 @@ pub struct RenderOptions { /// keeps per-frame flushing enabled (unlike pan/zoom, where /// `render_from_cache` drives target presentation). interactive_transform: bool, + content_quality: ContentQuality, /// Minimum on-screen size (CSS px at 1:1 zoom) above which vector antialiasing is enabled. pub antialias_threshold: f32, pub viewport_interest_area_threshold: i32, @@ -40,6 +51,7 @@ impl Default for RenderOptions { dpr: 1.0, fast_mode: false, interactive_transform: false, + content_quality: ContentQuality::Full, antialias_threshold: ANTIALIAS_THRESHOLD, viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD, dpr_viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD, @@ -60,7 +72,6 @@ impl RenderOptions { self.flags & PROFILE_REBUILD_TILES == PROFILE_REBUILD_TILES } - /// Use fast mode to enable / disable expensive operations pub fn is_fast_mode(&self) -> bool { self.fast_mode } @@ -69,19 +80,41 @@ impl RenderOptions { self.fast_mode = enabled; } + pub fn content_quality(&self) -> ContentQuality { + self.content_quality + } + + /// Returns `true` when the quality value changed. + pub fn set_content_quality(&mut self, quality: ContentQuality) -> bool { + if self.content_quality != quality { + self.content_quality = quality; + true + } else { + false + } + } + + /// Device-pixel size used to rasterize each tile atlas sprite. + pub fn raster_tile_size_px(&self) -> i32 { + match self.content_quality { + ContentQuality::Interactive if self.dpr > 1.05 => crate::tiles::TILE_SIZE_BASE as i32, + _ => crate::tiles::tile_size_px_i32(self.dpr), + } + } + + pub fn needs_full_quality_upgrade(&self) -> bool { + self.content_quality == ContentQuality::Interactive && self.dpr > 1.05 + } + #[cfg(target_arch = "wasm32")] pub fn set_capture_frames(&mut self, capture_frames: i32) { self.capture_frames = capture_frames; } - /// Updates the dpr viewport interest area threshold. - /// This function is updated when the dpr or the - /// viewport_interest_area_threshold is changed fn update_dpr_viewport_interest_area_threshold(&mut self) { - // TODO: this will likely need to change once we have the tile atlas in place - self.dpr_viewport_interest_area_threshold = - ((self.dpr * self.viewport_interest_area_threshold as f32).ceil() as i32) - .min(MIN_DPR_VIEWPORT_INTEREST_AREA_THRESHOLD); + // Interest stays in tile units; do not multiply by DPR (that ballooned + // the cache/Current pad after DPR-stable world tiles). + self.dpr_viewport_interest_area_threshold = self.viewport_interest_area_threshold; } /// Sets the devicePixelRatio. @@ -94,10 +127,6 @@ impl RenderOptions { false } - /// Interactive transform is ON while the user is dragging, resizing - /// or rotating a shape. Callers use it to keep per-frame flushing - /// enabled and to render visible tiles in a single frame so tiles - /// never appear sequentially or flicker during the gesture. pub fn is_interactive_transform(&self) -> bool { self.interactive_transform } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 7d0c0355e8..572c46fa73 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -29,20 +29,35 @@ const TILE_DRAWABLE_RECT: IRect = IRect { const DOC_ATLAS_MAX_DIM: i32 = 4096; /// Pixel size for Current + layer surfaces: viewport plus interest ring and -/// blur margins. Resized only with the window (not on zoom HQ passes). +/// blur margins. Default size after window resize / restore from paint-once. pub fn paint_surface_dims( viewport_w: i32, viewport_h: i32, interest_tiles: i32, + tile_size_px: i32, max_texture_size: i32, ) -> skia::ISize { let interest = interest_tiles.max(0); - let pad = interest * TILE_SIZE + TILE_MARGIN_SIZE; - let w = (viewport_w + 2 * pad).clamp(TILE_SIZE + 2 * TILE_MARGIN_SIZE, max_texture_size); - let h = (viewport_h + 2 * pad).clamp(TILE_SIZE + 2 * TILE_MARGIN_SIZE, max_texture_size); + let tile = tile_size_px.max(1); + let margin = tile * TILE_SIZE_MULTIPLIER / 4; + let pad = interest * tile + margin; + let min_dim = tile + 2 * margin; + let w = (viewport_w + 2 * pad).clamp(min_dim, max_texture_size); + let h = (viewport_h + 2 * pad).clamp(min_dim, max_texture_size); skia::ISize::new(w, h) } +/// Exact Current size needed for a paint-once doc region at `scale` (+ blur margins). +pub fn compute_paint_region_need_dims( + render_area: skia::Rect, + scale: f32, + margins: skia::ISize, +) -> skia::ISize { + let need_w = (render_area.width() * scale).ceil() as i32 + 2 * margins.width; + let need_h = (render_area.height() * scale).ceil() as i32 + 2 * margins.height; + skia::ISize::new(need_w.max(1), need_h.max(1)) +} + /// GPU→GPU copy of `src` from `from` into `dst` on `to_canvas`, without /// `image_snapshot` (avoids per-tile sync stalls on WebGL). fn draw_surface_src_rect_to_dst( @@ -66,7 +81,7 @@ fn draw_surface_src_rect_to_dst( to_canvas.restore(); } -pub fn get_cache_size(viewbox: &Viewbox, interest: i32) -> skia::ISize { +pub fn get_cache_size(viewbox: &Viewbox, interest: i32, tile_size_px: i32) -> skia::ISize { // First we retrieve the extended area of the viewport that we could render. let TileRect(isx, isy, iex, iey) = tiles::get_tiles_for_viewbox_with_interest(viewbox, interest); @@ -75,8 +90,8 @@ pub fn get_cache_size(viewbox: &Viewbox, interest: i32) -> skia::ISize { let dy = if isy.signum() != iey.signum() { 1 } else { 0 }; ( - ((iex - isx).abs() + dx) * TILE_SIZE, - ((iey - isy).abs() + dy) * TILE_SIZE, + ((iex - isx).abs() + dx) * tile_size_px, + ((iey - isy).abs() + dy) * tile_size_px, ) .into() } @@ -145,6 +160,15 @@ impl DocAtlas { }) } + /// Drop all document-space pixels and tile rects. + pub fn reset(&mut self) { + self.surface.canvas().clear(skia::Color::TRANSPARENT); + self.origin = skia::Point::new(0.0, 0.0); + self.size = skia::ISize::new(0, 0); + self.scale = 1.0; + self.tile_doc_rects.clear(); + } + // TODO: delete one docatlas is optional pub fn is_empty(&self) -> bool { self.size.width <= 0 || self.size.height <= 0 @@ -470,6 +494,8 @@ pub struct Surfaces { // Tracks which surfaces have content (dirty flag bitmask) dirty_surfaces: u32, extra_tile_dims: skia::ISize, + /// Device-pixel size of one atlas tile sprite (512 interactive / 512*dpr full). + tile_size_px: i32, dpr: f32, } @@ -483,12 +509,14 @@ impl Surfaces { ) -> Result { let gpu_state = get_gpu_state(); let max_texture_size = gpu_state.max_texture_size(); + let tile_size_px = tile_dims.width.max(1); // Current + layers cover viewport + interest + blur margins so a // paint-once region pass needs no surface recreate on zoom. let extra_tile_dims = - paint_surface_dims(width, height, interest_tiles, max_texture_size); - let margins = skia::ISize::new(TILE_MARGIN_SIZE, TILE_MARGIN_SIZE); + paint_surface_dims(width, height, interest_tiles, tile_size_px, max_texture_size); + let margin = tile_size_px * TILE_SIZE_MULTIPLIER / 4; + let margins = skia::ISize::new(margin, margin); let target = gpu_state.create_target_surface(width, height)?; let filter = gpu_state.create_surface_with_isize("filter".to_string(), extra_tile_dims)?; @@ -521,7 +549,7 @@ impl Surfaces { let debug = gpu_state.create_surface_with_dimensions("debug".to_string(), width, height)?; // 512, why not? - let tiles = TileTextureCache::new(tile_atlas.width(), 512); + let tiles = TileTextureCache::new(tile_atlas.width(), tile_size_px, 512); let atlas = DocAtlas::try_new()?; Ok(Self { target, @@ -549,12 +577,71 @@ impl Surfaces { margins, dirty_surfaces: 0, extra_tile_dims, - dpr: 1.0, + tile_size_px, + dpr: tile_size_px as f32 / tiles::TILE_SIZE_BASE, }) } - pub fn set_dpr(&mut self, dpr: f32) { + pub fn tile_size_px(&self) -> i32 { + self.tile_size_px + } + + fn tile_drawable_rect(&self) -> IRect { + IRect { + left: self.margins.width, + top: self.margins.height, + right: self.margins.width + self.tile_size_px, + bottom: self.margins.height + self.tile_size_px, + } + } + + pub fn set_view_dpr(&mut self, dpr: f32) -> Result<()> { self.dpr = dpr; + Ok(()) + } + + /// Update atlas sprite size for content-quality LOD. Does **not** resize + /// Current; paint-once shrinks Current to the region via + /// [`Self::resize_paint_surfaces_to`]. + pub fn set_raster_tile_size(&mut self, tile_size_px: i32) -> Result<()> { + let tile_size_px = tile_size_px.max(1); + if tile_size_px == self.tile_size_px { + return Ok(()); + } + let margin = tile_size_px * TILE_SIZE_MULTIPLIER / 4; + self.tile_size_px = tile_size_px; + self.margins = skia::ISize::new(margin, margin); + // Rebuild atlas packer for the new sprite size; keep Current as-is. + self.tiles = TileTextureCache::new(self.tile_atlas.width(), tile_size_px, 512); + self.tile_atlas_image = None; + Ok(()) + } + + pub fn set_dpr(&mut self, dpr: f32) -> Result<()> { + self.dpr = dpr; + let new_tile_px = tiles::tile_size_px_i32(dpr); + self.set_raster_tile_size(new_tile_px) + } + + pub fn invalidate_tile_cache(&mut self) { + // Drop tile sprites only. Keep DocAtlas pixels — zoom-out preview + // (`draw_atlas_to_backbuffer`) scales that document-space coverage. + // Resetting it here emptied the atlas and broke continuous zoom preview. + self.clear_tile_sprites(); + } + + /// Drop tile-atlas sprites and their doc rects without touching DocAtlas. + /// Used mid-zoom so `draw_combined` cannot overlay sprites from the old + /// grid while the DocAtlas backdrop still scales correctly. + pub fn clear_tile_sprites(&mut self) { + self.tiles.clear(); + self.tile_atlas_image = None; + self.tile_atlas.canvas().clear(skia::Color::TRANSPARENT); + self.atlas.tile_doc_rects.clear(); + } + + pub fn was_rendered_without_atlas_slot(&self, tile: Tile) -> bool { + self.tiles.was_rendered_without_slot(tile) } pub fn clear_tiles(&mut self) { @@ -629,6 +716,9 @@ impl Surfaces { /// Fast pan/zoom preview: draw the doc atlas as backdrop, then overlay HQ /// cached tile textures placed via their stored document rects (pan + scale). + /// + /// During zoom (`zoom_changed`), callers should use [`Self::draw_atlas_to_backbuffer`] + /// alone: tile sprites are keyed to the previous world-tile grid and streak. pub fn draw_combined_atlas_to_backbuffer( &mut self, viewbox: &Viewbox, @@ -696,7 +786,6 @@ impl Surfaces { } /// Recreate Current + layer surfaces for the viewport (+ interest pad). - /// Called only from window resize / init — not from zoom HQ passes. pub fn resize_paint_surfaces( &mut self, viewport_w: i32, @@ -704,12 +793,32 @@ impl Surfaces { interest_tiles: i32, ) -> Result<()> { let max_texture_size = get_gpu_state().max_texture_size(); - let dims = paint_surface_dims(viewport_w, viewport_h, interest_tiles, max_texture_size); + let dims = paint_surface_dims( + viewport_w, + viewport_h, + interest_tiles, + self.tile_size_px, + max_texture_size, + ); + self.resize_paint_surfaces_to(dims) + } + + /// Recreate Current + Fills/Strokes/shadows/Filter to an exact pixel size. + /// Used to shrink paint-once to the region (avoids clearing/flushing a + /// HiDPI-sized Current when soft settle only needs ~viewport@zoom pixels). + pub fn resize_paint_surfaces_to(&mut self, dims: skia::ISize) -> Result<()> { + let max_texture_size = get_gpu_state().max_texture_size(); + let min_dim = self.tile_size_px + 2 * self.margins.width; + let dims = skia::ISize::new( + dims.width.clamp(min_dim, max_texture_size), + dims.height.clamp(min_dim, max_texture_size), + ); if dims == self.extra_tile_dims { return Ok(()); } self.extra_tile_dims = dims; - self.margins = skia::ISize::new(TILE_MARGIN_SIZE, TILE_MARGIN_SIZE); + let margin = self.tile_size_px * TILE_SIZE_MULTIPLIER / 4; + self.margins = skia::ISize::new(margin, margin); let recreate = |name: &str, surface: &mut skia::Surface| -> Result<()> { *surface = surface @@ -743,6 +852,10 @@ impl Surfaces { TILE_DRAWABLE_RECT } + pub fn current_tile_drawable_rect(&self) -> IRect { + self.tile_drawable_rect() + } + pub fn snapshot(&mut self, id: SurfaceId) -> skia::Image { let surface = self.get_mut(id); surface.image_snapshot() @@ -1071,8 +1184,8 @@ impl Surfaces { .ok_or(Error::CriticalError("Failed to create surface".to_string()))?; self.cache.canvas().reset_matrix(); self.cache.canvas().translate(( - (interest_area_threshold * TILE_SIZE) as f32, - (interest_area_threshold * TILE_SIZE) as f32, + (interest_area_threshold * self.tile_size_px) as f32, + (interest_area_threshold * self.tile_size_px) as f32, )); Ok(()) } @@ -1080,17 +1193,24 @@ impl Surfaces { pub fn resize_cache_from_viewbox( &mut self, viewbox: &Viewbox, - cached_viewbox: &Viewbox, + _cached_viewbox: &Viewbox, interest_area_threshold: i32, ) -> Result<()> { - let viewbox_cache_size = get_cache_size(viewbox, interest_area_threshold); - let cached_viewbox_cache_size = get_cache_size(cached_viewbox, interest_area_threshold); - // Only resize cache if the new size is larger than the cached size - // This avoids unnecessary surface recreations when the cache size decreases - if viewbox_cache_size.width > cached_viewbox_cache_size.width - || viewbox_cache_size.height > cached_viewbox_cache_size.height - { - return self.resize_cache(viewbox_cache_size, interest_area_threshold); + let needed = get_cache_size(viewbox, interest_area_threshold, self.tile_size_px); + let current_w = self.cache.width(); + let current_h = self.cache.height(); + // Grow against the *actual* GPU surface. Comparing only to + // `get_cache_size(cached_viewbox)` re-allocated the same (~100+ MiB) + // cache on every progressive zoom/pan frame while `cached_viewbox` + // still lagged behind a surface that had already grown. + let new_w = needed.width.max(current_w); + let new_h = needed.height.max(current_h); + if new_w > current_w || new_h > current_h { + let result = self.resize_cache( + skia::ISize::new(new_w, new_h), + interest_area_threshold, + ); + return result; } Ok(()) } @@ -1293,7 +1413,7 @@ impl Surfaces { tile_rect, skip_cache_surface, tile_doc_rect, - skia::Rect::from(TILE_DRAWABLE_RECT), + skia::Rect::from(self.tile_drawable_rect()), ); } @@ -1320,16 +1440,17 @@ impl Surfaces { ); self.atlas.tile_doc_rects.insert(*tile, tile_doc_rect); - let tile_ref = self.tiles.add(tile_viewbox, tile); - let dst = tile_ref.rect; - let mut current = self.current.clone(); - draw_surface_src_rect_to_dst( - &mut current, - self.tile_atlas.canvas(), - src, - dst, - sampling, - ); + if let Some(tile_ref) = self.tiles.add(tile_viewbox, tile) { + let dst = tile_ref.rect; + let mut current = self.current.clone(); + draw_surface_src_rect_to_dst( + &mut current, + self.tile_atlas.canvas(), + src, + dst, + sampling, + ); + } if !skip_cache_surface { // Optional legacy Cache surface fill (debug). Pan/zoom preview @@ -1345,13 +1466,17 @@ impl Surfaces { } } - /// Whether a doc-space region (with blur margins) fits in Current at `scale`. + /// Pixel size needed for a paint-once region at `scale` (includes margins). + pub fn paint_region_need_dims(&self, render_area: skia::Rect, scale: f32) -> skia::ISize { + compute_paint_region_need_dims(render_area, scale, self.margins) + } + + /// Whether a doc-space region fits the **GPU max texture** at `scale` + /// (Current may be resized to the region before painting). pub fn region_fits_paint_surface(&self, render_area: skia::Rect, scale: f32) -> bool { - let need_w = - (render_area.width() * scale).ceil() as i32 + 2 * self.margins.width; - let need_h = - (render_area.height() * scale).ceil() as i32 + 2 * self.margins.height; - need_w <= self.current.width() && need_h <= self.current.height() + let need = self.paint_region_need_dims(render_area, scale); + let max = get_gpu_state().max_texture_size(); + need.width <= max && need.height <= max } /// Pixel rect inside Current for a tile given the region `render_area` and scale. @@ -1363,7 +1488,10 @@ impl Surfaces { ) -> skia::Rect { let x = self.margins.width as f32 + (tile_doc_rect.left - render_area.left) * scale; let y = self.margins.height as f32 + (tile_doc_rect.top - render_area.top) * scale; - skia::Rect::from_xywh(x, y, TILE_SIZE as f32, TILE_SIZE as f32) + // At view scale this is `512 * dpr`; do not hardcode TILE_SIZE_BASE. + let w = tile_doc_rect.width() * scale; + let h = tile_doc_rect.height() * scale; + skia::Rect::from_xywh(x, y, w, h) } pub fn has_cached_tile_surface(&self, tile: Tile) -> bool { @@ -1402,7 +1530,7 @@ impl Surfaces { let canvas = scratch.canvas(); canvas.clear(skia::Color::TRANSPARENT); - let tile_size = tiles::get_tile_size(scale); + let tile_size = tiles::get_tile_size(scale, self.dpr); let tr = tiles::get_tiles_for_rect(src_doc_bounds, tile_size); let ix0 = src_irect.left as f32; let iy0 = src_irect.top as f32; @@ -1411,7 +1539,7 @@ impl Surfaces { for ty in tr.y1()..=tr.y2() { for tx in tr.x1()..=tr.x2() { let tile = Tile(tx, ty); - let tile_doc = tiles::get_tile_rect(tile, scale); + let tile_doc = tiles::get_tile_rect(tile, scale, self.dpr); let mut clip_doc = tile_doc; if !clip_doc.intersect(src_doc_bounds) || clip_doc.is_empty() { continue; @@ -1533,8 +1661,10 @@ impl Surfaces { /// Used by `rebuild_tiles` (full rebuild). For shallow rebuilds that preserve /// the cache canvas for scaled previews, use `invalidate_tile_cache` instead. pub fn remove_cached_tiles(&mut self, color: skia::Color) { - self.tiles.clear(); - self.atlas.tile_doc_rects.clear(); + self.clear_tile_sprites(); + // Full rebuild path: also drop DocAtlas. Zoom-end uses + // `invalidate_tile_cache` instead so zoom preview keeps coverage. + self.atlas.reset(); self.cache.canvas().clear(color); } @@ -1542,12 +1672,6 @@ impl Surfaces { /// This forces all tiles to be re-rendered, but preserves the cache canvas /// so that `render_from_cache` can still show a scaled preview of the old /// content while new tiles are being rendered. - pub fn invalidate_tile_cache(&mut self) { - self.tiles.clear(); - self.atlas.tile_doc_rects.clear(); - self.tile_atlas_image = None; - } - pub fn gc(&mut self) { self.tiles.gc(); } @@ -1677,10 +1801,13 @@ pub struct TileTextureCache { tile_size: f32, is_updated: bool, provider: TileAtlasTextureProvider, - transforms: Vec, - textures: Vec, + pub transforms: Vec, + pub textures: Vec, grid: HashMap, removed: HashSet, + /// Interest tiles drawn without an atlas slot. Pending scheduler skips them; + /// `has()` stays false until they get a real sprite. + rendered_without_slot: HashSet, } pub struct AtlasDrawBatch { @@ -1695,24 +1822,26 @@ impl AtlasDrawBatch { } impl TileTextureCache { - pub fn new(texture_size: i32, capacity: usize) -> Self { + pub fn new(texture_size: i32, tile_size_px: i32, capacity: usize) -> Self { Self { - tile_size: tiles::TILE_SIZE, + tile_size: tile_size_px as f32, is_updated: false, - provider: TileAtlasTextureProvider::new(texture_size, TILE_SIZE), + provider: TileAtlasTextureProvider::new(texture_size, tile_size_px), transforms: Vec::with_capacity(capacity), textures: Vec::with_capacity(capacity), grid: HashMap::with_capacity(capacity), removed: HashSet::with_capacity(capacity), + rendered_without_slot: HashSet::with_capacity(capacity), } } fn gc(&mut self) { - // Make a real remove - for tile in self.removed.iter() { + let removed: Vec = self.removed.iter().copied().collect(); + for tile in removed.iter() { if let Some(tile_ref) = self.grid.remove(tile) { self.provider.deallocate(tile_ref); } + self.removed.remove(tile); } } @@ -1725,9 +1854,13 @@ impl TileTextureCache { } fn gc_non_visible(&mut self, tile_viewbox: &TileViewbox) { + self.gc_non_visible_limited(tile_viewbox, TEXTURES_BATCH_DELETE); + } + + fn gc_non_visible_limited(&mut self, tile_viewbox: &TileViewbox, limit: usize) { let marked: Vec<_> = self .grid - .iter_mut() + .iter() .filter_map(|(tile, _)| { if !tile_viewbox.is_visible(tile) { Some(*tile) @@ -1735,13 +1868,14 @@ impl TileTextureCache { None } }) - .take(TEXTURES_BATCH_DELETE) + .take(limit) .collect(); for tile in marked.iter() { if let Some(tile_ref) = self.grid.remove(tile) { self.provider.deallocate(tile_ref); } + self.removed.remove(tile); } } @@ -1765,6 +1899,12 @@ impl TileTextureCache { } let offset = viewbox.get_offset(); + let device_tile = tiles::device_tile_size_px(viewbox.dpr); + let scale = if self.tile_size > 0.0 { + device_tile / self.tile_size + } else { + 1.0 + }; let mut index = 0; for y in tile_viewbox.visible_rect.top()..=tile_viewbox.visible_rect.bottom() { for x in tile_viewbox.visible_rect.left()..=tile_viewbox.visible_rect.right() { @@ -1778,8 +1918,14 @@ impl TileTextureCache { continue; } - self.transforms[index].tx = x as f32 * self.tile_size - offset.x; - self.transforms[index].ty = y as f32 * self.tile_size - offset.y; + self.transforms[index] = skia::RSXform::new( + scale, + 0.0, + ( + x as f32 * device_tile - offset.x, + y as f32 * device_tile - offset.y, + ), + ); self.textures[index].set_ltrb( tile_ref.rect.left, @@ -1820,7 +1966,7 @@ impl TileTextureCache { let doc_rect = tile_doc_rects .get(&tile) .copied() - .unwrap_or_else(|| tiles::get_tile_rect(tile, s)); + .unwrap_or_else(|| tiles::get_tile_rect(tile, s, viewbox.dpr)); if doc_rect.is_empty() || !doc_rect.intersects(view_doc) { continue; } @@ -1834,8 +1980,6 @@ impl TileTextureCache { } } - // Cached tiles from a previous zoom level use indices outside visible_rect; - // place them via their stored document rect, not the current grid walk above. for (&tile, tile_ref) in &self.grid { if tile_viewbox.is_visible(&tile) || self.removed.contains(&tile) { continue; @@ -1844,7 +1988,7 @@ impl TileTextureCache { let doc_rect = tile_doc_rects .get(&tile) .copied() - .unwrap_or_else(|| tiles::get_tile_rect(tile, s)); + .unwrap_or_else(|| tiles::get_tile_rect(tile, s, viewbox.dpr)); if doc_rect.is_empty() || !doc_rect.intersects(view_doc) { continue; } @@ -1867,10 +2011,22 @@ impl TileTextureCache { self.grid.contains_key(&tile) && !self.removed.contains(&tile) } - pub fn add(&mut self, tile_viewbox: &TileViewbox, tile: &Tile) -> TileAtlasTextureRef { - // Evict against the real slot count (`provider.length`), not the - // hardcoded capacity — otherwise the guard never fires and the atlas - // fills up until `allocate()` has no slot left. + pub fn was_rendered_without_slot(&self, tile: Tile) -> bool { + self.rendered_without_slot.contains(&tile) + } + + pub fn add(&mut self, tile_viewbox: &TileViewbox, tile: &Tile) -> Option { + if let Some(existing) = self.grid.get(tile).cloned() { + if !self.removed.contains(tile) { + self.rendered_without_slot.remove(tile); + return Some(existing); + } + if let Some(tile_ref) = self.grid.remove(tile) { + self.provider.deallocate(tile_ref); + } + self.removed.remove(tile); + } + let capacity = self.provider.length.min(TEXTURES_CACHE_CAPACITY); if self.grid.len() >= capacity { @@ -1878,18 +2034,58 @@ impl TileTextureCache { self.gc_non_visible(tile_viewbox); } - let Some(tile_ref) = self.provider.allocate() else { - panic!("Tile texture allocation failed {}:{}", tile.0, tile.1); - }; - - self.grid.insert(*tile, tile_ref.clone()); - - if self.removed.contains(tile) { - self.removed.remove(tile); + if let Some(tile_ref) = self.provider.allocate() { + self.grid.insert(*tile, tile_ref.clone()); + self.rendered_without_slot.remove(tile); + self.is_updated = true; + return Some(tile_ref); } - self.is_updated = true; - tile_ref.clone() + self.gc(); + self.gc_non_visible_limited(tile_viewbox, usize::MAX); + if let Some(tile_ref) = self.provider.allocate() { + self.grid.insert(*tile, tile_ref.clone()); + self.rendered_without_slot.remove(tile); + self.is_updated = true; + return Some(tile_ref); + } + + if tile_viewbox.is_visible(tile) { + if let Some(victim) = self.farthest_visible_victim(tile_viewbox, tile) { + if let Some(tile_ref) = self.grid.remove(&victim) { + self.provider.deallocate(tile_ref); + } + self.removed.remove(&victim); + self.rendered_without_slot.insert(victim); + } + if let Some(tile_ref) = self.provider.allocate() { + self.grid.insert(*tile, tile_ref.clone()); + self.rendered_without_slot.remove(tile); + self.is_updated = true; + return Some(tile_ref); + } + } + + self.rendered_without_slot.insert(*tile); + None + } + + fn farthest_visible_victim( + &self, + tile_viewbox: &TileViewbox, + keep: &Tile, + ) -> Option { + let cx = (tile_viewbox.visible_rect.left() + tile_viewbox.visible_rect.right()) / 2; + let cy = (tile_viewbox.visible_rect.top() + tile_viewbox.visible_rect.bottom()) / 2; + self.grid + .keys() + .filter(|t| *t != keep && tile_viewbox.is_visible(t) && !self.removed.contains(t)) + .max_by_key(|t| { + let dx = t.0 - cx; + let dy = t.1 - cy; + dx * dx + dy * dy + }) + .copied() } pub fn get(&mut self, tile: Tile) -> Option<&TileAtlasTextureRef> { @@ -1907,12 +2103,19 @@ impl TileTextureCache { } self.is_updated = true; self.removed.insert(tile); + self.rendered_without_slot.remove(&tile); } pub fn clear(&mut self) { - for k in self.grid.keys() { - self.removed.insert(*k); + // Free GPU atlas slots immediately so DPR-scaled (few) slots are not stuck. + let keys: Vec = self.grid.keys().copied().collect(); + for tile in keys { + if let Some(tile_ref) = self.grid.remove(&tile) { + self.provider.deallocate(tile_ref); + } } + self.removed.clear(); + self.rendered_without_slot.clear(); self.is_updated = true; } } diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index d00dc25bdf..2c95ce310e 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -32,12 +32,12 @@ impl Tile { } #[inline(always)] - pub fn get_rect_with_offset(&self, offset: &skia::Point) -> skia::Rect { + pub fn get_rect_with_offset(&self, offset: &skia::Point, tile_size_px: f32) -> skia::Rect { skia::Rect::from_xywh( - self.0 as f32 * TILE_SIZE - offset.x, - self.1 as f32 * TILE_SIZE - offset.y, - TILE_SIZE, - TILE_SIZE, + self.0 as f32 * tile_size_px - offset.x, + self.1 as f32 * tile_size_px - offset.y, + tile_size_px, + tile_size_px, ) } } @@ -209,11 +209,40 @@ impl TileViewbox { } } -pub const TILE_SIZE: f32 = 512.; +/// Base tile texture size at DPR=1 (device pixels). +pub const TILE_SIZE_BASE: f32 = 512.; + +/// Alias for [`TILE_SIZE_BASE`]. Prefer [`device_tile_size_px`] for layout size. +pub const TILE_SIZE: f32 = TILE_SIZE_BASE; + +/// Device-pixel coverage of one tile on the backbuffer at the given view DPR. +/// At DPR=2 this is 1024 — used for placing sprites, not necessarily for +/// rasterizing them (see content-quality / interactive LOD). +#[inline(always)] +pub fn device_tile_size_px(dpr: f32) -> f32 { + (TILE_SIZE_BASE * dpr.max(0.01)).round().max(1.0) +} #[inline(always)] -pub fn get_tile_dimensions() -> skia::ISize { - (TILE_SIZE as i32, TILE_SIZE as i32).into() +pub fn device_tile_size_px_i32(dpr: f32) -> i32 { + device_tile_size_px(dpr) as i32 +} + +/// Alias kept for call sites that mean "full-quality raster size == device coverage". +#[inline(always)] +pub fn tile_size_px(dpr: f32) -> f32 { + device_tile_size_px(dpr) +} + +#[inline(always)] +pub fn tile_size_px_i32(dpr: f32) -> i32 { + device_tile_size_px_i32(dpr) +} + +#[inline(always)] +pub fn get_tile_dimensions(dpr: f32) -> skia::ISize { + let s = device_tile_size_px_i32(dpr); + (s, s).into() } pub fn get_tiles_for_rect(rect: skia::Rect, tile_size: f32) -> TileRect { @@ -227,7 +256,7 @@ pub fn get_tiles_for_rect(rect: skia::Rect, tile_size: f32) -> TileRect { } pub fn get_tiles_for_viewbox(viewbox: &Viewbox) -> TileRect { - let tile_size = get_tile_size(viewbox.get_scale()); + let tile_size = get_tile_size(viewbox.get_scale(), viewbox.dpr); get_tiles_for_rect(viewbox.area, tile_size) } @@ -241,20 +270,22 @@ pub fn get_tile_center_for_viewbox(viewbox: &Viewbox) -> Tile { Tile((ex - sx) / 2, (ey - sy) / 2) } -pub fn get_tile_pos(Tile(x, y): Tile, scale: f32) -> (f32, f32) { - ( - x as f32 * get_tile_size(scale), - y as f32 * get_tile_size(scale), - ) +pub fn get_tile_pos(Tile(x, y): Tile, scale: f32, dpr: f32) -> (f32, f32) { + let ts = get_tile_size(scale, dpr); + (x as f32 * ts, y as f32 * ts) } -pub fn get_tile_size(scale: f32) -> f32 { - 1. / scale * TILE_SIZE +/// World/document size of one tile at `scale = zoom * dpr`. +/// Always `BASE / zoom` so viewport tile *count* matches DPR=1 regardless of +/// the raster tile texture size used for LOD. +pub fn get_tile_size(scale: f32, dpr: f32) -> f32 { + let zoom = (scale / dpr.max(0.01)).max(1e-6); + TILE_SIZE_BASE / zoom } -pub fn get_tile_rect(tile: Tile, scale: f32) -> skia::Rect { - let (tx, ty) = get_tile_pos(tile, scale); - let ts = get_tile_size(scale); +pub fn get_tile_rect(tile: Tile, scale: f32, dpr: f32) -> skia::Rect { + let (tx, ty) = get_tile_pos(tile, scale, dpr); + let ts = get_tile_size(scale, dpr); skia::Rect::from_xywh(tx, ty, ts, ts) } @@ -341,11 +372,9 @@ impl PendingTiles { pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces, only_visible: bool) { self.list.clear(); - // During interactive transform, skip the interest-area ring - // 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. + // During interactive transform / soft HiDPI settle, skip the interest + // ring — every rAF is on the critical path. The ring is repopulated on + // gesture end or the full-quality upgrade pass. let tile_rect = if only_visible { &tile_viewbox.visible_rect } else { @@ -384,7 +413,11 @@ impl PendingTiles { for (_, tile) in self.tile_order.iter() { let tile = *tile; let is_visible = tile_viewbox.visible_rect.contains(&tile); - let is_cached = surfaces.has_cached_tile_surface(tile); + // Interest tiles that were drawn but could not get an atlas slot must + // not be re-queued (would loop forever with DPR-scaled 16-slot atlases). + // Once they become visible, `has` is false until they get a real slot. + let is_cached = surfaces.has_cached_tile_surface(tile) + || (!is_visible && surfaces.was_rendered_without_atlas_slot(tile)); match (is_visible, is_cached) { (true, true) => self.visible_cached.push(tile),