🐛 Pack tile atlas and clamp HiDPI surfaces under GPU limits

HQ tiles are 512px and the atlas stays at 4096² (64 full-size
slots). Browser zoom plus a forced ?dpr= can need more visible
tiles than that, and a framebuffer larger than the GPU allows.

Pack interest tiles into smaller atlas cells, blit at 512 then
scale, and inset Linear samples so seams do not bleed. Clamp the
canvas backing store and DPR together, wrap Skia at the real
drawingBuffer size, and wait one frame after DPR changes so CSS
client size and overlays stay aligned.
This commit is contained in:
Alejandro Alonso 2026-08-20 10:52:36 +02:00 committed by Elena Torró
parent 689d506788
commit 5dab689a6e
6 changed files with 283 additions and 43 deletions

View File

@ -2177,15 +2177,31 @@
(when (wasm/live?)
(h/call wasm/internal-module "_set_render_options" (debug-flags) new-dpr)))
(def ^:private max-surface-size
;; Must match `gpu_state::MAX_SURFACE_SIZE`.
8192)
(defn- clamp-physical-size
"Clamp physical pixel dimensions before assigning `canvas.width/height`.
Rust `resize` applies the same cap and syncs the effective DPR from the
real drawing buffer."
[w h]
(let [w (mth/max 1 w)
h (mth/max 1 h)
scale (mth/min 1 (/ max-surface-size w) (/ max-surface-size h))]
[(mth/max 1 (mth/floor (* scale w)))
(mth/max 1 (mth/floor (* scale h)))]))
(defn resize-offscreen-canvas!
"Resize a persistent OffscreenCanvas to new physical-pixel dimensions and
update the WASM render surfaces accordingly (via `_resize_viewbox`). The
design state (shape pool) is preserved so `set-objects` is not needed again."
[canvas new-physical-w new-physical-h]
(when (wasm/live?)
(let [dpr (get-dpr)]
(set! (.-width canvas) new-physical-w)
(set! (.-height canvas) new-physical-h)
(let [dpr (get-dpr)
[pw ph] (clamp-physical-size new-physical-w new-physical-h)]
(set! (.-width canvas) pw)
(set! (.-height canvas) ph)
(set-render-options! dpr)
(resize-viewbox (/ new-physical-w dpr) (/ new-physical-h dpr)))))
@ -2220,9 +2236,14 @@
(resize-canvas! canvas (get-dpr)))
([canvas new-dpr]
(when (wasm/live?)
(let [[css-w css-h] (canvas-css-size canvas new-dpr)]
(set! (.-width ^js canvas) (* new-dpr css-w))
(set! (.-height ^js canvas) (* new-dpr css-h))
(let [[css-w css-h] (canvas-css-size canvas new-dpr)
css-w (mth/max 1 css-w)
css-h (mth/max 1 css-h)
[phys-w phys-h] (clamp-physical-size
(mth/floor (* css-w new-dpr))
(mth/floor (* css-h new-dpr)))]
(set! (.-width ^js canvas) phys-w)
(set! (.-height ^js canvas) phys-h)
(set-render-options! new-dpr)
(resize-viewbox css-w css-h)))))

View File

@ -57,7 +57,7 @@ pub extern "C" fn set_viewport_interest_area_threshold(
viewport_interest_area_threshold: i32,
) -> Result<()> {
let render_state = get_render_state();
render_state.set_viewport_interest_area_threshold(viewport_interest_area_threshold);
render_state.set_viewport_interest_area_threshold(viewport_interest_area_threshold)?;
Ok(())
}

View File

