🐛 Preserve tile coverage when packing atlas slots (#11749)

Packed atlas compose inset Linear samples after blitting the full
drawable into the slot, which dropped edge texels and opened
multi-pixel gaps on text that crossed tile seams (#11696), especially
under HiDPI packing. Write the drawable into the inset content rect
and clamp-pad the 1px frame so Linear compose keeps coverage without
bleeding into the next cell.

Closes #11696
This commit is contained in:
Alejandro Alonso 2026-09-16 16:40:33 +02:00 committed by GitHub
parent fcae641c7b
commit 20547f658e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 62 additions and 26 deletions

View File

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

View File

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