From 5f921ecb9e466267d1143355cf1201c878d412a0 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 4 Aug 2026 10:57:42 +0200 Subject: [PATCH] WIP --- .../render-wasm/ffi-rendering-subtleties.md | 10 + render-wasm/src/main.rs | 15 +- render-wasm/src/render.rs | 410 ++++++++++++++---- render-wasm/src/render/debug.rs | 4 +- render-wasm/src/render/options.rs | 61 ++- render-wasm/src/render/surfaces.rs | 406 +++++++++++++---- render-wasm/src/tiles.rs | 83 ++-- 7 files changed, 781 insertions(+), 208 deletions(-) diff --git a/.serena/memories/render-wasm/ffi-rendering-subtleties.md b/.serena/memories/render-wasm/ffi-rendering-subtleties.md index f57856a17d..c29781021c 100644 --- a/.serena/memories/render-wasm/ffi-rendering-subtleties.md +++ b/.serena/memories/render-wasm/ffi-rendering-subtleties.md @@ -22,4 +22,14 @@ - `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. +- Tile texture raster size follows content quality: interactive pan/zoom refill uses `512` px (DPR=1 fill-rate) with **one paint-once tree walk**; if view DPR > 1, a second **per-tile** pass promotes to `512 * dpr`. No banding. World tile size stays `512/zoom`. Compositing uses device-scaled `RSXform` so 512 px sprites cover `512*dpr` backbuffer cells. +- **Critical:** paint CTM must use `get_raster_scale()` / `get_paint_scale()` (`raster_px / world_tile` = `zoom` at interactive LOD), including paint-once. Soft settle at view scale (`zoom*dpr`) re-introduces the DPR=2 zoom hitch. +- Soft settle presents and **stays Interactive** (no auto sharp). ZOOM-PERF showed sharp per-tile rewalk (~15× tree at 1024²) dominating ~2s after zoom; 512px sprites already cover the HiDPI backbuffer via `RSXform`. Re-enable sharp only via an explicit idle path. +- Partial frames **skip** GPU flush entirely. On WASM, `context.flush(None)` still blocked ~470ms after paint-once DONE (same class as `flush_and_submit`); hard sync only in `present_frame` / ViewportReady. Mid-walk soft flush is also skipped while `paint_region` is active. +- With full-quality `tile_size_px=1024` a 4096² tile atlas has only **16 slots**. `TileTextureCache::add` must never panic: reuse existing slots, `clear()` must deallocate immediately (not only mark `removed`), force-evict non-visible, and if still full skip the atlas blit. Interest tiles without a slot go into `rendered_without_slot` so the pending scheduler does not loop; `has()` stays false until they get a real sprite (so pan into them re-renders). Visible overflow steals the farthest other visible slot. Interactive quality (512 px) restores ~64 slots. +- Viewport interest ring is in tile units and does **not** scale with DPR (stays 1 by default). Scaling it with DPR after DPR-sized tiles doubled the device-pixel margin and ballooned the cache surface (~216 MiB). +- During interactive content quality, pending tiles are **visible-only** (skip interest ring) so the soft settle pass finishes quickly; interest fills on the full-quality pass. +- `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 c34313bcf9..abdeb82da3 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -371,17 +371,24 @@ 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. let t0 = performance::get_time(); + render_state.enter_interactive_content_quality()?; + let t_idx = performance::get_time(); render_state.rebuild_tile_index(&state.shapes); render_state.zoom_perf_log(&format!( - "set_view_end.rebuild_tile_index {}ms", - performance::get_time() - t0 + "set_view_end.rebuild_tile_index {}ms (after enter_interactive {}ms)", + performance::get_time() - t_idx, + t_idx - t0 )); let t1 = performance::get_time(); render_state.surfaces.invalidate_tile_cache(); render_state.zoom_perf_log(&format!( - "set_view_end.invalidate_tile_cache {}ms", - performance::get_time() - t1 + "set_view_end.invalidate_tile_cache {}ms | {}", + performance::get_time() - t1, + render_state.surfaces.surfaces_size_summary() )); } else { // Pure pan at the same zoom level: tile contents have not diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 322f9c8dc8..d3e585c057 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}; @@ -453,6 +453,12 @@ pub(crate) struct RenderState { zoom_perf_layered_other_n: u32, /// 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, } /// Active paint-once region (visible viewport tiles or interest ring). @@ -473,13 +479,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, @@ -490,12 +501,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); @@ -597,7 +609,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, )?; @@ -669,6 +681,8 @@ impl RenderState { zoom_perf_layered_nested_n: 0, zoom_perf_layered_other_n: 0, paint_region: None, + sharp_tile_refill: false, + pending_sharp_promote: false, }) } @@ -739,13 +753,15 @@ impl RenderState { self.zoom_perf_frame = 0; self.surfaces.set_paint_diag(true); println!( - "[ZOOM-PERF] BEGIN {reason} zoom={:.4} vbox=({:.1},{:.1},{:.1}x{:.1}) {}", + "[ZOOM-PERF] BEGIN {reason} zoom={:.4} dpr={:.2} quality={:?} vbox=({:.1},{:.1},{:.1}x{:.1}) | {}", self.viewbox.zoom, + self.options.dpr, + self.options.content_quality(), self.viewbox.area.left, self.viewbox.area.top, self.viewbox.area.width(), self.viewbox.area.height(), - self.surfaces.paint_diag_summary() + self.surfaces.surfaces_size_summary() ); } @@ -1037,17 +1053,106 @@ 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(()); + } + let t0 = performance::get_time(); + 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; + self.zoom_perf_log(&format!( + "enter_interactive_content_quality {}ms tile_px={} | {}", + performance::get_time() - t0, + self.surfaces.tile_size_px(), + self.surfaces.surfaces_size_summary() + )); + Ok(()) + } + + /// Promote to full-DPR tile textures after an interactive settle. Returns + /// `true` when a new progressive pass is required. + /// + /// The sharp refill is intentionally **per-tile** (see `sharp_tile_refill`): + /// soft settle already walked the tree once at DPR≤1 fill-rate. + pub fn try_begin_full_quality_pass(&mut self, _tree: ShapesPoolRef) -> Result { + if !self.options.needs_full_quality_upgrade() { + return Ok(false); + } + let t0 = performance::get_time(); + self.options.set_content_quality(ContentQuality::Full); + let t_raster = performance::get_time(); + self.surfaces + .set_raster_tile_size(self.options.raster_tile_size_px())?; + let raster_ms = performance::get_time() - t_raster; + let t_inv = performance::get_time(); + self.surfaces.invalidate_tile_cache(); + let inv_ms = performance::get_time() - t_inv; + 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); + let t_pend = performance::get_time(); + // Visible only — interest can wait; keeps the sharp refill from + // monopolizing the main thread after zoom. + self.pending_tiles + .update(&self.tile_viewbox, &self.surfaces, true); + let pend_ms = performance::get_time() - t_pend; + 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; + self.sharp_tile_refill = true; + self.pending_sharp_promote = false; + self.zoom_perf_log(&format!( + "try_begin_full_quality_pass total={}ms raster={}ms invalidate={}ms pending={}ms pending_n={} scale={:.3} | {}", + performance::get_time() - t0, + raster_ms, + inv_ms, + pend_ms, + self.pending_tiles.list.len(), + scale, + self.surfaces.surfaces_size_summary() + )); + Ok(true) + } + pub fn set_antialias_threshold(&mut self, value: f32) { self.options.set_antialias_threshold(value); } @@ -1117,8 +1222,19 @@ impl RenderState { /// on top of Target, then present. Backbuffer is left clean so it can be reused /// as-is across interactive-transform frames without stale overlay pixels. pub fn present_frame(&mut self, tree: ShapesPoolRef) { + let t0 = performance::get_time(); self.compose_frame(tree); + let t_compose = performance::get_time(); self.surfaces.flush_and_submit(SurfaceId::Target); + if self.zoom_perf_active { + self.zoom_perf_log(&format!( + "present_frame compose={}ms flush_submit={}ms total={}ms | {}", + t_compose - t0, + performance::get_time() - t_compose, + performance::get_time() - t0, + self.surfaces.surfaces_size_summary() + )); + } } /// Compose the frame on Target — the already-rendered Backbuffer plus the @@ -1542,14 +1658,15 @@ impl RenderState { // Only perceptible shadows need the layered Fills/Strokes path. Use the // same footprint LOD as when painting drop and inner shadows. - let scale = self.get_scale(); + let view_scale = self.get_scale(); + let paint_scale = self.get_paint_scale(); let shadows_need_layered = !skip_drop_shadows && (shape .drop_shadows_visible() - .any(|s| s.is_perceptible_at_scale_for(scale, shape.is_recursive())) + .any(|s| s.is_perceptible_at_scale_for(view_scale, shape.is_recursive())) || shape .inner_shadows_visible() - .any(|s| s.is_perceptible_at_scale_for(scale, shape.is_recursive()))); + .any(|s| s.is_perceptible_at_scale_for(view_scale, shape.is_recursive()))); // Clip is allowed: we apply the same stack on Current after scale+translate. // Opacity < 1 with SrcOver is OK: render_shape_enter already opened a @@ -1580,17 +1697,17 @@ impl RenderState { if can_render_directly { 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); }); if let Some(clips) = clip_bounds.as_ref() { - self.apply_clip_stack_to_surfaces(clips, target_surface as u32, scale, None); + self.apply_clip_stack_to_surfaces(clips, target_surface as u32, paint_scale, None); } if !shape.transform.is_identity() { @@ -2235,18 +2352,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) { @@ -2325,19 +2444,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 { @@ -2360,7 +2482,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, @@ -2400,6 +2523,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. @@ -2525,7 +2651,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(); @@ -2619,7 +2745,9 @@ impl RenderState { 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.sharp_tile_refill; self.pending_tiles .update(&self.tile_viewbox, &self.surfaces, only_visible); if zoom_perf { @@ -2643,10 +2771,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(); + // 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; let t_cont = performance::get_time(); frame_type = self.continue_render_loop(base_object, tree, timestamp, allow_stop)?; if zoom_perf { @@ -2719,11 +2847,37 @@ impl RenderState { ) -> Result { performance::begin_measure!("continue_render_loop"); let timestamp = self.render_budget_start(timestamp); + + // Soft already on screen: promote on a fresh rAF, then yield once more + // so the first sharp tile does not share the frame with atlas teardown. + if self.pending_sharp_promote { + self.pending_sharp_promote = false; + self.try_begin_full_quality_pass(tree)?; + crate::get_gpu_state().context.flush(None); + if self.zoom_perf_active { + self.zoom_perf_log("soft→sharp promote (deferred frame)"); + } + performance::end_measure!("continue_render_loop"); + return Ok(FrameType::Partial); + } + if self.zoom_perf_active { self.zoom_perf_frame += 1; } let t0 = performance::get_time(); let pending_before = self.pending_tiles.list.len(); + if self.zoom_perf_active { + self.zoom_perf_log(&format!( + "frame#{} continue enter pending={} sharp={} promote_pending={} quality={:?} budget={}ms | {}", + self.zoom_perf_frame, + pending_before, + self.sharp_tile_refill, + self.pending_sharp_promote, + self.options.content_quality(), + self.render_time_budget_ms(), + self.surfaces.surfaces_size_summary() + )); + } let frame_type = self.render_shape_tree_partial(base_object, tree, timestamp, allow_stop)?; @@ -2764,21 +2918,40 @@ impl RenderState { } } + // Soft settle at DPR≤1 fill-rate is enough for responsive zoom. + // Auto sharp (per-tile rewalk at 1024²) took ~2s in ZOOM-PERF and froze + // the frontend; 512px sprites are already placed with RSXform×dpr. + // Stay Interactive until an explicit idle/sharp path is added later. + if !self.options.is_interactive_transform() + && matches!(frame_type, FrameType::Full | FrameType::ViewportReady) + && self.options.needs_full_quality_upgrade() + { + self.present_frame(tree); + self.viewport_presented = true; + wapi::notify_tiles_render_complete!(); + if self.zoom_perf_active { + self.zoom_perf_log( + "soft settle complete — skip sharp promote (stay Interactive)", + ); + self.zoom_perf_end("soft settle (no sharp)"); + } + performance::end_measure!("continue_render_loop"); + return Ok(FrameType::Full); + } + match frame_type { FrameType::None => { panic!("FrameType::None"); } FrameType::Partial => { - // Drain tile GPU work (Current / tile atlas / cache) without - // presenting Target and without re-snapshotting the tile atlas. - // Composition stays deferred until ViewportReady/Full. - let t_f = performance::get_time(); - crate::get_gpu_state().context.flush_and_submit(); + // 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 / ViewportReady. if self.zoom_perf_active { self.zoom_perf_log(&format!( - "frame#{} Partial.flush_and_submit {}ms", - self.zoom_perf_frame, - performance::get_time() - t_f + "frame#{} Partial.flush(skip) 0ms", + self.zoom_perf_frame )); } } @@ -2811,6 +2984,7 @@ impl RenderState { } } FrameType::Full => { + self.sharp_tile_refill = false; if !self.viewport_presented { // A full-quality frame is now complete (no early viewport // present). Rebuild crop cache and present. @@ -2970,10 +3144,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); } @@ -2986,12 +3160,7 @@ impl RenderState { if iteration % self.options.node_batch_threshold != 0 { return false; } - // Multi-tile paint regions need fewer yields to finish visible HQ work. - let budget = if self.paint_region.is_some() { - self.options.max_blocking_time_ms.max(48) - } else { - self.options.max_blocking_time_ms - }; + let budget = self.render_time_budget_ms(); if performance::get_time() - timestamp <= budget { return false; } @@ -3011,6 +3180,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 + } + /// Skip all drop/inner shadows in fast mode, or when even a large design-space /// shadow would be subpixel. Otherwise filter per shadow via /// [`Shadow::is_perceptible_at_scale_for`] (stricter for recursive shapes). @@ -3301,7 +3481,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 { @@ -3329,15 +3509,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, ) } @@ -4185,13 +4366,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)); @@ -4203,7 +4385,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; @@ -4219,6 +4401,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], @@ -4227,11 +4412,18 @@ 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 = if self.pending_tiles.has_deferred_interest() { + let label = if self.options.content_quality() == ContentQuality::Interactive + || self.sharp_tile_refill + || self.pending_tiles.has_deferred_interest() + { "visible" } else { "interest" @@ -4255,10 +4447,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 { @@ -4266,8 +4459,12 @@ 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. + if region_tiles.len() == 1 + || !self + .surfaces + .region_fits_paint_surface(area, paint_scale) + { self.pending_tiles.list.extend(region_tiles); return Ok(false); } @@ -4311,7 +4508,7 @@ impl RenderState { if self.zoom_perf_active { self.zoom_perf_log(&format!( - "region={} START tiles={} shapes={} roots={} bg_blur={} skip_shadows={} zoom={:.3} scale={:.3} area=({:.1},{:.1},{:.1}x{:.1}) {}", + "region={} START tiles={} shapes={} roots={} bg_blur={} skip_shadows={} zoom={:.3} paint_scale={:.3} view_scale={:.3} area=({:.1},{:.1},{:.1}x{:.1}) {}", label, region_tiles.len(), shape_ids.len(), @@ -4319,7 +4516,8 @@ impl RenderState { region_has_bg_blur, self.should_skip_drop_shadows(), self.viewbox.zoom, - self.get_scale(), + paint_scale, + view_scale, area.left, area.top, area.width(), @@ -4350,15 +4548,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( @@ -4482,6 +4681,12 @@ impl RenderState { breakdown )); } + // 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 @@ -4529,6 +4734,17 @@ impl RenderState { breakdown )); } + // 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); @@ -4680,7 +4896,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 @@ -5013,6 +5229,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..973a429d66 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 (DPR=1 fill-rate) during pan/zoom; +/// Full uses sharp `512 * dpr` tiles after settle. +#[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 87e2f07a84..91555a65c3 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -34,12 +34,16 @@ 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) } @@ -66,7 +70,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 +79,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() } @@ -470,6 +474,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, /// When true, accumulate draw_into wall time (ZOOM-PERF diagnosis). paint_diag: bool, @@ -487,12 +493,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)?; @@ -525,7 +533,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, @@ -553,15 +561,81 @@ 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, paint_diag: false, draw_into_ms: 0, draw_into_n: 0, }) } - 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** shrink + /// Current (paint-once keeps a viewport-sized paint surface). + 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 t0 = performance::get_time(); + let old = self.tile_size_px; + 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; + if self.paint_diag { + println!( + "[ZOOM-PERF] set_raster_tile_size {}→{} {}ms | {}", + old, + tile_size_px, + performance::get_time() - t0, + self.surfaces_size_summary() + ); + } + 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) { + let t0 = performance::get_time(); + self.tiles.clear(); + self.tile_atlas_image = None; + self.atlas.tile_doc_rects.clear(); + if self.paint_diag { + println!( + "[ZOOM-PERF] invalidate_tile_cache {}ms | {}", + performance::get_time() - t0, + self.surfaces_size_summary() + ); + } + } + + pub fn was_rendered_without_atlas_slot(&self, tile: Tile) -> bool { + self.tiles.was_rendered_without_slot(tile) } pub fn clear_tiles(&mut self) { @@ -574,14 +648,20 @@ impl Surfaces { tile_viewbox: &TileViewbox, background: skia::Color, ) { + let t0 = performance::get_time(); self.tiles.update(viewbox, tile_viewbox); + let t_upd = performance::get_time(); + let mut snap_ms = 0; if self.tiles.needs_snapshot() || self.tile_atlas_image.is_none() { + let t_s = performance::get_time(); self.tile_atlas_image = Some(self.tile_atlas.image_snapshot()); self.tiles.snapshot(); + snap_ms = performance::get_time() - t_s; } let Some(atlas_image) = self.tile_atlas_image.as_ref() else { return; }; + let t_draw = performance::get_time(); let canvas = self.backbuffer.canvas(); canvas.clear(background); canvas.draw_atlas( @@ -594,6 +674,18 @@ impl Surfaces { None, None, ); + if self.paint_diag { + println!( + "[ZOOM-PERF] draw_tile_atlas_to_bb update={}ms snapshot={}ms draw={}ms total={}ms atlas={}x{} | {}", + t_upd - t0, + snap_ms, + performance::get_time() - t_draw, + performance::get_time() - t0, + self.tile_atlas.width(), + self.tile_atlas.height(), + self.surfaces_size_summary() + ); + } } /// Draw the persistent atlas onto the backbuffer using the current viewbox transform. @@ -711,12 +803,21 @@ 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, + ); if dims == self.extra_tile_dims { return Ok(()); } + let t0 = performance::get_time(); + let old = self.extra_tile_dims; 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 @@ -735,6 +836,17 @@ impl Surfaces { recreate("shape_fills", &mut self.shape_fills)?; recreate("shape_strokes", &mut self.shape_strokes)?; self.clear_all_dirty(); + if self.paint_diag { + println!( + "[ZOOM-PERF] resize_paint_surfaces {}x{}→{}x{} {}ms | {}", + old.width, + old.height, + dims.width, + dims.height, + performance::get_time() - t0, + self.surfaces_size_summary() + ); + } Ok(()) } @@ -750,6 +862,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() @@ -890,11 +1006,51 @@ impl Surfaces { pub fn paint_diag_summary(&self) -> String { format!( - "current={}x{} draw_into={}ms n={}", + "current={}x{} draw_into={}ms n={} | {}", self.current.width(), self.current.height(), self.draw_into_ms, - self.draw_into_n + self.draw_into_n, + self.surfaces_size_summary() + ) + } + + /// All GPU surface sizes relevant to HiDPI stalls (Current pad, atlas, cache). + pub fn surfaces_size_summary(&self) -> String { + let tile = self.tile_size_px.max(1); + let atlas_side = self.tile_atlas.width(); + let slots = (atlas_side / tile) * (atlas_side / tile); + format!( + "tile_px={} margins={}x{} slots~{} dpr={:.2} current={}x{} fills={}x{} strokes={}x{} drop={}x{} inner={}x{} text_drop={}x{} filter={}x{} bb={}x{} target={}x{} cache={}x{} tile_atlas={}x{} extra={}x{}", + self.tile_size_px, + self.margins.width, + self.margins.height, + slots, + self.dpr, + self.current.width(), + self.current.height(), + self.shape_fills.width(), + self.shape_fills.height(), + self.shape_strokes.width(), + self.shape_strokes.height(), + self.drop_shadows.width(), + self.drop_shadows.height(), + self.inner_shadows.width(), + self.inner_shadows.height(), + self.text_drop_shadows.width(), + self.text_drop_shadows.height(), + self.filter.width(), + self.filter.height(), + self.backbuffer.width(), + self.backbuffer.height(), + self.target.width(), + self.target.height(), + self.cache.width(), + self.cache.height(), + self.tile_atlas.width(), + self.tile_atlas.height(), + self.extra_tile_dims.width, + self.extra_tile_dims.height, ) } @@ -1107,8 +1263,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(()) } @@ -1116,17 +1272,36 @@ 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 t0 = performance::get_time(); + let result = self.resize_cache( + skia::ISize::new(new_w, new_h), + interest_area_threshold, + ); + if self.paint_diag { + println!( + "[ZOOM-PERF] resize_cache {}x{}→{}x{} {}ms | {}", + current_w, + current_h, + new_w, + new_h, + performance::get_time() - t0, + self.surfaces_size_summary() + ); + } + return result; } Ok(()) } @@ -1329,7 +1504,7 @@ impl Surfaces { tile_rect, skip_cache_surface, tile_doc_rect, - skia::Rect::from(TILE_DRAWABLE_RECT), + skia::Rect::from(self.tile_drawable_rect()), ); } @@ -1356,16 +1531,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 @@ -1399,7 +1575,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 { @@ -1438,7 +1617,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; @@ -1447,7 +1626,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; @@ -1578,12 +1757,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(); } @@ -1713,10 +1886,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 { @@ -1731,24 +1907,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); } } @@ -1761,9 +1939,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) @@ -1771,13 +1953,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); } } @@ -1801,6 +1984,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() { @@ -1814,8 +2003,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, @@ -1856,7 +2051,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; } @@ -1870,8 +2065,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; @@ -1880,7 +2073,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; } @@ -1903,10 +2096,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 { @@ -1914,18 +2119,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> { @@ -1943,12 +2188,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 f9e4867b79..a89a4bfce8 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) } @@ -345,11 +376,9 @@ impl PendingTiles { 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 - // 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 { @@ -388,7 +417,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),