🔧 Fix drag

This commit is contained in:
Elena Torro 2026-05-11 15:02:55 +02:00
parent 63ff5c87c2
commit 1141e91b86
2 changed files with 179 additions and 31 deletions

View File

@ -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<skia::IRect> = 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();
}

View File

@ -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<skia::Image>,
// 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<skia::Image>,
// 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)