🐛 Stabilize HiDPI tile grid, atlas cache, and paint budget

Keep the shape-to-tile grid zoom-only while scaling GPU paint by DPR
(capped at 1024px). Fix fractional browser zoom and pan, cap atlas slots
with eviction instead of panic, and split paint vs view scale with
explicit paint/atlas/screen tile sizes.
This commit is contained in:
Alejandro Alonso 2026-08-11 09:06:32 +02:00
parent 637f2ab044
commit 1453dc2a8a
8 changed files with 405 additions and 157 deletions

View File

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

View File

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

View File

@ -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<Uuid> = 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<Rect> {
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
}

View File

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

View File

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

View File

@ -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<Self> {
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<bool> {
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<skia::RSXform>,
@ -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;

View File

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

View File

@ -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) {