mirror of
https://github.com/penpot/penpot.git
synced 2026-05-14 20:43:55 +00:00
🎉 Rebuild drag-crop cache from tile textures with hybrid atlas fill
This commit is contained in:
parent
64f73ef23b
commit
d4be6686c7
@ -381,7 +381,7 @@ pub(crate) struct RenderState {
|
||||
/// interactive backdrop exactly once per gesture (first rAF) so we don't
|
||||
/// repeatedly overwrite tiles that have already been updated.
|
||||
pub interactive_target_seeded: bool,
|
||||
/// GPU crops from `Backbuffer` keyed by shape id. Filled on full-frame completion; during
|
||||
/// GPU crops from `Backbuffer` or tile atlas keyed by shape id. Filled on full-frame completion; during
|
||||
/// drag, entries for the moved top-level selection are ensured here
|
||||
pub backbuffer_crop_cache: HashMap<Uuid, InteractiveDragCrop>,
|
||||
}
|
||||
@ -389,9 +389,6 @@ pub(crate) struct RenderState {
|
||||
pub struct InteractiveDragCrop {
|
||||
pub src_doc_bounds: Rect,
|
||||
pub src_selrect: Rect,
|
||||
/// True if the captured crop bounds were fully inside the viewport at capture time.
|
||||
/// Used to avoid serving partial/offscreen crops during interactive drag.
|
||||
pub fits_viewport_at_capture: bool,
|
||||
/// Viewbox origin (doc-space) at capture time.
|
||||
pub capture_vb_left: f32,
|
||||
pub capture_vb_top: f32,
|
||||
@ -401,6 +398,49 @@ 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 [`Surfaces::max_texture_dimension_px`] (same budget as the atlas).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn drag_crop_snapshot_window_px(
|
||||
max_side_px: i32,
|
||||
out_w: i32,
|
||||
out_h: i32,
|
||||
viewport_doc: Rect,
|
||||
vb_left: f32,
|
||||
vb_top: f32,
|
||||
scale: f32,
|
||||
src_left_px: i32,
|
||||
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 {
|
||||
return (0, 0, out_w, out_h);
|
||||
}
|
||||
let win_w = out_w.min(cap);
|
||||
let win_h = out_h.min(cap);
|
||||
|
||||
let mut vis = viewport_doc;
|
||||
let has_vis = vis.intersect(src_doc_bounds);
|
||||
let (cx, cy) = if !has_vis || vis.is_empty() {
|
||||
(out_w as f32 * 0.5, out_h as f32 * 0.5)
|
||||
} else {
|
||||
let lx0 = (vis.left - vb_left) * scale - src_left_px as f32;
|
||||
let ly0 = (vis.top - vb_top) * scale - src_top_px as f32;
|
||||
let lx1 = (vis.right - vb_left) * scale - src_left_px as f32;
|
||||
let ly1 = (vis.bottom - vb_top) * scale - src_top_px as f32;
|
||||
((lx0 + lx1) * 0.5, (ly0 + ly1) * 0.5)
|
||||
};
|
||||
|
||||
let mut ox = (cx - win_w as f32 * 0.5).round() as i32;
|
||||
let mut oy = (cy - win_h as f32 * 0.5).round() as i32;
|
||||
ox = ox.clamp(0, out_w - win_w);
|
||||
oy = oy.clamp(0, out_h - win_h);
|
||||
(ox, oy, win_w, win_h)
|
||||
}
|
||||
|
||||
pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize {
|
||||
// First we retrieve the extended area of the viewport that we could render.
|
||||
let TileRect(isx, isy, iex, iey) =
|
||||
@ -458,10 +498,7 @@ impl RenderState {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(crop) = self.backbuffer_crop_cache.get(&node_id) else {
|
||||
return false;
|
||||
};
|
||||
if !crop.fits_viewport_at_capture {
|
||||
if !self.backbuffer_crop_cache.contains_key(&node_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -1666,10 +1703,12 @@ impl RenderState {
|
||||
self.backbuffer_crop_cache.clear();
|
||||
|
||||
// Collect candidate shapes that are "recortable" and visible in the current viewport.
|
||||
|
||||
// This is intentionally conservative; we only cache shapes that do not overlap with
|
||||
// ANY other candidate to guarantee the pixels under their bounds belong exclusively
|
||||
// to that shape in Backbuffer.
|
||||
let viewport = self.viewbox.area;
|
||||
let scale = self.get_scale();
|
||||
let mut candidates: Vec<(Uuid, Rect, Rect)> = Vec::new(); // (id, doc_bounds, selrect)
|
||||
|
||||
let root_ids: Vec<Uuid> = match tree.get(&Uuid::nil()) {
|
||||
@ -1713,10 +1752,21 @@ impl RenderState {
|
||||
non_overlapping.push((*id, *bounds, *selrect));
|
||||
}
|
||||
|
||||
// Snapshot from Backbuffer for each accepted shape.
|
||||
let scale = self.get_scale();
|
||||
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 = self.surfaces.max_texture_dimension_px();
|
||||
|
||||
// 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.
|
||||
let mut scratch_surface: Option<skia::Surface> = 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;
|
||||
@ -1726,12 +1776,6 @@ impl RenderState {
|
||||
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 src_doc_bounds = Rect::new(
|
||||
src_irect.left as f32 / scale + vb_left,
|
||||
@ -1739,30 +1783,92 @@ impl RenderState {
|
||||
src_irect.right as f32 / scale + vb_left,
|
||||
src_irect.bottom as f32 / scale + vb_top,
|
||||
);
|
||||
let fits_viewport_at_capture = doc_bounds.left >= viewport.left
|
||||
&& doc_bounds.top >= viewport.top
|
||||
&& doc_bounds.right <= viewport.right
|
||||
&& doc_bounds.bottom <= viewport.bottom;
|
||||
|
||||
// When the shape extends beyond the viewport to the left or top,
|
||||
// `left`/`top` are negative. Skia clamps `makeImageSnapshot` to the
|
||||
// surface bounds, so the returned image actually starts at pixel 0 —
|
||||
// not at the negative coordinate. Store the clamped value so that
|
||||
// `doc_left`/`doc_top` computed during the drag reflects the true
|
||||
// image origin in the backbuffer.
|
||||
let capture_src_left = left.max(0);
|
||||
let capture_src_top = top.max(0);
|
||||
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,
|
||||
full_w,
|
||||
full_h,
|
||||
viewport,
|
||||
vb_left,
|
||||
vb_top,
|
||||
scale,
|
||||
src_irect.left,
|
||||
src_irect.top,
|
||||
src_doc_bounds,
|
||||
);
|
||||
let window_irect = skia::IRect::new(
|
||||
src_irect.left + win_ox,
|
||||
src_irect.top + win_oy,
|
||||
src_irect.left + win_ox + win_w,
|
||||
src_irect.top + win_oy + win_h,
|
||||
);
|
||||
|
||||
let src_doc_window = Rect::new(
|
||||
window_irect.left as f32 / scale + vb_left,
|
||||
window_irect.top as f32 / scale + vb_top,
|
||||
window_irect.right as f32 / scale + vb_left,
|
||||
window_irect.bottom as f32 / scale + vb_top,
|
||||
);
|
||||
|
||||
let in_backbuffer = window_irect.left >= 0
|
||||
&& window_irect.top >= 0
|
||||
&& window_irect.right <= bb_w
|
||||
&& window_irect.bottom <= bb_h;
|
||||
|
||||
let backbuffer_snap = if in_backbuffer {
|
||||
self.surfaces
|
||||
.snapshot_rect(SurfaceId::Backbuffer, window_irect)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let image = if let Some(img) = backbuffer_snap {
|
||||
img
|
||||
} else {
|
||||
// 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.
|
||||
let needs_alloc = scratch_surface
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.width() < win_w || s.height() < win_h);
|
||||
if needs_alloc {
|
||||
scratch_surface = get_gpu_state()
|
||||
.create_surface_with_isize(
|
||||
"drag_crop_scratch".to_string(),
|
||||
skia::ISize::new(win_w, win_h),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
let Some(scratch) = scratch_surface.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
let Some(img) = self.surfaces.try_snapshot_doc_rect_from_tiles_and_atlas(
|
||||
scratch,
|
||||
atlas_snap.as_ref(),
|
||||
src_doc_window,
|
||||
window_irect,
|
||||
win_w,
|
||||
win_h,
|
||||
vb_left,
|
||||
vb_top,
|
||||
scale,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
img
|
||||
};
|
||||
|
||||
self.backbuffer_crop_cache.insert(
|
||||
id,
|
||||
InteractiveDragCrop {
|
||||
src_doc_bounds,
|
||||
src_doc_bounds: src_doc_window,
|
||||
src_selrect: selrect,
|
||||
fits_viewport_at_capture,
|
||||
capture_vb_left: vb_left,
|
||||
capture_vb_top: vb_top,
|
||||
capture_src_left,
|
||||
capture_src_top,
|
||||
capture_src_left: window_irect.left,
|
||||
capture_src_top: window_irect.top,
|
||||
image,
|
||||
},
|
||||
);
|
||||
@ -3054,7 +3160,10 @@ 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()));
|
||||
let bw = crop_image.width() as f32;
|
||||
let bh = crop_image.height() as f32;
|
||||
let dst = skia::Rect::from_xywh(x, y, bw, bh);
|
||||
canvas.draw_image_rect(crop_image, None, dst, &skia::Paint::default());
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
@ -5,7 +5,10 @@ use crate::{get_gpu_state, performance};
|
||||
|
||||
use skia_safe::{self as skia, IRect, Paint, RRect};
|
||||
|
||||
use super::{gpu_state::GpuState, tiles::Tile, tiles::TileViewbox, tiles::TILE_SIZE};
|
||||
use super::{
|
||||
gpu_state::GpuState,
|
||||
tiles::{self, Tile, TileViewbox, TILE_SIZE},
|
||||
};
|
||||
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@ -183,6 +186,11 @@ impl Surfaces {
|
||||
self.max_atlas_texture_size = max_px.clamp(TILE_SIZE as i32, MAX_ATLAS_TEXTURE_SIZE);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn max_texture_dimension_px(&self) -> i32 {
|
||||
self.max_atlas_texture_size
|
||||
}
|
||||
|
||||
/// Sets the document-space bounds used to clamp atlas updates.
|
||||
/// Pass `None` to disable clamping.
|
||||
pub fn set_atlas_doc_bounds(&mut self, bounds: Option<skia::Rect>) {
|
||||
@ -1053,6 +1061,118 @@ impl Surfaces {
|
||||
self.tiles.has(tile)
|
||||
}
|
||||
|
||||
/// Returns a snapshot of the atlas together with its scale and origin, so the
|
||||
/// caller can take it **once** per `rebuild_backbuffer_crop_cache` and share it
|
||||
/// across all shapes that need the tile/atlas fallback path — avoiding an
|
||||
/// `image_snapshot` (and potential GPU flush) per shape.
|
||||
pub fn atlas_snapshot_for_drag_crop(&mut self) -> Option<(skia::Image, f32, skia::Point)> {
|
||||
if !self.has_atlas() {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
self.atlas.image_snapshot(),
|
||||
self.atlas_scale.max(0.01),
|
||||
self.atlas_origin,
|
||||
))
|
||||
}
|
||||
|
||||
/// Builds a 1:1 workspace-pixel snapshot for `src_doc_bounds` / `src_irect` into
|
||||
/// `scratch`, then returns the sub-region `[0, out_w) × [0, out_h)` as an image.
|
||||
///
|
||||
/// `scratch` must be at least `out_w × out_h` pixels — the caller is responsible
|
||||
/// for allocating (and **reusing across shapes**) a surface large enough to hold
|
||||
/// the largest window needed in one `rebuild_backbuffer_crop_cache` pass.
|
||||
///
|
||||
/// `atlas_snap` is a pre-snapshotted view of the persistent atlas produced by
|
||||
/// [`Surfaces::atlas_snapshot_for_drag_crop`]; pass `None` when no atlas exists.
|
||||
///
|
||||
/// For each tile cell intersecting `src_doc_bounds`: draws from
|
||||
/// [`TileTextureCache`] when present; otherwise samples the atlas.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn try_snapshot_doc_rect_from_tiles_and_atlas(
|
||||
&mut self,
|
||||
scratch: &mut skia::Surface,
|
||||
atlas_snap: Option<&(skia::Image, f32, skia::Point)>,
|
||||
src_doc_bounds: skia::Rect,
|
||||
src_irect: IRect,
|
||||
out_w: i32,
|
||||
out_h: i32,
|
||||
vb_left: f32,
|
||||
vb_top: f32,
|
||||
scale: f32,
|
||||
) -> Option<skia::Image> {
|
||||
if out_w <= 0 || out_h <= 0 || src_doc_bounds.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let canvas = scratch.canvas();
|
||||
canvas.clear(skia::Color::TRANSPARENT);
|
||||
|
||||
let tile_size = tiles::get_tile_size(scale);
|
||||
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;
|
||||
let paint = skia::Paint::default();
|
||||
|
||||
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 mut clip_doc = tile_doc;
|
||||
if !clip_doc.intersect(src_doc_bounds) || clip_doc.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let dst = skia::Rect::from_ltrb(
|
||||
(clip_doc.left - vb_left) * scale - ix0,
|
||||
(clip_doc.top - vb_top) * scale - iy0,
|
||||
(clip_doc.right - vb_left) * scale - ix0,
|
||||
(clip_doc.bottom - vb_top) * scale - iy0,
|
||||
);
|
||||
|
||||
if let Some(tile_image) = self.tiles.get(tile) {
|
||||
let iw = tile_image.width() as f32;
|
||||
let ih = tile_image.height() as f32;
|
||||
let td_w = tile_doc.width().max(1e-6);
|
||||
let td_h = tile_doc.height().max(1e-6);
|
||||
|
||||
let src = skia::Rect::from_ltrb(
|
||||
((clip_doc.left - tile_doc.left) / td_w) * iw,
|
||||
((clip_doc.top - tile_doc.top) / td_h) * ih,
|
||||
((clip_doc.right - tile_doc.left) / td_w) * iw,
|
||||
((clip_doc.bottom - tile_doc.top) / td_h) * ih,
|
||||
);
|
||||
|
||||
canvas.draw_image_rect(
|
||||
tile_image,
|
||||
Some((&src, skia::canvas::SrcRectConstraint::Fast)),
|
||||
dst,
|
||||
&paint,
|
||||
);
|
||||
} else {
|
||||
let snap = atlas_snap?;
|
||||
let (atlas, a_scale, atlas_origin) = (&snap.0, snap.1, snap.2);
|
||||
let sx = (clip_doc.left - atlas_origin.x) * a_scale;
|
||||
let sy = (clip_doc.top - atlas_origin.y) * a_scale;
|
||||
let sw = clip_doc.width() * a_scale;
|
||||
let sh = clip_doc.height() * a_scale;
|
||||
if sw <= 0.0 || sh <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let src = skia::Rect::from_xywh(sx, sy, sw, sh);
|
||||
canvas.draw_image_rect(
|
||||
atlas,
|
||||
Some((&src, skia::canvas::SrcRectConstraint::Fast)),
|
||||
dst,
|
||||
&paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scratch.image_snapshot_with_bounds(IRect::new(0, 0, out_w, out_h))
|
||||
}
|
||||
|
||||
pub fn remove_cached_tile_surface(&mut self, tile: Tile) {
|
||||
let gpu_state = get_gpu_state();
|
||||
// Mark tile as invalid
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user