diff --git a/render-wasm/docs/tile_rendering.md b/render-wasm/docs/tile_rendering.md index baf09bd287..266b41d5e0 100644 --- a/render-wasm/docs/tile_rendering.md +++ b/render-wasm/docs/tile_rendering.md @@ -151,13 +151,15 @@ Tile caching (via `TileTextureCache`) boosts performance: ## Scaling and Tile Size Utilities -Tile size in screen space is constant, but its actual visual size depends on zoom level (`scale`). +The **document** tile grid depends only on zoom (`get_tile_size(zoom) = 512 / zoom`), +so a shape occupies the same tiles at any DPR. Paint scale remains `zoom × dpr` for +HiDPI sharpness, and each GPU tile slot is `512 × dpr` device pixels. -To deal with this, the system uses utility functions: +Utility functions: -- `get_tile_size(scale)`: Gets the logical size of a tile at the current scale. -- `get_tile_rect(tile, scale)`: Gets the rectangular area a tile covers. -- `get_tile_pos(tile, scale)`: Gets the tile’s position in canvas coordinates. +- `get_tile_size(zoom)`: Document-space size of one tile (DPR-independent). +- `get_tile_rect(tile, zoom)` / `get_tile_pos(tile, zoom)`: Doc-space geometry. +- `physical_tile_size(dpr)`: GPU edge length `ceil(512 × dpr)`. These allow rendering logic to adapt tile positions/sizes dynamically. diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 050f8820f4..839d2fb03b 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -555,7 +555,7 @@ impl RenderState { let surfaces = Surfaces::try_new( (width, height), sampling_options, - tiles::get_tile_dimensions(), + tiles::get_tile_dimensions(1.0), )?; Self::assemble(width, height, surfaces) @@ -876,15 +876,24 @@ impl RenderState { // Only when this function returns true (it means the value // was properly changed) the rest of the functions is called. if self.options.set_dpr(dpr) { + // Grid is zoom-only; interest is a tile-count margin (not ×dpr). self.tile_viewbox .set_interest(self.options.dpr_viewport_interest_area_threshold); + self.viewbox.set_dpr(dpr); + get_resources().fonts.set_scale_debug_font(dpr); + + // Viewport surfaces (Target/Backbuffer) scale with CSS×dpr. self.resize( self.viewbox.width().floor() as i32, self.viewbox.height().floor() as i32, )?; - get_resources().fonts.set_scale_debug_font(dpr); - self.viewbox.set_dpr(dpr); - self.surfaces.set_dpr(dpr); + + // Grow/shrink physical tile work surfaces + atlas slots; drop + // cached textures (same doc grid, wrong pixel density). + if self.surfaces.set_dpr(dpr)? { + self.surfaces.invalidate_tile_cache(); + self.tile_viewbox.update(&self.viewbox); + } } Ok(()) } @@ -2001,8 +2010,10 @@ impl RenderState { pub fn update_render_context(&mut self, tile: tiles::Tile) { self.current_tile = Some(tile); + let zoom = self.viewbox.zoom(); let scale = self.get_scale(); - self.render_area = tiles::get_tile_rect(tile, scale); + // Doc-space tile rect depends only on zoom (DPR-independent grid). + self.render_area = tiles::get_tile_rect(tile, zoom); let margins = self.surfaces.margins(); let margin_w = margins.width as f32 / scale; let margin_h = margins.height as f32 / scale; @@ -2012,6 +2023,7 @@ impl RenderState { self.render_area.right + margin_w, self.render_area.bottom + margin_h, ); + // Canvas CTM still uses paint scale (zoom×dpr) for HiDPI sharpness. self.surfaces.update_render_context(self.render_area, scale); } @@ -2942,7 +2954,8 @@ impl RenderState { .current_tile .ok_or(Error::CriticalError("Current tile not found".to_string()))?; let offset = self.viewbox.get_offset(); - Ok(tile.get_rect_with_offset(&offset)) + let phys = self.surfaces.physical_tile_size() as f32; + Ok(tile.get_rect_with_offset(&offset, phys)) } pub fn get_rect_bounds(&mut self, rect: skia::Rect) -> Rect { @@ -2970,15 +2983,14 @@ impl RenderState { pub fn get_aligned_tile_bounds(&mut self, tile: tiles::Tile) -> Rect { let scale = self.get_scale(); - let start_tile_x = - (self.viewbox.area.left * scale / tiles::TILE_SIZE).floor() * tiles::TILE_SIZE; - let start_tile_y = - (self.viewbox.area.top * scale / tiles::TILE_SIZE).floor() * tiles::TILE_SIZE; + let phys = self.surfaces.physical_tile_size() as f32; + let start_tile_x = (self.viewbox.area.left * scale / phys).floor() * phys; + let start_tile_y = (self.viewbox.area.top * scale / phys).floor() * phys; Rect::from_xywh( - (tile.x() as f32 * tiles::TILE_SIZE) - start_tile_x, - (tile.y() as f32 * tiles::TILE_SIZE) - start_tile_y, - tiles::TILE_SIZE, - tiles::TILE_SIZE, + (tile.x() as f32 * phys) - start_tile_x, + (tile.y() as f32 * phys) - start_tile_y, + phys, + phys, ) } @@ -2987,9 +2999,7 @@ impl RenderState { // // Unlike `get_current_tile_bounds`, which calculates bounds using the exact // scaled offset of the viewbox, this method snaps the origin to the nearest - // lower multiple of `TILE_SIZE`. This ensures the tile bounds are aligned - // with the global tile grid, which is useful for rendering tiles in a - /// consistent and predictable layout. + // lower multiple of the physical tile size (`512 × dpr`). pub fn get_current_aligned_tile_bounds(&mut self) -> Result { Ok(self.get_aligned_tile_bounds( self.current_tile @@ -3976,9 +3986,10 @@ impl RenderState { * render_shape_tree_partial_uncached, ensuring all shapes render correctly. */ pub fn get_tiles_for_shape(&mut self, shape: &Shape, tree: ShapesPoolRef) -> TileRect { - let scale = self.get_scale(); - let extrect = self.get_cached_extrect(shape, tree, scale); - let tile_size = tiles::get_tile_size(scale); + let zoom = self.viewbox.zoom(); + // Extents and tile size use zoom only so the grid is DPR-independent. + let extrect = self.get_cached_extrect(shape, tree, zoom); + let tile_size = tiles::get_tile_size(zoom); let shape_tiles = tiles::get_tiles_for_rect(extrect, tile_size); let interest_rect = &self.tile_viewbox.interest_rect; // Calculate the intersection of shape_tiles with interest_rect diff --git a/render-wasm/src/render/debug.rs b/render-wasm/src/render/debug.rs index d661fd2bd1..efc7a0def1 100644 --- a/render-wasm/src/render/debug.rs +++ b/render-wasm/src/render/debug.rs @@ -92,14 +92,14 @@ pub fn render_debug_tiles_for_viewbox(render_state: &mut RenderState) { // Renders the tiles in the viewbox pub fn render_debug_viewbox_tiles(render_state: &mut RenderState) { - let scale = render_state.get_scale(); + let zoom = render_state.viewbox.zoom(); let canvas = render_state.surfaces.canvas(SurfaceId::Debug); let mut paint = skia::Paint::default(); paint.set_style(skia::PaintStyle::Stroke); paint.set_color(skia::Color::MAGENTA); paint.set_stroke_width(1.); - let tile_size = tiles::get_tile_size(scale); + let tile_size = tiles::get_tile_size(zoom); let tile_rect = tiles::get_tiles_for_rect(render_state.viewbox.area, tile_size); let tiles::TileRect(sx, sy, ex, ey) = tile_rect; @@ -108,7 +108,7 @@ pub fn render_debug_viewbox_tiles(render_state: &mut RenderState) { let debug_font = get_resources().fonts.debug_font(); canvas.draw_str(str_rect, skia::Point::new(100.0, 100.0), debug_font, &paint); - let tile_size = tiles::get_tile_size(scale); + let tile_size = tiles::get_tile_size(zoom); for tile in tile_rect.iter(true) { let tiles::Tile(x, y) = tile; let rect = tile.get_rect_with_size(tile_size); diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 5dfe0ac2fb..b071a0ae90 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -8,7 +8,6 @@ const SHOW_WASM_INFO: u32 = 0x08; // This is the extra area used for tile rendering (tiles beyond viewport). // Higher values pre-render more tiles, reducing empty squares during pan but using more memory. const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 1; -const MIN_DPR_VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 2; const MAX_BLOCKING_TIME_MS: i32 = 32; const NODE_BATCH_THRESHOLD: i32 = 3; /// Soft-drain GPU every N walker nodes on progressive Partials. Keeps ops @@ -82,14 +81,10 @@ impl RenderOptions { self.capture_frames = capture_frames; } - /// Updates the dpr viewport interest area threshold. - /// This function is updated when the dpr or the - /// viewport_interest_area_threshold is changed + /// Interest is measured in tile counts. The document grid no longer densifies + /// with DPR, so this tracks the configured threshold directly (no ×dpr). fn update_dpr_viewport_interest_area_threshold(&mut self) { - // TODO: this will likely need to change once we have the tile atlas in place - self.dpr_viewport_interest_area_threshold = - ((self.dpr * self.viewport_interest_area_threshold as f32).ceil() as i32) - .min(MIN_DPR_VIEWPORT_INTEREST_AREA_THRESHOLD); + self.dpr_viewport_interest_area_threshold = self.viewport_interest_area_threshold; } /// Sets the devicePixelRatio. diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index b93cd9628e..84a996bb27 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -14,19 +14,27 @@ use std::collections::{HashMap, HashSet}; const TEXTURES_CACHE_CAPACITY: usize = 1024; const TEXTURES_BATCH_DELETE: usize = 256; -// This is the amount of extra space we're going to give to all the surfaces to render shapes. -// If it's too big it could affect performance. -const TILE_SIZE: i32 = tiles::TILE_SIZE as i32; +/// Current / effect surfaces are `physical_tile × TILE_SIZE_MULTIPLIER` so +/// blur/outset can bleed into a ¼-tile margin on each side. const TILE_SIZE_MULTIPLIER: i32 = 2; -const TILE_MARGIN_SIZE: i32 = TILE_SIZE * TILE_SIZE_MULTIPLIER / 4; -const TILE_DRAWABLE_RECT: IRect = IRect { - left: TILE_MARGIN_SIZE, - top: TILE_MARGIN_SIZE, - right: TILE_MARGIN_SIZE + TILE_SIZE, - bottom: TILE_MARGIN_SIZE + TILE_SIZE, -}; const DOC_ATLAS_MAX_DIM: i32 = 4096; +#[inline(always)] +fn tile_margin_size(physical_tile: i32) -> i32 { + physical_tile * TILE_SIZE_MULTIPLIER / 4 +} + +#[inline(always)] +fn tile_drawable_irect(physical_tile: i32) -> IRect { + let m = tile_margin_size(physical_tile); + IRect { + left: m, + top: m, + right: m + physical_tile, + bottom: m + physical_tile, + } +} + /// GPU→GPU copy of `src` from `from` into `dst` on `to_canvas`, without /// `image_snapshot` (avoids per-tile sync stalls on WebGL). fn draw_surface_src_rect_to_dst( @@ -50,7 +58,7 @@ fn draw_surface_src_rect_to_dst( to_canvas.restore(); } -pub fn get_cache_size(viewbox: &Viewbox, interest: i32) -> skia::ISize { +pub fn get_cache_size(viewbox: &Viewbox, interest: i32, physical_tile: i32) -> skia::ISize { // First we retrieve the extended area of the viewport that we could render. let TileRect(isx, isy, iex, iey) = tiles::get_tiles_for_viewbox_with_interest(viewbox, interest); @@ -59,8 +67,8 @@ pub fn get_cache_size(viewbox: &Viewbox, interest: i32) -> skia::ISize { let dy = if isy.signum() != iey.signum() { 1 } else { 0 }; ( - ((iex - isx).abs() + dx) * TILE_SIZE, - ((iey - isy).abs() + dy) * TILE_SIZE, + ((iex - isx).abs() + dx) * physical_tile, + ((iey - isy).abs() + dy) * physical_tile, ) .into() } @@ -205,7 +213,7 @@ impl DocAtlas { // Keep the highest possible scale (closest to 1.0) that still fits. let cap = gpu_state .max_texture_size() - .clamp(TILE_SIZE, DOC_ATLAS_MAX_DIM) as f32; + .clamp(tiles::TILE_SIZE as i32, DOC_ATLAS_MAX_DIM) as f32; let required_scale = (cap / doc_w).min(cap / doc_h).clamp(0.01, 1.0); @@ -454,6 +462,8 @@ pub struct Surfaces { // Tracks which surfaces have content (dirty flag bitmask) dirty_surfaces: u32, extra_tile_dims: skia::ISize, + /// Device-pixel edge of one atlas tile (`512 × dpr`). + physical_tile: i32, dpr: f32, } @@ -465,10 +475,11 @@ impl Surfaces { tile_dims: skia::ISize, ) -> Result { let gpu_state = get_gpu_state(); + let physical_tile = tile_dims.width.max(1); let extra_tile_dims = skia::ISize::new( - tile_dims.width * TILE_SIZE_MULTIPLIER, - tile_dims.height * TILE_SIZE_MULTIPLIER, + physical_tile * TILE_SIZE_MULTIPLIER, + physical_tile * TILE_SIZE_MULTIPLIER, ); let margins = skia::ISize::new(extra_tile_dims.width / 4, extra_tile_dims.height / 4); @@ -503,9 +514,9 @@ 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)?; - // 512, why not? - let tiles = TileTextureCache::new(tile_atlas.width(), 512); + let tiles = TileTextureCache::new(tile_atlas.width(), physical_tile, 512); let atlas = DocAtlas::try_new()?; + let dpr = physical_tile as f32 / tiles::TILE_SIZE; Ok(Self { target, filter, @@ -532,12 +543,63 @@ impl Surfaces { margins, dirty_surfaces: 0, extra_tile_dims, - dpr: 1.0, + physical_tile, + dpr, }) } - pub fn set_dpr(&mut self, dpr: f32) { + pub fn physical_tile_size(&self) -> i32 { + self.physical_tile + } + + pub fn drawable_irect(&self) -> IRect { + tile_drawable_irect(self.physical_tile) + } + + /// Recreate Current / effect / atlas-slot surfaces for a new DPR. + /// Returns true when physical tile size changed (caller should invalidate caches). + pub fn set_dpr(&mut self, dpr: f32) -> Result { self.dpr = dpr; + let new_phys = tiles::physical_tile_size(dpr); + if new_phys == self.physical_tile { + return Ok(false); + } + + let gpu_state = get_gpu_state(); + let extra_tile_dims = skia::ISize::new( + new_phys * TILE_SIZE_MULTIPLIER, + new_phys * TILE_SIZE_MULTIPLIER, + ); + let margins = skia::ISize::new(extra_tile_dims.width / 4, extra_tile_dims.height / 4); + + self.filter = + gpu_state.create_surface_with_isize("filter".to_string(), extra_tile_dims)?; + self.current = + gpu_state.create_surface_with_isize("current".to_string(), extra_tile_dims)?; + self.drop_shadows = + gpu_state.create_surface_with_isize("drop_shadows".to_string(), extra_tile_dims)?; + self.inner_shadows = + gpu_state.create_surface_with_isize("inner_shadows".to_string(), extra_tile_dims)?; + self.text_drop_shadows = + gpu_state.create_surface_with_isize("text_drop_shadows".to_string(), extra_tile_dims)?; + self.shape_fills = + gpu_state.create_surface_with_isize("shape_fills".to_string(), extra_tile_dims)?; + self.shape_strokes = + gpu_state.create_surface_with_isize("shape_strokes".to_string(), extra_tile_dims)?; + // Keep export at least as large as the new tile work surface. + self.export = + gpu_state.create_surface_with_isize("export".to_string(), extra_tile_dims)?; + + self.tiles = TileTextureCache::new(self.tile_atlas.width(), new_phys, 512); + self.tile_atlas.canvas().clear(skia::Color::TRANSPARENT); + self.tile_atlas_image = None; + self.atlas.tile_doc_rects.clear(); + + self.physical_tile = new_phys; + self.extra_tile_dims = extra_tile_dims; + self.margins = margins; + self.clear_all_dirty(); + Ok(true) } pub fn clear_tiles(&mut self) { @@ -996,8 +1058,8 @@ impl Surfaces { .ok_or(Error::CriticalError("Failed to create surface".to_string()))?; self.cache.canvas().reset_matrix(); self.cache.canvas().translate(( - (interest_area_threshold * TILE_SIZE) as f32, - (interest_area_threshold * TILE_SIZE) as f32, + (interest_area_threshold * self.physical_tile) as f32, + (interest_area_threshold * self.physical_tile) as f32, )); Ok(()) } @@ -1008,8 +1070,10 @@ impl Surfaces { cached_viewbox: &Viewbox, interest_area_threshold: i32, ) -> Result<()> { - let viewbox_cache_size = get_cache_size(viewbox, interest_area_threshold); - let cached_viewbox_cache_size = get_cache_size(cached_viewbox, interest_area_threshold); + let viewbox_cache_size = + get_cache_size(viewbox, interest_area_threshold, self.physical_tile); + let cached_viewbox_cache_size = + get_cache_size(cached_viewbox, interest_area_threshold, self.physical_tile); // Only resize cache if the new size is larger than the cached size // This avoids unnecessary surface recreations when the cache size decreases if viewbox_cache_size.width > cached_viewbox_cache_size.width @@ -1207,8 +1271,8 @@ impl Surfaces { /// Debug: semi-transparent tint unique per tile coords, baked into Current /// before atlas/backbuffer blit so tile boundaries are visible on screen. pub fn paint_debug_tile_overlay(&mut self, tile: &Tile) { + let rect = skia::Rect::from(self.drawable_irect()); let canvas = self.current.canvas(); - let rect = skia::Rect::from(TILE_DRAWABLE_RECT); // Stable pseudo-random RGB from tile coords (same tile → same color). let h = (tile.x() as u32) @@ -1242,7 +1306,7 @@ impl Surfaces { self.paint_debug_tile_overlay(tile); let gpu_state = get_gpu_state(); - let src = skia::Rect::from(TILE_DRAWABLE_RECT); + let src = skia::Rect::from(self.drawable_irect()); let sampling = self.sampling_options; // DocAtlas + tile atlas via Surface::draw (no image_snapshot sync). @@ -1310,7 +1374,9 @@ impl Surfaces { let canvas = scratch.canvas(); canvas.clear(skia::Color::TRANSPARENT); - let tile_size = tiles::get_tile_size(scale); + // Grid uses zoom only; `scale` is paint scale (zoom×dpr) for screen mapping. + let zoom = scale / self.dpr.max(0.01); + let tile_size = tiles::get_tile_size(zoom); let tr = tiles::get_tiles_for_rect(src_doc_bounds, tile_size); let ix0 = src_irect.left as f32; let iy0 = src_irect.top as f32; @@ -1319,7 +1385,7 @@ impl Surfaces { for ty in tr.y1()..=tr.y2() { for tx in tr.x1()..=tr.x2() { let tile = Tile(tx, ty); - let tile_doc = tiles::get_tile_rect(tile, scale); + let tile_doc = tiles::get_tile_rect(tile, zoom); let mut clip_doc = tile_doc; if !clip_doc.intersect(src_doc_bounds) || clip_doc.is_empty() { continue; @@ -1612,11 +1678,12 @@ impl AtlasDrawBatch { } impl TileTextureCache { - pub fn new(texture_size: i32, capacity: usize) -> Self { + pub fn new(texture_size: i32, physical_tile: i32, capacity: usize) -> Self { + let physical_tile = physical_tile.max(1); Self { - tile_size: tiles::TILE_SIZE, + tile_size: physical_tile as f32, is_updated: false, - provider: TileAtlasTextureProvider::new(texture_size, TILE_SIZE), + provider: TileAtlasTextureProvider::new(texture_size, physical_tile), transforms: Vec::with_capacity(capacity), textures: Vec::with_capacity(capacity), grid: HashMap::with_capacity(capacity), @@ -1737,7 +1804,7 @@ impl TileTextureCache { let doc_rect = tile_doc_rects .get(&tile) .copied() - .unwrap_or_else(|| tiles::get_tile_rect(tile, s)); + .unwrap_or_else(|| tiles::get_tile_rect(tile, viewbox.zoom())); if doc_rect.is_empty() || !doc_rect.intersects(view_doc) { continue; } @@ -1761,7 +1828,7 @@ impl TileTextureCache { let doc_rect = tile_doc_rects .get(&tile) .copied() - .unwrap_or_else(|| tiles::get_tile_rect(tile, s)); + .unwrap_or_else(|| tiles::get_tile_rect(tile, viewbox.zoom())); if doc_rect.is_empty() || !doc_rect.intersects(view_doc) { continue; } diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index d00dc25bdf..3dadacc857 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -31,13 +31,14 @@ impl Tile { ) } + /// Screen-space rect for this tile using the physical tile size (512×dpr). #[inline(always)] - pub fn get_rect_with_offset(&self, offset: &skia::Point) -> skia::Rect { + pub fn get_rect_with_offset(&self, offset: &skia::Point, physical_tile_size: f32) -> skia::Rect { skia::Rect::from_xywh( - self.0 as f32 * TILE_SIZE - offset.x, - self.1 as f32 * TILE_SIZE - offset.y, - TILE_SIZE, - TILE_SIZE, + self.0 as f32 * physical_tile_size - offset.x, + self.1 as f32 * physical_tile_size - offset.y, + physical_tile_size, + physical_tile_size, ) } } @@ -211,9 +212,23 @@ impl TileViewbox { pub const TILE_SIZE: f32 = 512.; +/// Document-space size of one tile. Depends only on zoom (not DPR), so the +/// shape→tile grid stays stable across HiDPI. #[inline(always)] -pub fn get_tile_dimensions() -> skia::ISize { - (TILE_SIZE as i32, TILE_SIZE as i32).into() +pub fn get_tile_size(zoom: f32) -> f32 { + TILE_SIZE / zoom +} + +/// Physical GPU tile edge in device pixels: `512 × dpr` (ceiled). +#[inline(always)] +pub fn physical_tile_size(dpr: f32) -> i32 { + (TILE_SIZE * dpr).ceil().max(1.0) as i32 +} + +#[inline(always)] +pub fn get_tile_dimensions(dpr: f32) -> skia::ISize { + let s = physical_tile_size(dpr); + (s, s).into() } pub fn get_tiles_for_rect(rect: skia::Rect, tile_size: f32) -> TileRect { @@ -227,7 +242,7 @@ pub fn get_tiles_for_rect(rect: skia::Rect, tile_size: f32) -> TileRect { } pub fn get_tiles_for_viewbox(viewbox: &Viewbox) -> TileRect { - let tile_size = get_tile_size(viewbox.get_scale()); + let tile_size = get_tile_size(viewbox.zoom()); get_tiles_for_rect(viewbox.area, tile_size) } @@ -241,20 +256,14 @@ pub fn get_tile_center_for_viewbox(viewbox: &Viewbox) -> Tile { Tile((ex - sx) / 2, (ey - sy) / 2) } -pub fn get_tile_pos(Tile(x, y): Tile, scale: f32) -> (f32, f32) { - ( - x as f32 * get_tile_size(scale), - y as f32 * get_tile_size(scale), - ) +pub fn get_tile_pos(Tile(x, y): Tile, zoom: f32) -> (f32, f32) { + let ts = get_tile_size(zoom); + (x as f32 * ts, y as f32 * ts) } -pub fn get_tile_size(scale: f32) -> f32 { - 1. / scale * TILE_SIZE -} - -pub fn get_tile_rect(tile: Tile, scale: f32) -> skia::Rect { - let (tx, ty) = get_tile_pos(tile, scale); - let ts = get_tile_size(scale); +pub fn get_tile_rect(tile: Tile, zoom: f32) -> skia::Rect { + let (tx, ty) = get_tile_pos(tile, zoom); + let ts = get_tile_size(zoom); skia::Rect::from_xywh(tx, ty, ts, ts) }