diff --git a/frontend/src/app/main/ui/workspace/viewport/actions.cljs b/frontend/src/app/main/ui/workspace/viewport/actions.cljs index f9f9319b06..68d1eb7b03 100644 --- a/frontend/src/app/main/ui/workspace/viewport/actions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/actions.cljs @@ -369,7 +369,8 @@ ;; in the future (when we handle the UI in the render) should be better to ;; have a "wasm.api/pointer-move" function that works as an entry point for ;; all the pointer-move events. - (wasm.api/text-editor-pointer-move (.-x off-pt) (.-y off-pt)) + (when (wasm.api/text-editor-is-active?) + (wasm.api/text-editor-pointer-move (.-x off-pt) (.-y off-pt))) (rx/push! move-stream pt) (reset! last-position raw-pt) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 27dc63e5c6..d9227a1b89 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -966,12 +966,13 @@ (fns/debounce do-render DEBOUNCE_DELAY_MS))) (def render-pan - (letfn [(do-render-pan [ts] - ;; Check if context is still initialized before executing - ;; to prevent errors when navigating quickly - (when wasm/context-initialized? + (letfn [(do-render-pan [_ts] + ;; During panning we want the cheapest possible draw path. + ;; `_render_from_cache` should use the persistent atlas (when present) + ;; and avoid triggering heavy tile rendering work per frame. + (when (and wasm/context-initialized? (not @wasm/context-lost?)) (perf/begin-measure "render-pan") - (render ts) + (h/call wasm/internal-module "_render_from_cache" 0) (perf/end-measure "render-pan")))] (fns/throttle do-render-pan THROTTLE_DELAY_MS))) diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index e586dc3d1a..0f4f1c9024 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -31,6 +31,9 @@ [app.main.errors :as errors] [app.main.repo :as rp] [app.main.store :as st] + [app.render-wasm.helpers :as wasm.h] + [app.render-wasm.mem :as wasm.mem] + [app.render-wasm.wasm :as wasm] [app.util.debug :as dbg] [app.util.dom :as dom] [app.util.http :as http] @@ -117,6 +120,100 @@ (js/console.log str (json/->js val)) val)) +(defn- wasm-read-len-prefixed-utf8 + "Reads a `[u32 byte_len][utf8 bytes...]` buffer returned by WASM and frees it. + Returns a JS string (possibly empty)." + [ptr] + (when (and ptr (not (zero? ptr))) + (let [heap-u8 (wasm.mem/get-heap-u8) + heap-u32 (wasm.mem/get-heap-u32) + len (aget heap-u32 (wasm.mem/->offset-32 ptr)) + start (+ ptr 4) + end (+ start len) + decoder (js/TextDecoder. "utf-8") + text (.decode decoder (.subarray heap-u8 start end))] + (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 wasmAtlasRectBase64 + "Returns the atlas rect PNG base64 (doc-space x/y/w/h)." + [x y w h] + (let [module wasm/internal-module + f (when module (unchecked-get module "_debug_atlas_rect_base64"))] + (if (fn? f) + (let [ptr (wasm.h/call module "_debug_atlas_rect_base64" x y w h) + s (or (wasm-read-len-prefixed-utf8 ptr) "")] + s) + (do + (js/console.warn "[debug] render-wasm module not ready or missing _debug_atlas_rect_base64") + "")))) + +(defn ^:export wasmZoomAtlasConsole + "Logs the current render-wasm zoom_atlas as an image in the JS console (if present)." + [] + (let [module wasm/internal-module + f (when module (unchecked-get module "_debug_zoom_atlas_console"))] + (if (fn? f) + (wasm.h/call module "_debug_zoom_atlas_console") + (js/console.warn "[debug] render-wasm module not ready or missing _debug_zoom_atlas_console")))) + +(defn ^:export wasmZoomAtlasBase64 + "Returns the zoom_atlas PNG base64 (empty string if missing/empty)." + [] + (let [module wasm/internal-module + f (when module (unchecked-get module "_debug_zoom_atlas_base64"))] + (if (fn? f) + (let [ptr (wasm.h/call module "_debug_zoom_atlas_base64") + s (or (wasm-read-len-prefixed-utf8 ptr) "")] + s) + (do + (js/console.warn "[debug] render-wasm module not ready or missing _debug_zoom_atlas_base64") + "")))) + +(defn ^:export wasmCacheConsole + "Logs the current render-wasm cache surface as an image in the JS console." + [] + (let [module wasm/internal-module + f (when module (unchecked-get module "_debug_cache_console"))] + (if (fn? f) + (wasm.h/call module "_debug_cache_console") + (js/console.warn "[debug] render-wasm module not ready or missing _debug_cache_console")))) + +(defn ^:export wasmCacheBase64 + "Returns the cache surface PNG base64 (empty string if missing/empty)." + [] + (let [module wasm/internal-module + f (when module (unchecked-get module "_debug_cache_base64"))] + (if (fn? f) + (let [ptr (wasm.h/call module "_debug_cache_base64") + s (or (wasm-read-len-prefixed-utf8 ptr) "")] + s) + (do + (js/console.warn "[debug] render-wasm module not ready or missing _debug_cache_base64") + "")))) + (when (exists? js/window) (set! (.-dbg ^js js/window) json/->js) (set! (.-pp ^js js/window) pprint)) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 42a8c46671..a467d0d33b 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -882,6 +882,112 @@ pub extern "C" fn render_shape_pixels( }) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn debug_atlas_console() -> Result<()> { + #[cfg(target_arch = "wasm32")] + { + use crate::run_script; + with_state_mut!(state, { + let b64 = state + .render_state_mut() + .surfaces + .base64_snapshot_atlas()? + .unwrap_or_default(); + + if b64.is_empty() { + run_script!("console.log('[render-wasm] atlas: ')".to_string()); + } else { + run_script!(format!("console.log('%c ', 'font-size: 1px; background: url(data:image/png;base64,{b64}) no-repeat; padding: 256px; background-size: contain;')")); + } + }); + } + Ok(()) +} + +/// Returns `[u32 byte_len][utf8 bytes...]` with the base64 PNG of the atlas. +/// When atlas is empty, returns len=0 (no bytes). +#[no_mangle] +#[wasm_error] +pub extern "C" fn debug_atlas_base64() -> Result<*mut u8> { + with_state_mut!(state, { + let b64 = state + .render_state_mut() + .surfaces + .base64_snapshot_atlas()? + .unwrap_or_default(); + + let bytes = b64.as_bytes(); + let len = bytes.len() as u32; + let mut buf = Vec::with_capacity(4 + bytes.len()); + buf.extend_from_slice(&len.to_le_bytes()); + buf.extend_from_slice(bytes); + Ok(mem::write_bytes(buf)) + }) +} + +/// Same as `debug_atlas_base64` but only for a doc-space rect (x,y,w,h). +#[no_mangle] +#[wasm_error] +pub extern "C" fn debug_atlas_rect_base64(x: f32, y: f32, w: f32, h: f32) -> Result<*mut u8> { + with_state_mut!(state, { + let rect = skia::Rect::from_xywh(x, y, w, h); + let b64 = state + .render_state_mut() + .surfaces + .base64_snapshot_atlas_rect(rect)? + .unwrap_or_default(); + + let bytes = b64.as_bytes(); + let len = bytes.len() as u32; + let mut buf = Vec::with_capacity(4 + bytes.len()); + buf.extend_from_slice(&len.to_le_bytes()); + buf.extend_from_slice(bytes); + Ok(mem::write_bytes(buf)) + }) +} + +#[no_mangle] +#[wasm_error] +pub extern "C" fn debug_cache_console() -> Result<()> { + #[cfg(target_arch = "wasm32")] + { + use crate::run_script; + with_state_mut!(state, { + let b64 = state + .render_state_mut() + .surfaces + .base64_snapshot(crate::render::SurfaceId::Cache)?; + + if b64.is_empty() { + run_script!("console.log('[render-wasm] cache: ')".to_string()); + } else { + run_script!(format!("console.log('%c ', 'font-size: 1px; background: url(data:image/png;base64,{b64}) no-repeat; padding: 256px; background-size: contain;')")); + } + }); + } + Ok(()) +} + +/// Returns `[u32 byte_len][utf8 bytes...]` with the base64 PNG of the cache surface. +#[no_mangle] +#[wasm_error] +pub extern "C" fn debug_cache_base64() -> Result<*mut u8> { + with_state_mut!(state, { + let b64 = state + .render_state_mut() + .surfaces + .base64_snapshot(crate::render::SurfaceId::Cache)?; + + let bytes = b64.as_bytes(); + let len = bytes.len() as u32; + let mut buf = Vec::with_capacity(4 + bytes.len()); + buf.extend_from_slice(&len.to_le_bytes()); + buf.extend_from_slice(bytes); + Ok(mem::write_bytes(buf)) + }) +} + fn main() { #[cfg(target_arch = "wasm32")] init_gl!(); diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 17e29f08d2..db3cca6c42 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -337,6 +337,9 @@ pub(crate) struct RenderState { /// Preview render mode - when true, uses simplified rendering for progressive loading pub preview_mode: bool, pub export_context: Option<(Rect, f32)>, + /// Cleared at the beginning of a render pass; set to true after we clear Cache the first + /// time we are about to blit a tile into Cache for this pass. + pub cache_cleared_this_render: bool, } pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize { @@ -411,6 +414,7 @@ impl RenderState { ignore_nested_blurs: false, preview_mode: false, export_context: None, + cache_cleared_this_render: false, }) } @@ -665,13 +669,22 @@ impl RenderState { } pub fn apply_render_to_final_canvas(&mut self, rect: skia::Rect) -> Result<()> { + // Decide *now* (at the first real cache blit) whether we need to clear Cache. + // This avoids clearing Cache on renders that don't actually paint tiles (e.g. hover/UI), + // while still preventing stale pixels from surviving across full-quality renders. + if !self.options.is_fast_mode() && !self.cache_cleared_this_render { + self.surfaces.clear_cache(self.background_color); + self.cache_cleared_this_render = true; + } let tile_rect = self.get_current_aligned_tile_bounds()?; 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, + self.render_area, ); self.surfaces.draw_cached_tile_surface( @@ -1410,8 +1423,15 @@ impl RenderState { performance::begin_measure!("render_from_cache"); let scale = self.get_cached_scale(); + println!("render_from_cache"); // Check if we have a valid cached viewbox (non-zero dimensions indicate valid cache) if self.cached_viewbox.area.width() > 0.0 { + // For zoom-in, the best fast-path is to reproject the cached frame + // (viewport-sized) with a matrix. This avoids depending on atlas + // resolution (which may be downscaled for huge documents) and avoids + // any tile/atlas composition. + let zooming_in = self.viewbox.zoom > self.cached_viewbox.zoom; + // Scale and translate the target according to the cached data let navigate_zoom = self.viewbox.zoom / self.cached_viewbox.zoom; @@ -1427,6 +1447,61 @@ impl RenderState { 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; + println!("cache_covers: {}", cache_covers); + 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(), + self.background_color, + ); + + if self.options.is_debug_visible() { + debug::render(self); + } + + ui::render(self, shapes); + debug::render_wasm_label(self); + self.flush_and_submit(); + println!("atlas drawn"); + return; + } + } + } + // Setup canvas transform { let canvas = self.surfaces.canvas(SurfaceId::Target); @@ -1437,6 +1512,7 @@ impl RenderState { } // Draw directly from cache surface, avoiding snapshot overhead + println!("drawn from cache"); self.surfaces.draw_cache_to_target(); // Restore canvas state @@ -1451,6 +1527,7 @@ impl RenderState { self.flush_and_submit(); } + performance::end_measure!("render_from_cache"); performance::end_timed_log!("render_from_cache", _start); } @@ -1497,6 +1574,7 @@ impl RenderState { performance::begin_measure!("render"); performance::begin_measure!("start_render_loop"); + self.cache_cleared_this_render = false; self.reset_canvas(); let surface_ids = SurfaceId::Strokes as u32 | SurfaceId::Fills as u32 diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 2ab32d1a61..039a94cecb 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,13 @@ const TEXTURES_BATCH_DELETE: usize = 256; // If it's too big it could affect performance. const TILE_SIZE_MULTIPLIER: i32 = 2; +/// Hard cap for the persistent atlas texture dimensions. +/// +/// WebGL textures have a maximum size. When the document would require a larger +/// atlas, we keep the atlas within this cap by downscaling its stored content. +// TODO: set this value from frontend +const MAX_ATLAS_TEXTURE_SIZE: i32 = 16384; + #[repr(u32)] #[derive(Debug, PartialEq, Clone, Copy)] pub enum SurfaceId { @@ -57,6 +65,15 @@ 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: Option, + 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, sampling_options: skia::SamplingOptions, pub margins: skia::ISize, // Tracks which surfaces have content (dirty flag bitmask) @@ -115,6 +132,10 @@ impl Surfaces { debug, export, tiles, + atlas: None, + atlas_origin: skia::Point::new(0.0, 0.0), + atlas_size: skia::ISize::new(0, 0), + atlas_scale: 1.0, sampling_options, margins, dirty_surfaces: 0, @@ -122,10 +143,180 @@ impl Surfaces { }) } + 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.is_none() || 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.max(256.0); + 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 required_scale = ((MAX_ATLAS_TEXTURE_SIZE as f32) / doc_w) + .min((MAX_ATLAS_TEXTURE_SIZE as f32) / doc_h) + .min(1.0) + .max(0.01); + + // 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, MAX_ATLAS_TEXTURE_SIZE as f32) as i32; + let new_h = (doc_h * new_scale) + .ceil() + .clamp(1.0, MAX_ATLAS_TEXTURE_SIZE as f32) 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 let Some(mut old) = self.atlas.take() { + 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 = old.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 = Some(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)?; + let Some(atlas) = self.atlas.as_mut() else { + return Ok(()); + }; + + // 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, + ); + + 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.is_some() && 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) { + let Some(atlas) = self.atlas.as_mut() else { + 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)); + + atlas.draw(canvas, (0.0, 0.0), self.sampling_options, Some(&skia::Paint::default())); + + canvas.restore(); + } + pub fn margins(&self) -> skia::ISize { self.margins } @@ -178,6 +369,68 @@ impl Surfaces { } } + pub fn base64_snapshot_atlas(&mut self) -> Result> { + let Some(atlas) = self.atlas.as_mut() else { + return Ok(None); + }; + let image = atlas.image_snapshot(); + let mut context = atlas.direct_context(); + let encoded_image = image + .encode(context.as_mut(), skia::EncodedImageFormat::PNG, None) + .ok_or(Error::CriticalError("Failed to encode image".to_string()))?; + Ok(Some( + general_purpose::STANDARD.encode(encoded_image.as_bytes()), + )) + } + + /// doc_rect is in document space (1 unit == 1px at 100%). + /// Returns None when atlas is empty or rect doesn't intersect atlas. + pub fn base64_snapshot_atlas_rect(&mut self, doc_rect: skia::Rect) -> Result> { + let Some(atlas) = self.atlas.as_mut() else { + return Ok(None); + }; + if doc_rect.is_empty() || self.atlas_size.width <= 0 || self.atlas_size.height <= 0 { + return Ok(None); + } + + let mut local = 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, + ); + + let atlas_bounds = skia::Rect::from_xywh( + 0.0, + 0.0, + self.atlas_size.width as f32, + self.atlas_size.height as f32, + ); + + if !local.intersect(atlas_bounds) { + return Ok(None); + } + + let irect = skia::IRect::from_ltrb( + local.left.floor() as i32, + local.top.floor() as i32, + local.right.ceil() as i32, + local.bottom.ceil() as i32, + ); + + if let Some(image) = atlas.image_snapshot_with_bounds(irect) { + let mut context = atlas.direct_context(); + let encoded_image = image + .encode(context.as_mut(), skia::EncodedImageFormat::PNG, None) + .ok_or(Error::CriticalError("Failed to encode image".to_string()))?; + Ok(Some( + general_purpose::STANDARD.encode(encoded_image.as_bytes()), + )) + } else { + Ok(None) + } + } + /// Returns a mutable reference to the canvas and automatically marks /// render surfaces as dirty when accessed. This tracks which surfaces /// have content for optimization purposes. @@ -255,6 +508,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 { @@ -533,11 +790,22 @@ impl Surfaces { self.clear_all_dirty(); } + /// Clears the whole cache surface without disturbing its configured transform. + pub fn clear_cache(&mut self, color: skia::Color) { + let canvas = self.cache.canvas(); + canvas.save(); + canvas.reset_matrix(); + canvas.clear(color); + canvas.restore(); + } + pub fn cache_current_tile_texture( &mut self, + gpu_state: &mut GpuState, tile_viewbox: &TileViewbox, tile: &Tile, tile_rect: &skia::Rect, + tile_doc_rect: skia::Rect, ) { let rect = IRect::from_xywh( self.margins.width, @@ -557,7 +825,12 @@ impl Surfaces { &skia::Paint::default(), ); - self.tiles.add(tile_viewbox, tile, tile_image); + // Keep a copy to also blit into the persistent atlas. + self.tiles.add(tile_viewbox, tile, tile_image.clone()); + + // 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); } }