diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 876cee8b0c..c15b4fc5db 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -52,6 +52,32 @@ fn draw_surface_src_rect_to_dst( to_canvas.restore(); } +/// Copy a rendered tile into an atlas slot. Packed slots keep a 1px border so +/// Linear filtering on compose does not sample the neighboring cell. +fn blit_drawable_into_tile_atlas_slot( + current: &mut skia::Surface, + atlas_canvas: &skia::Canvas, + src: skia::Rect, + slot: skia::Rect, + slot_size: i32, +) { + let nearest = skia::SamplingOptions::new(skia::FilterMode::Nearest, skia::MipmapMode::None); + let content = tiles::tile_atlas_content_rect(slot, slot_size); + if content == slot { + draw_surface_src_rect_to_dst(current, atlas_canvas, src, slot, nearest); + return; + } + + let linear = skia::SamplingOptions::new(skia::FilterMode::Linear, skia::MipmapMode::None); + draw_surface_src_rect_to_dst(current, atlas_canvas, src, content, linear); + + atlas_canvas.save(); + atlas_canvas.clip_rect(slot, None, false); + atlas_canvas.clip_rect(content, Some(skia::ClipOp::Difference), false); + draw_surface_src_rect_to_dst(current, atlas_canvas, src, slot, nearest); + atlas_canvas.restore(); +} + pub fn get_cache_size(viewbox: &Viewbox, interest: i32) -> skia::ISize { // First we retrieve the extended area of the viewport that we could render. let TileRect(isx, isy, iex, iey) = @@ -1287,9 +1313,14 @@ impl Surfaces { .tiles .add(tile_viewbox, tile, scale, view_doc, &mut tile_doc_rects); self.atlas.tile_doc_rects = tile_doc_rects; - let dst = tile_ref.rect; let mut current = self.current.clone(); - draw_surface_src_rect_to_dst(&mut current, self.tile_atlas.canvas(), src, dst, sampling); + blit_drawable_into_tile_atlas_slot( + &mut current, + self.tile_atlas.canvas(), + src, + tile_ref.rect, + self.tiles.slot_size(), + ); if !skip_cache_surface { // Optional legacy Cache surface fill (debug). Pan/zoom preview @@ -1709,20 +1740,6 @@ impl TileTextureCache { tiles::tile_atlas_compose_scale(self.slot_size) } - fn compose_src_rect(&self, rect: Rect) -> Rect { - if self.slot_size < TILE_SIZE { - let inset = tiles::TILE_ATLAS_SAMPLE_INSET; - Rect::new( - rect.left + inset, - rect.top + inset, - rect.right - inset, - rect.bottom - inset, - ) - } else { - rect - } - } - pub fn repack(&mut self, texture_width: i32, texture_height: i32, slot_size: i32) { let capacity = ((texture_width / slot_size) * (texture_height / slot_size)) as usize; self.slot_size = slot_size; @@ -1844,7 +1861,7 @@ impl TileTextureCache { ), ); - let src = self.compose_src_rect(tile_ref.rect); + let src = tiles::tile_atlas_content_rect(tile_ref.rect, self.slot_size); self.textures[index].set_ltrb(src.left, src.top, src.right, src.bottom); index += 1; @@ -1884,7 +1901,7 @@ impl TileTextureCache { continue; } - let src = self.compose_src_rect(tile_ref.rect); + let src = tiles::tile_atlas_content_rect(tile_ref.rect, self.slot_size); let scos = doc_rect.width() * s / src.width(); let tx = ((doc_rect.left + viewbox.pan.x) * s).round(); let ty = ((doc_rect.top + viewbox.pan.y) * s).round(); @@ -1914,7 +1931,7 @@ impl TileTextureCache { continue; } - let src = self.compose_src_rect(tile_ref.rect); + let src = tiles::tile_atlas_content_rect(tile_ref.rect, self.slot_size); let tx = ((doc_rect.left + viewbox.pan.x) * s).round(); let ty = ((doc_rect.top + viewbox.pan.y) * s).round(); let scos = doc_rect.width() * s / src.width(); diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index ea36363f6d..c4783af4f4 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -269,20 +269,30 @@ pub fn tile_atlas_slot_size(needed_slots: usize, atlas_px: i32) -> i32 { (atlas_px / side).clamp(MIN_SLOT, TILE_SIZE as i32) } -/// Inset (texels) applied when sampling a packed atlas slot with Linear -/// filtering, so upsample kernels do not bleed into the neighboring cell. +/// 1px pad around packed-slot content so Linear compose does not bleed into +/// the next cell. The drawable is written into the inner rect (full spatial +/// coverage); sampling that inner rect avoids the edge-drop gaps of #11696. pub const TILE_ATLAS_SAMPLE_INSET: f32 = 1.0; -/// Source size inside a packed slot after the Linear-filter inset. -pub fn tile_atlas_compose_src_size(slot_size: i32) -> f32 { +/// Rect inside `slot` that holds the tile drawable (inset when packed). +pub fn tile_atlas_content_rect(slot: skia::Rect, slot_size: i32) -> skia::Rect { if slot_size < TILE_SIZE as i32 { - (slot_size as f32 - 2.0 * TILE_ATLAS_SAMPLE_INSET).max(1.0) + let i = TILE_ATLAS_SAMPLE_INSET; + skia::Rect::new(slot.left + i, slot.top + i, slot.right - i, slot.bottom - i) } else { - slot_size as f32 + slot } } -/// `draw_atlas` scale so the destination sprite stays `TILE_SIZE` after inset. +pub fn tile_atlas_compose_src_size(slot_size: i32) -> f32 { + tile_atlas_content_rect( + skia::Rect::from_wh(slot_size as f32, slot_size as f32), + slot_size, + ) + .width() + .max(1.0) +} + pub fn tile_atlas_compose_scale(slot_size: i32) -> f32 { TILE_SIZE / tile_atlas_compose_src_size(slot_size) } @@ -519,6 +529,15 @@ mod tests { assert!(src < slot as f32); } + #[test] + fn atlas_content_rect_matches_compose_src_when_packed() { + let slot = skia::Rect::from_xywh(10.0, 20.0, 315.0, 315.0); + let inner = tile_atlas_content_rect(slot, 315); + assert_eq!(inner, skia::Rect::new(11.0, 21.0, 324.0, 334.0)); + assert_eq!(tile_atlas_content_rect(slot, 512), slot); + assert!((inner.width() - tile_atlas_compose_src_size(315)).abs() < 1e-4); + } + #[test] fn edit_dirty_rect_includes_pre_rotate_extent_outside_current_index() { // Indexed tiles are interest-clipped; old AABB still covers wings.