From 8b06adbb28848be2d46dae56e6661f25262a00cc Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 13 Apr 2026 17:02:56 +0200 Subject: [PATCH] :tada: Tiles atlas support --- frontend/src/app/render_wasm/api.cljs | 2 + frontend/src/app/render_wasm/api/webgl.cljs | 9 + frontend/src/debug.cljs | 22 ++ render-wasm/src/main.rs | 12 ++ render-wasm/src/render.rs | 91 +++++++- render-wasm/src/render/debug.rs | 39 ++++ render-wasm/src/render/surfaces.rs | 220 ++++++++++++++++++++ 7 files changed, 387 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 3d8fe0d0df..b9e0458631 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1536,6 +1536,8 @@ (h/call wasm/internal-module "_set_render_options" flags dpr) (when-let [t (wasm-aa-threshold-from-route-params)] (h/call wasm/internal-module "_set_antialias_threshold" t)) + (when-let [max-tex (webgl/max-texture-size context)] + (h/call wasm/internal-module "_set_max_atlas_texture_size" max-tex)) ;; Set browser and canvas size only after initialization (h/call wasm/internal-module "_set_browser" browser) diff --git a/frontend/src/app/render_wasm/api/webgl.cljs b/frontend/src/app/render_wasm/api/webgl.cljs index c6741944a2..eac0c42df7 100644 --- a/frontend/src/app/render_wasm/api/webgl.cljs +++ b/frontend/src/app/render_wasm/api/webgl.cljs @@ -12,6 +12,15 @@ [app.util.dom :as dom] [promesa.core :as p])) +(defn max-texture-size + "Returns `gl.MAX_TEXTURE_SIZE` (max dimension of a 2D texture), or nil if + unavailable." + [gl] + (when gl + (let [n (.getParameter ^js gl (.-MAX_TEXTURE_SIZE ^js gl))] + (when (and (number? n) (pos? n) (js/isFinite n)) + (js/Math.floor n))))) + (defn get-webgl-context "Gets the WebGL context from the WASM module" [] diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index ee3f58b74c..65ac296895 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -135,6 +135,28 @@ (wasm.mem/free) text))) +(defn ^:export wasmAtlasConsole + "Logs the current render-wasm atlas as an image in the JS console (if present)." + [] + (let [module wasm/internal-module + f (when module (unchecked-get module "_debug_atlas_console"))] + (if (fn? f) + (wasm.h/call module "_debug_atlas_console") + (js/console.warn "[debug] render-wasm module not ready or missing _debug_atlas_console")))) + +(defn ^:export wasmAtlasBase64 + "Returns the atlas PNG base64 (empty string if missing/empty)." + [] + (let [module wasm/internal-module + f (when module (unchecked-get module "_debug_atlas_base64"))] + (if (fn? f) + (let [ptr (wasm.h/call module "_debug_atlas_base64") + s (or (wasm-read-len-prefixed-utf8 ptr) "")] + s) + (do + (js/console.warn "[debug] render-wasm module not ready or missing _debug_atlas_base64") + "")))) + (defn ^:export wasmCacheConsole "Logs the current render-wasm cache surface as an image in the JS console." [] diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 747b6018f8..740fae8104 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -154,6 +154,18 @@ pub extern "C" fn set_antialias_threshold(threshold: f32) -> Result<()> { Ok(()) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_max_atlas_texture_size(max_px: i32) -> Result<()> { + with_state_mut!(state, { + state + .render_state_mut() + .surfaces + .set_max_atlas_texture_size(max_px); + }); + Ok(()) +} + #[no_mangle] #[wasm_error] pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> { diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index aee90b6320..c247341590 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -715,12 +715,14 @@ impl RenderState { // In fast mode the viewport is moving (pan/zoom) so Cache surface // positions would be wrong — only save to the tile HashMap. self.surfaces.cache_current_tile_texture( + &mut self.gpu_state, &self.tile_viewbox, &self .current_tile .ok_or(Error::CriticalError("Current tile not found".to_string()))?, &tile_rect, fast_mode, + self.render_area, ); self.surfaces.draw_cached_tile_surface( @@ -1459,6 +1461,28 @@ impl RenderState { performance::begin_measure!("render_from_cache"); let cached_scale = self.get_cached_scale(); + let bg_color = self.background_color; + + // During fast mode (pan/zoom), if a previous full-quality render still has pending tiles, + // always prefer the persistent atlas. The atlas is incrementally updated as tiles finish, + // and drawing from it avoids mixing a partially-updated Cache surface with missing tiles. + if self.options.is_fast_mode() && self.render_in_progress && self.surfaces.has_atlas() { + self.surfaces + .draw_atlas_to_target(self.viewbox, self.options.dpr(), bg_color); + + if self.options.is_debug_visible() { + debug::render(self); + } + + ui::render(self, shapes); + debug::render_wasm_label(self); + + self.flush_and_submit(); + performance::end_measure!("render_from_cache"); + performance::end_timed_log!("render_from_cache", _start); + return; + } + // Check if we have a valid cached viewbox (non-zero dimensions indicate valid cache) if self.cached_viewbox.area.width() > 0.0 { // Scale and translate the target according to the cached data @@ -1475,7 +1499,62 @@ impl RenderState { let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr(); let translate_x = (start_tile_x as f32 * tiles::TILE_SIZE) - offset_x; let translate_y = (start_tile_y as f32 * tiles::TILE_SIZE) - offset_y; - let bg_color = self.background_color; + + // For zoom-out, prefer cache only if it fully covers the viewport. + // Otherwise, atlas will provide a more correct full-viewport preview. + let zooming_out = self.viewbox.zoom < self.cached_viewbox.zoom; + if zooming_out { + let cache_dim = self.surfaces.cache_dimensions(); + let cache_w = cache_dim.width as f32; + let cache_h = cache_dim.height as f32; + + // Viewport in target pixels. + let vw = (self.viewbox.width * self.options.dpr()).max(1.0); + let vh = (self.viewbox.height * self.options.dpr()).max(1.0); + + // Inverse-map viewport corners into cache coordinates. + // target = (cache * navigate_zoom) translated by (translate_x, translate_y) (in cache coords). + // => cache = (target / navigate_zoom) - translate + let inv = if navigate_zoom.abs() > f32::EPSILON { + 1.0 / navigate_zoom + } else { + 0.0 + }; + + let cx0 = (0.0 * inv) - translate_x; + let cy0 = (0.0 * inv) - translate_y; + let cx1 = (vw * inv) - translate_x; + let cy1 = (vh * inv) - translate_y; + + let min_x = cx0.min(cx1); + let min_y = cy0.min(cy1); + let max_x = cx0.max(cx1); + let max_y = cy0.max(cy1); + + let cache_covers = + min_x >= 0.0 && min_y >= 0.0 && max_x <= cache_w && max_y <= cache_h; + if !cache_covers { + // Early return only if atlas exists; otherwise keep cache path. + if self.surfaces.has_atlas() { + self.surfaces.draw_atlas_to_target( + self.viewbox, + self.options.dpr(), + bg_color, + ); + + if self.options.is_debug_visible() { + debug::render(self); + } + + ui::render(self, shapes); + debug::render_wasm_label(self); + self.flush_and_submit(); + performance::end_measure!("render_from_cache"); + performance::end_timed_log!("render_from_cache", _start); + return; + } + } + } // Setup canvas transform { @@ -1531,6 +1610,7 @@ impl RenderState { self.flush_and_submit(); } + performance::end_measure!("render_from_cache"); performance::end_timed_log!("render_from_cache", _start); } @@ -2699,13 +2779,8 @@ impl RenderState { } } else { performance::begin_measure!("render_shape_tree::uncached"); - // Only allow stopping (yielding) if the current tile is NOT visible. - // This ensures all visible tiles render synchronously before showing, - // eliminating empty squares during zoom. Interest-area tiles can still yield. - let tile_is_visible = self.tile_viewbox.is_visible(¤t_tile); - let can_stop = allow_stop && !tile_is_visible; - let (is_empty, early_return) = - self.render_shape_tree_partial_uncached(tree, timestamp, can_stop, false)?; + let (is_empty, early_return) = self + .render_shape_tree_partial_uncached(tree, timestamp, allow_stop, false)?; if early_return { return Ok(()); diff --git a/render-wasm/src/render/debug.rs b/render-wasm/src/render/debug.rs index 47b739b484..f374e32af3 100644 --- a/render-wasm/src/render/debug.rs +++ b/render-wasm/src/render/debug.rs @@ -194,6 +194,15 @@ pub fn console_debug_surface(render_state: &mut RenderState, id: SurfaceId) { run_script!(format!("console.log('%c ', 'font-size: 1px; background: url(data:image/png;base64,{base64_image}) no-repeat; padding: 100px; background-size: contain;')")); } +pub fn console_debug_surface_base64(render_state: &mut RenderState, id: SurfaceId) { + let base64_image = render_state + .surfaces + .base64_snapshot(id) + .expect("Failed to get base64 image"); + + println!("{}", base64_image); +} + #[allow(dead_code)] #[cfg(target_arch = "wasm32")] pub fn console_debug_surface_rect(render_state: &mut RenderState, id: SurfaceId, rect: skia::Rect) { @@ -223,3 +232,33 @@ pub extern "C" fn debug_cache_console() -> Result<()> { }); Ok(()) } + +#[no_mangle] +#[wasm_error] +#[cfg(target_arch = "wasm32")] +pub extern "C" fn debug_cache_base64() -> Result<()> { + with_state_mut!(state, { + console_debug_surface_base64(state.render_state_mut(), SurfaceId::Cache); + }); + Ok(()) +} + +#[no_mangle] +#[wasm_error] +#[cfg(target_arch = "wasm32")] +pub extern "C" fn debug_atlas_console() -> Result<()> { + with_state_mut!(state, { + console_debug_surface(state.render_state_mut(), SurfaceId::Atlas); + }); + Ok(()) +} + +#[no_mangle] +#[wasm_error] +#[cfg(target_arch = "wasm32")] +pub extern "C" fn debug_atlas_base64() -> Result<()> { + with_state_mut!(state, { + console_debug_surface_base64(state.render_state_mut(), SurfaceId::Atlas); + }); + Ok(()) +} diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 1c5a77c72c..7374e803d0 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -1,6 +1,7 @@ use crate::error::{Error, Result}; use crate::performance; use crate::shapes::Shape; +use crate::view::Viewbox; use skia_safe::{self as skia, IRect, Paint, RRect}; @@ -15,6 +16,16 @@ const TEXTURES_BATCH_DELETE: usize = 256; // If it's too big it could affect performance. const TILE_SIZE_MULTIPLIER: i32 = 2; +/// Atlas texture size limits (px per side). +/// +/// - `DEFAULT_MAX_ATLAS_TEXTURE_SIZE` is the startup fallback used until the +/// frontend reads the real `gl.MAX_TEXTURE_SIZE` and sends it via +/// [`Surfaces::set_max_atlas_texture_size`]. +/// - `MAX_ATLAS_TEXTURE_SIZE` is a hard upper bound to clamp the runtime value +/// (defensive cap to avoid accidentally creating oversized GPU textures). +const MAX_ATLAS_TEXTURE_SIZE: i32 = 4096; +const DEFAULT_MAX_ATLAS_TEXTURE_SIZE: i32 = 1024; + #[repr(u32)] #[derive(Debug, PartialEq, Clone, Copy)] pub enum SurfaceId { @@ -30,6 +41,7 @@ pub enum SurfaceId { Export = 0b010_0000_0000, UI = 0b100_0000_0000, Debug = 0b100_0000_0001, + Atlas = 0b100_0000_0010, } pub struct Surfaces { @@ -57,6 +69,18 @@ pub struct Surfaces { export: skia::Surface, tiles: TileTextureCache, + // Persistent 1:1 document-space atlas that gets incrementally updated as tiles render. + // It grows dynamically to include any rendered document rect. + atlas: skia::Surface, + atlas_origin: skia::Point, + atlas_size: skia::ISize, + /// Atlas pixel density relative to document pixels (1.0 == 1:1 doc px). + /// When the atlas would exceed `max_atlas_texture_size`, this value is + /// reduced so the atlas stays within the fixed texture cap. + atlas_scale: f32, + /// Max width/height in pixels for the atlas surface (typically browser + /// `MAX_TEXTURE_SIZE`). Set from ClojureScript after WebGL context creation. + max_atlas_texture_size: i32, sampling_options: skia::SamplingOptions, pub margins: skia::ISize, // Tracks which surfaces have content (dirty flag bitmask) @@ -99,6 +123,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)?; + // Keep atlas as a regular surface like the rest. Start with a tiny + // transparent surface and grow it on demand. + let mut atlas = gpu_state.create_surface_with_dimensions("atlas".to_string(), 1, 1)?; + atlas.canvas().clear(skia::Color::TRANSPARENT); let tiles = TileTextureCache::new(); Ok(Surfaces { @@ -115,6 +143,11 @@ impl Surfaces { debug, export, tiles, + atlas, + atlas_origin: skia::Point::new(0.0, 0.0), + atlas_size: skia::ISize::new(0, 0), + atlas_scale: 1.0, + max_atlas_texture_size: DEFAULT_MAX_ATLAS_TEXTURE_SIZE, sampling_options, margins, dirty_surfaces: 0, @@ -122,10 +155,186 @@ impl Surfaces { }) } + /// Sets the maximum atlas texture dimension (one side). Should match the + /// WebGL `MAX_TEXTURE_SIZE` reported by the browser. Values are clamped to + /// a small minimum so the atlas logic stays well-defined. + pub fn set_max_atlas_texture_size(&mut self, max_px: i32) { + println!("set_max_atlas_texture_size: {}", max_px); + self.max_atlas_texture_size = max_px.clamp(TILE_SIZE as i32, MAX_ATLAS_TEXTURE_SIZE); + } + + fn ensure_atlas_contains( + &mut self, + gpu_state: &mut GpuState, + doc_rect: skia::Rect, + ) -> Result<()> { + if doc_rect.is_empty() { + return Ok(()); + } + + // Current atlas bounds in document space (1 unit == 1 px). + let current_left = self.atlas_origin.x; + let current_top = self.atlas_origin.y; + let atlas_scale = self.atlas_scale.max(0.01); + let current_right = current_left + (self.atlas_size.width as f32) / atlas_scale; + let current_bottom = current_top + (self.atlas_size.height as f32) / atlas_scale; + + let mut new_left = current_left; + let mut new_top = current_top; + let mut new_right = current_right; + let mut new_bottom = current_bottom; + + // If atlas is empty/uninitialized, seed to rect (expanded to tile boundaries for fewer reallocs). + let needs_init = self.atlas_size.width <= 0 || self.atlas_size.height <= 0; + if needs_init { + new_left = doc_rect.left.floor(); + new_top = doc_rect.top.floor(); + new_right = doc_rect.right.ceil(); + new_bottom = doc_rect.bottom.ceil(); + } else { + new_left = new_left.min(doc_rect.left.floor()); + new_top = new_top.min(doc_rect.top.floor()); + new_right = new_right.max(doc_rect.right.ceil()); + new_bottom = new_bottom.max(doc_rect.bottom.ceil()); + } + + // Add padding to reduce realloc frequency. + let pad = TILE_SIZE; + new_left -= pad; + new_top -= pad; + new_right += pad; + new_bottom += pad; + + let doc_w = (new_right - new_left).max(1.0); + let doc_h = (new_bottom - new_top).max(1.0); + + // Compute atlas scale needed to fit within the fixed texture cap. + // Keep the highest possible scale (closest to 1.0) that still fits. + let cap = self.max_atlas_texture_size.max(TILE_SIZE as i32) as f32; + let required_scale = (cap / doc_w).min(cap / doc_h).clamp(0.01, 1.0); + + // Never upscale the atlas (it would add blur and churn). + let new_scale = self.atlas_scale.min(required_scale).max(0.01); + + let new_w = (doc_w * new_scale).ceil().clamp(1.0, cap) as i32; + let new_h = (doc_h * new_scale).ceil().clamp(1.0, cap) as i32; + + // Fast path: existing atlas already contains the rect. + if !needs_init + && doc_rect.left >= current_left + && doc_rect.top >= current_top + && doc_rect.right <= current_right + && doc_rect.bottom <= current_bottom + { + return Ok(()); + } + + let mut new_atlas = + gpu_state.create_surface_with_dimensions("atlas".to_string(), new_w, new_h)?; + new_atlas.canvas().clear(skia::Color::TRANSPARENT); + + // Copy old atlas into the new one with offset. + if !needs_init { + let old_scale = self.atlas_scale.max(0.01); + let scale_ratio = new_scale / old_scale; + let dx = (current_left - new_left) * new_scale; + let dy = (current_top - new_top) * new_scale; + + let image = self.atlas.image_snapshot(); + let src = skia::Rect::from_xywh( + 0.0, + 0.0, + self.atlas_size.width as f32, + self.atlas_size.height as f32, + ); + let dst = skia::Rect::from_xywh( + dx, + dy, + (self.atlas_size.width as f32) * scale_ratio, + (self.atlas_size.height as f32) * scale_ratio, + ); + new_atlas.canvas().draw_image_rect( + &image, + Some((&src, skia::canvas::SrcRectConstraint::Fast)), + dst, + &skia::Paint::default(), + ); + } + + self.atlas_origin = skia::Point::new(new_left, new_top); + self.atlas_size = skia::ISize::new(new_w, new_h); + self.atlas_scale = new_scale; + self.atlas = new_atlas; + Ok(()) + } + + fn blit_tile_image_into_atlas( + &mut self, + gpu_state: &mut GpuState, + tile_image: &skia::Image, + doc_rect: skia::Rect, + ) -> Result<()> { + self.ensure_atlas_contains(gpu_state, doc_rect)?; + + // Destination is document-space rect mapped into atlas pixel coords. + let dst = skia::Rect::from_xywh( + (doc_rect.left - self.atlas_origin.x) * self.atlas_scale, + (doc_rect.top - self.atlas_origin.y) * self.atlas_scale, + doc_rect.width() * self.atlas_scale, + doc_rect.height() * self.atlas_scale, + ); + + self.atlas + .canvas() + .draw_image_rect(tile_image, None, dst, &skia::Paint::default()); + Ok(()) + } + pub fn clear_tiles(&mut self) { self.tiles.clear(); } + pub fn has_atlas(&self) -> bool { + self.atlas_size.width > 0 && self.atlas_size.height > 0 + } + + /// Draw the persistent atlas onto the target using the current viewbox transform. + /// Intended for fast pan/zoom-out previews (avoids per-tile composition). + pub fn draw_atlas_to_target(&mut self, viewbox: Viewbox, dpr: f32, background: skia::Color) { + if !self.has_atlas() { + return; + }; + + let canvas = self.target.canvas(); + canvas.save(); + canvas.reset_matrix(); + let size = canvas.base_layer_size(); + canvas.clip_rect( + skia::Rect::from_xywh(0.0, 0.0, size.width as f32, size.height as f32), + None, + true, + ); + + let s = viewbox.zoom * dpr; + let atlas_scale = self.atlas_scale.max(0.01); + + canvas.clear(background); + canvas.translate(( + (self.atlas_origin.x + viewbox.pan_x) * s, + (self.atlas_origin.y + viewbox.pan_y) * s, + )); + canvas.scale((s / atlas_scale, s / atlas_scale)); + + self.atlas.draw( + canvas, + (0.0, 0.0), + self.sampling_options, + Some(&skia::Paint::default()), + ); + + canvas.restore(); + } + pub fn margins(&self) -> skia::ISize { self.margins } @@ -255,6 +464,10 @@ impl Surfaces { ); } + pub fn cache_dimensions(&self) -> skia::ISize { + skia::ISize::new(self.cache.width(), self.cache.height()) + } + pub fn apply_mut(&mut self, ids: u32, mut f: impl FnMut(&mut skia::Surface)) { performance::begin_measure!("apply_mut::flags"); if ids & SurfaceId::Target as u32 != 0 { @@ -352,6 +565,7 @@ impl Surfaces { SurfaceId::Debug => &mut self.debug, SurfaceId::UI => &mut self.ui, SurfaceId::Export => &mut self.export, + SurfaceId::Atlas => &mut self.atlas, } } @@ -369,6 +583,7 @@ impl Surfaces { SurfaceId::Debug => &self.debug, SurfaceId::UI => &self.ui, SurfaceId::Export => &self.export, + SurfaceId::Atlas => &self.atlas, } } @@ -546,10 +761,12 @@ impl Surfaces { pub fn cache_current_tile_texture( &mut self, + gpu_state: &mut GpuState, tile_viewbox: &TileViewbox, tile: &Tile, tile_rect: &skia::Rect, skip_cache_surface: bool, + tile_doc_rect: skia::Rect, ) { let rect = IRect::from_xywh( self.margins.width, @@ -571,6 +788,9 @@ impl Surfaces { ); } + // Incrementally update persistent 1:1 atlas in document space. + // `tile_doc_rect` is in world/document coordinates (1 unit == 1 px at 100%). + let _ = self.blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect); self.tiles.add(tile_viewbox, tile, tile_image); } }