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