From cba51cfb7e09f3cf3c7f36afe52daab337f6ff41 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 29 Sep 2026 12:38:04 +0200 Subject: [PATCH] :bug: Keep adjacent tile compose origins abutted (#11968) Round the view offset once and place tiles at origin + k * TILE_SIZE so half-pixel pans do not open a 1px background seam between neighbors. --- render-wasm/src/render/surfaces.rs | 16 +++--------- render-wasm/src/tiles.rs | 39 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index c15b4fc5db..761b2d02d6 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -1852,14 +1852,8 @@ impl TileTextureCache { continue; } - self.transforms[index] = skia::RSXform::new( - dest_scale, - 0.0, - ( - (x as f32 * self.tile_size - offset.x).round(), - (y as f32 * self.tile_size - offset.y).round(), - ), - ); + let (tx, ty) = tiles::tile_screen_xy(Tile(x, y), self.tile_size, offset); + self.transforms[index] = skia::RSXform::new(dest_scale, 0.0, (tx, ty)); 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); @@ -1903,8 +1897,7 @@ impl TileTextureCache { 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(); + let (tx, ty) = tiles::doc_rect_screen_xy(doc_rect, viewbox.pan, s); transforms.push(skia::RSXform::new(scos, 0.0, (tx, ty))); textures.push(src); @@ -1932,8 +1925,7 @@ impl TileTextureCache { } 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 (tx, ty) = tiles::doc_rect_screen_xy(doc_rect, viewbox.pan, s); let scos = doc_rect.width() * s / src.width(); transforms.push(skia::RSXform::new(scos, 0.0, (tx, ty))); diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index c4783af4f4..8180266fb9 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -211,6 +211,22 @@ impl TileViewbox { pub const TILE_SIZE: f32 = 512.; +/// Screen-space top-left of `tile`: `round(-offset) + tile * tile_size`. +pub fn tile_screen_xy(tile: Tile, tile_size: f32, offset: skia::Point) -> (f32, f32) { + ( + (-offset.x).round() + tile.x() as f32 * tile_size, + (-offset.y).round() + tile.y() as f32 * tile_size, + ) +} + +/// Screen-space top-left of `doc_rect`: `doc * scale + round(pan * scale)`. +pub fn doc_rect_screen_xy(doc_rect: skia::Rect, pan: skia::Point, scale: f32) -> (f32, f32) { + ( + doc_rect.left * scale + (pan.x * scale).round(), + doc_rect.top * scale + (pan.y * scale).round(), + ) +} + #[inline(always)] pub fn get_tile_dimensions() -> skia::ISize { (TILE_SIZE as i32, TILE_SIZE as i32).into() @@ -501,6 +517,29 @@ mod tests { use super::*; use skia_safe as skia; + #[test] + fn tile_screen_xy_keeps_neighbors_abutted_at_half_pixel_offset() { + let offset = skia::Point::new(103.5, 0.0); + let (x0, y0) = tile_screen_xy(Tile(0, 0), TILE_SIZE, offset); + let (x1, y1) = tile_screen_xy(Tile(1, 0), TILE_SIZE, offset); + let (x2, y2) = tile_screen_xy(Tile(0, 1), TILE_SIZE, offset); + assert_eq!(x1 - x0, TILE_SIZE); + assert_eq!(y2 - y0, TILE_SIZE); + assert_eq!(y1, y0); + assert_eq!(x2, x0); + } + + #[test] + fn doc_rect_screen_xy_keeps_neighbors_abutted_at_half_pixel_pan() { + let pan = skia::Point::new(-103.5, 0.0); + let scale = 1.0_f32; + let a = skia::Rect::from_xywh(0.0, 0.0, TILE_SIZE, TILE_SIZE); + let b = skia::Rect::from_xywh(TILE_SIZE, 0.0, TILE_SIZE, TILE_SIZE); + let (x0, _) = doc_rect_screen_xy(a, pan, scale); + let (x1, _) = doc_rect_screen_xy(b, pan, scale); + assert_eq!(x1 - x0, TILE_SIZE); + } + #[test] fn atlas_slot_is_full_size_when_tiles_fit() { assert_eq!(tile_atlas_slot_size(64, 4096), 512);