diff --git a/.serena/memories/render-wasm/ffi-rendering-subtleties.md b/.serena/memories/render-wasm/ffi-rendering-subtleties.md index f57856a17d..7cbf4e1d80 100644 --- a/.serena/memories/render-wasm/ffi-rendering-subtleties.md +++ b/.serena/memories/render-wasm/ffi-rendering-subtleties.md @@ -17,6 +17,11 @@ ## Tile/render behavior +- Three device sizes (do not conflate): `paint_tile_size(dpr)` (raster/Current, capped), + `atlas_slot_size(paint, atlas)` (tile_atlas packing), `screen_tile_size(dpr)` (Target/ + Backbuffer placement, uncapped `512×dpr`). Doc grid is zoom-only: `512/zoom`. +- Scales: `get_paint_scale()` matches tile CTM; `get_view_scale()` is `zoom×dpr` for + viewport/backbuffer mapping. `get_scale()` is an alias of paint scale (legacy name). - Interactive transforms are distinct from viewport fast mode. `set_modifiers_start` enables fast mode and interactive transform; interactive transform still flushes each animation frame. - During interactive transform, modifier tile invalidation is deferred to `render()` once per rAF. Outside interactive transform, `set_modifiers` rebuilds modifier tiles immediately. - `set_modifiers_end` disables fast/interactive state and cancels pending async render; the caller must request the final full-quality render. diff --git a/render-wasm/docs/tile_rendering.md b/render-wasm/docs/tile_rendering.md index baf09bd287..c8ec1855d8 100644 --- a/render-wasm/docs/tile_rendering.md +++ b/render-wasm/docs/tile_rendering.md @@ -151,13 +151,17 @@ 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 `round(512 × dpr)`. +- `screen_tile_size(dpr)`: continuous Target placement size `512 × dpr`. +- `tile_paint_scale(zoom, phys)`: CTM that fills the integer GPU tile. 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..a956dda186 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,27 @@ 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 when density changes. + let _ = self.surfaces.set_dpr(dpr)?; + + // Always drop cached tile textures on DPR change so we never + // composite stale device-scale tiles after browser zoom. + self.surfaces.invalidate_tile_cache(); + self.tile_viewbox.update(&self.viewbox); + self.cached_viewbox = self.viewbox; + self.surfaces.clear_backbuffer(self.background_color); } Ok(()) } @@ -2001,8 +2013,11 @@ impl RenderState { pub fn update_render_context(&mut self, tile: tiles::Tile) { self.current_tile = Some(tile); - let scale = self.get_scale(); - self.render_area = tiles::get_tile_rect(tile, scale); + let zoom = self.viewbox.zoom(); + // Fill the integer GPU tile exactly (must match `get_scale` / paint CTM). + let scale = self.get_paint_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; @@ -2024,7 +2039,7 @@ impl RenderState { // ANY other candidate to guarantee the pixels under their bounds belong exclusively // to that shape in Backbuffer. let viewport = self.viewbox.area; - let scale = self.get_scale(); + let scale = self.get_view_scale(); let mut candidates: Vec<(Uuid, Rect, Rect)> = Vec::new(); // (id, doc_bounds, selrect) let root_ids: Vec = match tree.get(&Uuid::nil()) { @@ -2942,11 +2957,12 @@ 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 screen = tiles::screen_tile_size(self.viewbox.dpr); + Ok(tile.get_rect_with_offset(&offset, screen)) } pub fn get_rect_bounds(&mut self, rect: skia::Rect) -> Rect { - let scale = self.get_scale(); + let scale = self.get_view_scale(); let offset_x = self.viewbox.area.left * scale; let offset_y = self.viewbox.area.top * scale; Rect::from_xywh( @@ -2963,22 +2979,21 @@ impl RenderState { } pub fn get_shape_extrect_bounds(&mut self, shape: &Shape, tree: ShapesPoolRef) -> Rect { - let scale = self.get_scale(); + let scale = self.get_paint_scale(); let rect = self.get_cached_extrect(shape, tree, scale); self.get_rect_bounds(rect) } 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 scale = self.get_view_scale(); + let screen = tiles::screen_tile_size(self.viewbox.dpr); + let start_tile_x = (self.viewbox.area.left * scale / screen).floor() * screen; + let start_tile_y = (self.viewbox.area.top * scale / screen).floor() * screen; 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 * screen) - start_tile_x, + (tile.y() as f32 * screen) - start_tile_y, + screen, + screen, ) } @@ -2987,9 +3002,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 screen 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 +3989,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 @@ -4292,14 +4306,28 @@ impl RenderState { Ok(()) } - pub fn get_scale(&self) -> f32 { - // During export, use the export scale instead of the workspace zoom. + /// CTM scale used when painting into tile work surfaces (`paint_tile_size`). + pub fn get_paint_scale(&self) -> f32 { + if let Some((_, export_scale)) = self.export_context { + return export_scale; + } + tiles::tile_paint_scale(self.viewbox.zoom(), self.surfaces.paint_tile_size()) + } + + /// Device scale for the viewport canvas (`zoom × dpr`, uncapped). + pub fn get_view_scale(&self) -> f32 { if let Some((_, export_scale)) = self.export_context { return export_scale; } self.viewbox.get_scale() } + /// Alias for [`Self::get_paint_scale`]. Prefer the explicit name in new code; + /// tile raster historically called this `get_scale`. + pub fn get_scale(&self) -> f32 { + self.get_paint_scale() + } + pub fn zoom_changed(&self) -> bool { (self.viewbox.zoom - self.cached_viewbox.zoom).abs() > f32::EPSILON } 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..a8f969e2b8 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 `paint_size × 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(paint_size: i32) -> i32 { + paint_size * TILE_SIZE_MULTIPLIER / 4 +} + +#[inline(always)] +fn tile_drawable_irect(paint_size: i32) -> IRect { + let m = tile_margin_size(paint_size); + IRect { + left: m, + top: m, + right: m + paint_size, + bottom: m + paint_size, + } +} + /// 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, screen_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); @@ -58,9 +66,11 @@ pub fn get_cache_size(viewbox: &Viewbox, interest: i32) -> skia::ISize { let dx = if isx.signum() != iex.signum() { 1 } else { 0 }; let dy = if isy.signum() != iey.signum() { 1 } else { 0 }; + // Cache mosaic is laid out in screen pixels (`512 × dpr`), matching + // `get_aligned_tile_bounds` — not the possibly-capped paint tile size. ( - ((iex - isx).abs() + dx) * TILE_SIZE, - ((iey - isy).abs() + dy) * TILE_SIZE, + ((iex - isx).abs() + dx) * screen_tile, + ((iey - isy).abs() + dy) * screen_tile, ) .into() } @@ -205,7 +215,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 +464,8 @@ pub struct Surfaces { // Tracks which surfaces have content (dirty flag bitmask) dirty_surfaces: u32, extra_tile_dims: skia::ISize, + /// Paint-tile edge ([`tiles::paint_tile_size`]); work surfaces use this ×2. + paint_size: i32, dpr: f32, } @@ -465,10 +477,11 @@ impl Surfaces { tile_dims: skia::ISize, ) -> Result { let gpu_state = get_gpu_state(); + let paint_size = tile_dims.width.max(1); let extra_tile_dims = skia::ISize::new( - tile_dims.width * TILE_SIZE_MULTIPLIER, - tile_dims.height * TILE_SIZE_MULTIPLIER, + paint_size * TILE_SIZE_MULTIPLIER, + paint_size * TILE_SIZE_MULTIPLIER, ); let margins = skia::ISize::new(extra_tile_dims.width / 4, extra_tile_dims.height / 4); @@ -503,9 +516,10 @@ 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(), paint_size, 512); let atlas = DocAtlas::try_new()?; + // Until `set_dpr`, approximate DPR from paint size (may be capped). + let dpr = paint_size as f32 / tiles::TILE_SIZE; Ok(Self { target, filter, @@ -532,12 +546,68 @@ impl Surfaces { margins, dirty_surfaces: 0, extra_tile_dims, - dpr: 1.0, + paint_size, + dpr, }) } - pub fn set_dpr(&mut self, dpr: f32) { + pub fn paint_tile_size(&self) -> i32 { + self.paint_size + } + + /// Atlas packing cell edge (≤ paint; see [`tiles::atlas_slot_size`]). + pub fn atlas_slot_size(&self) -> i32 { + self.tiles.slot_size() + } + + pub fn drawable_irect(&self) -> IRect { + tile_drawable_irect(self.paint_size) + } + + /// Recreate Current / effect / atlas-slot surfaces when paint size changes. + /// High DPRs that share the same capped paint size return `false`. + pub fn set_dpr(&mut self, dpr: f32) -> Result { self.dpr = dpr; + let new_paint = tiles::paint_tile_size(dpr); + if new_paint == self.paint_size { + return Ok(false); + } + + let gpu_state = get_gpu_state(); + let extra_tile_dims = skia::ISize::new( + new_paint * TILE_SIZE_MULTIPLIER, + new_paint * 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_paint, 512); + self.tile_atlas.canvas().clear(skia::Color::TRANSPARENT); + self.tile_atlas_image = None; + self.atlas.tile_doc_rects.clear(); + + self.paint_size = new_paint; + self.extra_tile_dims = extra_tile_dims; + self.margins = margins; + self.clear_all_dirty(); + Ok(true) } pub fn clear_tiles(&mut self) { @@ -995,9 +1065,10 @@ impl Surfaces { .new_surface_with_dimensions(cache_dims) .ok_or(Error::CriticalError("Failed to create surface".to_string()))?; self.cache.canvas().reset_matrix(); + let screen = tiles::screen_tile_size_i32(self.dpr); self.cache.canvas().translate(( - (interest_area_threshold * TILE_SIZE) as f32, - (interest_area_threshold * TILE_SIZE) as f32, + (interest_area_threshold * screen) as f32, + (interest_area_threshold * screen) as f32, )); Ok(()) } @@ -1008,8 +1079,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 screen = tiles::screen_tile_size_i32(viewbox.dpr); + let viewbox_cache_size = get_cache_size(viewbox, interest_area_threshold, screen); + let cached_viewbox_cache_size = + get_cache_size(cached_viewbox, interest_area_threshold, screen); // 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 +1280,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 +1315,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 +1383,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 view 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 +1394,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; @@ -1406,42 +1481,24 @@ impl Surfaces { draw_on_cache: DrawOnCache, ) { let sampling_options = self.sampling_options; - let src_rect = IRect::from_xywh( - self.margins.width, - self.margins.height, - self.current.width() - TILE_SIZE_MULTIPLIER * self.margins.width, - self.current.height() - TILE_SIZE_MULTIPLIER * self.margins.height, - ); - let src_rect_f = skia::Rect::from(src_rect); + let src = skia::Rect::from(self.drawable_irect()); - let backbuffer_canvas = self.backbuffer.canvas(); - - // Draw background - // let mut paint = skia::Paint::default(); - // paint.set_color(color); - // backbuffer_canvas.draw_rect(tile_rect, &paint); - - // Draw current surface directly to target (no snapshot) - self.current.draw( - backbuffer_canvas, - ( - tile_rect.left - src_rect_f.left, - tile_rect.top - src_rect_f.top, - ), + // Scale integer drawable → continuous screen tile rect (fractional DPR). + draw_surface_src_rect_to_dst( + &mut self.current, + self.backbuffer.canvas(), + src, + *tile_rect, sampling_options, - None, ); - // Also draw to cache for render_from_cache if draw_on_cache == DrawOnCache::Yes { - self.current.draw( + draw_surface_src_rect_to_dst( + &mut self.current, self.cache.canvas(), - ( - tile_rect.left - src_rect_f.left, - tile_rect.top - src_rect_f.top, - ), + src, + *tile_rect, sampling_options, - None, ); } } @@ -1591,7 +1648,8 @@ impl TileAtlasTextureProvider { } pub struct TileTextureCache { - tile_size: f32, + /// Atlas packing cell edge (see [`tiles::atlas_slot_size`]). + slot_size: f32, is_updated: bool, provider: TileAtlasTextureProvider, transforms: Vec, @@ -1612,11 +1670,12 @@ impl AtlasDrawBatch { } impl TileTextureCache { - pub fn new(texture_size: i32, capacity: usize) -> Self { + pub fn new(texture_size: i32, paint_size: i32, capacity: usize) -> Self { + let slot = tiles::atlas_slot_size(paint_size, texture_size); Self { - tile_size: tiles::TILE_SIZE, + slot_size: slot as f32, is_updated: false, - provider: TileAtlasTextureProvider::new(texture_size, TILE_SIZE), + provider: TileAtlasTextureProvider::new(texture_size, slot), transforms: Vec::with_capacity(capacity), textures: Vec::with_capacity(capacity), grid: HashMap::with_capacity(capacity), @@ -1624,6 +1683,10 @@ impl TileTextureCache { } } + pub fn slot_size(&self) -> i32 { + self.slot_size as i32 + } + fn gc(&mut self) { // Make a real remove for tile in self.removed.iter() { @@ -1631,6 +1694,7 @@ impl TileTextureCache { self.provider.deallocate(tile_ref); } } + self.removed.clear(); } pub fn needs_snapshot(&self) -> bool { @@ -1659,9 +1723,65 @@ impl TileTextureCache { if let Some(tile_ref) = self.grid.remove(tile) { self.provider.deallocate(tile_ref); } + self.removed.remove(tile); } } + /// Evict any cached tile (prefer non-visible) until one atlas slot is free. + fn force_evict_one(&mut self, tile_viewbox: &TileViewbox) -> bool { + self.gc(); + let victim = self + .grid + .keys() + .find(|t| !tile_viewbox.is_visible(t)) + .copied() + .or_else(|| self.grid.keys().next().copied()); + + if let Some(tile) = victim { + if let Some(tile_ref) = self.grid.remove(&tile) { + self.provider.deallocate(tile_ref); + } + self.removed.remove(&tile); + return true; + } + false + } + + pub fn add(&mut self, tile_viewbox: &TileViewbox, tile: &Tile) -> TileAtlasTextureRef { + // Evict against the real slot count (`provider.length`), not the + // hardcoded capacity — otherwise the guard never fires and the atlas + // fills up until `allocate()` has no slot left. + let capacity = self.provider.length.min(TEXTURES_CACHE_CAPACITY); + + if self.grid.len() >= capacity { + self.gc(); + self.gc_non_visible(tile_viewbox); + } + + // If this tile was previously cached, free its slot before re-adding. + if let Some(old) = self.grid.remove(tile) { + self.provider.deallocate(old); + self.removed.remove(tile); + } + + let tile_ref = loop { + if let Some(tile_ref) = self.provider.allocate() { + break tile_ref; + } + if !self.force_evict_one(tile_viewbox) { + // Unreachable when the atlas has ≥1 slot (`atlas_slot_size`). + break self + .provider + .allocate() + .expect("tile atlas has no slots configured"); + } + }; + + self.grid.insert(*tile, tile_ref.clone()); + self.is_updated = true; + tile_ref + } + pub fn update(&mut self, viewbox: &Viewbox, tile_viewbox: &TileViewbox) { if self.transforms.len() != tile_viewbox.visible_rect.len() as usize { self.transforms.resize( @@ -1682,6 +1802,8 @@ impl TileTextureCache { } let offset = viewbox.get_offset(); + let screen = tiles::screen_tile_size(viewbox.dpr); + let scos = screen / self.slot_size; let mut index = 0; for y in tile_viewbox.visible_rect.top()..=tile_viewbox.visible_rect.bottom() { for x in tile_viewbox.visible_rect.left()..=tile_viewbox.visible_rect.right() { @@ -1695,8 +1817,16 @@ impl TileTextureCache { continue; } - self.transforms[index].tx = x as f32 * self.tile_size - offset.x; - self.transforms[index].ty = y as f32 * self.tile_size - offset.y; + // Place at continuous screen slots (`512×dpr`); scale atlas + // cells to fit (paint/atlas may be capped below screen size). + self.transforms[index] = skia::RSXform::new( + scos, + 0.0, + ( + x as f32 * screen - offset.x, + y as f32 * screen - offset.y, + ), + ); self.textures[index].set_ltrb( tile_ref.rect.left, @@ -1737,12 +1867,12 @@ 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; } - let scos = doc_rect.width() * s / self.tile_size; + let scos = doc_rect.width() * s / self.slot_size; let tx = (doc_rect.left + viewbox.pan.x) * s; let ty = (doc_rect.top + viewbox.pan.y) * s; @@ -1761,14 +1891,14 @@ 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; } let tx = (doc_rect.left + viewbox.pan.x) * s; let ty = (doc_rect.top + viewbox.pan.y) * s; - let scos = doc_rect.width() * s / self.tile_size; + let scos = doc_rect.width() * s / self.slot_size; transforms.push(skia::RSXform::new(scos, 0.0, (tx, ty))); textures.push(tile_ref.rect); @@ -1784,31 +1914,6 @@ impl TileTextureCache { self.grid.contains_key(&tile) && !self.removed.contains(&tile) } - pub fn add(&mut self, tile_viewbox: &TileViewbox, tile: &Tile) -> TileAtlasTextureRef { - // Evict against the real slot count (`provider.length`), not the - // hardcoded capacity — otherwise the guard never fires and the atlas - // fills up until `allocate()` has no slot left. - let capacity = self.provider.length.min(TEXTURES_CACHE_CAPACITY); - - if self.grid.len() >= capacity { - self.gc(); - self.gc_non_visible(tile_viewbox); - } - - let Some(tile_ref) = self.provider.allocate() else { - panic!("Tile texture allocation failed {}:{}", tile.0, tile.1); - }; - - self.grid.insert(*tile, tile_ref.clone()); - - if self.removed.contains(tile) { - self.removed.remove(tile); - } - - self.is_updated = true; - tile_ref.clone() - } - pub fn get(&mut self, tile: Tile) -> Option<&TileAtlasTextureRef> { if self.removed.contains(&tile) { return None; diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index d00dc25bdf..d9895c3769 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, screen_tile: 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 * screen_tile - offset.x, + self.1 as f32 * screen_tile - offset.y, + screen_tile, + screen_tile, ) } } @@ -211,9 +212,75 @@ impl TileViewbox { pub const TILE_SIZE: f32 = 512.; +/// Max edge (px) for tile **paint** work surfaces (`Current`, shadows, …). +/// +/// Without a cap, `512 × dpr` at DPR 4 yields 2048px tiles and 4096² effect +/// surfaces (~512 MB GPU for the scratchpads alone) and freezes on zoom. +pub const TILE_PAINT_SIZE_CAP: i32 = 1024; + +/// Minimum atlas grid side so large paint tiles still pack enough slots +/// (e.g. 4096/8 → 512px slots → 64 entries). +pub const ATLAS_MIN_SLOTS_SIDE: i32 = 8; + +// --------------------------------------------------------------------------- +// Three tile sizes (keep these distinct — mixing them caused HiDPI bugs): +// +// paint_tile_size(dpr) — raster into Current/effects (capped) +// atlas_slot_size(...) — packing cell in tile_atlas (≤ paint, capacity) +// screen_tile_size(dpr) — placement on Target/Backbuffer (`512 × dpr`) +// +// Doc grid stays zoom-only: get_tile_size(zoom) = 512 / zoom. +// --------------------------------------------------------------------------- + +/// 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 +} + +/// GPU **paint** tile edge: `min(round(512 × dpr), TILE_PAINT_SIZE_CAP)`. +/// Sizes Current/effect surfaces and the paint CTM ([`tile_paint_scale`]). +#[inline(always)] +pub fn paint_tile_size(dpr: f32) -> i32 { + let ideal = (TILE_SIZE * dpr).round().max(1.0) as i32; + ideal.min(TILE_PAINT_SIZE_CAP) +} + +/// Continuous **screen** size of one tile on Target/Backbuffer (`512 × dpr`, +/// uncapped). Atlas compose upscales from [`atlas_slot_size`] / paint when +/// the paint budget is below this. +#[inline(always)] +pub fn screen_tile_size(dpr: f32) -> f32 { + TILE_SIZE * dpr +} + +/// Integer screen tile edge for mosaic layout (cache surface, etc.). +#[inline(always)] +pub fn screen_tile_size_i32(dpr: f32) -> i32 { + screen_tile_size(dpr).ceil().max(1.0) as i32 +} + +/// Atlas pack size: full paint tile when it fits, otherwise capped so the +/// atlas always has at least `ATLAS_MIN_SLOTS_SIDE²` slots. +#[inline(always)] +pub fn atlas_slot_size(paint_size: i32, atlas_texture_size: i32) -> i32 { + let paint_size = paint_size.max(1); + let atlas_texture_size = atlas_texture_size.max(1); + let max_slot = (atlas_texture_size / ATLAS_MIN_SLOTS_SIDE).max(1); + paint_size.min(max_slot) +} + +/// CTM scale that maps a zoom-only doc tile onto an integer paint texture. +#[inline(always)] +pub fn tile_paint_scale(zoom: f32, paint_size: i32) -> f32 { + paint_size as f32 * zoom / TILE_SIZE +} + +#[inline(always)] +pub fn get_tile_dimensions(dpr: f32) -> skia::ISize { + let s = paint_tile_size(dpr); + (s, s).into() } pub fn get_tiles_for_rect(rect: skia::Rect, tile_size: f32) -> TileRect { @@ -227,7 +294,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 +308,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) } @@ -404,3 +465,47 @@ impl PendingTiles { self.list.pop() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paint_tile_size_matches_ideal_until_cap() { + assert_eq!(paint_tile_size(1.0), 512); + assert_eq!(paint_tile_size(2.0), 1024); + assert_eq!(paint_tile_size(2.0), TILE_PAINT_SIZE_CAP); + } + + #[test] + fn paint_tile_size_caps_high_dpr() { + assert_eq!(paint_tile_size(3.0), TILE_PAINT_SIZE_CAP); + assert_eq!(paint_tile_size(4.0), TILE_PAINT_SIZE_CAP); + assert_eq!(paint_tile_size(4.0), 1024); + } + + #[test] + fn screen_tile_size_stays_uncapped() { + assert_eq!(screen_tile_size(4.0), 2048.0); + assert!(screen_tile_size(4.0) > paint_tile_size(4.0) as f32); + } + + #[test] + fn paint_scale_diverges_from_view_scale_when_capped() { + let zoom = 1.0; + let dpr = 4.0; + let paint = tile_paint_scale(zoom, paint_tile_size(dpr)); + let view = zoom * dpr; + assert_eq!(paint, 2.0); + assert_eq!(view, 4.0); + assert!(paint < view); + } + + #[test] + fn atlas_slot_preserves_paint_when_atlas_is_large_enough() { + // 8192 / 8 = 1024 → paint 1024 fits at full res with 64 slots. + assert_eq!(atlas_slot_size(1024, 8192), 1024); + assert_eq!(atlas_slot_size(1024, 4096), 512); + assert_eq!(atlas_slot_size(512, 4096), 512); + } +} diff --git a/render-wasm/src/view.rs b/render-wasm/src/view.rs index efa5394012..f6fc77df76 100644 --- a/render-wasm/src/view.rs +++ b/render-wasm/src/view.rs @@ -63,8 +63,14 @@ impl Viewbox { pub fn set_wh(&mut self, width: f32, height: f32) { self.size.set(width, height); - self.area - .set_wh(self.size.width / self.zoom, self.size.height / self.zoom); + // Keep pan origin. skia::Rect::set_wh resets to (0,0,w,h) and would + // drop the viewport pan on every resize/DPR change (browser zoom). + self.area.set_xywh( + -self.pan.x, + -self.pan.y, + self.size.width / self.zoom, + self.size.height / self.zoom, + ); } pub fn set_dpr(&mut self, dpr: f32) {