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, ); } }