diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 6272d8d9a3..2920496de4 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -720,26 +720,24 @@ impl RenderState { self.surfaces.clear_cache(self.background_color); self.cache_cleared_this_render = true; } - let tile_rect = self.get_current_aligned_tile_bounds()?; // In fast mode the viewport is moving (pan/zoom) so Cache surface // positions would be wrong — only save to the tile HashMap. + let tile_rect = self.get_current_aligned_tile_bounds()?; + let current_tile = *self + .current_tile + .as_ref() + .ok_or(Error::CriticalError("Current tile not found".to_string()))?; self.surfaces.cache_current_tile_texture( &mut self.gpu_state, &self.tile_viewbox, - &self - .current_tile - .ok_or(Error::CriticalError("Current tile not found".to_string()))?, + ¤t_tile, &tile_rect, fast_mode, self.render_area, ); - self.surfaces.draw_cached_tile_surface( - self.current_tile - .ok_or(Error::CriticalError("Current tile not found".to_string()))?, - rect, - self.background_color, - ); + self.surfaces + .draw_cached_tile_surface(current_tile, rect, self.background_color); Ok(()) } @@ -1674,6 +1672,12 @@ impl RenderState { self.cache_cleared_this_render = false; self.reset_canvas(); + // Compute and set document-space bounds (1 unit == 1 doc px @ 100% zoom) + // 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); + // During an interactive shape transform (drag/resize/rotate) the // Target is repainted tile-by-tile. If only a subset of the // invalidated tiles finishes in this rAF the remaining area @@ -1767,6 +1771,37 @@ impl RenderState { Ok(()) } + fn compute_document_bounds( + &mut self, + base_object: Option<&Uuid>, + tree: ShapesPoolRef, + ) -> Option { + let ids: Vec = if let Some(id) = base_object { + vec![*id] + } else { + let root = tree.get(&Uuid::nil())?; + root.children_ids(false) + }; + + let mut acc: Option = None; + for id in ids.iter() { + let Some(shape) = tree.get(id) else { + continue; + }; + let r = self.get_cached_extrect(shape, tree, 1.0); + if r.is_empty() { + continue; + } + acc = Some(if let Some(mut a) = acc { + a.join(r); + a + } else { + r + }); + } + acc + } + pub fn process_animation_frame( &mut self, base_object: Option<&Uuid>, @@ -2927,11 +2962,6 @@ impl RenderState { s.canvas().draw_rect(aligned_rect, &paint); }); } - - // Clear atlas region to transparent so background shows through. - let _ = self - .surfaces - .clear_doc_rect_in_atlas(&mut self.gpu_state, self.render_area); } } } @@ -3156,7 +3186,8 @@ impl RenderState { } pub fn remove_cached_tile(&mut self, tile: tiles::Tile) { - self.surfaces.remove_cached_tile_surface(tile); + self.surfaces + .remove_cached_tile_surface(&mut self.gpu_state, tile); } /// Rebuild the tile index (shape→tile mapping) for all top-level shapes. diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index ca7f2a3ef2..8d769eea0f 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -78,10 +78,16 @@ pub struct Surfaces { /// 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, /// 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, 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, pub margins: skia::ISize, // Tracks which surfaces have content (dirty flag bitmask) dirty_surfaces: u32, @@ -147,8 +153,10 @@ impl Surfaces { 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, @@ -162,6 +170,28 @@ impl Surfaces { self.max_atlas_texture_size = max_px.clamp(TILE_SIZE as i32, 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) { + 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, @@ -271,21 +301,51 @@ impl Surfaces { &mut self, gpu_state: &mut GpuState, tile_image: &skia::Image, - doc_rect: skia::Rect, + tile_doc_rect: skia::Rect, ) -> Result<()> { - self.ensure_atlas_contains(gpu_state, doc_rect)?; + 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( - (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, + (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, ); - self.atlas - .canvas() - .draw_image_rect(tile_image, None, dst, &skia::Paint::default()); + // 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(()) } @@ -294,6 +354,7 @@ impl Surfaces { 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(()); } @@ -316,6 +377,18 @@ impl Surfaces { 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(()) + } + pub fn clear_tiles(&mut self) { self.tiles.clear(); } @@ -817,6 +890,7 @@ 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); self.tiles.add(tile_viewbox, tile, tile_image); } } @@ -825,11 +899,14 @@ impl Surfaces { self.tiles.has(tile) } - pub fn remove_cached_tile_surface(&mut self, tile: Tile) { + pub fn remove_cached_tile_surface(&mut self, gpu_state: &mut GpuState, tile: Tile) { // Mark tile as invalid // Old content stays visible until new tile overwrites it atomically, // preventing flickering during tile re-renders. 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); } pub fn draw_cached_tile_surface(&mut self, tile: Tile, rect: skia::Rect, color: skia::Color) { @@ -914,6 +991,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.cache.canvas().clear(color); } @@ -923,6 +1001,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(); } pub fn gc(&mut self) {