@ -7,6 +7,10 @@ use skia_safe::{self as skia, ISize};
const MIN_MAX_TEXTURE_SIZE: i32 = 512;
const MAX_MAX_TEXTURE_SIZE: i32 = 4096;
/// Cap for the canvas framebuffer / backbuffer (not the tile atlas).
/// Larger than a typical viewport at DPR 2 but below sizes that would exceed
/// GPU limits when CSS dimensions are very large.
pub const MAX_SURFACE_SIZE: i32 = 8192;
#[derive(Debug, Clone)]
pub struct GpuState {
@ -57,6 +61,33 @@ impl GpuState {
.clamp(MIN_MAX_TEXTURE_SIZE, MAX_MAX_TEXTURE_SIZE)
}
pub fn max_surface_size(&self) -> i32 {
self.context
.max_texture_size()
.clamp(MIN_MAX_TEXTURE_SIZE, MAX_SURFACE_SIZE)
}
/// Actual default-framebuffer size after the canvas backing store is set.
/// Browsers may allocate a smaller `drawingBuffer` than `canvas.width`;
/// wrapping Skia at the requested size then shifts content (GL origin is
/// bottom-left). Native builds have no canvas; return `None`.
pub fn drawing_buffer_size(&self) -> Option<(i32, i32)> {
#[cfg(target_arch = "wasm32")]
{
let w = crate::run_script_int!(
"(typeof GLctx!=='undefined'&&GLctx)?GLctx.drawingBufferWidth:0"
);
let h = crate::run_script_int!(
"(typeof GLctx!=='undefined'&&GLctx)?GLctx.drawingBufferHeight:0"
);
if w > 0 && h > 0 {
return Some((w, h));
}
}
let _ = self;
None
}
fn delete_gl_texture(&mut self, texture_id: gl::types::GLuint) -> bool {
unsafe {
gl::DeleteTextures(1, &texture_id);

View File

@ -503,8 +503,7 @@ 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(), tile_atlas.height());
let atlas = DocAtlas::try_new()?;
Ok(Self {
target,
@ -536,6 +535,20 @@ impl Surfaces {
})
}
/// Pack `needed_slots` into the existing 4096 atlas by shrinking the
/// physical cell size. No-op when the layout already fits.
pub fn ensure_tile_atlas_layout(&mut self, needed_slots: usize) {
let atlas_px = self.tile_atlas.width().min(self.tile_atlas.height());
let slot = tiles::tile_atlas_slot_size(needed_slots, atlas_px);
if slot == self.tiles.slot_size() {
return;
}
self.tiles
.repack(self.tile_atlas.width(), self.tile_atlas.height(), slot);
self.tile_atlas.canvas().clear(skia::Color::TRANSPARENT);
self.tile_atlas_image = None;
}
pub fn set_dpr(&mut self, dpr: f32) {
self.dpr = dpr;
}
@ -544,6 +557,14 @@ impl Surfaces {
self.tiles.clear();
}
fn tile_atlas_sampling(&self) -> skia::SamplingOptions {
if self.tiles.slot_size() < TILE_SIZE {
skia::SamplingOptions::new(skia::FilterMode::Linear, skia::MipmapMode::None)
} else {
self.atlas_sampling_options
}
}
pub fn draw_tile_atlas_to_backbuffer(
&mut self,
viewbox: &Viewbox,
@ -558,6 +579,7 @@ impl Surfaces {
let Some(atlas_image) = self.tile_atlas_image.as_ref() else {
return;
};
let sampling = self.tile_atlas_sampling();
let canvas = self.backbuffer.canvas();
canvas.clear(background);
canvas.draw_atlas(
@ -566,7 +588,7 @@ impl Surfaces {
&self.tiles.textures,
None,
skia::BlendMode::SrcOver,
self.atlas_sampling_options,
sampling,
None,
None,
);
@ -643,6 +665,7 @@ impl Surfaces {
let Some(atlas_image) = self.tile_atlas_image.as_ref() else {
return;
};
let sampling = self.tile_atlas_sampling();
let canvas = self.backbuffer.canvas();
canvas.save();
@ -654,7 +677,7 @@ impl Surfaces {
&batch.textures,
None,
skia::BlendMode::SrcOver,
self.atlas_sampling_options,
sampling,
None,
None,
);
@ -1515,21 +1538,24 @@ pub struct TileAtlasTextureProvider {
}
impl TileAtlasTextureProvider {
pub fn new(texture_size: i32, tile_size: i32) -> Self {
let side = texture_size / tile_size;
let length = side * side;
let mut rects = Vec::with_capacity(length as usize);
for i in 0..length {
let left = (i % side) as f32 * tile_size as f32;
let top = (i / side) as f32 * tile_size as f32;
let right = left + tile_size as f32;
let bottom = top + tile_size as f32;
rects.push(Rect::new(left, top, right, bottom));
pub fn new(texture_width: i32, texture_height: i32, tile_size: i32) -> Self {
let cols = texture_width / tile_size;
let rows = texture_height / tile_size;
let length = (cols * rows) as usize;
let mut rects = Vec::with_capacity(length);
for row in 0..rows {
for col in 0..cols {
let left = col as f32 * tile_size as f32;
let top = row as f32 * tile_size as f32;
let right = left + tile_size as f32;
let bottom = top + tile_size as f32;
rects.push(Rect::new(left, top, right, bottom));
}
}
Self {
index: 0,
length: length as usize,
in_use: vec![false; length as usize],
length,
in_use: vec![false; length],
rects,
}
}
@ -1563,6 +1589,7 @@ impl TileAtlasTextureProvider {
pub struct TileTextureCache {
tile_size: f32,
slot_size: i32,
is_updated: bool,
provider: TileAtlasTextureProvider,
transforms: Vec<skia::RSXform>,
@ -1583,11 +1610,13 @@ impl AtlasDrawBatch {
}
impl TileTextureCache {
pub fn new(texture_size: i32, capacity: usize) -> Self {
pub fn new(texture_width: i32, texture_height: i32) -> Self {
let capacity = ((texture_width / TILE_SIZE) * (texture_height / TILE_SIZE)) as usize;
Self {
tile_size: tiles::TILE_SIZE,
slot_size: TILE_SIZE,
is_updated: false,
provider: TileAtlasTextureProvider::new(texture_size, TILE_SIZE),
provider: TileAtlasTextureProvider::new(texture_width, texture_height, TILE_SIZE),
transforms: Vec::with_capacity(capacity),
textures: Vec::with_capacity(capacity),
grid: HashMap::with_capacity(capacity),
@ -1595,6 +1624,39 @@ impl TileTextureCache {
}
}
pub fn slot_size(&self) -> i32 {
self.slot_size
}
fn dest_scale(&self) -> f32 {
tiles::tile_atlas_compose_scale(self.slot_size)
}
fn compose_src_rect(&self, rect: Rect) -> Rect {
if self.slot_size < TILE_SIZE {
let inset = tiles::TILE_ATLAS_SAMPLE_INSET;
Rect::new(
rect.left + inset,
rect.top + inset,
rect.right - inset,
rect.bottom - inset,
)
} else {
rect
}
}
pub fn repack(&mut self, texture_width: i32, texture_height: i32, slot_size: i32) {
let capacity = ((texture_width / slot_size) * (texture_height / slot_size)) as usize;
self.slot_size = slot_size;
self.is_updated = true;
self.provider = TileAtlasTextureProvider::new(texture_width, texture_height, slot_size);
self.transforms = Vec::with_capacity(capacity);
self.textures = Vec::with_capacity(capacity);
self.grid = HashMap::with_capacity(capacity);
self.removed = HashSet::with_capacity(capacity);
}
fn gc(&mut self) {
// Make a real remove
for tile in self.removed.iter() {
@ -1634,10 +1696,11 @@ impl TileTextureCache {
}
pub fn update(&mut self, viewbox: &Viewbox, tile_viewbox: &TileViewbox) {
let dest_scale = self.dest_scale();
if self.transforms.len() != tile_viewbox.visible_rect.len() as usize {
self.transforms.resize(
tile_viewbox.visible_rect.len() as usize,
skia::RSXform::new(1.0, 0.0, Point::default()),
skia::RSXform::new(dest_scale, 0.0, Point::default()),
);
}
@ -1666,16 +1729,18 @@ 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;
self.textures[index].set_ltrb(
tile_ref.rect.left,
tile_ref.rect.top,
tile_ref.rect.right,
tile_ref.rect.bottom,
self.transforms[index] = skia::RSXform::new(
dest_scale,
0.0,
(
(x as f32 * self.tile_size - offset.x).round(),
(y as f32 * self.tile_size - offset.y).round(),
),
);
let src = self.compose_src_rect(tile_ref.rect);
self.textures[index].set_ltrb(src.left, src.top, src.right, src.bottom);
index += 1;
}
}
@ -1713,12 +1778,13 @@ impl TileTextureCache {
continue;
}
let scos = doc_rect.width() * s / self.tile_size;
let tx = (doc_rect.left + viewbox.pan.x) * s;
let ty = (doc_rect.top + viewbox.pan.y) * s;
let src = self.compose_src_rect(tile_ref.rect);
let scos = doc_rect.width() * s / src.width();
let tx = ((doc_rect.left + viewbox.pan.x) * s).round();
let ty = ((doc_rect.top + viewbox.pan.y) * s).round();
transforms.push(skia::RSXform::new(scos, 0.0, (tx, ty)));
textures.push(tile_ref.rect);
textures.push(src);
}
}
@ -1737,12 +1803,13 @@ impl TileTextureCache {
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 src = self.compose_src_rect(tile_ref.rect);
let tx = ((doc_rect.left + viewbox.pan.x) * s).round();
let ty = ((doc_rect.top + viewbox.pan.y) * s).round();
let scos = doc_rect.width() * s / src.width();
transforms.push(skia::RSXform::new(scos, 0.0, (tx, ty)));
textures.push(tile_ref.rect);
textures.push(src);
}
AtlasDrawBatch {
@ -1769,7 +1836,10 @@ impl TileTextureCache {
let Some(tile_ref) = self.provider.allocate() else {
panic!("Tile texture allocation failed {}:{}", tile.0, tile.1);
};
self.insert(tile, tile_ref)
}
fn insert(&mut self, tile: &Tile, tile_ref: TileAtlasTextureRef) -> TileAtlasTextureRef {
self.grid.insert(*tile, tile_ref.clone());
if self.removed.contains(tile) {
@ -1777,7 +1847,7 @@ impl TileTextureCache {
}
self.is_updated = true;
tile_ref.clone()
tile_ref
}
pub fn get(&mut self, tile: Tile) -> Option<&TileAtlasTextureRef> {

View File

@ -258,6 +258,35 @@ pub fn get_tile_rect(tile: Tile, scale: f32) -> skia::Rect {
skia::Rect::from_xywh(tx, ty, ts, ts)
}
/// Physical atlas cell size so `needed_slots` fit in a square `atlas_px`
/// texture. Never larger than `TILE_SIZE` (tiles are stored 1:1 when they
/// fit). Smaller cells mean more slots, scaled down on blit into the atlas.
pub fn tile_atlas_slot_size(needed_slots: usize, atlas_px: i32) -> i32 {
const MIN_SLOT: i32 = 64;
let needed = needed_slots.max(1);
let side = (needed as f64).sqrt().ceil() as i32;
let side = side.max(1);
(atlas_px / side).clamp(MIN_SLOT, TILE_SIZE as i32)
}
/// Inset (texels) applied when sampling a packed atlas slot with Linear
/// filtering, so upsample kernels do not bleed into the neighboring cell.
pub const TILE_ATLAS_SAMPLE_INSET: f32 = 1.0;
/// Source size inside a packed slot after the Linear-filter inset.
pub fn tile_atlas_compose_src_size(slot_size: i32) -> f32 {
if slot_size < TILE_SIZE as i32 {
(slot_size as f32 - 2.0 * TILE_ATLAS_SAMPLE_INSET).max(1.0)
} else {
slot_size as f32
}
}
/// `draw_atlas` scale so the destination sprite stays `TILE_SIZE` after inset.
pub fn tile_atlas_compose_scale(slot_size: i32) -> f32 {
TILE_SIZE / tile_atlas_compose_src_size(slot_size)
}
// This structure is useful to keep all the shape uuids by shape id.
pub struct TileHashMap {
grid: HashMap<Tile, HashSet<Uuid>>,
@ -404,3 +433,36 @@ impl PendingTiles {
self.list.pop()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn atlas_slot_is_full_size_when_tiles_fit() {
assert_eq!(tile_atlas_slot_size(64, 4096), 512);
assert_eq!(tile_atlas_slot_size(1, 4096), 512);
}
#[test]
fn atlas_slot_shrinks_to_pack_interest_tiles() {
// 150 slots → 13×13 grid, 4096/13 = 315.
assert_eq!(tile_atlas_slot_size(150, 4096), 315);
let side = 4096 / 315;
assert!(side * side >= 150);
}
#[test]
fn atlas_compose_scale_is_one_at_full_slot() {
assert_eq!(tile_atlas_compose_scale(512), 1.0);
}
#[test]
fn atlas_compose_scale_keeps_dest_tile_size_when_packed() {
let slot = 315;
let scale = tile_atlas_compose_scale(slot);
let src = tile_atlas_compose_src_size(slot);
assert!((scale * src - TILE_SIZE).abs() < 1e-4);
assert!(src < slot as f32);
}
}

View File

@ -94,3 +94,59 @@ impl Viewbox {
matrix
}
}
/// Scale `dpr` down so `floor(css * dpr)` fits in `max_dim` on both axes.
/// Used when a large viewport combined with a high DPR would exceed the GPU
/// (or our surface cap) on either axis.
pub fn clamp_dpr_for_surface(css_w: f32, css_h: f32, dpr: f32, max_dim: i32) -> f32 {
let css_w = css_w.max(1.0);
let css_h = css_h.max(1.0);
let dpr = dpr.max(0.0);
let max_dim = max_dim.max(1) as f32;
let raw_w = (css_w * dpr).floor().max(1.0);
let raw_h = (css_h * dpr).floor().max(1.0);
let scale = (max_dim / raw_w).min(max_dim / raw_h).min(1.0);
dpr * scale
}
#[cfg(test)]
mod tests {
use super::clamp_dpr_for_surface;
#[test]
fn clamp_dpr_keeps_hidpi_viewport_under_cap() {
let dpr = clamp_dpr_for_surface(2560.0, 1440.0, 2.0, 8192);
assert!((dpr - 2.0).abs() < 1e-5);
assert!((2560.0 * dpr).floor() <= 8192.0);
}
#[test]
fn clamp_dpr_caps_very_large_viewport_at_dpr2() {
// 10240×5760 CSS at DPR 2 → 20480 px unclamped on the long edge.
let dpr = clamp_dpr_for_surface(10240.0, 5760.0, 2.0, 8192);
assert!((10240.0 * dpr).floor() <= 8192.0);
assert!((5760.0 * dpr).floor() <= 8192.0);
assert!(dpr < 2.0);
}
#[test]
fn clamp_dpr_caps_large_viewport_at_dpr2() {
let dpr = clamp_dpr_for_surface(5120.0, 2880.0, 2.0, 8192);
assert!((5120.0 * dpr).floor() <= 8192.0);
assert!(dpr < 2.0);
assert!(dpr > 1.0);
}
#[test]
fn clamp_dpr_physical_size_is_floor_of_css_times_dpr() {
let css_w = 5120.0;
let css_h = 2880.0;
let dpr = clamp_dpr_for_surface(css_w, css_h, 2.0, 8192);
let phys_w = (css_w * dpr).floor();
let phys_h = (css_h * dpr).floor();
assert!(phys_w <= 8192.0);
assert!(phys_h <= 8192.0);
assert!((css_w * dpr - phys_w).abs() < 1.0);
assert!((css_h * dpr - phys_h).abs() < 1.0);
}
}