From 5dab689a6e4e0737358ac05463e44b633432d556 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 20 Aug 2026 10:52:36 +0200 Subject: [PATCH] :bug: Pack tile atlas and clamp HiDPI surfaces under GPU limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/app/render_wasm/api.cljs | 33 ++++-- render-wasm/src/main.rs | 2 +- render-wasm/src/render/gpu_state.rs | 31 ++++++ render-wasm/src/render/surfaces.rs | 142 +++++++++++++++++++------- render-wasm/src/tiles.rs | 62 +++++++++++ render-wasm/src/view.rs | 56 ++++++++++ 6 files changed, 283 insertions(+), 43 deletions(-) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 9e4e4c1010..94d55480ee 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -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))))) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 2d007d18e4..c5bff2105f 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -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(()) } diff --git a/render-wasm/src/render/gpu_state.rs b/render-wasm/src/render/gpu_state.rs index 5e104efbff..7a158b5fc6 100644 --- a/render-wasm/src/render/gpu_state.rs +++ b/render-wasm/src/render/gpu_state.rs @@ -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); diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index b8f4b51c9d..f234c9f711 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -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, @@ -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> { diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index d00dc25bdf..0a59bda8da 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -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>, @@ -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); + } +} diff --git a/render-wasm/src/view.rs b/render-wasm/src/view.rs index efa5394012..3463db6a8f 100644 --- a/render-wasm/src/view.rs +++ b/render-wasm/src/view.rs @@ -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); + } +}