diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 41d7869cc0..724cf813e7 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -395,10 +395,14 @@ pub struct InteractiveDragCrop { /// Viewbox origin (doc-space) at capture time. pub capture_vb_left: f32, pub capture_vb_top: f32, - /// Backbuffer pixel origin used for `snapshot_rect` (so we can do 1:1 blits). + /// Pixel origin (in target/snapshot space) for 1:1 blits during drag. pub capture_src_left: i32, pub capture_src_top: i32, + /// Shared raster image of the full Target taken at settle. All crops point at the same + /// image so Skia's texture cache uploads it once per interactive transform. pub image: skia::Image, + /// Sub-rect inside `image` belonging to this shape. + pub src_irect: skia::IRect, } pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize { @@ -1691,31 +1695,68 @@ impl RenderState { non_overlapping.push((*id, *bounds, *selrect)); } - // Snapshot from Backbuffer for each accepted shape. + // Two-pass build: + // 1. Compute each candidate's clamped target-pixel IRect and the union of all of + // them. + // 2. Carve the union out of `target_snapshot` and pre-warm it as a non-FBO GPU + // texture via `take_crop_atlas`. All crops then share that smaller atlas image, + // so the first drag frame doesn't pay an upload of the full viewport and per- + // shape `draw_image_rect`s sample from a compact, ready-to-go texture. + let (snap_w, snap_h) = match self.surfaces.target_snapshot() { + Some(img) => (img.width(), img.height()), + None => return, + }; + let scale = self.get_scale(); let vb_left = self.viewbox.area.left; let vb_top = self.viewbox.area.top; + + let mut valid: Vec<(Uuid, skia::IRect, Rect, Rect)> = + Vec::with_capacity(non_overlapping.len()); + let mut union: Option = None; for (id, doc_bounds, selrect) in non_overlapping { let left = ((doc_bounds.left - vb_left) * scale).floor() as i32; let top = ((doc_bounds.top - vb_top) * scale).floor() as i32; let right = ((doc_bounds.right - vb_left) * scale).ceil() as i32; let bottom = ((doc_bounds.bottom - vb_top) * scale).ceil() as i32; + // Clamp to the snapshot's pixel bounds. + let left = left.max(0); + let top = top.max(0); + let right = right.min(snap_w); + let bottom = bottom.min(snap_h); if right <= left || bottom <= top { continue; } - let src_irect = skia::IRect::new(left, top, right, bottom); - let Some(image) = self - .surfaces - .snapshot_rect(SurfaceId::Backbuffer, src_irect) - else { - continue; - }; + let irect = skia::IRect::new(left, top, right, bottom); + union = Some(match union { + None => irect, + Some(u) => skia::IRect::join(&u, &irect), + }); + valid.push((id, irect, doc_bounds, selrect)); + } + let Some(union) = union else { + return; + }; + + self.surfaces.take_crop_atlas(union); + let Some(atlas) = self.surfaces.crop_atlas().cloned() else { + return; + }; + let atlas_origin = self.surfaces.crop_atlas_origin(); + + for (id, target_irect, doc_bounds, selrect) in valid { + let local_irect = skia::IRect::new( + target_irect.left - atlas_origin.x, + target_irect.top - atlas_origin.y, + target_irect.right - atlas_origin.x, + target_irect.bottom - atlas_origin.y, + ); let src_doc_bounds = Rect::new( - src_irect.left as f32 / scale + vb_left, - src_irect.top as f32 / scale + vb_top, - src_irect.right as f32 / scale + vb_left, - src_irect.bottom as f32 / scale + vb_top, + target_irect.left as f32 / scale + vb_left, + target_irect.top as f32 / scale + vb_top, + target_irect.right as f32 / scale + vb_left, + target_irect.bottom as f32 / scale + vb_top, ); let fits_viewport_at_capture = doc_bounds.left >= viewport.left && doc_bounds.top >= viewport.top @@ -1729,9 +1770,10 @@ impl RenderState { fits_viewport_at_capture, capture_vb_left: vb_left, capture_vb_top: vb_top, - capture_src_left: src_irect.left, - capture_src_top: src_irect.top, - image, + capture_src_left: target_irect.left, + capture_src_top: target_irect.top, + image: atlas.clone(), + src_irect: local_irect, }, ); } @@ -2105,7 +2147,7 @@ impl RenderState { // A full-quality frame is now complete. Refresh Backbuffer and regenerate // the per-shape crop cache so interactive drags can reuse pixels. if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { - self.surfaces.copy_target_to_backbuffer(); + self.surfaces.take_target_snapshot(); self.rebuild_backbuffer_crop_cache(tree); } wapi::notify_tiles_render_complete!(); @@ -3054,7 +3096,22 @@ impl RenderState { let x = (doc_left + translation.0) * scale; let y = (doc_top + translation.1) * scale; - canvas.draw_image(crop_image, (x, y), Some(&skia::Paint::default())); + // The shared raster image covers the whole Target; each crop draws + // its own `src_irect` slice. Reusing the image identity across crops + // lets Skia upload the bitmap to a GPU texture once per drag. + let src_rect = skia::Rect::from_irect(crop.src_irect); + let dst_rect = skia::Rect::from_xywh( + x, + y, + crop.src_irect.width() as f32, + crop.src_irect.height() as f32, + ); + canvas.draw_image_rect( + crop_image, + Some((&src_rect, skia::canvas::SrcRectConstraint::Fast)), + dst_rect, + &skia::Paint::default(), + ); canvas.restore(); } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index f4a3456e9b..5bce310c79 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -69,7 +69,19 @@ pub struct Surfaces { // for drawing tiles. export: skia::Surface, // Persistent viewport-sized surface used to keep the last presented frame. + // Kept for API compatibility but no longer the live cache — see `target_snapshot`. backbuffer: skia::Surface, + // CPU-backed raster `Image` populated from `target` at full-render settle via + // `read_pixels`. Used to seed interactive frames. Lives in system memory, never in an FBO. + target_snapshot: Option, + // Pre-warmed GPU texture image cropped to the union of cacheable shapes' bounds. + // Built from `target_snapshot` at settle and used as the shared source for per-shape + // drag crops, so the first drag frame doesn't pay a CPU→GPU upload. Created via + // `texture_from_image`, which produces a plain (non-FBO) texture. + crop_atlas: Option, + // Top-left of `crop_atlas` in target-pixel space. Subtract from a shape's target-space + // IRect to get atlas-local coords for `draw_image_rect`. + crop_atlas_origin: skia::IPoint, tiles: TileTextureCache, // Persistent 1:1 document-space atlas that gets incrementally updated as tiles render. @@ -155,6 +167,9 @@ impl Surfaces { debug, export, backbuffer, + target_snapshot: None, + crop_atlas: None, + crop_atlas_origin: skia::IPoint::new(0, 0), tiles, atlas, atlas_origin: skia::Point::new(0.0, 0.0), @@ -707,23 +722,96 @@ impl Surfaces { (s.width(), s.height()) } - /// Copy the current `Target` contents into the persistent `Backbuffer`. - /// This is a GPU→GPU copy via Skia (no ReadPixels). - pub fn copy_target_to_backbuffer(&mut self) { - let sampling_options = self.sampling_options; - self.target.clone().draw( - self.backbuffer.canvas(), - (0.0, 0.0), - sampling_options, - Some(&skia::Paint::default()), - ); + /// Copy `Target` pixels into a CPU `Bitmap` and cache the result as a + /// raster `Image`. Replaces the old `Backbuffer` `skia::Surface` (which was + /// a GPU framebuffer) with system-memory storage so the cache never holds + /// a live FBO — on Firefox + NVIDIA the FBO is suspected of forcing extra + /// driver-state serialization between settle and the next interactive + /// frame. The trade-off is one GPU→CPU readback at settle; the raster + /// image is re-uploaded as a GPU texture on first draw and Skia caches the + /// upload for subsequent frames. + pub fn take_target_snapshot(&mut self) { + let w = self.target.width(); + let h = self.target.height(); + if w <= 0 || h <= 0 { + self.target_snapshot = None; + return; + } + let mut bitmap = skia::Bitmap::new(); + bitmap.alloc_n32_pixels((w, h), false); + if !self.target.read_pixels_to_bitmap(&bitmap, (0, 0)) { + self.target_snapshot = None; + return; + } + bitmap.set_immutable(); + self.target_snapshot = skia::images::raster_from_bitmap(&bitmap); } - /// Seed `Target` from `Backbuffer` (last presented frame). + /// Returns the cached `Target` snapshot taken at the previous settle, if any. + pub fn target_snapshot(&self) -> Option<&skia::Image> { + self.target_snapshot.as_ref() + } + + /// Carve `src_irect` (target-pixel space) out of `target_snapshot` and upload it as a + /// non-FBO GPU texture image. Called at settle once the candidate union is known so the + /// first drag frame can blit from a ready GPU texture instead of paying an upload cost. + /// Falls back to the raster subset if the GPU upload isn't possible. + pub fn take_crop_atlas(&mut self, src_irect: skia::IRect) { + self.crop_atlas = None; + self.crop_atlas_origin = skia::IPoint::new(0, 0); + let Some(raster) = self.target_snapshot.as_ref() else { + return; + }; + let bounds = skia::IRect::new(0, 0, raster.width(), raster.height()); + let Some(clipped) = skia::IRect::intersect(&src_irect, &bounds) else { + return; + }; + if clipped.is_empty() { + return; + } + let Some(subset) = raster.make_subset( + None, + &clipped, + skia::image::RequiredProperties::default(), + ) else { + return; + }; + let warmed = self + .target + .direct_context() + .and_then(|mut ctx| { + skia::gpu::images::texture_from_image( + &mut ctx, + &subset, + skia::gpu::Mipmapped::No, + skia::gpu::Budgeted::Yes, + ) + }) + .unwrap_or(subset); + self.crop_atlas_origin = skia::IPoint::new(clipped.left, clipped.top); + self.crop_atlas = Some(warmed); + } + + /// Returns the pre-warmed crop atlas image, if any. + pub fn crop_atlas(&self) -> Option<&skia::Image> { + self.crop_atlas.as_ref() + } + + /// Returns the target-pixel origin of `crop_atlas`. + pub fn crop_atlas_origin(&self) -> skia::IPoint { + self.crop_atlas_origin + } + + /// Seed `Target` with the previously-snapshotted frame (the equivalent of + /// the old `Backbuffer` blit, but driven by the cached `Image` so we don't + /// have to write into `Backbuffer` at settle time). pub fn seed_target_from_backbuffer(&mut self) { + let Some(image) = self.target_snapshot.clone() else { + return; + }; let sampling_options = self.sampling_options; - self.backbuffer.clone().draw( - self.target.canvas(), + self.target.canvas().draw_image_with_sampling_options( + &image, (0.0, 0.0), sampling_options, Some(&skia::Paint::default()), @@ -733,6 +821,9 @@ impl Surfaces { fn reset_from_target(&mut self, target: skia::Surface) -> Result<()> { let dim = (target.width(), target.height()); self.target = target; + self.target_snapshot = None; + self.crop_atlas = None; + self.crop_atlas_origin = skia::IPoint::new(0, 0); self.filter = self .target .new_surface_with_dimensions(dim)