Keep tile grid zoom-only; scale GPU tiles by DPR

Document tile indices no longer densify with devicePixelRatio, so shapes
stay on the same tiles across HiDPI while physical atlas slots grow as
512×dpr for sharp rasterization.

AI-assisted-by: Cursor
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alejandro Alonso 2026-08-07 13:05:18 +02:00
parent ccdab34ed5
commit b48021b8ab
6 changed files with 173 additions and 89 deletions

View File

@ -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 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 `ceil(512 × dpr)`.
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,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<Rect> {
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

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 `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<Self> {
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<bool> {
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;
}

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, 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)
}