From cebb6d416099dcc864e10689e543eb8dc51cf628 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 31 Jul 2026 10:33:25 +0200 Subject: [PATCH 1/6] :zap: Avoid per-tile image_snapshot when filling atlases Copy Current into DocAtlas and the tile atlas with Surface::draw instead of image_snapshot_with_bounds, matching the interactive path and removing a GPU sync stall on every completed tile. --- render-wasm/src/render/surfaces.rs | 115 ++++++++++++++++++----------- 1 file changed, 70 insertions(+), 45 deletions(-) diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index e68c0df0c3..efe191860f 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -27,6 +27,29 @@ const TILE_DRAWABLE_RECT: IRect = IRect { }; const DOC_ATLAS_MAX_DIM: i32 = 4096; +/// 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( + from: &mut skia::Surface, + to_canvas: &skia::Canvas, + src: skia::Rect, + dst: skia::Rect, + sampling: skia::SamplingOptions, +) { + if src.is_empty() || dst.is_empty() { + return; + } + to_canvas.save(); + to_canvas.clip_rect(dst, None, true); + let sx = dst.width() / src.width(); + let sy = dst.height() / src.height(); + to_canvas.translate((dst.left, dst.top)); + to_canvas.scale((sx, sy)); + to_canvas.translate((-src.left, -src.top)); + from.draw(to_canvas, (0.0, 0.0), sampling, None); + to_canvas.restore(); +} + pub fn get_cache_size(viewbox: &Viewbox, interest: i32) -> skia::ISize { // First we retrieve the extended area of the viewport that we could render. let TileRect(isx, isy, iex, iey) = @@ -238,17 +261,20 @@ impl DocAtlas { Ok(()) } - fn blit_tile_image_into_atlas( + /// Blit a Current-surface drawable rect into the doc atlas without + /// `image_snapshot` (GPU→GPU draw; avoids per-tile sync stalls). + fn blit_current_drawable_into_atlas( &mut self, gpu_state: &mut GpuState, - tile_image: &skia::Image, + current: &mut skia::Surface, + drawable_src: skia::Rect, tile_doc_rect: skia::Rect, + sampling: skia::SamplingOptions, ) -> Result<()> { - if tile_doc_rect.is_empty() { + if tile_doc_rect.is_empty() || drawable_src.is_empty() { return Ok(()); } - // Clamp to document bounds (if any) and compute a matching source-rect in tile pixels. let mut clipped_doc_rect = tile_doc_rect; if let Some(bounds) = self.doc_bounds { if !clipped_doc_rect.intersect(bounds) { @@ -261,7 +287,6 @@ impl DocAtlas { self.ensure_atlas_contains(gpu_state, clipped_doc_rect)?; - // Destination is document-space rect mapped into atlas pixel coords. let dst = skia::Rect::from_xywh( (clipped_doc_rect.left - self.origin.x) * self.scale, (clipped_doc_rect.top - self.origin.y) * self.scale, @@ -269,24 +294,18 @@ impl DocAtlas { clipped_doc_rect.height() * self.scale, ); - // Compute source rect in tile_image pixel coordinates. - let img_w = tile_image.width() as f32; - let img_h = tile_image.height() as f32; let tw = tile_doc_rect.width().max(1.0); let th = tile_doc_rect.height().max(1.0); - - let sx = ((clipped_doc_rect.left - tile_doc_rect.left) / tw) * img_w; - let sy = ((clipped_doc_rect.top - tile_doc_rect.top) / th) * img_h; - let sw = (clipped_doc_rect.width() / tw) * img_w; - let sh = (clipped_doc_rect.height() / th) * img_h; - let src = skia::Rect::from_xywh(sx, sy, sw, sh); - - self.surface.canvas().draw_image_rect( - tile_image, - Some((&src, skia::canvas::SrcRectConstraint::Fast)), - dst, - &skia::Paint::default(), + let dw = drawable_src.width(); + let dh = drawable_src.height(); + let src = skia::Rect::from_xywh( + drawable_src.left + ((clipped_doc_rect.left - tile_doc_rect.left) / tw) * dw, + drawable_src.top + ((clipped_doc_rect.top - tile_doc_rect.top) / th) * dh, + (clipped_doc_rect.width() / tw) * dw, + (clipped_doc_rect.height() / th) * dh, ); + + draw_surface_src_rect_to_dst(current, self.surface.canvas(), src, dst, sampling); Ok(()) } @@ -1198,34 +1217,40 @@ impl Surfaces { tile_doc_rect: skia::Rect, ) { let gpu_state = get_gpu_state(); - let rect = TILE_DRAWABLE_RECT; + let src = skia::Rect::from(TILE_DRAWABLE_RECT); + let sampling = self.sampling_options; - let tile_image_opt = self.current.image_snapshot_with_bounds(rect); - if let Some(tile_image) = tile_image_opt { - if !skip_cache_surface { - // Draw to cache surface for render_from_cache - self.cache.canvas().draw_image_rect( - &tile_image, - None, - tile_rect, - &skia::Paint::default(), - ); - } + // DocAtlas + tile atlas via Surface::draw (no image_snapshot sync). + let _ = self.atlas.blit_current_drawable_into_atlas( + gpu_state, + &mut self.current, + src, + tile_doc_rect, + sampling, + ); + self.atlas.tile_doc_rects.insert(*tile, tile_doc_rect); - // Incrementally update persistent 1:1 atlas in document space. - // `tile_doc_rect` is in world/document coordinates (1 unit == 1 px at 100%). - let _ = self - .atlas - .blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect); - 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, + ); - // Draws current tile into tile atlas - let tile_ref = self.tiles.add(tile_viewbox, tile); - self.tile_atlas.canvas().draw_image_rect( - &tile_image, - None, - tile_ref.rect, - &skia::Paint::default(), + if !skip_cache_surface { + // Optional legacy Cache surface fill (debug). Pan/zoom preview + // uses DocAtlas + tile-atlas textures via render_from_cache. + let mut current = self.current.clone(); + draw_surface_src_rect_to_dst( + &mut current, + self.cache.canvas(), + src, + *tile_rect, + sampling, ); } } From 016e20b1078817d20268c49bb004b5f247202a8e Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 3 Aug 2026 08:28:06 +0200 Subject: [PATCH 2/6] :zap: Fix progressive render budget when timestamp is stale Pass performance.now from finalize/debounce and re-anchor the WASM budget if the stamp is 0 or already past max_blocking_time, so HQ tiles are not yielded after a few nodes with almost no real work. --- frontend/src/app/render_wasm/api.cljs | 4 ++-- render-wasm/src/render.rs | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index b8601102ee..cd21f63546 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1406,7 +1406,7 @@ ;; this implicitly (`zoom_changed`); this extends it to pan/resize-triggered ;; ends (e.g. selecting a shape opens the options panel and resizes the ;; viewport), which previously blanked. - (internal-render 0 RENDER-FLAG-SYNC-TILES) + (internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES) ;; The direct render above bypasses the rAF `render` loop, so repaint the ;; editor overlay explicitly. Only when this was a full frame: a progressive ;; render keeps painting through the rAF loop and its partial frames must not @@ -1421,7 +1421,7 @@ (if (view-gesture-active?) ;; Pan/zoom pause: render without ending the interaction. (do - (internal-render 0 RENDER-FLAG-SYNC-TILES) + (internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES) (render-text-editor-overlay-after-frame!)) (finalize-view-interaction!))))] (fns/debounce do-render DEBOUNCE_DELAY_MS))) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 7ff4423f7a..d1c4228343 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -2357,6 +2357,7 @@ impl RenderState { allow_stop: bool, ) -> Result { performance::begin_measure!("continue_render_loop"); + let timestamp = self.render_budget_start(timestamp); let frame_type = self.render_shape_tree_partial(base_object, tree, timestamp, allow_stop)?; @@ -2404,6 +2405,7 @@ impl RenderState { tree: ShapesPoolRef, timestamp: i32, ) -> Result { + let timestamp = self.render_budget_start(timestamp); self.render_shape_tree_partial(base_object, tree, timestamp, false)?; // Same composition as `continue_render_loop` for full frames: snapshot only the @@ -2538,6 +2540,24 @@ impl RenderState { Ok((data.as_bytes().to_vec(), width, height)) } + /// Anchor the progressive render budget to wall-clock now when the + /// caller-provided timestamp is unusable: + /// - Frontend sometimes passes `0` (finalize-view / debounced zoom-end). + /// - rAF may hand a timestamp that is already older than the budget when + /// the handler runs late. Using that stamp made `should_stop_rendering` + /// yield after a few nodes with ~0ms of real work. + #[inline] + fn render_budget_start(&self, timestamp: i32) -> i32 { + let now = performance::get_time(); + if timestamp <= 0 { + return now; + } + if now - timestamp > self.options.max_blocking_time_ms { + return now; + } + timestamp + } + #[inline] pub fn should_stop_rendering(&self, iteration: i32, timestamp: i32) -> bool { if iteration % self.options.node_batch_threshold != 0 { From af3393bdbff127ca7af8b8f6601d6c8b0d02132f Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 3 Aug 2026 08:33:33 +0200 Subject: [PATCH 3/6] :zap: Paint visible and interest tiles once then crop to atlas Size Current and layer surfaces to the viewport plus interest pad on window resize, walk each uncached batch once, and GPU-crop 512 tiles into the atlases so post-zoom HQ avoids re-walking the tree per tile. Soft-flush the GPU during paint-region batches so Partial submit does not stall on a full command backlog. --- render-wasm/src/render.rs | 367 +++++++++++++++++++++++------ render-wasm/src/render/surfaces.rs | 180 +++++++++++--- 2 files changed, 432 insertions(+), 115 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index d1c4228343..265063bd47 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -44,6 +44,7 @@ pub(crate) use resources::RenderResources; type ClipStack = Vec<(Rect, Option, Matrix)>; +#[derive(Clone, Copy)] #[repr(u8)] pub enum FrameType { None = 0, @@ -413,6 +414,14 @@ pub(crate) struct RenderState { /// a tile before its text glyph uploads complete (blank first/center tile). /// One explicit flush warms the submit path for the rest of the pass. pub tile_atlas_flushed: bool, + /// Multi-tile paint-once into Current, then crop to atlas slots. + paint_region: Option, +} + +/// Active paint-once region (visible viewport tiles or interest ring). +struct PaintRegion { + tiles: Vec, + label: &'static str, } pub struct InteractiveDragCrop { @@ -552,6 +561,7 @@ impl RenderState { (width, height), sampling_options, tiles::get_tile_dimensions(), + RenderOptions::default().dpr_viewport_interest_area_threshold, )?; Self::assemble(width, height, surfaces) @@ -596,6 +606,7 @@ impl RenderState { preserve_target_during_render: false, backbuffer_crop_cache: HashMap::default(), tile_atlas_flushed: false, + paint_region: None, }) } @@ -897,6 +908,13 @@ impl RenderState { // affect pending_tiles generation. self.tile_viewbox .set_interest(self.options.dpr_viewport_interest_area_threshold); + let dpr_width = (self.viewbox.width() * self.options.dpr).floor() as i32; + let dpr_height = (self.viewbox.height() * self.options.dpr).floor() as i32; + let _ = self.surfaces.resize_paint_surfaces( + dpr_width, + dpr_height, + self.options.dpr_viewport_interest_area_threshold, + ); } } @@ -923,7 +941,11 @@ impl RenderState { pub fn resize(&mut self, width: i32, height: i32) -> Result<()> { let dpr_width = (width as f32 * self.options.dpr).floor() as i32; let dpr_height = (height as f32 * self.options.dpr).floor() as i32; - self.surfaces.resize(dpr_width, dpr_height)?; + self.surfaces.resize( + dpr_width, + dpr_height, + self.options.dpr_viewport_interest_area_threshold, + )?; self.viewbox.set_wh(width as f32, height as f32); self.tile_viewbox.update(&self.viewbox); @@ -1169,6 +1191,7 @@ impl RenderState { // Clear dirty flags for surfaces we just cleared self.surfaces.clear_dirty(dirty_surfaces_to_clear); } + } pub fn clear_focus_mode(&mut self) { @@ -2190,6 +2213,7 @@ impl RenderState { // reorder by distance to the center. self.current_tile = None; + self.paint_region = None; } pub fn start_render_loop( @@ -2200,6 +2224,7 @@ impl RenderState { sync_render: bool, ) -> Result { self.clear(tree); + let timestamp = self.render_budget_start(timestamp); let _start = performance::begin_timed_log!("start_render_loop"); let scale = self.get_scale(); @@ -2213,6 +2238,7 @@ 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); @@ -2221,6 +2247,7 @@ 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); } @@ -2276,6 +2303,7 @@ 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(); self.pending_tiles .update(&self.tile_viewbox, &self.surfaces, only_visible); @@ -2294,6 +2322,7 @@ impl RenderState { // 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(); frame_type = self.continue_render_loop(base_object, tree, timestamp, allow_stop)?; // This is an option to debug frames. @@ -2358,9 +2387,14 @@ impl RenderState { ) -> Result { performance::begin_measure!("continue_render_loop"); let timestamp = self.render_budget_start(timestamp); + let t0 = performance::get_time(); + let pending_before = self.pending_tiles.list.len(); + 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. @@ -2386,12 +2420,14 @@ impl RenderState { // 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!(); + performance::end_measure!("render"); } } @@ -2405,7 +2441,6 @@ impl RenderState { tree: ShapesPoolRef, timestamp: i32, ) -> Result { - let timestamp = self.render_budget_start(timestamp); self.render_shape_tree_partial(base_object, tree, timestamp, false)?; // Same composition as `continue_render_loop` for full frames: snapshot only the @@ -2540,24 +2575,6 @@ impl RenderState { Ok((data.as_bytes().to_vec(), width, height)) } - /// Anchor the progressive render budget to wall-clock now when the - /// caller-provided timestamp is unusable: - /// - Frontend sometimes passes `0` (finalize-view / debounced zoom-end). - /// - rAF may hand a timestamp that is already older than the budget when - /// the handler runs late. Using that stamp made `should_stop_rendering` - /// yield after a few nodes with ~0ms of real work. - #[inline] - fn render_budget_start(&self, timestamp: i32) -> i32 { - let now = performance::get_time(); - if timestamp <= 0 { - return now; - } - if now - timestamp > self.options.max_blocking_time_ms { - return now; - } - timestamp - } - #[inline] pub fn should_stop_rendering(&self, iteration: i32, timestamp: i32) -> bool { if iteration % self.options.node_batch_threshold != 0 { @@ -2582,6 +2599,31 @@ impl RenderState { true } + /// 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] + fn soft_flush_gpu(&mut self) { + crate::get_gpu_state().context.flush(None); + } + + /// Normalize the render time budget start. + /// + /// - Frontend sometimes passes `0` (finalize-view / debounced zoom-end). + /// - rAF may hand a timestamp that is already older than the budget when + /// the handler runs late. Using that stamp made `should_stop_rendering` + /// yield after a few nodes with ~0ms of real work. + #[inline] + fn render_budget_start(&self, timestamp: i32) -> i32 { + let now = performance::get_time(); + if timestamp <= 0 { + return now; + } + if now - timestamp > self.options.max_blocking_time_ms { + return now; + } + timestamp + } + #[inline] fn clip_target_surface_to_stack( &mut self, @@ -3117,6 +3159,7 @@ impl RenderState { /// Renders element drop shadows to DropShadows surface and composites to Current. /// Used for both normal shadow rendering and pre-layer rendering (frame_clip_layer_blur). + /// Returns `true` when at least one visible drop shadow was composited. #[allow(clippy::too_many_arguments)] fn render_element_drop_shadows_and_composite( &mut self, @@ -3546,6 +3589,7 @@ impl RenderState { Cow::Borrowed(element) }; + let is_text = matches!(element_for_inline.shape_type, Type::Text(_)); self.render_shape( &element_for_inline, clip_bounds.clone(), @@ -3631,6 +3675,13 @@ 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(); + } + } if allow_stop && self.should_stop_rendering(iteration, timestamp) { return Ok((is_empty, true)); } @@ -3640,6 +3691,153 @@ impl RenderState { Ok((is_empty, false)) } + fn update_render_context_for_area(&mut self, area: Rect) { + let scale = self.get_scale(); + self.render_area = area; + let margins = self.surfaces.margins(); + let margin_w = margins.width as f32 / scale; + let margin_h = margins.height as f32 / 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); + } + + /// Drain pending uncached tiles into a paint-once region when safe. + /// Returns true when `paint_region` was started and nodes were seeded. + fn try_begin_paint_region( + &mut self, + root_ids: &[Uuid], + tree: ShapesPoolRef, + ) -> Result { + if self.viewer_masked_pass() || self.options.is_interactive_transform() { + return Ok(false); + } + if self.paint_region.is_some() || !self.pending_nodes.is_empty() { + return Ok(false); + } + + let label = "region"; + + let mut region_tiles = Vec::new(); + let mut remaining = Vec::new(); + for tile in self.pending_tiles.list.drain(..) { + if self.surfaces.has_cached_tile_surface(tile) { + continue; + } + if self.tiles.is_empty_at(tile) { + remaining.push(tile); + continue; + } + region_tiles.push(tile); + } + self.pending_tiles.list = remaining; + + if region_tiles.is_empty() { + return Ok(false); + } + + let scale = self.get_scale(); + let mut area = Rect::new_empty(); + for tile in ®ion_tiles { + let r = tiles::get_tile_rect(*tile, scale); + if area.is_empty() { + area = r; + } else { + area.join(r); + } + } + + if region_tiles.len() == 1 || !self.surfaces.region_fits_paint_surface(area, scale) { + // Restore for the single-tile path (pop from end). + self.pending_tiles.list.extend(region_tiles); + return Ok(false); + } + + self.update_render_context_for_area(area); + self.current_tile = Some(region_tiles[0]); + self.current_tile_had_shapes = true; + self.tile_atlas_flushed = false; + + let mut shape_ids: HashSet = HashSet::default(); + let mut region_has_bg_blur = false; + for tile in ®ion_tiles { + if let Some(ids) = self.tiles.get_shapes_at(*tile) { + for id in ids { + shape_ids.insert(*id); + if !region_has_bg_blur { + region_has_bg_blur = tree + .get(id) + .is_some_and(|s| s.visible_background_blur().is_some()); + } + } + } + } + + let mut valid_ids = Vec::new(); + if region_has_bg_blur { + valid_ids.extend(root_ids.iter().copied()); + } else { + for root_id in root_ids { + if shape_ids.contains(root_id) { + valid_ids.push(*root_id); + } + } + } + + if valid_ids.is_empty() { + self.pending_tiles.list.extend(region_tiles); + return Ok(false); + } + + self.pending_nodes + .extend(valid_ids.into_iter().map(|id| NodeRenderState { + id, + visited_children: false, + clip_bounds: None, + visited_mask: false, + mask: false, + flattened: false, + })); + + self.paint_region = Some(PaintRegion { + tiles: region_tiles, + label, + }); + Ok(true) + } + + fn apply_paint_region_to_atlas(&mut self, region: &PaintRegion) -> Result<()> { + if self.tile_atlas_flushed { + crate::get_gpu_state().context.flush_and_submit(); + } + self.cache_cleared_this_render = true; + let scale = self.get_scale(); + let render_area = self.render_area; + + for tile in ®ion.tiles { + let tile_doc_rect = tiles::get_tile_rect(*tile, scale); + let src = self.surfaces.tile_drawable_src_in_region( + tile_doc_rect, + render_area, + scale, + ); + let aligned = self.get_aligned_tile_bounds(*tile); + self.surfaces.draw_current_src_into_tile_atlas( + &self.tile_viewbox, + tile, + &aligned, + true, + tile_doc_rect, + src, + ); + } + Ok(()) + } + pub fn render_shape_tree_partial( &mut self, base_object: Option<&Uuid>, @@ -3683,31 +3881,37 @@ impl RenderState { } performance::end_measure!("render_shape_tree::uncached"); - let tile_rect = self.get_current_tile_bounds()?; - // Composite if the walker did work in this PAF (`!is_empty`) OR - // the tile has unfinished work from a previous PAF - // (`current_tile_had_shapes` was set when we populated pending_nodes - // for this tile). - if !is_empty || self.current_tile_had_shapes { - if self.options.is_interactive_transform() { - // During drag, avoid snapshot-based caching. Draw Current directly - // into Target (and Cache) to reduce stalls. - self.surfaces.draw_current_tile_into_backbuffer( - &tile_rect, - self.background_color, - surfaces::DrawOnCache::Yes, - ); - } else { - self.apply_render_to_final_canvas()?; + if let Some(region) = self.paint_region.take() { + if !is_empty || self.current_tile_had_shapes { + self.apply_paint_region_to_atlas(®ion)?; } + } else { + let tile_rect = self.get_current_tile_bounds()?; + // Composite if the walker did work in this PAF (`!is_empty`) OR + // the tile has unfinished work from a previous PAF + // (`current_tile_had_shapes` was set when we populated pending_nodes + // for this tile). + if !is_empty || self.current_tile_had_shapes { + if self.options.is_interactive_transform() { + // During drag, avoid snapshot-based caching. Draw Current directly + // into Target (and Cache) to reduce stalls. + self.surfaces.draw_current_tile_into_backbuffer( + &tile_rect, + self.background_color, + surfaces::DrawOnCache::No, + ); + } else { + self.apply_render_to_final_canvas()?; + } - if self.options.is_debug_visible() { - debug::render_workspace_current_tile( - self, - "".to_string(), - current_tile, - tile_rect, - ); + if self.options.is_debug_visible() { + debug::render_workspace_current_tile( + self, + "".to_string(), + current_tile, + tile_rect, + ); + } } } } else if self.tiles.is_empty_at(current_tile) { @@ -3719,6 +3923,11 @@ impl RenderState { .canvas(SurfaceId::Current) .clear(self.background_color); + // Prefer paint-once for the remaining uncached batch (visible or interest). + if self.try_begin_paint_region(&root_ids, tree)? { + continue; + } + // If we finish processing every node rendering is complete // let's check if there are more pending nodes if let Some(next_tile) = self.pending_tiles.pop() { @@ -3732,44 +3941,47 @@ impl RenderState { let viewer_masked_pass = self.viewer_masked_pass(); - let Some(ids) = self.tiles.get_shapes_at(next_tile) else { - // If the tile is empty we do not need to render it. - continue; - }; + let valid_ids = { + let Some(ids) = self.tiles.get_shapes_at(next_tile) else { + // If the tile is empty we do not need to render it. + continue; + }; - // Never skip based on cached surfaces during viewer masked passes. - if !viewer_masked_pass && self.surfaces.has_cached_tile_surface(next_tile) { - // If the tile is cached, then we do not need to - // render it. - continue; - } + // Never skip based on cached surfaces during viewer masked passes. + if !viewer_masked_pass && self.surfaces.has_cached_tile_surface(next_tile) { + // If the tile is cached, then we do not need to + // render it. + continue; + } - // Check if any shape on this tile has a background blur. - // If so, we need ALL root shapes rendered (not just those - // assigned to this tile) because the blur snapshots Current - // which must contain the shapes behind it. - let tile_has_bg_blur = ids.iter().any(|id| { - tree.get(id) - .is_some_and(|s| s.visible_background_blur().is_some()) - }); + // Check if any shape on this tile has a background blur. + // If so, we need ALL root shapes rendered (not just those + // assigned to this tile) because the blur snapshots Current + // which must contain the shapes behind it. + let tile_has_bg_blur = ids.iter().any(|id| { + tree.get(id) + .is_some_and(|s| s.visible_background_blur().is_some()) + }); - // We only need first level shapes, in the same order as the parent node. - // - // During interactive transforms we may invalidate only the modified shapes - // (to avoid massive ancestor eviction). However, we still composite full - // tiles (we clear the tile rect before drawing Current), so we must render - // all root shapes that can contribute to this tile; otherwise, unchanged - // siblings inside the same tile would disappear. - let mut valid_ids = Vec::with_capacity(ids.len()); - if self.options.is_interactive_transform() || tile_has_bg_blur { - valid_ids.extend(root_ids.iter().copied()); - } else { - for root_id in root_ids.iter() { - if ids.contains(root_id) { - valid_ids.push(*root_id); + // We only need first level shapes, in the same order as the parent node. + // + // During interactive transforms we may invalidate only the modified shapes + // (to avoid massive ancestor eviction). However, we still composite full + // tiles (we clear the tile rect before drawing Current), so we must render + // all root shapes that can contribute to this tile; otherwise, unchanged + // siblings inside the same tile would disappear. + let mut valid_ids = Vec::with_capacity(ids.len()); + if self.options.is_interactive_transform() || tile_has_bg_blur { + valid_ids.extend(root_ids.iter().copied()); + } else { + for root_id in root_ids.iter() { + if ids.contains(root_id) { + valid_ids.push(*root_id); + } } } - } + valid_ids + }; if !valid_ids.is_empty() { self.current_tile_had_shapes = true; @@ -3978,11 +4190,14 @@ 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 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; if zoom_changed { let _ = self.update_shape_tiles(shape, tree); } else { diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index efe191860f..7d0c0355e8 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -14,11 +14,12 @@ use std::collections::{HashMap, HashSet}; const TEXTURES_CACHE_CAPACITY: usize = 1024; const TEXTURES_BATCH_DELETE: usize = 256; -// This is the amount of extra space we're going to give to all the surfaces to render shapes. -// If it's too big it could affect performance. +// Per-tile blur/shadow spill into the paint surface. Also used as the +// outer pad when Current covers a multi-tile region. const TILE_SIZE: i32 = tiles::TILE_SIZE as i32; const TILE_SIZE_MULTIPLIER: i32 = 2; const TILE_MARGIN_SIZE: i32 = TILE_SIZE * TILE_SIZE_MULTIPLIER / 4; +/// Drawable 512² for the legacy single-tile path (content starts after margins). const TILE_DRAWABLE_RECT: IRect = IRect { left: TILE_MARGIN_SIZE, top: TILE_MARGIN_SIZE, @@ -27,6 +28,21 @@ 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). +pub fn paint_surface_dims( + viewport_w: i32, + viewport_h: i32, + interest_tiles: 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); + skia::ISize::new(w, h) +} + /// 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( @@ -463,14 +479,16 @@ impl Surfaces { (width, height): (i32, i32), sampling_options: skia::SamplingOptions, tile_dims: skia::ISize, + interest_tiles: i32, ) -> Result { let gpu_state = get_gpu_state(); + let max_texture_size = gpu_state.max_texture_size(); - let extra_tile_dims = skia::ISize::new( - tile_dims.width * TILE_SIZE_MULTIPLIER, - tile_dims.height * TILE_SIZE_MULTIPLIER, - ); - let margins = skia::ISize::new(extra_tile_dims.width / 4, extra_tile_dims.height / 4); + // 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); let target = gpu_state.create_target_surface(width, height)?; let filter = gpu_state.create_surface_with_isize("filter".to_string(), extra_tile_dims)?; @@ -478,7 +496,6 @@ impl Surfaces { let backbuffer = gpu_state.create_surface_with_dimensions("backbuffer".to_string(), width, height)?; - let max_texture_size = gpu_state.max_texture_size(); let tile_atlas = gpu_state.create_surface_with_dimensions( "tile_atlas".to_string(), max_texture_size, @@ -498,7 +515,7 @@ impl Surfaces { gpu_state.create_surface_with_isize("shape_fills".to_string(), extra_tile_dims)?; let shape_strokes = gpu_state.create_surface_with_isize("shape_strokes".to_string(), extra_tile_dims)?; - let export = gpu_state.create_surface_with_isize("export".to_string(), extra_tile_dims)?; + let export = gpu_state.create_surface_with_isize("export".to_string(), tile_dims)?; let ui = gpu_state.create_surface_with_dimensions("ui".to_string(), width, height)?; let debug = gpu_state.create_surface_with_dimensions("debug".to_string(), width, height)?; @@ -665,13 +682,67 @@ impl Surfaces { self.margins } - pub fn resize(&mut self, new_width: i32, new_height: i32) -> Result<()> { + pub fn resize( + &mut self, + new_width: i32, + new_height: i32, + interest_tiles: i32, + ) -> Result<()> { let gpu_state = get_gpu_state(); self.reset_from_target(gpu_state.create_target_surface(new_width, new_height)?)?; + self.resize_paint_surfaces(new_width, new_height, interest_tiles)?; Ok(()) } + /// 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, + viewport_h: i32, + 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); + 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 recreate = |name: &str, surface: &mut skia::Surface| -> Result<()> { + *surface = surface + .new_surface_with_dimensions(dims) + .ok_or(Error::CriticalError(format!( + "Failed to recreate {name} surface" + )))?; + Ok(()) + }; + + recreate("current", &mut self.current)?; + recreate("filter", &mut self.filter)?; + recreate("drop_shadows", &mut self.drop_shadows)?; + recreate("inner_shadows", &mut self.inner_shadows)?; + recreate("text_drop_shadows", &mut self.text_drop_shadows)?; + recreate("shape_fills", &mut self.shape_fills)?; + recreate("shape_strokes", &mut self.shape_strokes)?; + self.clear_all_dirty(); + Ok(()) + } + + pub fn paint_surface_size(&self) -> skia::ISize { + self.extra_tile_dims + } + + pub fn tile_margin_size() -> i32 { + TILE_MARGIN_SIZE + } + + pub fn single_tile_drawable_rect() -> IRect { + TILE_DRAWABLE_RECT + } + pub fn snapshot(&mut self, id: SurfaceId) -> skia::Image { let surface = self.get_mut(id); surface.image_snapshot() @@ -1215,9 +1286,28 @@ impl Surfaces { tile_rect: &skia::Rect, skip_cache_surface: bool, tile_doc_rect: skia::Rect, + ) { + self.draw_current_src_into_tile_atlas( + tile_viewbox, + tile, + tile_rect, + skip_cache_surface, + tile_doc_rect, + skia::Rect::from(TILE_DRAWABLE_RECT), + ); + } + + /// Upload an arbitrary Current src rect (region paint-once crop) into the atlases. + pub fn draw_current_src_into_tile_atlas( + &mut self, + tile_viewbox: &TileViewbox, + tile: &Tile, + tile_rect: &skia::Rect, + skip_cache_surface: bool, + tile_doc_rect: skia::Rect, + src: skia::Rect, ) { let gpu_state = get_gpu_state(); - let src = skia::Rect::from(TILE_DRAWABLE_RECT); let sampling = self.sampling_options; // DocAtlas + tile atlas via Surface::draw (no image_snapshot sync). @@ -1255,6 +1345,27 @@ impl Surfaces { } } + /// Whether a doc-space region (with blur margins) fits in Current at `scale`. + 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() + } + + /// Pixel rect inside Current for a tile given the region `render_area` and scale. + pub fn tile_drawable_src_in_region( + &self, + tile_doc_rect: skia::Rect, + render_area: skia::Rect, + scale: f32, + ) -> 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) + } + pub fn has_cached_tile_surface(&self, tile: Tile) -> bool { self.tiles.has(tile) } @@ -1387,42 +1498,33 @@ impl Surfaces { draw_on_cache: DrawOnCache, ) { let sampling_options = self.sampling_options; - let src_rect = IRect::from_xywh( - self.margins.width, - self.margins.height, - self.current.width() - TILE_SIZE_MULTIPLIER * self.margins.width, - self.current.height() - TILE_SIZE_MULTIPLIER * self.margins.height, + // Current is viewport-sized for paint-once regions. During interactive + // drag we still paint one tile into the top-left padded slot; blit only + // that slot or we wipe the rest of Target with cleared Current pixels. + let pad = (TILE_SIZE + 2 * TILE_MARGIN_SIZE) as f32; + let src = skia::Rect::from_xywh(0.0, 0.0, pad, pad); + let dst = skia::Rect::from_xywh( + tile_rect.left - TILE_MARGIN_SIZE as f32, + tile_rect.top - TILE_MARGIN_SIZE as f32, + pad, + pad, ); - let src_rect_f = skia::Rect::from(src_rect); - let backbuffer_canvas = self.backbuffer.canvas(); - - // Draw background - // let mut paint = skia::Paint::default(); - // paint.set_color(color); - // backbuffer_canvas.draw_rect(tile_rect, &paint); - - // Draw current surface directly to target (no snapshot) - self.current.draw( - backbuffer_canvas, - ( - tile_rect.left - src_rect_f.left, - tile_rect.top - src_rect_f.top, - ), + draw_surface_src_rect_to_dst( + &mut self.current, + self.backbuffer.canvas(), + src, + dst, sampling_options, - None, ); - // Also draw to cache for render_from_cache if draw_on_cache == DrawOnCache::Yes { - self.current.draw( + draw_surface_src_rect_to_dst( + &mut self.current, self.cache.canvas(), - ( - tile_rect.left - src_rect_f.left, - tile_rect.top - src_rect_f.top, - ), + src, + dst, sampling_options, - None, ); } } From c12bc0eab45f41fe558f89f24986558d4893aaae Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 4 Aug 2026 10:57:42 +0200 Subject: [PATCH 4/6] :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), From 215e0548517d886ab99b1dfca9ada6de1885942c Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 5 Aug 2026 15:33:45 +0200 Subject: [PATCH 5/6] :books: Clarify Partial flush sync is on Full present Drop the stale ViewportReady wording from the HiDPI render-wasm memory notes. AI-assisted-by: composer-2 Co-authored-by: Cursor --- .serena/memories/render-wasm/ffi-rendering-subtleties.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.serena/memories/render-wasm/ffi-rendering-subtleties.md b/.serena/memories/render-wasm/ffi-rendering-subtleties.md index c468403eb2..bf12697b8f 100644 --- a/.serena/memories/render-wasm/ffi-rendering-subtleties.md +++ b/.serena/memories/render-wasm/ffi-rendering-subtleties.md @@ -24,7 +24,7 @@ - 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. +- Partial frames **skip** GPU flush; hard sync belongs in `present_frame` / Full. - 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). From 2620c6fb13a5ee13ba85dd0d530052c58f6cf795 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 5 Aug 2026 15:34:26 +0200 Subject: [PATCH 6/6] :paperclip: Format render-wasm after HiDPI paint-once port AI-assisted-by: composer-2 Co-authored-by: Cursor --- render-wasm/src/render.rs | 30 ++++++++---------------------- render-wasm/src/render/surfaces.rs | 27 ++++++++++----------------- 2 files changed, 18 insertions(+), 39 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 5dc4f570e1..7451fe4a1b 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1274,7 +1274,6 @@ impl RenderState { // Clear dirty flags for surfaces we just cleared self.surfaces.clear_dirty(dirty_surfaces_to_clear); } - } pub fn clear_focus_mode(&mut self) { @@ -3043,10 +3042,8 @@ 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 / device_tile).floor() * device_tile; - let start_tile_y = - (self.viewbox.area.top * scale / device_tile).floor() * device_tile; + let start_tile_x = (self.viewbox.area.left * scale / device_tile).floor() * device_tile; + let start_tile_y = (self.viewbox.area.top * scale / device_tile).floor() * device_tile; Rect::from_xywh( (tile.x() as f32 * device_tile) - start_tile_x, (tile.y() as f32 * device_tile) - start_tile_y, @@ -3851,11 +3848,7 @@ impl RenderState { /// /// 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], - tree: ShapesPoolRef, - ) -> Result { + fn try_begin_paint_region(&mut self, root_ids: &[Uuid], tree: ShapesPoolRef) -> Result { if self.viewer_masked_pass() || self.options.is_interactive_transform() { return Ok(false); } @@ -3905,11 +3898,7 @@ impl RenderState { // 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) - { + if region_tiles.len() == 1 || !self.surfaces.region_fits_paint_surface(area, paint_scale) { self.pending_tiles.list.extend(region_tiles); return Ok(false); } @@ -3983,11 +3972,9 @@ impl RenderState { for tile in ®ion.tiles { 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, - paint_scale, - ); + let src = + self.surfaces + .tile_drawable_src_in_region(tile_doc_rect, render_area, paint_scale); let aligned = self.get_aligned_tile_bounds(*tile); self.surfaces.draw_current_src_into_tile_atlas( &self.tile_viewbox, @@ -4555,8 +4542,7 @@ impl RenderState { 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); + let world_tile = tiles::get_tile_size(self.viewbox.get_scale(), self.viewbox.dpr).max(1e-6); raster / world_tile } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 572c46fa73..0af91ae60a 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -513,8 +513,13 @@ impl Surfaces { // 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, tile_size_px, max_texture_size); + let extra_tile_dims = 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); @@ -772,12 +777,7 @@ impl Surfaces { self.margins } - pub fn resize( - &mut self, - new_width: i32, - new_height: i32, - interest_tiles: i32, - ) -> Result<()> { + pub fn resize(&mut self, new_width: i32, new_height: i32, interest_tiles: i32) -> Result<()> { let gpu_state = get_gpu_state(); self.reset_from_target(gpu_state.create_target_surface(new_width, new_height)?)?; @@ -1206,10 +1206,7 @@ impl Surfaces { 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, - ); + let result = self.resize_cache(skia::ISize::new(new_w, new_h), interest_area_threshold); return result; } Ok(()) @@ -2070,11 +2067,7 @@ impl TileTextureCache { None } - fn farthest_visible_victim( - &self, - tile_viewbox: &TileViewbox, - keep: &Tile, - ) -> Option { + 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