🎉 Rebuild drag-crop cache from tile textures with hybrid atlas fill

This commit is contained in:
Alejandro Alonso 2026-05-12 13:43:16 +02:00
parent 64f73ef23b
commit d4be6686c7
2 changed files with 263 additions and 34 deletions

View File

@ -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();
}

View File

@ -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