♻️ Refactor document atlas

This commit is contained in:
Aitor Moreno 2026-05-22 11:32:36 +02:00
parent 5ad148641d
commit 0e862c4ff1
5 changed files with 378 additions and 355 deletions

View File

@ -1872,7 +1872,7 @@
(when-let [t (wasm-blur-downscale-threshold-from-route-params)]
(h/call wasm/internal-module "_set_blur_downscale_threshold" t))
(when-let [max-tex (webgl/max-texture-size context)]
(h/call wasm/internal-module "_set_max_atlas_texture_size" max-tex))
(h/call wasm/internal-module "_set_max_texture_size" max-tex))
;; Set browser and canvas size only after initialization
(h/call wasm/internal-module "_set_browser" browser)

View File

@ -92,10 +92,10 @@ pub extern "C" fn set_antialias_threshold(threshold: f32) -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_max_atlas_texture_size(max_px: i32) -> Result<()> {
pub extern "C" fn set_max_texture_size(max_px: i32) -> Result<()> {
get_render_state()
.surfaces
.set_max_atlas_texture_size(max_px);
.set_max_texture_size(max_px);
Ok(())
}

View File

@ -1771,7 +1771,7 @@ impl RenderState {
// 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();
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.
@ -1894,7 +1894,7 @@ impl RenderState {
// During fast mode (pan/zoom), if a previous full-quality render still has pending tiles,
// always prefer the persistent atlas. The atlas is incrementally updated as tiles finish,
// and drawing from it avoids mixing a partially-updated Cache surface with missing tiles.
if self.options.is_fast_mode() && self.surfaces.has_atlas() {
if self.options.is_fast_mode() && !self.surfaces.atlas.is_empty() {
self.surfaces
.draw_atlas_to_backbuffer(self.viewbox, bg_color);
@ -1952,7 +1952,7 @@ impl RenderState {
min_x >= 0.0 && min_y >= 0.0 && max_x <= cache_w && max_y <= cache_h;
if !cache_covers {
// Early return only if atlas exists; otherwise keep cache path.
if self.surfaces.has_atlas() {
if !self.surfaces.atlas.is_empty() {
self.surfaces
.draw_atlas_to_backbuffer(self.viewbox, bg_color);
@ -2078,7 +2078,7 @@ impl RenderState {
// to clamp atlas updates. This prevents zoom-out tiles from forcing atlas
// growth far beyond real content.
let doc_bounds = self.compute_document_bounds(base_object, tree);
self.surfaces.set_atlas_doc_bounds(doc_bounds);
self.surfaces.atlas.set_doc_bounds(doc_bounds);
self.cache_cleared_this_render = false;
if self.options.is_interactive_transform() {
@ -3566,7 +3566,7 @@ impl RenderState {
// When the shape has an active modifier (i.e. is being moved/resized),
// clear its OLD doc-space extent from the atlas using the raw
// (pre-modifier) shape. The per-tile clearing done later via
// `clear_tile_in_atlas` only covers tiles tracked in `atlas_tile_doc_rects`
// `clear_tile_in_atlas` only covers tiles tracked in `atlas.tile_doc_rects`
// at the current zoom level. However, the atlas may also contain stale
// pixels from previous zoom levels (tiles are larger / smaller in doc
// space at different zoom scales) that were never re-tracked after a zoom
@ -3578,7 +3578,7 @@ impl RenderState {
if tree.get_modifier(&shape.id).is_some() {
if let Some(raw_shape) = tree.get_raw(&shape.id) {
let old_extrect = raw_shape.extrect(tree, 1.0);
self.surfaces.clear_doc_rect_in_atlas_clipped(old_extrect);
self.surfaces.atlas.clear_doc_rect_in_atlas_clipped(old_extrect);
}
}

View File

@ -28,6 +28,9 @@ impl GpuState {
let context = gpu::direct_contexts::make_gl(interface, Some(&context_options)).ok_or(
Error::CriticalError("Failed to create GL context".to_string()),
)?;
println!("max_texture_size {}", context.max_texture_size());
let framebuffer_info = {
let mut fboid: gl::types::GLint = 0;
unsafe { gl::GetIntegerv(gl::FRAMEBUFFER_BINDING, &mut fboid) };

View File

@ -28,13 +28,12 @@ const TILE_DRAWABLE_RECT: IRect = IRect {
/// Atlas texture size limits (px per side).
///
/// - `DEFAULT_MAX_ATLAS_TEXTURE_SIZE` is the startup fallback used until the
/// - `DEFAULT_ATLAS_TEXTURE_SIZE` is the startup fallback used until the
/// frontend reads the real `gl.MAX_TEXTURE_SIZE` and sends it via
/// [`Surfaces::set_max_atlas_texture_size`].
/// - `MAX_ATLAS_TEXTURE_SIZE` is a hard upper bound to clamp the runtime value
/// [`Surfaces::atlas.set_max_texture_size`].
/// - `atlas.max_texture_size` is a hard upper bound to clamp the runtime value
/// (defensive cap to avoid accidentally creating oversized GPU textures).
const MAX_ATLAS_TEXTURE_SIZE: i32 = 8192;
const DEFAULT_MAX_ATLAS_TEXTURE_SIZE: i32 = 1024;
pub fn get_cache_size(viewbox: &Viewbox, interest: i32) -> skia::ISize {
// First we retrieve the extended area of the viewport that we could render.
@ -78,6 +77,347 @@ pub enum SurfaceId {
TileAtlas = 0b100_0000_1000,
}
pub struct DocAtlas {
// Persistent 1:1 document-space atlas that gets incrementally updated as tiles render.
// It grows dynamically to include any rendered document rect.
pub surface: skia::Surface,
pub origin: skia::Point,
pub size: skia::ISize,
/// Atlas pixel density relative to document pixels (1.0 == 1:1 doc px).
/// When the atlas would exceed `max_texture_size`, this value is
/// reduced so the atlas stays within the fixed texture cap.
pub scale: f32,
/// Optional document-space bounds (1 unit == 1 doc px @ 100% zoom) used to
/// clamp atlas writes/clears so the atlas doesn't grow due to outlier tile rects.
pub doc_bounds: Option<skia::Rect>,
/// Max width/height in pixels for the atlas surface (typically browser
/// `MAX_TEXTURE_SIZE`). Set from ClojureScript after WebGL context creation.
pub max_texture_size: i32,
/// Tracks the last document-space rect written to the atlas per tile.
/// Used to clear old content without clearing the whole (potentially huge) tile rect.
pub tile_doc_rects: HashMap<Tile, skia::Rect>,
}
impl DocAtlas {
pub fn try_new() -> Result<Self> {
// Keep atlas as a regular surface like the rest. Start with a tiny
// transparent surface and grow it on demand.
let mut surface = get_gpu_state()
.create_surface_with_dimensions("atlas".to_string(), 1, 1)?;
surface.canvas().clear(skia::Color::TRANSPARENT);
Ok(Self {
surface,
origin: skia::Point::new(0.0, 0.0),
size: skia::ISize::new(0, 0),
scale: 1.0,
doc_bounds: None,
max_texture_size: MAX_ATLAS_TEXTURE_SIZE,
tile_doc_rects: HashMap::default()
})
}
pub fn is_empty(&self) -> bool {
self.size.width <= 0 || self.size.height <= 0
}
/// Sets the maximum atlas texture dimension (one side). Should match the
/// WebGL `MAX_TEXTURE_SIZE` reported by the browser. Values are clamped to
/// a small minimum so the atlas logic stays well-defined.
pub fn set_max_texture_size(&mut self, max_px: i32) {
self.max_texture_size = max_px.clamp(TILE_SIZE, self.max_texture_size);
}
/// Sets the document-space bounds used to clamp atlas updates.
/// Pass `None` to disable clamping.
pub fn set_doc_bounds(&mut self, bounds: Option<skia::Rect>) {
self.doc_bounds = bounds.filter(|b| !b.is_empty());
}
fn clamp_doc_rect_to_bounds(&self, doc_rect: skia::Rect) -> skia::Rect {
if doc_rect.is_empty() {
return doc_rect;
}
if let Some(bounds) = self.doc_bounds {
let mut r = doc_rect;
if r.intersect(bounds) {
r
} else {
skia::Rect::new_empty()
}
} else {
doc_rect
}
}
fn ensure_atlas_contains(
&mut self,
gpu_state: &mut GpuState,
doc_rect: skia::Rect,
) -> Result<()> {
if doc_rect.is_empty() {
return Ok(());
}
// Current atlas bounds in document space (1 unit == 1 px).
let current_left = self.origin.x;
let current_top = self.origin.y;
let scale = self.scale.max(0.01);
let current_right = current_left + (self.size.width as f32) / scale;
let current_bottom = current_top + (self.size.height as f32) / scale;
let mut new_left = current_left;
let mut new_top = current_top;
let mut new_right = current_right;
let mut new_bottom = current_bottom;
// If atlas is empty/uninitialized, seed to rect (expanded to tile boundaries for fewer reallocs).
let needs_init = self.size.width <= 0 || self.size.height <= 0;
if needs_init {
new_left = doc_rect.left.floor();
new_top = doc_rect.top.floor();
new_right = doc_rect.right.ceil();
new_bottom = doc_rect.bottom.ceil();
} else {
new_left = new_left.min(doc_rect.left.floor());
new_top = new_top.min(doc_rect.top.floor());
new_right = new_right.max(doc_rect.right.ceil());
new_bottom = new_bottom.max(doc_rect.bottom.ceil());
}
// Add padding to reduce realloc frequency.
let pad = tiles::TILE_SIZE;
new_left -= pad;
new_top -= pad;
new_right += pad;
new_bottom += pad;
let doc_w = (new_right - new_left).max(1.0);
let doc_h = (new_bottom - new_top).max(1.0);
// Compute atlas scale needed to fit within the fixed texture cap.
// Keep the highest possible scale (closest to 1.0) that still fits.
let cap = self.max_texture_size.max(TILE_SIZE) as f32;
let required_scale = (cap / doc_w).min(cap / doc_h).clamp(0.01, 1.0);
// Never upscale the atlas (it would add blur and churn).
let new_scale = self.scale.min(required_scale).max(0.01);
let new_w = (doc_w * new_scale).ceil().clamp(1.0, cap) as i32;
let new_h = (doc_h * new_scale).ceil().clamp(1.0, cap) as i32;
// Fast path: existing atlas already contains the rect.
if !needs_init
&& doc_rect.left >= current_left
&& doc_rect.top >= current_top
&& doc_rect.right <= current_right
&& doc_rect.bottom <= current_bottom
{
return Ok(());
}
let mut new_surface =
gpu_state.create_surface_with_dimensions("atlas".to_string(), new_w, new_h)?;
new_surface.canvas().clear(skia::Color::TRANSPARENT);
// Copy old atlas into the new one with offset.
if !needs_init {
let old_scale = self.scale.max(0.01);
let scale_ratio = new_scale / old_scale;
let dx = (current_left - new_left) * new_scale;
let dy = (current_top - new_top) * new_scale;
let image = self.surface.image_snapshot();
let src = skia::Rect::from_xywh(
0.0,
0.0,
self.size.width as f32,
self.size.height as f32,
);
let dst = skia::Rect::from_xywh(
dx,
dy,
(self.size.width as f32) * scale_ratio,
(self.size.height as f32) * scale_ratio,
);
new_surface.canvas().draw_image_rect(
&image,
Some((&src, skia::canvas::SrcRectConstraint::Fast)),
dst,
&skia::Paint::default(),
);
}
self.origin = skia::Point::new(new_left, new_top);
self.size = skia::ISize::new(new_w, new_h);
self.scale = new_scale;
gpu_state.delete_surface(&mut self.surface);
self.surface = new_surface;
Ok(())
}
fn blit_tile_image_into_atlas(
&mut self,
gpu_state: &mut GpuState,
tile_image: &skia::Image,
tile_doc_rect: skia::Rect,
) -> Result<()> {
if tile_doc_rect.is_empty() {
return Ok(());
}
// Clamp to document bounds (if any) and compute a matching source-rect in tile pixels.
let mut clipped_doc_rect = tile_doc_rect;
if let Some(bounds) = self.doc_bounds {
if !clipped_doc_rect.intersect(bounds) {
return Ok(());
}
}
if clipped_doc_rect.is_empty() {
return Ok(());
}
self.ensure_atlas_contains(gpu_state, clipped_doc_rect)?;
// Destination is document-space rect mapped into atlas pixel coords.
let dst = skia::Rect::from_xywh(
(clipped_doc_rect.left - self.origin.x) * self.scale,
(clipped_doc_rect.top - self.origin.y) * self.scale,
clipped_doc_rect.width() * self.scale,
clipped_doc_rect.height() * self.scale,
);
// Compute source rect in tile_image pixel coordinates.
let img_w = tile_image.width() as f32;
let img_h = tile_image.height() as f32;
let tw = tile_doc_rect.width().max(1.0);
let th = tile_doc_rect.height().max(1.0);
let sx = ((clipped_doc_rect.left - tile_doc_rect.left) / tw) * img_w;
let sy = ((clipped_doc_rect.top - tile_doc_rect.top) / th) * img_h;
let sw = (clipped_doc_rect.width() / tw) * img_w;
let sh = (clipped_doc_rect.height() / th) * img_h;
let src = skia::Rect::from_xywh(sx, sy, sw, sh);
self.surface.canvas().draw_image_rect(
tile_image,
Some((&src, skia::canvas::SrcRectConstraint::Fast)),
dst,
&skia::Paint::default(),
);
Ok(())
}
/// Clears a doc-space rect from the atlas **without** growing it.
///
/// Unlike [`clear_doc_rect_in_atlas`], this method clips `doc_rect` to the
/// current atlas bounds and skips silently if there is no overlap. Use this
/// when evicting stale shape content (e.g. before a drag re-render) where
/// growing the atlas to accommodate an out-of-range rect would be wasteful.
pub fn clear_doc_rect_in_atlas_clipped(&mut self, doc_rect: skia::Rect) {
if self.is_empty() || doc_rect.is_empty() {
return;
}
let scale = self.scale.max(0.01);
let atlas_doc_right = self.origin.x + (self.size.width as f32) / scale;
let atlas_doc_bottom = self.origin.y + (self.size.height as f32) / scale;
// Intersect with current atlas bounds in doc space.
let mut clipped = doc_rect;
let atlas_bounds = skia::Rect::from_ltrb(
self.origin.x,
self.origin.y,
atlas_doc_right,
atlas_doc_bottom,
);
if !clipped.intersect(atlas_bounds) {
return;
}
// Apply doc_bounds clamping.
if let Some(bounds) = self.doc_bounds {
if !clipped.intersect(bounds) {
return;
}
}
if clipped.is_empty() {
return;
}
let dst = skia::Rect::from_xywh(
(clipped.left - self.origin.x) * scale,
(clipped.top - self.origin.y) * scale,
clipped.width() * scale,
clipped.height() * scale,
);
let canvas = self.surface.canvas();
canvas.save();
canvas.clip_rect(dst, None, true);
canvas.clear(skia::Color::TRANSPARENT);
canvas.restore();
}
pub fn clear_doc_rect_in_atlas(
&mut self,
gpu_state: &mut GpuState,
doc_rect: skia::Rect,
) -> Result<()> {
let doc_rect = self.clamp_doc_rect_to_bounds(doc_rect);
if doc_rect.is_empty() {
return Ok(());
}
self.ensure_atlas_contains(gpu_state, doc_rect)?;
// Destination is document-space rect mapped into atlas pixel coords.
let dst = skia::Rect::from_xywh(
(doc_rect.left - self.origin.x) * self.scale,
(doc_rect.top - self.origin.y) * self.scale,
doc_rect.width() * self.scale,
doc_rect.height() * self.scale,
);
let canvas = self.surface.canvas();
canvas.save();
canvas.clip_rect(dst, None, true);
canvas.clear(skia::Color::TRANSPARENT);
canvas.restore();
Ok(())
}
/// Clears the last atlas region written by `tile` (if any).
///
/// This avoids clearing the entire logical tile rect which, at very low
/// zoom levels, can be enormous in document space and would unnecessarily
/// grow / rescale the atlas.
pub fn clear_tile_in_atlas(&mut self, gpu_state: &mut GpuState, tile: Tile) -> Result<()> {
if let Some(doc_rect) = self.tile_doc_rects.remove(&tile) {
self.clear_doc_rect_in_atlas(gpu_state, doc_rect)?;
}
Ok(())
}
/// 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 snapshot_for_drag_crop(&mut self) -> Option<(skia::Image, f32, skia::Point)> {
if self.is_empty() {
return None;
}
Some((
self.surface.image_snapshot(),
self.scale.max(0.01),
self.origin,
))
}
}
pub struct Surfaces {
// is the final destination surface, the one that it is represented in the canvas element.
target: skia::Surface,
@ -107,25 +447,8 @@ pub struct Surfaces {
tile_atlas: skia::Surface,
tiles: TileTextureCache,
// Persistent 1:1 document-space atlas that gets incrementally updated as tiles render.
// It grows dynamically to include any rendered document rect.
atlas: skia::Surface,
atlas_origin: skia::Point,
atlas_size: skia::ISize,
/// Atlas pixel density relative to document pixels (1.0 == 1:1 doc px).
/// When the atlas would exceed `max_atlas_texture_size`, this value is
/// reduced so the atlas stays within the fixed texture cap.
atlas_scale: f32,
/// Optional document-space bounds (1 unit == 1 doc px @ 100% zoom) used to
/// clamp atlas writes/clears so the atlas doesn't grow due to outlier tile rects.
atlas_doc_bounds: Option<skia::Rect>,
/// Max width/height in pixels for the atlas surface (typically browser
/// `MAX_TEXTURE_SIZE`). Set from ClojureScript after WebGL context creation.
max_atlas_texture_size: i32,
pub atlas: DocAtlas,
sampling_options: skia::SamplingOptions,
/// Tracks the last document-space rect written to the atlas per tile.
/// Used to clear old content without clearing the whole (potentially huge) tile rect.
atlas_tile_doc_rects: HashMap<Tile, skia::Rect>,
pub margins: skia::ISize,
// Tracks which surfaces have content (dirty flag bitmask)
dirty_surfaces: u32,
@ -180,13 +503,10 @@ impl Surfaces {
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)?;
// Keep atlas as a regular surface like the rest. Start with a tiny
// transparent surface and grow it on demand.
let mut atlas = gpu_state.create_surface_with_dimensions("atlas".to_string(), 1, 1)?;
atlas.canvas().clear(skia::Color::TRANSPARENT);
// 512, why not?
let tiles = TileTextureCache::new(tile_atlas.width(), 512);
let atlas = DocAtlas::try_new()?;
Ok(Self {
target,
filter,
@ -204,13 +524,7 @@ impl Surfaces {
tile_atlas,
tiles,
atlas,
atlas_origin: skia::Point::new(0.0, 0.0),
atlas_size: skia::ISize::new(0, 0),
atlas_scale: 1.0,
atlas_doc_bounds: None,
max_atlas_texture_size: DEFAULT_MAX_ATLAS_TEXTURE_SIZE,
sampling_options,
atlas_tile_doc_rects: HashMap::default(),
margins,
dirty_surfaces: 0,
extra_tile_dims,
@ -222,298 +536,19 @@ impl Surfaces {
self.dpr = dpr;
}
/// Sets the maximum atlas texture dimension (one side). Should match the
/// WebGL `MAX_TEXTURE_SIZE` reported by the browser. Values are clamped to
/// a small minimum so the atlas logic stays well-defined.
pub fn set_max_atlas_texture_size(&mut self, max_px: i32) {
self.max_atlas_texture_size = max_px.clamp(TILE_SIZE, MAX_ATLAS_TEXTURE_SIZE);
pub fn set_max_texture_size(&mut self, max_texture_size: i32) {
self.atlas.set_max_texture_size(max_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>) {
self.atlas_doc_bounds = bounds.filter(|b| !b.is_empty());
}
fn clamp_doc_rect_to_bounds(&self, doc_rect: skia::Rect) -> skia::Rect {
if doc_rect.is_empty() {
return doc_rect;
}
if let Some(bounds) = self.atlas_doc_bounds {
let mut r = doc_rect;
if r.intersect(bounds) {
r
} else {
skia::Rect::new_empty()
}
} else {
doc_rect
}
}
fn ensure_atlas_contains(
&mut self,
gpu_state: &mut GpuState,
doc_rect: skia::Rect,
) -> Result<()> {
if doc_rect.is_empty() {
return Ok(());
}
// Current atlas bounds in document space (1 unit == 1 px).
let current_left = self.atlas_origin.x;
let current_top = self.atlas_origin.y;
let atlas_scale = self.atlas_scale.max(0.01);
let current_right = current_left + (self.atlas_size.width as f32) / atlas_scale;
let current_bottom = current_top + (self.atlas_size.height as f32) / atlas_scale;
let mut new_left = current_left;
let mut new_top = current_top;
let mut new_right = current_right;
let mut new_bottom = current_bottom;
// If atlas is empty/uninitialized, seed to rect (expanded to tile boundaries for fewer reallocs).
let needs_init = self.atlas_size.width <= 0 || self.atlas_size.height <= 0;
if needs_init {
new_left = doc_rect.left.floor();
new_top = doc_rect.top.floor();
new_right = doc_rect.right.ceil();
new_bottom = doc_rect.bottom.ceil();
} else {
new_left = new_left.min(doc_rect.left.floor());
new_top = new_top.min(doc_rect.top.floor());
new_right = new_right.max(doc_rect.right.ceil());
new_bottom = new_bottom.max(doc_rect.bottom.ceil());
}
// Add padding to reduce realloc frequency.
let pad = tiles::TILE_SIZE;
new_left -= pad;
new_top -= pad;
new_right += pad;
new_bottom += pad;
let doc_w = (new_right - new_left).max(1.0);
let doc_h = (new_bottom - new_top).max(1.0);
// Compute atlas scale needed to fit within the fixed texture cap.
// Keep the highest possible scale (closest to 1.0) that still fits.
let cap = self.max_atlas_texture_size.max(TILE_SIZE) as f32;
let required_scale = (cap / doc_w).min(cap / doc_h).clamp(0.01, 1.0);
// Never upscale the atlas (it would add blur and churn).
let new_scale = self.atlas_scale.min(required_scale).max(0.01);
let new_w = (doc_w * new_scale).ceil().clamp(1.0, cap) as i32;
let new_h = (doc_h * new_scale).ceil().clamp(1.0, cap) as i32;
// Fast path: existing atlas already contains the rect.
if !needs_init
&& doc_rect.left >= current_left
&& doc_rect.top >= current_top
&& doc_rect.right <= current_right
&& doc_rect.bottom <= current_bottom
{
return Ok(());
}
let mut new_atlas =
gpu_state.create_surface_with_dimensions("atlas".to_string(), new_w, new_h)?;
new_atlas.canvas().clear(skia::Color::TRANSPARENT);
// Copy old atlas into the new one with offset.
if !needs_init {
let old_scale = self.atlas_scale.max(0.01);
let scale_ratio = new_scale / old_scale;
let dx = (current_left - new_left) * new_scale;
let dy = (current_top - new_top) * new_scale;
let image = self.atlas.image_snapshot();
let src = skia::Rect::from_xywh(
0.0,
0.0,
self.atlas_size.width as f32,
self.atlas_size.height as f32,
);
let dst = skia::Rect::from_xywh(
dx,
dy,
(self.atlas_size.width as f32) * scale_ratio,
(self.atlas_size.height as f32) * scale_ratio,
);
new_atlas.canvas().draw_image_rect(
&image,
Some((&src, skia::canvas::SrcRectConstraint::Fast)),
dst,
&skia::Paint::default(),
);
}
self.atlas_origin = skia::Point::new(new_left, new_top);
self.atlas_size = skia::ISize::new(new_w, new_h);
self.atlas_scale = new_scale;
gpu_state.delete_surface(&mut self.atlas);
self.atlas = new_atlas;
Ok(())
}
fn blit_tile_image_into_atlas(
&mut self,
gpu_state: &mut GpuState,
tile_image: &skia::Image,
tile_doc_rect: skia::Rect,
) -> Result<()> {
if tile_doc_rect.is_empty() {
return Ok(());
}
// Clamp to document bounds (if any) and compute a matching source-rect in tile pixels.
let mut clipped_doc_rect = tile_doc_rect;
if let Some(bounds) = self.atlas_doc_bounds {
if !clipped_doc_rect.intersect(bounds) {
return Ok(());
}
}
if clipped_doc_rect.is_empty() {
return Ok(());
}
self.ensure_atlas_contains(gpu_state, clipped_doc_rect)?;
// Destination is document-space rect mapped into atlas pixel coords.
let dst = skia::Rect::from_xywh(
(clipped_doc_rect.left - self.atlas_origin.x) * self.atlas_scale,
(clipped_doc_rect.top - self.atlas_origin.y) * self.atlas_scale,
clipped_doc_rect.width() * self.atlas_scale,
clipped_doc_rect.height() * self.atlas_scale,
);
// Compute source rect in tile_image pixel coordinates.
let img_w = tile_image.width() as f32;
let img_h = tile_image.height() as f32;
let tw = tile_doc_rect.width().max(1.0);
let th = tile_doc_rect.height().max(1.0);
let sx = ((clipped_doc_rect.left - tile_doc_rect.left) / tw) * img_w;
let sy = ((clipped_doc_rect.top - tile_doc_rect.top) / th) * img_h;
let sw = (clipped_doc_rect.width() / tw) * img_w;
let sh = (clipped_doc_rect.height() / th) * img_h;
let src = skia::Rect::from_xywh(sx, sy, sw, sh);
self.atlas.canvas().draw_image_rect(
tile_image,
Some((&src, skia::canvas::SrcRectConstraint::Fast)),
dst,
&skia::Paint::default(),
);
Ok(())
}
pub fn clear_doc_rect_in_atlas(
&mut self,
gpu_state: &mut GpuState,
doc_rect: skia::Rect,
) -> Result<()> {
let doc_rect = self.clamp_doc_rect_to_bounds(doc_rect);
if doc_rect.is_empty() {
return Ok(());
}
self.ensure_atlas_contains(gpu_state, doc_rect)?;
// Destination is document-space rect mapped into atlas pixel coords.
let dst = skia::Rect::from_xywh(
(doc_rect.left - self.atlas_origin.x) * self.atlas_scale,
(doc_rect.top - self.atlas_origin.y) * self.atlas_scale,
doc_rect.width() * self.atlas_scale,
doc_rect.height() * self.atlas_scale,
);
let canvas = self.atlas.canvas();
canvas.save();
canvas.clip_rect(dst, None, true);
canvas.clear(skia::Color::TRANSPARENT);
canvas.restore();
Ok(())
}
/// Clears the last atlas region written by `tile` (if any).
///
/// This avoids clearing the entire logical tile rect which, at very low
/// zoom levels, can be enormous in document space and would unnecessarily
/// grow / rescale the atlas.
pub fn clear_tile_in_atlas(&mut self, gpu_state: &mut GpuState, tile: Tile) -> Result<()> {
if let Some(doc_rect) = self.atlas_tile_doc_rects.remove(&tile) {
self.clear_doc_rect_in_atlas(gpu_state, doc_rect)?;
}
Ok(())
}
/// Clears a doc-space rect from the atlas **without** growing it.
///
/// Unlike [`clear_doc_rect_in_atlas`], this method clips `doc_rect` to the
/// current atlas bounds and skips silently if there is no overlap. Use this
/// when evicting stale shape content (e.g. before a drag re-render) where
/// growing the atlas to accommodate an out-of-range rect would be wasteful.
pub fn clear_doc_rect_in_atlas_clipped(&mut self, doc_rect: skia::Rect) {
if !self.has_atlas() || doc_rect.is_empty() {
return;
}
let atlas_scale = self.atlas_scale.max(0.01);
let atlas_doc_right = self.atlas_origin.x + (self.atlas_size.width as f32) / atlas_scale;
let atlas_doc_bottom = self.atlas_origin.y + (self.atlas_size.height as f32) / atlas_scale;
// Intersect with current atlas bounds in doc space.
let mut clipped = doc_rect;
let atlas_bounds = skia::Rect::from_ltrb(
self.atlas_origin.x,
self.atlas_origin.y,
atlas_doc_right,
atlas_doc_bottom,
);
if !clipped.intersect(atlas_bounds) {
return;
}
// Apply atlas_doc_bounds clamping.
if let Some(bounds) = self.atlas_doc_bounds {
if !clipped.intersect(bounds) {
return;
}
}
if clipped.is_empty() {
return;
}
let dst = skia::Rect::from_xywh(
(clipped.left - self.atlas_origin.x) * atlas_scale,
(clipped.top - self.atlas_origin.y) * atlas_scale,
clipped.width() * atlas_scale,
clipped.height() * atlas_scale,
);
let canvas = self.atlas.canvas();
canvas.save();
canvas.clip_rect(dst, None, true);
canvas.clear(skia::Color::TRANSPARENT);
canvas.restore();
self.atlas.max_texture_size
}
pub fn clear_tiles(&mut self) {
self.tiles.clear();
}
pub fn has_atlas(&self) -> bool {
self.atlas_size.width > 0 && self.atlas_size.height > 0
}
pub fn draw_tile_atlas_to_backbuffer(&mut self, viewbox: &Viewbox, tile_viewbox: &TileViewbox) {
let sampling_options =
skia::SamplingOptions::new(skia::FilterMode::Nearest, skia::MipmapMode::None);
@ -536,7 +571,7 @@ impl Surfaces {
/// Clears Backbuffer to `background` first so atlas-uncovered regions don't
/// show stale content when the atlas only partially covers the viewport.
pub fn draw_atlas_to_backbuffer(&mut self, viewbox: Viewbox, background: skia::Color) {
if !self.has_atlas() {
if self.atlas.is_empty() {
return;
}
@ -552,14 +587,14 @@ impl Surfaces {
canvas.clear(background);
let s = viewbox.get_scale();
let atlas_scale = self.atlas_scale.max(0.01);
let scale = self.atlas.scale.max(0.01);
canvas.translate((
(self.atlas_origin.x + viewbox.pan.x) * s,
(self.atlas_origin.y + viewbox.pan.y) * s,
(self.atlas.origin.x + viewbox.pan.x) * s,
(self.atlas.origin.y + viewbox.pan.y) * s,
));
canvas.scale((s / atlas_scale, s / atlas_scale));
canvas.scale((s / scale, s / scale));
self.atlas.draw(
self.atlas.surface.draw(
canvas,
(0.0, 0.0),
self.sampling_options,
@ -812,7 +847,7 @@ impl Surfaces {
SurfaceId::Debug => &mut self.debug,
SurfaceId::UI => &mut self.ui,
SurfaceId::Export => &mut self.export,
SurfaceId::Atlas => &mut self.atlas,
SurfaceId::Atlas => &mut self.atlas.surface,
SurfaceId::TileAtlas => &mut self.tile_atlas,
}
}
@ -833,7 +868,7 @@ impl Surfaces {
SurfaceId::Debug => &self.debug,
SurfaceId::UI => &self.ui,
SurfaceId::Export => &self.export,
SurfaceId::Atlas => &self.atlas,
SurfaceId::Atlas => &self.atlas.surface,
SurfaceId::TileAtlas => &self.tile_atlas,
}
}
@ -1122,8 +1157,8 @@ impl Surfaces {
// Incrementally update persistent 1:1 atlas in document space.
// `tile_doc_rect` is in world/document coordinates (1 unit == 1 px at 100%).
let _ = self.blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect);
self.atlas_tile_doc_rects.insert(*tile, tile_doc_rect);
let _ = self.atlas.blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect);
self.atlas.tile_doc_rects.insert(*tile, tile_doc_rect);
// Draws current tile into tile atlas
let tile_ref = self.tiles.add(tile_viewbox, tile);
@ -1140,21 +1175,6 @@ 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.
///
@ -1163,7 +1183,7 @@ impl Surfaces {
/// 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.
/// [`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.
@ -1240,9 +1260,9 @@ impl Surfaces {
);
} 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 (atlas, a_scale, origin) = (&snap.0, snap.1, snap.2);
let sx = (clip_doc.left - origin.x) * a_scale;
let sy = (clip_doc.top - 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 {
@ -1270,7 +1290,7 @@ impl Surfaces {
self.tiles.remove(tile);
// Also clear the corresponding region in the persistent atlas to avoid
// leaving stale pixels when shapes move/delete.
let _ = self.clear_tile_in_atlas(gpu_state, tile);
let _ = self.atlas.clear_tile_in_atlas(gpu_state, tile);
}
pub fn get_tile_image_from_tile_atlas(&mut self, tile: Tile) -> Option<skia::Image> {
@ -1384,7 +1404,7 @@ impl Surfaces {
/// the cache canvas for scaled previews, use `invalidate_tile_cache` instead.
pub fn remove_cached_tiles(&mut self, color: skia::Color) {
self.tiles.clear();
self.atlas_tile_doc_rects.clear();
self.atlas.tile_doc_rects.clear();
self.cache.canvas().clear(color);
}
@ -1394,7 +1414,7 @@ impl Surfaces {
/// content while new tiles are being rendered.
pub fn invalidate_tile_cache(&mut self) {
self.tiles.clear();
self.atlas_tile_doc_rects.clear();
self.atlas.tile_doc_rects.clear();
}
pub fn gc(&mut self) {