diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index 32a0bdf944..fa59641cc4 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -180,28 +180,6 @@ (wasm.h/call module "_debug_surface_console" id) (js/console.warn "[debug] render-wasm module not ready or missing _debug_surface_console")))) -(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 ddd30fe356..acc014de15 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -337,11 +337,10 @@ pub extern "C" fn set_view_end() -> Result<()> { if render_state.options.is_profile_rebuild_tiles() { state.rebuild_tiles(); } else if render_state.zoom_changed() { - // Zoom changed: tile sizes differ so all cached tile - // textures are invalid (wrong scale). Rebuild the tile - // index and clear the tile texture cache, but *preserve* - // the cache canvas so render_from_cache can show a scaled - // preview of the old content while new tiles render. + // Zoom changed: tile sizes differ so all cached tile textures are + // invalid (wrong scale). Rebuild the tile index and invalidate the + // tile cache; the DocAtlas preview covers the viewport while new + // tiles render. render_state.rebuild_tile_index(&state.shapes); render_state.surfaces.invalidate_tile_cache(); } else { diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index b613ec5586..b1dbfe85d4 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -25,7 +25,6 @@ use options::RenderOptions; pub use surfaces::{SurfaceId, Surfaces}; use crate::error::{Error, Result}; -use crate::math; use crate::shapes::{ all_with_ancestors, radius_to_sigma, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor, Stroke, StrokeKind, TextContent, Type, @@ -42,6 +41,13 @@ pub use images::*; type ClipStack = Vec<(Rect, Option, Matrix)>; +/// Per-frame deadline for uncached-tile rendering, measured from the rAF +/// timestamp (frame start), so JS work that ran earlier in the tick counts +/// against it. The loop yields (Partial frame) once exceeded so heavy +/// settles stream at frame rate instead of blocking the main thread. +/// ~12ms leaves headroom for compositing + present within a 16.7ms frame. +const TILE_FRAME_TIME_BUDGET_MS: i32 = 12; + #[repr(u8)] pub enum FrameType { None = 0, @@ -387,9 +393,6 @@ 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, /// True if the current tile had shapes assigned to it when we /// started rendering it. Lets us distinguish a genuinely empty /// tile (skip composite, just clear) from a tile whose walker @@ -397,139 +400,17 @@ pub(crate) struct RenderState { /// (must composite to present the work). Reset when current_tile /// changes. pub current_tile_had_shapes: bool, + /// Count of uncached tiles composited in the current rAF. Reset at the + /// top of `render_shape_tree_partial`. The yield decision is time-based: + /// see `TILE_FRAME_TIME_BUDGET_MS`. + pub tiles_on_frame: i32, /// During interactive transforms we keep `Target` between rAFs. Seed the /// interactive backdrop exactly once per gesture (first rAF) so we don't /// repeatedly overwrite tiles that have already been updated. pub interactive_target_seeded: bool, - /// GPU crops from `Backbuffer` or tile atlas keyed by shape id. Filled on full-frame completion; during - /// drag, entries for the moved top-level selection are ensured here - pub backbuffer_crop_cache: HashMap, -} - -pub struct InteractiveDragCrop { - pub src_doc_bounds: Rect, - pub src_selrect: Rect, - /// Viewbox origin (doc-space) at capture time. - pub capture_vb_left: f32, - pub capture_vb_top: f32, - /// Backbuffer pixel origin used for `snapshot_rect` (so we can do 1:1 blits). - pub capture_src_left: i32, - pub capture_src_top: i32, - pub image: skia::Image, -} - -/// Chooses a window inside the full workspace-pixel crop `[0, out_w) × [0, out_h)` with each side -/// at most `max_side_px` (**without scaling**): centered on the projection of -/// `viewport_doc ∩ src_doc_bounds`, or on the full crop if that intersection is empty. -/// `max_side_px` should match [`GpuState::max_texture_size`] (same budget as the atlas). -#[allow(clippy::too_many_arguments)] -fn drag_crop_snapshot_window_px( - max_side_px: i32, - out_w: i32, - out_h: i32, - viewport_doc: Rect, - vb_left: f32, - vb_top: f32, - scale: f32, - src_left_px: i32, - src_top_px: i32, - src_doc_bounds: Rect, -) -> (i32, i32, i32, i32) { - let cap = max_side_px.max(1); - if out_w <= cap && out_h <= cap { - return (0, 0, out_w, out_h); - } - let win_w = out_w.min(cap); - let win_h = out_h.min(cap); - - let mut vis = viewport_doc; - let has_vis = vis.intersect(src_doc_bounds); - let (cx, cy) = if !has_vis || vis.is_empty() { - (out_w as f32 * 0.5, out_h as f32 * 0.5) - } else { - let lx0 = (vis.left - vb_left) * scale - src_left_px as f32; - let ly0 = (vis.top - vb_top) * scale - src_top_px as f32; - let lx1 = (vis.right - vb_left) * scale - src_left_px as f32; - let ly1 = (vis.bottom - vb_top) * scale - src_top_px as f32; - ((lx0 + lx1) * 0.5, (ly0 + ly1) * 0.5) - }; - - let mut ox = (cx - win_w as f32 * 0.5).round() as i32; - let mut oy = (cy - win_h as f32 * 0.5).round() as i32; - ox = ox.clamp(0, out_w - win_w); - oy = oy.clamp(0, out_h - win_h); - (ox, oy, win_w, win_h) } impl RenderState { - /// Decide whether a top-level node can be served from `backbuffer_crop_cache` during an - /// interactive transform (drag/resize/rotate). - /// - /// We only reuse cached pixels when it is safe and visually correct: - /// - **Top-level only**: cache entries are built for direct children of the root. - /// - **Moved node**: only allow cache reuse for *pure translations* (no scale/rotate/skew), - /// because other transforms would require resampling and can diverge from the live render. - /// - **Other cached nodes**: if the moving bounds overlap this cached crop, invalidate it so - /// we don't show stale content while something moves over/inside it. - fn should_use_cached_top_level_during_interactive( - &mut self, - node_id: Uuid, - tree: ShapesPoolRef, - moved_ids: &[Uuid], - moved_bounds: Option, - ) -> bool { - if !self.backbuffer_crop_cache.contains_key(&node_id) { - return false; - } - let Some(raw) = tree.get_raw(&node_id) else { - return false; - }; - if raw.parent_id != Some(Uuid::nil()) { - return false; - } - - // If this top-level shape itself is being moved, always allow using its cached pixels. - // BUT only for pure translations. For non-translation transforms (scale/rotate/skew), - // cached pixels won't match the live result (and may require resampling), so render live. - if moved_ids.contains(&node_id) { - let Some(m) = tree.get_modifier(&node_id) else { - return false; - }; - // Only allow using the cached pixels for pure translations. - // For non-translation transforms (scale/rotate/skew), cached pixels won't match. - // If the transform is the identity means a reflow, we need to redraw as well. - if math::identitish(m) || !math::is_move_only_matrix(m) { - return false; - } - - if !self.backbuffer_crop_cache.contains_key(&node_id) { - return false; - } - - // Additionally require this node to be safe to serve from a rectangular backbuffer - // crop while moving; otherwise it must be rendered live (e.g. text, overflow frames). - return tree - .get(&node_id) - .is_some_and(|s| s.is_safe_for_drag_crop_cache(tree)); - } - - // If the moving content overlaps this cached crop, do not use the cached pixels - // for this frame. We intentionally keep the cache entry: overlap is typically - // transient during drag, and once the moving content leaves the area the crop - // becomes valid again (stationary shape unchanged). - if let Some(moved) = moved_bounds { - let intersects = self - .backbuffer_crop_cache - .get(&node_id) - .is_some_and(|crop| moved.intersects(crop.src_doc_bounds)); - - if intersects { - return false; - } - } - true - } - pub fn try_new(width: i32, height: i32) -> Result { // This needs to be done once per WebGL context. let sampling_options = @@ -581,10 +462,9 @@ impl RenderState { ignore_nested_blurs: false, preview_mode: false, export_context: None, - cache_cleared_this_render: false, current_tile_had_shapes: false, + tiles_on_frame: 0, interactive_target_seeded: false, - backbuffer_crop_cache: HashMap::default(), }) } @@ -847,10 +727,6 @@ impl RenderState { Ok(()) } - pub fn flush(&mut self) { - self.surfaces.flush(SurfaceId::Backbuffer); - } - pub fn flush_and_submit(&mut self) { self.surfaces.flush_and_submit(SurfaceId::Target); } @@ -950,11 +826,7 @@ impl RenderState { // the interaction ends. if self.options.is_interactive_transform() { let tile_rect = self.get_current_aligned_tile_bounds()?; - self.surfaces.draw_current_tile_into_backbuffer( - &tile_rect, - self.background_color, - surfaces::DrawOnCache::No, - ); + self.surfaces.draw_current_tile_into_backbuffer(&tile_rect); return Ok(()); } @@ -964,26 +836,10 @@ impl RenderState { // Use viewbox-aligned bounds (not grid-snapped) to match interactive-transform // compositing and avoid a visible offset vs the DOM canvas. let tile_rect = self.get_current_tile_bounds()?; - self.surfaces.draw_current_tile_into_backbuffer( - &tile_rect, - self.background_color, - surfaces::DrawOnCache::No, - ); + self.surfaces.draw_current_tile_into_backbuffer(&tile_rect); return Ok(()); } - let fast_mode = self.options.is_fast_mode(); - // 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 !fast_mode && !self.cache_cleared_this_render { - self.surfaces.clear_cache(self.background_color); - self.cache_cleared_this_render = true; - } - // In fast mode the viewport is moving (pan/zoom) so Cache surface - // positions would be wrong — only save to the tile HashMap. - let tile_rect = self.get_current_aligned_tile_bounds()?; - let current_tile = *self .current_tile .as_ref() @@ -992,8 +848,6 @@ impl RenderState { self.surfaces.draw_current_tile_into_tile_atlas( &self.tile_viewbox, ¤t_tile, - &tile_rect, - fast_mode, self.render_area, ); @@ -1422,7 +1276,6 @@ impl RenderState { let text_fill_inset = (count_inner_strokes > 0).then(|| 1.0 / self.get_scale()); let text_stroke_blur_outset = Stroke::max_bounds_width(shape.visible_strokes(), false); - let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); let stroke_kinds: Vec = shape.visible_strokes().rev().map(|s| s.kind).collect(); let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape @@ -1439,16 +1292,13 @@ impl RenderState { .unzip(); if skip_effects { // Fast path: render fills and strokes only (skip shadows/blur). - text::render( - Some(self), - None, + text::render_fill_cached( + self, &shape, - &mut paragraph_builders, + text_content, Some(fills_surface_id), None, - None, text_fill_inset, - None, )?; for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list @@ -1496,23 +1346,33 @@ impl RenderState { let inner_shadows = shape.inner_shadow_paints(); let blur_filter = shape.image_filter(1.); - let mut paragraphs_with_shadows = - text_content.paragraph_builder_group_from_text(Some(true)); + let needs_shadow_builders = parent_shadows.is_some() + || !drop_shadows.is_empty() + || !inner_shadows.is_empty(); + let mut paragraphs_with_shadows = if needs_shadow_builders { + text_content.paragraph_builder_group_from_text(Some(true)) + } else { + Vec::new() + }; let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): ( Vec<_>, Vec<_>, - ) = shape - .visible_strokes() - .rev() - .map(|stroke| { - text::stroke_paragraph_builder_group_from_text( - text_content, - stroke, - &shape.selrect(), - Some(true), - ) - }) - .unzip(); + ) = if needs_shadow_builders { + shape + .visible_strokes() + .rev() + .map(|stroke| { + text::stroke_paragraph_builder_group_from_text( + text_content, + stroke, + &shape.selrect(), + Some(true), + ) + }) + .unzip() + } else { + (Vec::new(), Vec::new()) + }; if let Some(parent_shadows) = parent_shadows { if !shape.has_visible_strokes() { @@ -1560,17 +1420,14 @@ impl RenderState { } } - // 2. Text fills - text::render( - Some(self), - None, + // 2. Text fills — reuse cached laid-out paragraphs (no per-tile reshape). + text::render_fill_cached( + self, &shape, - &mut paragraph_builders, + text_content, Some(fills_surface_id), - None, blur_filter.as_ref(), text_fill_inset, - None, )?; // 3. Stroke drop shadows @@ -1790,310 +1647,17 @@ impl RenderState { self.surfaces.update_render_context(self.render_area, scale); } - fn rebuild_backbuffer_crop_cache(&mut self, tree: ShapesPoolRef) { - self.backbuffer_crop_cache.clear(); - - // Collect candidate shapes that are "recortable" and visible in the current viewport. - - // This is intentionally conservative; we only cache shapes that do not overlap with - // ANY other candidate to guarantee the pixels under their bounds belong exclusively - // to that shape in Backbuffer. - let viewport = self.viewbox.area; - let scale = self.get_scale(); - let mut candidates: Vec<(Uuid, Rect, Rect)> = Vec::new(); // (id, doc_bounds, selrect) - - let root_ids: Vec = match tree.get(&Uuid::nil()) { - Some(root) => root.children_ids(false), - None => Vec::new(), - }; - - for shape_id in root_ids { - let Some(shape) = tree.get(&shape_id) else { - continue; - }; - if shape.hidden { - continue; - } - - let doc_bounds = self.get_cached_extrect(shape, tree, 1.0); - if !doc_bounds.intersects(viewport) { - continue; - } - - // Also require selrect to be visible; used for drag delta placement. - let selrect = shape.selrect(); - if !selrect.intersects(viewport) { - continue; - } - - candidates.push((shape.id, doc_bounds, selrect)); - } - - // Filter out any candidate that overlaps with any other candidate. - // Sort by left edge so the inner loop can break early once no further - // x-overlap is possible, reducing comparisons from O(N²) to O(N log N) - // in typical layouts where shapes are spread out. - candidates.sort_unstable_by(|a, b| { - a.1.left - .partial_cmp(&b.1.left) - .unwrap_or(std::cmp::Ordering::Equal) - }); - let n = candidates.len(); - let mut is_overlapping = vec![false; n]; - for i in 0..n { - for j in (i + 1)..n { - if candidates[j].1.left >= candidates[i].1.right { - break; // sorted: no further x-overlap possible for i - } - if is_overlapping[i] && is_overlapping[j] { - continue; // both already excluded, skip check - } - if candidates[i].1.intersects(candidates[j].1) { - is_overlapping[i] = true; - is_overlapping[j] = true; - } - } - } - let non_overlapping: Vec<(Uuid, Rect, Rect)> = candidates - .iter() - .zip(is_overlapping.iter()) - .filter_map(|((id, bounds, selrect), ov)| { - if !ov { - Some((*id, *bounds, *selrect)) - } else { - None - } - }) - .collect(); - - let vb_left = self.viewbox.area.left; - let vb_top = self.viewbox.area.top; - let (bb_w, bb_h) = self.surfaces.surface_size(SurfaceId::Backbuffer); - let max_snap_px = get_gpu_state().max_texture_size(); - - // Snapshot the atlas once for the whole pass so that all shapes sharing - // the tile/atlas fallback path reuse the same GPU image rather than each - // triggering a separate `image_snapshot` flush. - let atlas_snap = self.surfaces.atlas.snapshot_for_drag_crop(); - - // Scratch surface reused across all shapes that need the tile/atlas - // fallback — avoids one WebGL texture allocation per shape. - // Created lazily on first use and grown if a later shape needs more space. - let mut scratch_surface: Option = None; - - for (id, doc_bounds, selrect) in non_overlapping { - let left = ((doc_bounds.left - vb_left) * scale).floor() as i32; - let top = ((doc_bounds.top - vb_top) * scale).floor() as i32; - let right = ((doc_bounds.right - vb_left) * scale).ceil() as i32; - let bottom = ((doc_bounds.bottom - vb_top) * scale).ceil() as i32; - if right <= left || bottom <= top { - continue; - } - let src_irect = skia::IRect::new(left, top, right, bottom); - - let src_doc_bounds = Rect::new( - src_irect.left as f32 / scale + vb_left, - src_irect.top as f32 / scale + vb_top, - src_irect.right as f32 / scale + vb_left, - src_irect.bottom as f32 / scale + vb_top, - ); - - let full_w = src_irect.width(); - let full_h = src_irect.height(); - let (win_ox, win_oy, win_w, win_h) = drag_crop_snapshot_window_px( - max_snap_px, - full_w, - full_h, - viewport, - vb_left, - vb_top, - scale, - src_irect.left, - src_irect.top, - src_doc_bounds, - ); - let window_irect = skia::IRect::new( - src_irect.left + win_ox, - src_irect.top + win_oy, - src_irect.left + win_ox + win_w, - src_irect.top + win_oy + win_h, - ); - - let src_doc_window = Rect::new( - window_irect.left as f32 / scale + vb_left, - window_irect.top as f32 / scale + vb_top, - window_irect.right as f32 / scale + vb_left, - window_irect.bottom as f32 / scale + vb_top, - ); - - let in_backbuffer = window_irect.left >= 0 - && window_irect.top >= 0 - && window_irect.right <= bb_w - && window_irect.bottom <= bb_h; - - let backbuffer_snap = if in_backbuffer { - self.surfaces - .snapshot_rect(SurfaceId::Backbuffer, window_irect) - } else { - None - }; - - let image = if let Some(img) = backbuffer_snap { - img - } else { - // Ensure the scratch surface is large enough for this window. - // Grow (reallocate) only when necessary so that the common case - // of similarly-sized shapes pays zero extra allocation cost. - let needs_alloc = scratch_surface - .as_ref() - .is_none_or(|s| s.width() < win_w || s.height() < win_h); - if needs_alloc { - scratch_surface = get_gpu_state() - .create_surface_with_isize( - "drag_crop_scratch".to_string(), - skia::ISize::new(win_w, win_h), - ) - .ok(); - } - let Some(scratch) = scratch_surface.as_mut() else { - continue; - }; - let Some(img) = self.surfaces.try_snapshot_doc_rect_from_tiles_and_atlas( - scratch, - atlas_snap.as_ref(), - src_doc_window, - window_irect, - win_w, - win_h, - vb_left, - vb_top, - scale, - ) else { - continue; - }; - img - }; - - self.backbuffer_crop_cache.insert( - id, - InteractiveDragCrop { - src_doc_bounds: src_doc_window, - src_selrect: selrect, - capture_vb_left: vb_left, - capture_vb_top: vb_top, - capture_src_left: window_irect.left, - capture_src_top: window_irect.top, - image, - }, - ); - } - } - + /// Present a fast preview during a pan/zoom gesture from the single preview + /// source: the DocAtlas (a 1:1 document-space mosaic of everything rendered + /// so far), drawn scaled under the current viewbox transform. This covers + /// pan and zoom in one path; the post-gesture settle re-renders crisp tiles. pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) { let _start = performance::begin_timed_log!("render_from_cache"); performance::begin_measure!("render_from_cache"); - 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.surfaces.atlas.is_empty() { + if !self.surfaces.atlas.is_empty() { self.surfaces - .draw_atlas_to_backbuffer(self.viewbox, bg_color); - - self.present_frame(shapes); - 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 - let navigate_zoom = self.viewbox.zoom / self.cached_viewbox.zoom; - - let interest = self.options.dpr_viewport_interest_area_threshold; - let TileRect(start_tile_x, start_tile_y, _, _) = - tiles::get_tiles_for_viewbox_with_interest(&self.cached_viewbox, interest); - let offset_x = self.viewbox.area.left * self.cached_viewbox.zoom * self.options.dpr; - 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; - - // 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.dpr_width().max(1.0); - let vh = self.viewbox.dpr_height().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; - // NOTA: 0.0 * inv => siempre 0 - let cx0 = -translate_x; - let cy0 = -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.atlas.is_empty() { - self.surfaces - .draw_atlas_to_backbuffer(self.viewbox, bg_color); - - self.present_frame(shapes); - performance::end_measure!("render_from_cache"); - performance::end_timed_log!("render_from_cache", _start); - return; - } - } - } - - // Draw directly from cache surface, avoiding snapshot overhead - self.surfaces.draw_cache_to_backbuffer(); - - // During pure pan (same zoom), draw tiles from the HashMap - // on top of the scaled Cache surface. Cached tile textures - // include full-quality effects (shadows, blur) from the last - // render, so blitting them avoids re-rendering and keeps pan - // smooth. During zoom the tile grid changes so HashMap tiles - // would be at wrong positions — skip them and let the full - // render after set_view_end handle it. - if !self.zoom_changed() { - let visible_rect = tiles::get_tiles_for_viewbox(&self.viewbox); - let offset = self.viewbox.get_offset(); - for tx in visible_rect.x1()..=visible_rect.x2() { - for ty in visible_rect.y1()..=visible_rect.y2() { - let tile = tiles::Tile::from(tx, ty); - if self.surfaces.has_cached_tile_surface(tile) { - let rect = tile.get_rect_with_offset(&offset); - self.surfaces.draw_cached_tile_into_backbuffer(tile, &rect); - } - } - } - } - + .draw_atlas_to_backbuffer(self.viewbox, self.background_color); self.present_frame(shapes); } @@ -2175,7 +1739,6 @@ impl RenderState { let doc_bounds = self.compute_document_bounds(base_object, tree); self.surfaces.atlas.set_doc_bounds(doc_bounds); - self.cache_cleared_this_render = false; if self.options.is_interactive_transform() { // Keep `Target` as the previous frame and overwrite only the tiles // that changed. This avoids clearing + redrawing an atlas backdrop @@ -2208,12 +1771,6 @@ impl RenderState { s.canvas().scale((scale, scale)); }); - self.surfaces.resize_cache_from_viewbox( - &self.viewbox, - &self.cached_viewbox, - self.options.dpr_viewport_interest_area_threshold, - )?; - // FIXME - review debug // debug::render_debug_tiles_for_viewbox(self); @@ -2241,14 +1798,10 @@ impl RenderState { self.options.capture_frames -= 1; } - // Update cached_viewbox after visible tiles render - // synchronously so that render_from_cache uses the correct - // zoom ratio even if interest-area tiles are still rendering - // asynchronously. Without this, panning right after a zoom - // would keep scaling the Cache surface by the old zoom ratio - // (pixelated/wrong-scale tiles) because the async render - // never completes — each pan frame cancels it. - if self.cache_cleared_this_render { + // Mark cached_viewbox after a full-quality (non-fast) render so + // zoom_changed() is correct on the next gesture, even if interest-area + // tiles are still rendering asynchronously. + if !self.options.is_fast_mode() { self.cached_viewbox = self.viewbox; } } @@ -2298,33 +1851,51 @@ impl RenderState { performance::begin_measure!("continue_render_loop"); let frame_type = self.render_shape_tree_partial(base_object, tree, timestamp, true)?; - if !self.options.is_interactive_transform() { - self.surfaces.draw_tile_atlas_to_backbuffer( - &self.viewbox, - &self.tile_viewbox, - self.background_color, - ); - } - match frame_type { FrameType::None => { panic!("FrameType::None"); } FrameType::Partial => { - // Partial frame: just flush GPU work. The display shows the last - // fully submitted frame; no need to copy or draw UI overlays here. - self.flush(); + if !self.options.is_fast_mode() + && !self.options.is_interactive_transform() + && !self.viewer_masked_pass() + { + // Progressive present: DocAtlas preview as backdrop (covers + // not-yet-rendered tiles), finished tiles composited on top. + // Cheap since the page atlas made compositing snapshot-free. + self.surfaces + .draw_atlas_to_backbuffer(self.viewbox, self.background_color); + self.surfaces + .draw_cached_tiles_over_backbuffer(&self.viewbox, &self.tile_viewbox); + self.present_frame(tree); + } else { + // Gesture previews / masked passes present elsewhere: just + // flush so the GPU executes this chunk's draws now. + self.surfaces.flush_and_submit(SurfaceId::Backbuffer); + } } FrameType::Full => { - // A full-quality frame is now complete. Rebuild the per-shape crop - // cache from the clean Backbuffer (no UI overlay yet) so that - // interactive drag backgrounds don't include the grid overlay. - if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { - self.rebuild_backbuffer_crop_cache(tree); + // A settle can complete mid-zoom-gesture (fast_mode with the + // zoom already diverged from cached_viewbox). The grid then + // still holds old-zoom tiles — compositing them at new-zoom + // positions flashes wrong-scale tiles with background gaps. + // Skip the composite + present and let the preview own the + // screen; the post-set_view_end settle presents the real frame. + let mid_zoom_gesture = self.options.is_fast_mode() && self.zoom_changed(); + if !mid_zoom_gesture && !self.options.is_interactive_transform() { + self.surfaces.draw_tile_atlas_to_backbuffer( + &self.viewbox, + &self.tile_viewbox, + self.background_color, + ); + } + if mid_zoom_gesture { + self.surfaces.flush_and_submit(SurfaceId::Backbuffer); + } else { + // present_frame: copy clean Backbuffer → Target, draw UI/debug + // overlays on Target only, then flush. Backbuffer stays overlay-free. + self.present_frame(tree); } - // present_frame: copy clean Backbuffer → Target, draw UI/debug - // overlays on Target only, then flush. Backbuffer stays overlay-free. - self.present_frame(tree); wapi::notify_tiles_render_complete!(); performance::end_measure!("render"); } @@ -3156,48 +2727,6 @@ impl RenderState { target_surface = SurfaceId::Export; } - // During interactive transforms we compute the union of the current bounds of all - // modified shapes (doc-space @ 100% zoom, scale=1.0). This is used as a cheap overlap - // guard to decide when cached top-level crops are unsafe to reuse (something is moving - // over/inside them), without doing expensive ancestor walks per node. - // - // `modifier_ids` is pre-computed once here and reused throughout the loop to avoid - // repeated allocations (formerly O(N_shapes) HashMap builds) per node. - let modifier_ids = tree.modifier_ids(); - let moved_bounds = if self.options.is_interactive_transform() && !modifier_ids.is_empty() { - let mut acc: Option = None; - for id in modifier_ids.iter() { - // Current (post-modifier) bounds - if let Some(s) = tree.get(id) { - let r = self.get_cached_extrect(s, tree, 1.0); - acc = Some(match acc { - None => r, - Some(mut prev) => { - prev.join(r); - prev - } - }); - } - - // Pre-modifier bounds: important so cached top-level crops that still contain the - // shape at its original position are considered "unsafe" even after the shape - // has moved away (e.g. dragging a child out of a clipped frame). - if let Some(raw) = tree.get_raw(id) { - let r0 = self.get_cached_extrect(raw, tree, 1.0); - acc = Some(match acc { - None => r0, - Some(mut prev) => { - prev.join(r0); - prev - } - }); - } - } - acc - } else { - None - }; - while let Some(node_render_state) = self.pending_nodes.pop() { let node_id = node_render_state.id; let visited_children = node_render_state.visited_children; @@ -3295,71 +2824,6 @@ impl RenderState { } } - // Interactive drag cache: if this node is cacheable during interactive transform, - // draw it directly from Backbuffer crop on the current tile surface and skip - // traversing/rendering the subtree. - if self.options.is_interactive_transform() { - let use_cached = self.should_use_cached_top_level_during_interactive( - node_id, - tree, - modifier_ids, - moved_bounds, - ); - - if use_cached { - if let Some(crop) = self.backbuffer_crop_cache.get(&node_id) { - let crop_image = &crop.image; - let crop_src_selrect = crop.src_selrect; - - let cur_selrect = tree.get(&node_id).map(|s| s.selrect()); - let (dx, dy) = match cur_selrect { - Some(cur) => ( - cur.left - crop_src_selrect.left, - cur.top - crop_src_selrect.top, - ), - None => (0.0, 0.0), - }; - let scale = self.get_scale(); - let translation = self - .surfaces - .get_render_context_translation(self.render_area, scale); - - let canvas = self.surfaces.canvas(target_surface); - canvas.save(); - canvas.reset_matrix(); - // If the crop includes shadows/blur (extrect pixels outside the fill/stroke - // silhouette), do NOT apply the silhouette clip or we'd cut those pixels. - let should_clip_crop = element.shadows.is_empty() && element.blur.is_none(); - if should_clip_crop { - if let Some(clip_path) = element.drag_crop_clip_path() { - let mut doc_to_tile = Matrix::new_identity(); - // Map document-space coordinates into tile pixels. - // Rendering surfaces apply: scale(scale) then translate(translation) in doc units. - // Equivalent point mapping: (doc + translation) * scale. - doc_to_tile.post_translate((translation.0, translation.1)); - doc_to_tile.post_scale((scale, scale), None); - let clip_path = clip_path.make_transform(&doc_to_tile); - canvas.clip_path(&clip_path, skia::ClipOp::Intersect, true); - } - } - let doc_left = - crop.capture_vb_left + (crop.capture_src_left as f32 / scale) + dx; - let doc_top = - crop.capture_vb_top + (crop.capture_src_top as f32 / scale) + dy; - - let x = (doc_left + translation.0) * scale; - let y = (doc_top + translation.1) * scale; - let bw = crop_image.width() as f32; - let bh = crop_image.height() as f32; - let dst = skia::Rect::from_xywh(x, y, bw, bh); - canvas.draw_image_rect(crop_image, None, dst, &skia::Paint::default()); - - canvas.restore(); - } - continue; - } - } - let can_flatten = element.can_flatten() && !self.focus_mode.should_focus(&element.id); // Skip render_shape_enter/exit for flattened containers @@ -3545,6 +3009,18 @@ impl RenderState { ) -> Result { let mut should_stop = false; self.viewer_render_root = base_object.copied(); + self.tiles_on_frame = 0; + // Budget against the rAF frame deadline, not this call: `timestamp` is + // the rAF DOMHighResTimeStamp (same epoch as get_time()), so time the + // page spent in JS before this tick counts against the budget too. + // Fall back to "now" when no usable timestamp was passed (0 on the + // first render, sync/test paths, or a stale value). + let now = performance::get_time(); + let frame_start = if timestamp > 0 && now >= timestamp && now - timestamp < 100 { + timestamp + } else { + now + }; let root_ids = { if let Some(shape_id) = base_object { vec![*shape_id] @@ -3586,13 +3062,9 @@ impl RenderState { // for this tile). if !is_empty || self.current_tile_had_shapes { if self.options.is_interactive_transform() { - // During drag, avoid snapshot-based caching. Draw Current directly - // into Target (and Cache) to reduce stalls. - self.surfaces.draw_current_tile_into_backbuffer( - &tile_rect, - self.background_color, - surfaces::DrawOnCache::Yes, - ); + // During drag, avoid snapshot-based caching. Draw Current + // directly into Backbuffer to reduce stalls. + self.surfaces.draw_current_tile_into_backbuffer(&tile_rect); } else { self.apply_render_to_final_canvas()?; } @@ -3605,6 +3077,20 @@ impl RenderState { tile_rect, ); } + + // Time-box the work per rAF (at least one tile per + // tick guarantees progress). Skipped during interactive + // transforms. + if allow_stop + && !self.options.is_interactive_transform() + && !self.viewer_masked_pass() + { + self.tiles_on_frame += 1; + if performance::get_time() - frame_start >= TILE_FRAME_TIME_BUDGET_MS { + self.viewer_render_root = None; + return Ok(FrameType::Partial); + } + } } } else if self.tiles.is_empty_at(current_tile) { self.surfaces.remove_cached_tile_surface(current_tile); @@ -3689,8 +3175,7 @@ impl RenderState { self.viewer_render_root = None; - // Mark cache as valid for render_from_cache. - // Only update for full-quality renders (non-fast mode). + // Mark cached_viewbox only for full-quality renders (non-fast mode). // An async render can complete while fast mode is active // (e.g. interest-area tiles finish during a pan gesture). // Those tiles lack effects (shadows, blur). Updating @@ -3745,6 +3230,17 @@ impl RenderState { } } + pub fn get_tiles_for_shape_unclamped( + &mut self, + shape: &Shape, + tree: ShapesPoolRef, + ) -> TileRect { + let scale = self.get_scale(); + let extrect = self.get_cached_extrect(shape, tree, scale); + let tile_size = tiles::get_tile_size(scale); + tiles::get_tiles_for_rect(extrect, tile_size) + } + /* * Given a shape, check the indexes and update it's location in the tile set * returns the tiles that have changed in the process. @@ -3869,6 +3365,10 @@ impl RenderState { self.surfaces.remove_cached_tile_surface(tile); } + pub fn invalidate_cached_tile_content(&mut self, tile: tiles::Tile) { + self.surfaces.invalidate_cached_tile_surface(tile); + } + /// Rebuild the tile index (shape→tile mapping) for all top-level shapes. /// This does NOT invalidate the tile texture cache — cached tile images /// survive so that fast-mode renders during pan still show shadows/blur. @@ -3903,7 +3403,7 @@ impl RenderState { // Zoom changes world tile size: a partial cache update would mix scales in the // mosaic and glitch. Same zoom as last finished render (typical pan): drop only - // tile textures and keep the cache canvas for render_from_cache. + // tile textures while the DocAtlas preview keeps covering the viewport. if self.zoom_changed() { self.surfaces.remove_cached_tiles(self.background_color); } else { @@ -3963,13 +3463,14 @@ impl RenderState { if let Some(shape) = tree.get(shape_id) { if shape_id != &Uuid::nil() { all_tiles.extend(self.update_shape_tiles(shape, tree)); + let unclamped = self.get_tiles_for_shape_unclamped(shape, tree); + all_tiles.extend(self.surfaces.cached_tiles_in_rect(&unclamped)); } } } - // Update the changed tiles for tile in all_tiles { - self.remove_cached_tile(tile); + self.invalidate_cached_tile_content(tile); } performance::end_measure!("rebuild_touched_tiles"); @@ -4062,7 +3563,6 @@ impl RenderState { pub fn prepare_context_loss_cleanup(&mut self) { // Drop cached GPU-backed snapshots before dropping the render state. - self.backbuffer_crop_cache.clear(); self.surfaces.invalidate_tile_cache(); // Mark context as abandoned so resource destructors avoid issuing // GL commands when the browser has already lost/restored the context. diff --git a/render-wasm/src/render/debug.rs b/render-wasm/src/render/debug.rs index 0574e3ab28..b7790ef164 100644 --- a/render-wasm/src/render/debug.rs +++ b/render-wasm/src/render/debug.rs @@ -36,16 +36,6 @@ fn render_debug_view(render_state: &mut RenderState) { .draw_rect(rect, &paint); } -pub fn render_debug_cache_surface(render_state: &mut RenderState) { - let canvas = render_state.surfaces.canvas(SurfaceId::Debug); - canvas.save(); - canvas.scale((0.1, 0.1)); - render_state - .surfaces - .draw_into(SurfaceId::Cache, SurfaceId::Debug, None); - render_state.surfaces.canvas(SurfaceId::Debug).restore(); -} - pub fn render_wasm_label(render_state: &mut RenderState) { if render_state.preview_mode || !render_state.options.show_wasm_info() { return; @@ -128,9 +118,6 @@ pub fn render(render_state: &mut RenderState) { // DEBUG VIEWBOX TILES - magenta - buggy? // render_debug_viewbox_tiles(render_state); - // DEBUG CACHE SURFACE - noisy - ? - // render_debug_cache_surface(render_state); - render_state.surfaces.draw_into( SurfaceId::Debug, SurfaceId::Target, @@ -270,22 +257,6 @@ pub extern "C" fn capture_frames(capture_frames: i32) -> Result<()> { Ok(()) } -#[no_mangle] -#[wasm_error] -#[cfg(target_arch = "wasm32")] -pub extern "C" fn debug_cache_console() -> Result<()> { - console_debug_surface(get_render_state(), SurfaceId::Cache); - Ok(()) -} - -#[no_mangle] -#[wasm_error] -#[cfg(target_arch = "wasm32")] -pub extern "C" fn debug_cache_base64() -> Result<()> { - console_debug_surface_base64(get_render_state(), SurfaceId::Cache); - Ok(()) -} - #[no_mangle] #[wasm_error] #[cfg(target_arch = "wasm32")] diff --git a/render-wasm/src/render/gpu_state.rs b/render-wasm/src/render/gpu_state.rs index e934dd4f34..3416c55621 100644 --- a/render-wasm/src/render/gpu_state.rs +++ b/render-wasm/src/render/gpu_state.rs @@ -1,13 +1,15 @@ use crate::error::{Error, Result}; use skia_safe::gpu::{ - self, ganesh::context_options::Enable, gl::FramebufferInfo, gl::TextureInfo, ContextOptions, - DirectContext, + self, ganesh::context_options::Enable, gl::FramebufferInfo, ContextOptions, DirectContext, }; use skia_safe::{self as skia, ISize}; const MIN_MAX_TEXTURE_SIZE: i32 = 512; const MAX_MAX_TEXTURE_SIZE: i32 = 8192 * 2; +/// GPU resource-cache budget for Skia-managed (budgeted) render targets. +const RESOURCE_CACHE_LIMIT_BYTES: usize = 512 * 1024 * 1024; + #[derive(Debug, Clone)] pub struct GpuState { pub context: DirectContext, @@ -28,10 +30,17 @@ impl GpuState { // context_options.allow_multiple_glyph_cache_textures = Enable::Yes; // context_options.allow_path_mask_caching = false; - let context = gpu::direct_contexts::make_gl(interface, Some(&context_options)).ok_or( + let mut context = gpu::direct_contexts::make_gl(interface, Some(&context_options)).ok_or( Error::CriticalError("Failed to create GL context".to_string()), )?; + // Skia-managed render targets are budgeted against the GPU resource + // cache. The default cap is 256 MB (GrResourceCache kDefaultMaxSize), + // which a single atlas-sized transient can exhaust on its own. Raise it + // so freed atlas/snapshot textures recycle as scratch instead of being + // re-allocated from the driver every frame. + context.set_resource_cache_limit(RESOURCE_CACHE_LIMIT_BYTES); + let framebuffer_info = { let mut fboid: gl::types::GLint = 0; unsafe { gl::GetIntegerv(gl::FRAMEBUFFER_BINDING, &mut fboid) }; @@ -57,54 +66,6 @@ impl GpuState { .clamp(MIN_MAX_TEXTURE_SIZE, MAX_MAX_TEXTURE_SIZE) } - fn delete_gl_texture(&mut self, texture_id: gl::types::GLuint) -> bool { - unsafe { - gl::DeleteTextures(1, &texture_id); - gl::GetError() == 0 - } - } - - fn create_gl_texture(&mut self, width: i32, height: i32) -> gl::types::GLuint { - let mut texture_id: gl::types::GLuint = 0; - - unsafe { - gl::GenTextures(1, &mut texture_id); - gl::BindTexture(gl::TEXTURE_2D, texture_id); - - gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_MIN_FILTER, gl::LINEAR as i32); - gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_MAG_FILTER, gl::LINEAR as i32); - gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_S, gl::CLAMP_TO_EDGE as i32); - gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_T, gl::CLAMP_TO_EDGE as i32); - - gl::TexImage2D( - gl::TEXTURE_2D, - 0, - gl::RGBA8 as i32, - width, - height, - 0, - gl::RGBA, - gl::UNSIGNED_BYTE, - std::ptr::null(), - ); - } - - texture_id - } - - pub fn delete_surface(&mut self, surface: &mut skia::Surface) -> bool { - let Some(texture) = skia::gpu::surfaces::get_backend_texture( - surface, - skia_safe::surface::BackendHandleAccess::FlushRead, - ) else { - return false; - }; - let Some(texture_info) = gpu::backend_textures::get_gl_texture_info(&texture) else { - return false; - }; - self.delete_gl_texture(texture_info.id) - } - pub fn create_surface_with_isize( &mut self, label: String, @@ -115,28 +76,29 @@ impl GpuState { pub fn create_surface_with_dimensions( &mut self, - label: String, + _label: String, width: i32, height: i32, ) -> Result { - let backend_texture = unsafe { - let texture_id = self.create_gl_texture(width, height); - let texture_info = TextureInfo { - target: gl::TEXTURE_2D, - id: texture_id, - format: gl::RGBA8, - protected: skia::gpu::Protected::No, - }; - gpu::backend_textures::make_gl((width, height), gpu::Mipmapped::No, texture_info, label) - }; + // Skia-managed render target (not a wrapped client texture): snapshots + // take the cheap COW-guarded path (`fCachedImage`, dropped for free on + // the next write when uniquely held) instead of scheduling an eager + // full-texture copy at snapshot time. See draw-atlas-analysis Part III. + let image_info = skia::ImageInfo::new( + (width, height), + skia::ColorType::RGBA8888, + skia::AlphaType::Premul, + None, + ); - let surface = gpu::surfaces::wrap_backend_texture( + let surface = gpu::surfaces::render_target( &mut self.context, - &backend_texture, + gpu::Budgeted::Yes, + &image_info, + None, gpu::SurfaceOrigin::BottomLeft, None, - skia::ColorType::RGBA8888, - None, + false, None, ) .ok_or(Error::CriticalError( @@ -165,39 +127,4 @@ impl GpuState { Ok(surface) } - - #[allow(dead_code)] - pub fn create_surface_from_texture( - &mut self, - width: i32, - height: i32, - texture_id: u32, - ) -> skia::Surface { - let texture_info = TextureInfo { - target: gl::TEXTURE_2D, - id: texture_id, - format: gl::RGBA8, - protected: skia::gpu::Protected::No, - }; - - let backend_texture = unsafe { - gpu::backend_textures::make_gl( - (width, height), - gpu::Mipmapped::No, - texture_info, - String::from("export_texture"), - ) - }; - - gpu::surfaces::wrap_backend_texture( - &mut self.context, - &backend_texture, - gpu::SurfaceOrigin::BottomLeft, - None, - skia::ColorType::RGBA8888, - None, - None, - ) - .unwrap() - } } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 867b657c7b..fe7b46cc24 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -11,7 +11,6 @@ use crate::math::Point; use base64::{engine::general_purpose, Engine as _}; use std::collections::{HashMap, HashSet}; -const TEXTURES_CACHE_CAPACITY: usize = 1024; const TEXTURES_BATCH_DELETE: usize = 256; // This is the amount of extra space we're going to give to all the surfaces to render shapes. @@ -26,34 +25,12 @@ const TILE_DRAWABLE_RECT: IRect = IRect { bottom: TILE_MARGIN_SIZE + TILE_SIZE, }; -pub fn get_cache_size(viewbox: &Viewbox, interest: i32) -> skia::ISize { - // First we retrieve the extended area of the viewport that we could render. - let TileRect(isx, isy, iex, iey) = - tiles::get_tiles_for_viewbox_with_interest(viewbox, interest); - - let dx = if isx.signum() != iex.signum() { 1 } else { 0 }; - let dy = if isy.signum() != iey.signum() { 1 } else { 0 }; - - ( - ((iex - isx).abs() + dx) * TILE_SIZE, - ((iey - isy).abs() + dy) * TILE_SIZE, - ) - .into() -} - -#[derive(Debug, PartialEq)] -pub enum DrawOnCache { - Yes, - No, -} - #[repr(u32)] #[derive(Debug, PartialEq, Clone, Copy)] #[allow(unused)] pub enum SurfaceId { Target = 0b000_0000_0001, Filter = 0b000_0000_0010, - Cache = 0b000_0000_0100, Current = 0b000_0000_1000, Fills = 0b000_0001_0000, Strokes = 0b000_0010_0000, @@ -166,12 +143,39 @@ impl DocAtlas { new_bottom = new_bottom.max(doc_rect.bottom.ceil()); } - // Add padding to reduce realloc frequency. + // Pad to reduce realloc frequency let pad = tiles::TILE_SIZE; - new_left -= pad; - new_top -= pad; - new_right += pad; - new_bottom += pad; + if needs_init { + new_left -= pad; + new_top -= pad; + new_right += pad; + new_bottom += pad; + } else { + let grow_x = ((current_right - current_left) * 0.5).max(4.0 * tiles::TILE_SIZE); + let grow_y = ((current_bottom - current_top) * 0.5).max(4.0 * tiles::TILE_SIZE); + if new_left < current_left { + new_left -= grow_x; + } + if new_right > current_right { + new_right += grow_x; + } + if new_top < current_top { + new_top -= grow_y; + } + if new_bottom > current_bottom { + new_bottom += grow_y; + } + if let Some(bounds) = self.doc_bounds { + new_left = new_left.max((bounds.left - pad).min(doc_rect.left.floor())); + new_top = new_top.max((bounds.top - pad).min(doc_rect.top.floor())); + new_right = new_right.min((bounds.right + pad).max(doc_rect.right.ceil())); + new_bottom = new_bottom.min((bounds.bottom + pad).max(doc_rect.bottom.ceil())); + } + new_left = new_left.min(current_left); + new_top = new_top.min(current_top); + new_right = new_right.max(current_right); + new_bottom = new_bottom.max(current_bottom); + } let doc_w = (new_right - new_left).max(1.0); let doc_h = (new_bottom - new_top).max(1.0); @@ -228,7 +232,8 @@ impl DocAtlas { self.origin = skia::Point::new(new_left, new_top); self.size = skia::ISize::new(new_w, new_h); self.scale = new_scale; - gpu_state.delete_surface(&mut self.surface); + // Old surface is Skia-managed: dropping it on reassignment frees the + // texture back to the resource cache. self.surface = new_surface; Ok(()) } @@ -377,27 +382,12 @@ impl DocAtlas { Ok(()) } - /// Returns a snapshot of the atlas together with its scale and origin, so the - /// caller can take it **once** per `rebuild_backbuffer_crop_cachef` and share it - /// across all shapes that need the tile/atlas fallback path — avoiding an - /// `image_snapshot` (and potential GPU flush) per shape. - pub fn snapshot_for_drag_crop(&mut self) -> Option<(skia::Image, f32, skia::Point)> { - if self.is_empty() { - return None; - } - Some(( - self.surface.image_snapshot(), - self.scale.max(0.01), - self.origin, - )) - } } pub struct Surfaces { // is the final destination surface, the one that it is represented in the canvas element. target: skia::Surface, filter: skia::Surface, - cache: skia::Surface, // keeps the current render current: skia::Surface, // keeps the current shape's fills @@ -419,8 +409,6 @@ pub struct Surfaces { // Persistent viewport-sized surface used to keep the last presented frame. backbuffer: skia::Surface, // Atlas used to keep tiles. - tile_atlas: skia::Surface, - tiles: TileTextureCache, pub atlas: DocAtlas, sampling_options: skia::SamplingOptions, @@ -432,7 +420,6 @@ pub struct Surfaces { dpr: f32, } -#[allow(dead_code)] impl Surfaces { pub fn try_new( (width, height): (i32, i32), @@ -449,16 +436,10 @@ impl Surfaces { let target = gpu_state.create_target_surface(width, height)?; let filter = gpu_state.create_surface_with_isize("filter".to_string(), extra_tile_dims)?; - let cache = gpu_state.create_surface_with_dimensions("cache".to_string(), width, height)?; let backbuffer = gpu_state.create_surface_with_dimensions("backbuffer".to_string(), width, height)?; let max_texture_size = gpu_state.max_texture_size(); - let tile_atlas = gpu_state.create_surface_with_dimensions( - "tile_atlas".to_string(), - max_texture_size, - max_texture_size, - )?; let current = gpu_state.create_surface_with_isize("current".to_string(), extra_tile_dims)?; @@ -478,13 +459,11 @@ 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::try_new(max_texture_size)?; let atlas = DocAtlas::try_new()?; Ok(Self { target, filter, - cache, current, drop_shadows, inner_shadows, @@ -495,7 +474,6 @@ impl Surfaces { debug, export, backbuffer, - tile_atlas, tiles, atlas, sampling_options, @@ -514,27 +492,38 @@ impl Surfaces { self.dpr = dpr; } - pub fn clear_tiles(&mut self) { - self.tiles.clear(); - } - pub fn draw_tile_atlas_to_backbuffer( &mut self, viewbox: &Viewbox, tile_viewbox: &TileViewbox, background: skia::Color, ) { - self.tiles.update(viewbox, tile_viewbox); - let atlas_image = self.tile_atlas.image_snapshot(); - let canvas = self.backbuffer.canvas(); - canvas.clear(background); - canvas.draw_atlas( - &atlas_image, - &self.tiles.transforms, - &self.tiles.textures, + self.backbuffer.canvas().clear(background); + self.draw_cached_tiles_over_backbuffer(viewbox, tile_viewbox); + } + + /// Composite the visible cached tiles onto Backbuffer without clearing it + /// first. Used by progressive (Partial-frame) presents, where the DocAtlas + /// preview is drawn underneath as a backdrop for not-yet-rendered tiles. + pub fn draw_cached_tiles_over_backbuffer( + &mut self, + viewbox: &Viewbox, + tile_viewbox: &TileViewbox, + ) { + let (xforms, texs) = self.tiles.visible_batch(viewbox, tile_viewbox); + if xforms.is_empty() { + return; + } + let sampling_options = self.atlas_sampling_options; + // One snapshot of the managed atlas, one batched draw from it. + let image = self.tiles.snapshot(); + self.backbuffer.canvas().draw_atlas( + &image, + &xforms, + &texs, None, skia::BlendMode::SrcOver, - self.atlas_sampling_options, + sampling_options, None, None, ); @@ -627,11 +616,6 @@ impl Surfaces { } } - pub fn snapshot_rect(&mut self, id: SurfaceId, irect: skia::IRect) -> Option { - let surface = self.get_mut(id); - surface.image_snapshot_with_bounds(irect) - } - /// 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. @@ -681,12 +665,6 @@ impl Surfaces { self.dirty_surfaces = 0; } - pub fn flush(&mut self, id: SurfaceId) { - let gpu_state = get_gpu_state(); - let surface = self.get_mut(id); - gpu_state.context.flush_surface(surface); - } - pub fn flush_and_submit(&mut self, id: SurfaceId) { let gpu_state = get_gpu_state(); let surface = self.get_mut(id); @@ -704,22 +682,6 @@ impl Surfaces { ); } - /// Draws the cache surface directly to the backbuffer canvas. - /// This avoids creating an intermediate snapshot, reducing GPU stalls. - pub fn draw_cache_to_backbuffer(&mut self) { - let sampling_options = self.sampling_options; - self.cache.draw( - self.backbuffer.canvas(), - (0.0, 0.0), - sampling_options, - Some(&skia::Paint::default()), - ); - } - - 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 { @@ -731,9 +693,6 @@ impl Surfaces { if ids & SurfaceId::Current as u32 != 0 { f(self.get_mut(SurfaceId::Current)); } - if ids & SurfaceId::Cache as u32 != 0 { - f(self.get_mut(SurfaceId::Cache)); - } if ids & SurfaceId::Backbuffer as u32 != 0 { f(self.get_mut(SurfaceId::Backbuffer)); } @@ -810,7 +769,6 @@ impl Surfaces { match id { SurfaceId::Target => &mut self.target, SurfaceId::Filter => &mut self.filter, - SurfaceId::Cache => &mut self.cache, SurfaceId::Backbuffer => &mut self.backbuffer, SurfaceId::Current => &mut self.current, SurfaceId::DropShadows => &mut self.drop_shadows, @@ -822,7 +780,7 @@ impl Surfaces { SurfaceId::UI => &mut self.ui, SurfaceId::Export => &mut self.export, SurfaceId::Atlas => &mut self.atlas.surface, - SurfaceId::TileAtlas => &mut self.tile_atlas, + SurfaceId::TileAtlas => self.tiles.surface_mut(), } } @@ -831,7 +789,6 @@ impl Surfaces { match id { SurfaceId::Target => &self.target, SurfaceId::Filter => &self.filter, - SurfaceId::Cache => &self.cache, SurfaceId::Backbuffer => &self.backbuffer, SurfaceId::Current => &self.current, SurfaceId::DropShadows => &self.drop_shadows, @@ -843,7 +800,7 @@ impl Surfaces { SurfaceId::UI => &self.ui, SurfaceId::Export => &self.export, SurfaceId::Atlas => &self.atlas.surface, - SurfaceId::TileAtlas => &self.tile_atlas, + SurfaceId::TileAtlas => self.tiles.surface(), } } @@ -885,18 +842,7 @@ impl Surfaces { } pub fn clear_tile_atlas(&mut self) { - self.tile_atlas.canvas().clear(skia::Color::TRANSPARENT); - } - - /// Seed `Backbuffer` from `Target` (last presented frame). - pub fn seed_backbuffer_from_target(&mut self) { - let sampling_options = self.sampling_options; - self.target.draw( - self.backbuffer.canvas(), - (0.0, 0.0), - sampling_options, - Some(&skia::Paint::default()), - ); + self.tiles.reset_all(); } fn reset_from_target(&mut self, target: skia::Surface) -> Result<()> { @@ -922,41 +868,6 @@ impl Surfaces { Ok(()) } - pub fn resize_cache( - &mut self, - cache_dims: skia::ISize, - interest_area_threshold: i32, - ) -> Result<()> { - self.cache = self - .target - .new_surface_with_dimensions(cache_dims) - .ok_or(Error::CriticalError("Failed to create surface".to_string()))?; - self.cache.canvas().reset_matrix(); - self.cache.canvas().translate(( - (interest_area_threshold * TILE_SIZE) as f32, - (interest_area_threshold * TILE_SIZE) as f32, - )); - Ok(()) - } - - pub fn resize_cache_from_viewbox( - &mut self, - viewbox: &Viewbox, - cached_viewbox: &Viewbox, - interest_area_threshold: i32, - ) -> Result<()> { - let viewbox_cache_size = get_cache_size(viewbox, interest_area_threshold); - let cached_viewbox_cache_size = get_cache_size(cached_viewbox, interest_area_threshold); - // Only resize cache if the new size is larger than the cached size - // This avoids unnecessary surface recreations when the cache size decreases - if viewbox_cache_size.width > cached_viewbox_cache_size.width - || viewbox_cache_size.height > cached_viewbox_cache_size.height - { - return self.resize_cache(viewbox_cache_size, interest_area_threshold); - } - Ok(()) - } - pub fn draw_rect_to( &mut self, id: SurfaceId, @@ -1055,12 +966,6 @@ impl Surfaces { self.backbuffer.canvas().clear(color); } - pub fn clear_backbuffer_rect(&mut self, rect: skia::Rect, color: skia::Color) { - let mut paint = Paint::default(); - paint.set_color(color); - self.backbuffer.canvas().draw_rect(rect, &paint); - } - pub fn reset(&mut self, color: skia::Color) { self.canvas(SurfaceId::Fills).restore_to_count(1); self.canvas(SurfaceId::InnerShadows).restore_to_count(1); @@ -1132,21 +1037,10 @@ 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 draw_current_tile_into_tile_atlas( &mut self, tile_viewbox: &TileViewbox, tile: &Tile, - tile_rect: &skia::Rect, - skip_cache_surface: bool, tile_doc_rect: skia::Rect, ) { let gpu_state = get_gpu_state(); @@ -1154,16 +1048,6 @@ impl Surfaces { let tile_image_opt = self.current.image_snapshot_with_bounds(rect); if let Some(tile_image) = tile_image_opt { - if !skip_cache_surface { - // Draw to cache surface for render_from_cache - self.cache.canvas().draw_image_rect( - &tile_image, - None, - tile_rect, - &skia::Paint::default(), - ); - } - // 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 @@ -1171,9 +1055,9 @@ impl Surfaces { .blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect); self.atlas.tile_doc_rects.insert(*tile, tile_doc_rect); - // Draws current tile into tile atlas + // Write the tile into its allocated slot on the atlas surface. let tile_ref = self.tiles.add(tile_viewbox, tile); - self.tile_atlas.canvas().draw_image_rect( + self.tiles.surface_mut().canvas().draw_image_rect( &tile_image, None, tile_ref.rect, @@ -1186,155 +1070,44 @@ impl Surfaces { self.tiles.has(tile) } - /// Builds a 1:1 workspace-pixel snapshot for `src_doc_bounds` / `src_irect` into - /// `scratch`, then returns the sub-region `[0, out_w) × [0, out_h)` as an image. - /// - /// `scratch` must be at least `out_w × out_h` pixels — the caller is responsible - /// for allocating (and **reusing across shapes**) a surface large enough to hold - /// the largest window needed in one `rebuild_backbuffer_crop_cache` pass. - /// - /// `atlas_snap` is a pre-snapshotted view of the persistent atlas produced by - /// [`Surfaces::atlas.snapshot_for_drag_crop`]; pass `None` when no atlas exists. - /// - /// For each tile cell intersecting `src_doc_bounds`: draws from - /// [`TileTextureCache`] when present; otherwise samples the atlas. - #[allow(clippy::too_many_arguments)] - pub fn try_snapshot_doc_rect_from_tiles_and_atlas( - &mut self, - scratch: &mut skia::Surface, - atlas_snap: Option<&(skia::Image, f32, skia::Point)>, - src_doc_bounds: skia::Rect, - src_irect: IRect, - out_w: i32, - out_h: i32, - vb_left: f32, - vb_top: f32, - scale: f32, - ) -> Option { - if out_w <= 0 || out_h <= 0 || src_doc_bounds.is_empty() { - return None; - } - - let canvas = scratch.canvas(); - canvas.clear(skia::Color::TRANSPARENT); - - let tile_size = tiles::get_tile_size(scale); - let tr = tiles::get_tiles_for_rect(src_doc_bounds, tile_size); - let ix0 = src_irect.left as f32; - let iy0 = src_irect.top as f32; - let paint = skia::Paint::default(); - - for ty in tr.y1()..=tr.y2() { - for tx in tr.x1()..=tr.x2() { - let tile = Tile(tx, ty); - let tile_doc = tiles::get_tile_rect(tile, scale); - let mut clip_doc = tile_doc; - if !clip_doc.intersect(src_doc_bounds) || clip_doc.is_empty() { - continue; - } - - let dst = skia::Rect::from_ltrb( - (clip_doc.left - vb_left) * scale - ix0, - (clip_doc.top - vb_top) * scale - iy0, - (clip_doc.right - vb_left) * scale - ix0, - (clip_doc.bottom - vb_top) * scale - iy0, - ); - - if let Some(tile_ref) = self.tiles.get(tile) { - let bounds = skia::IRect::from_ltrb( - tile_ref.rect.left as i32, - tile_ref.rect.top as i32, - tile_ref.rect.right as i32, - tile_ref.rect.bottom as i32, - ); - let Some(tile_image) = self.tile_atlas.image_snapshot_with_bounds(bounds) - else { - panic!("Cannot retrieve tile image"); - }; - let iw = tile_image.width() as f32; - let ih = tile_image.height() as f32; - let td_w = tile_doc.width().max(1e-6); - let td_h = tile_doc.height().max(1e-6); - - let src = skia::Rect::from_ltrb( - ((clip_doc.left - tile_doc.left) / td_w) * iw, - ((clip_doc.top - tile_doc.top) / td_h) * ih, - ((clip_doc.right - tile_doc.left) / td_w) * iw, - ((clip_doc.bottom - tile_doc.top) / td_h) * ih, - ); - - canvas.draw_image_rect( - tile_image, - Some((&src, skia::canvas::SrcRectConstraint::Fast)), - dst, - &paint, - ); - } else { - let snap = atlas_snap?; - let (atlas, a_scale, origin) = (&snap.0, snap.1, snap.2); - let sx = (clip_doc.left - origin.x) * a_scale; - let sy = (clip_doc.top - origin.y) * a_scale; - let sw = clip_doc.width() * a_scale; - let sh = clip_doc.height() * a_scale; - if sw <= 0.0 || sh <= 0.0 { - continue; - } - let src = skia::Rect::from_xywh(sx, sy, sw, sh); - canvas.draw_image_rect( - atlas, - Some((&src, skia::canvas::SrcRectConstraint::Fast)), - dst, - &paint, - ); - } - } - } - - scratch.image_snapshot_with_bounds(IRect::new(0, 0, out_w, out_h)) + pub fn cached_tiles_in_rect(&self, rect: &TileRect) -> Vec { + self.tiles.cached_tiles_in_rect(rect) } pub fn remove_cached_tile_surface(&mut self, tile: Tile) { let gpu_state = get_gpu_state(); - // Mark tile as invalid - // Old content stays visible until new tile overwrites it atomically, - // preventing flickering during tile re-renders. self.tiles.remove(tile); // Also clear the corresponding region in the persistent atlas to avoid // leaving stale pixels when shapes move/delete. let _ = self.atlas.clear_tile_in_atlas(gpu_state, tile); } - pub fn get_tile_image_from_tile_atlas(&mut self, tile: Tile) -> Option { - let Some(tile_ref) = self.tiles.get(tile) else { - panic!("Tile not found {}:{}", tile.0, tile.1); - }; - - let rect = IRect::from_ltrb( - tile_ref.rect.left as i32, - tile_ref.rect.top as i32, - tile_ref.rect.right as i32, - tile_ref.rect.bottom as i32, - ); - self.tile_atlas.image_snapshot_with_bounds(rect) + pub fn invalidate_cached_tile_surface(&mut self, tile: Tile) { + self.tiles.mark_stale(tile); } pub fn draw_cached_tile_into_backbuffer(&mut self, tile: Tile, rect: &Rect) { - if let Some(image) = self.get_tile_image_from_tile_atlas(tile) { - // let rect = tile.get_rect_with_offset(&offset); - let backbuffer_canvas = self.backbuffer.canvas(); - backbuffer_canvas.draw_image_rect(&image, None, rect, &skia::Paint::default()); + let Some(slot) = self.tiles.slot(tile) else { + panic!("Tile not found {}:{}", tile.0, tile.1); + }; + let src = slot.rect; + if src.width() <= 0.0 || src.height() <= 0.0 { + return; } + // One snapshot of the managed atlas, plain image draw from it. + let image = self.tiles.snapshot(); + self.backbuffer.canvas().draw_image_rect( + &image, + Some((&src, skia::canvas::SrcRectConstraint::Fast)), + *rect, + &skia::Paint::default(), + ); } - /// Draws the current tile directly to the backbuffer and cache surfaces without - /// creating a snapshot. This avoids GPU stalls from ReadPixels but doesn't - /// populate the tile texture cache (suitable for one-shot renders like tests). - pub fn draw_current_tile_into_backbuffer( - &mut self, - tile_rect: &skia::Rect, - _color: skia::Color, - draw_on_cache: DrawOnCache, - ) { + /// Draws the current tile directly to the backbuffer without creating a + /// snapshot. This avoids GPU stalls from ReadPixels but doesn't populate the + /// tile texture cache (suitable for one-shot renders like tests). + pub fn draw_current_tile_into_backbuffer(&mut self, tile_rect: &skia::Rect) { let sampling_options = self.sampling_options; let src_rect = IRect::from_xywh( self.margins.width, @@ -1346,11 +1119,6 @@ impl Surfaces { let backbuffer_canvas = self.backbuffer.canvas(); - // Draw background - // let mut paint = skia::Paint::default(); - // paint.set_color(color); - // backbuffer_canvas.draw_rect(tile_rect, &paint); - // Draw current surface directly to target (no snapshot) self.current.draw( backbuffer_canvas, @@ -1361,34 +1129,17 @@ impl Surfaces { sampling_options, None, ); - - // Also draw to cache for render_from_cache - if draw_on_cache == DrawOnCache::Yes { - self.current.draw( - self.cache.canvas(), - ( - tile_rect.left - src_rect_f.left, - tile_rect.top - src_rect_f.top, - ), - sampling_options, - None, - ); - } } - /// Full cache reset: clears both the tile texture cache and the cache canvas. - /// Used by `rebuild_tiles` (full rebuild). For shallow rebuilds that preserve - /// the cache canvas for scaled previews, use `invalidate_tile_cache` instead. - pub fn remove_cached_tiles(&mut self, color: skia::Color) { + /// Full tile-cache reset. Used by `rebuild_tiles` (full rebuild). For shallow + /// rebuilds that should keep the cache for previews, use `invalidate_tile_cache`. + pub fn remove_cached_tiles(&mut self, _color: skia::Color) { self.tiles.clear(); self.atlas.tile_doc_rects.clear(); - self.cache.canvas().clear(color); } - /// Invalidate the tile texture cache without clearing the cache canvas. - /// This forces all tiles to be re-rendered, but preserves the cache canvas - /// so that `render_from_cache` can still show a scaled preview of the old - /// content while new tiles are being rendered. + /// Invalidate the tile texture cache. This forces all tiles to be + /// re-rendered while the DocAtlas preview keeps covering the viewport. pub fn invalidate_tile_cache(&mut self) { self.tiles.clear(); self.atlas.tile_doc_rects.clear(); @@ -1453,216 +1204,242 @@ impl Surfaces { } } +/// Side length of the single tile atlas texture (clamped to the GPU cap). +const ATLAS_SIZE: i32 = 4096; + #[derive(Debug, Clone)] pub struct TileAtlasTextureRef { pub index: usize, pub rect: skia::Rect, } -impl TileAtlasTextureRef { - pub fn new(index: usize, rect: skia::Rect) -> Self { - Self { index, rect } - } -} - -pub struct TileAtlasTextureProvider { - pub index: usize, - pub length: usize, - pub in_use: Vec, - pub rects: Vec, -} - -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)); - } - Self { - index: 0, - length: length as usize, - in_use: vec![false; length as usize], - rects, - } - } - - pub fn allocate(&mut self) -> Option { - let start = self.index; - loop { - if !self.in_use[self.index] { - self.in_use[self.index] = true; - return Some(TileAtlasTextureRef::new(self.index, self.rects[self.index])); - } - - self.index = (self.index + 1) % self.length; - if self.index == start { - return None; - } - } - } - - pub fn deallocate(&mut self, reference: TileAtlasTextureRef) -> bool { - // In this case the user of the provider it's trying to release - // a reference already freed. - if !self.in_use[reference.index] { - return false; - } - self.in_use[reference.index] = false; - self.index = reference.index; - true - } -} - +/// A single Skia-managed atlas texture holding screen-space tiles at the +/// current zoom. Because the surface is Skia-owned (budgeted), `image_snapshot` +/// takes the cheap COW-guarded path: in the render loop's `write → snapshot → +/// draw` order the snapshot is dropped before the next write, so no full-texture +/// copy ever executes. That makes the per-page freeze/recycle/evict machinery +/// (needed only to keep writes and snapshots off the same wrapped texture) +/// unnecessary — one surface plus a slot free list is enough. See +/// draw-atlas-analysis Part III. pub struct TileTextureCache { tile_size: f32, - provider: TileAtlasTextureProvider, - transforms: Vec, - textures: Vec, + side: usize, + slots: usize, + surface: skia::Surface, grid: HashMap, + free: Vec, + next: usize, removed: HashSet, + stale: HashSet, } impl TileTextureCache { - pub fn new(texture_size: i32, capacity: usize) -> Self { - Self { + pub fn try_new(max_texture_size: i32) -> Result { + let atlas_size = ATLAS_SIZE.min(max_texture_size.max(TILE_SIZE)); + let side = (atlas_size / TILE_SIZE) as usize; + let mut surface = get_gpu_state().create_surface_with_dimensions( + "tile_atlas".to_string(), + atlas_size, + atlas_size, + )?; + surface.canvas().clear(skia::Color::TRANSPARENT); + Ok(Self { tile_size: tiles::TILE_SIZE, - provider: TileAtlasTextureProvider::new(texture_size, TILE_SIZE), - transforms: Vec::with_capacity(capacity), - textures: Vec::with_capacity(capacity), - grid: HashMap::with_capacity(capacity), - removed: HashSet::with_capacity(capacity), - } + side, + slots: side * side, + surface, + grid: HashMap::with_capacity(256), + free: Vec::new(), + next: 0, + removed: HashSet::with_capacity(256), + stale: HashSet::with_capacity(256), + }) + } + + fn slot_rect(&self, index: usize) -> skia::Rect { + let ts = TILE_SIZE as f32; + let left = (index % self.side) as f32 * ts; + let top = (index / self.side) as f32 * ts; + Rect::from_xywh(left, top, ts, ts) } fn gc(&mut self) { - // Make a real remove - for tile in self.removed.iter() { - if let Some(tile_ref) = self.grid.remove(tile) { - self.provider.deallocate(tile_ref); + let removed: Vec = self.removed.drain().collect(); + for tile in removed { + if let Some(slot) = self.grid.remove(&tile) { + self.free.push(slot.index); } + self.stale.remove(&tile); } } fn gc_non_visible(&mut self, tile_viewbox: &TileViewbox) { let marked: Vec<_> = self .grid - .iter_mut() - .filter_map(|(tile, _)| { - if !tile_viewbox.is_visible(tile) { - Some(*tile) - } else { - None - } - }) + .keys() + .filter(|tile| !tile_viewbox.is_in_interest_area(tile)) .take(TEXTURES_BATCH_DELETE) + .copied() .collect(); for tile in marked.iter() { - if let Some(tile_ref) = self.grid.remove(tile) { - self.provider.deallocate(tile_ref); + if let Some(slot) = self.grid.remove(tile) { + self.free.push(slot.index); } + self.removed.remove(tile); + self.stale.remove(tile); } } - pub fn update(&mut self, viewbox: &Viewbox, tile_viewbox: &TileViewbox) { - 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()), - ); - } - - if self.textures.len() != tile_viewbox.visible_rect.len() as usize { - self.textures.resize( - tile_viewbox.visible_rect.len() as usize, - skia::Rect::new_empty(), - ); - } - - for texture in self.textures.iter_mut() { - texture.set_empty(); - } - + /// Visible cached tiles as a single (xform, tex-rect) batch, ready for one + /// `draw_atlas` call from a single snapshot of the atlas surface. + pub fn visible_batch( + &self, + viewbox: &Viewbox, + tile_viewbox: &TileViewbox, + ) -> (Vec, Vec) { + let mut xforms: Vec = vec![]; + let mut texs: Vec = vec![]; let offset = viewbox.get_offset(); - let mut index = 0; for y in tile_viewbox.visible_rect.top()..=tile_viewbox.visible_rect.bottom() { for x in tile_viewbox.visible_rect.left()..=tile_viewbox.visible_rect.right() { let tile = Tile(x, y); - - let Some(tile_ref) = self.grid.get(&tile) else { + if self.removed.contains(&tile) { + continue; + } + let Some(slot) = self.grid.get(&tile) else { 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, - ); - - index += 1; + xforms.push(skia::RSXform::new( + 1.0, + 0.0, + Point::new( + x as f32 * self.tile_size - offset.x, + y as f32 * self.tile_size - offset.y, + ), + )); + texs.push(slot.rect); } } + (xforms, texs) + } + + /// A snapshot of the atlas surface, for sampling cached tiles. Cheap on the + /// Skia-managed surface: in `write → snapshot → draw` order the returned + /// image is dropped before the next write, so no copy executes. + pub fn snapshot(&mut self) -> skia::Image { + self.surface.image_snapshot() + } + + pub fn surface(&self) -> &skia::Surface { + &self.surface + } + + pub fn surface_mut(&mut self) -> &mut skia::Surface { + &mut self.surface + } + + pub fn reset_all(&mut self) { + self.grid.clear(); + self.removed.clear(); + self.stale.clear(); + self.free.clear(); + self.next = 0; + self.surface.canvas().clear(skia::Color::TRANSPARENT); } pub fn has(&self, tile: Tile) -> bool { - self.grid.contains_key(&tile) && !self.removed.contains(&tile) + self.grid.contains_key(&tile) + && !self.removed.contains(&tile) + && !self.stale.contains(&tile) } + pub fn cached_tiles_in_rect(&self, rect: &TileRect) -> Vec { + self.grid + .keys() + .filter(|tile| !self.removed.contains(tile) && rect.contains(tile)) + .copied() + .collect() + } + + /// Allocate a writable slot for `tile`. pub fn add(&mut self, tile_viewbox: &TileViewbox, tile: &Tile) -> TileAtlasTextureRef { - if self.grid.len() > TEXTURES_CACHE_CAPACITY { - // First we try to remove the obsolete tiles. - self.gc(); + if let Some(old) = self.grid.remove(tile) { + self.free.push(old.index); } - - // If we still have a texture capacity problem, then - // we try to remove all of those tiles that aren't - // visible. - if self.grid.len() > TEXTURES_CACHE_CAPACITY { - self.gc_non_visible(tile_viewbox); - } - - let Some(tile_ref) = self.provider.allocate() else { - panic!("Tile texture allocation failed {}:{}", tile.0, tile.1); + let index = self.allocate_index(tile_viewbox); + let slot = TileAtlasTextureRef { + index, + rect: self.slot_rect(index), }; - - self.grid.insert(*tile, tile_ref.clone()); - - if self.removed.contains(tile) { - self.removed.remove(tile); - } - - tile_ref.clone() + self.grid.insert(*tile, slot.clone()); + self.removed.remove(tile); + self.stale.remove(tile); + slot } - pub fn get(&mut self, tile: Tile) -> Option<&TileAtlasTextureRef> { + fn try_take_slot(&mut self) -> Option { + self.free.pop().or_else(|| { + (self.next < self.slots).then(|| { + self.next += 1; + self.next - 1 + }) + }) + } + + /// Evict one cached tile (prefer one outside the interest area) and reuse + /// its slot. The evicted tile simply re-renders on a later frame. + fn evict_one(&mut self, tile_viewbox: &TileViewbox) -> Option { + let victim = self + .grid + .keys() + .find(|tile| !tile_viewbox.is_in_interest_area(tile)) + .or_else(|| self.grid.keys().next()) + .copied()?; + let slot = self.grid.remove(&victim)?; + self.removed.remove(&victim); + self.stale.remove(&victim); + Some(slot.index) + } + + fn allocate_index(&mut self, tile_viewbox: &TileViewbox) -> usize { + if let Some(index) = self.try_take_slot() { + return index; + } + self.gc(); + if let Some(index) = self.try_take_slot() { + return index; + } + self.gc_non_visible(tile_viewbox); + if let Some(index) = self.try_take_slot() { + return index; + } + // Atlas full: evict a tile and reuse its slot. + self.evict_one(tile_viewbox).unwrap_or(0) + } + + pub fn slot(&self, tile: Tile) -> Option { if self.removed.contains(&tile) { return None; } - self.grid.get(&tile) + self.grid.get(&tile).cloned() } pub fn remove(&mut self, tile: Tile) { - if let Some(tile_ref) = self.grid.get(&tile) { - if tile_ref.index < self.textures.len() { - self.textures[tile_ref.index].set_empty(); - } - } self.removed.insert(tile); } + pub fn mark_stale(&mut self, tile: Tile) { + if self.grid.contains_key(&tile) && !self.removed.contains(&tile) { + self.stale.insert(tile); + } + } + pub fn clear(&mut self) { for k in self.grid.keys() { self.removed.insert(*k); } + // `removed` supersedes `stale` everywhere; drop the now-moot marks. + self.stale.clear(); } } diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index d651e3f457..3d24610821 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1367,107 +1367,6 @@ impl Shape { } } - /// Same `concat` applied around [`center`](Self::center) as in `render_shape` (non-text branch). - fn shape_document_transform(&self) -> Matrix { - let c = self.center(); - let mut m = self.transform; - m.post_translate(c); - m.pre_translate(-c); - m - } - - /// Fill silhouette only, document space (matches fill rendering). - fn drag_crop_fill_clip_path_skia(&self) -> Option { - match &self.shape_type { - Type::Rect(r) => { - let p = Path::new(shape_to_path::rect_segments(self, r.corners)); - Some(p.to_skia_path(self.svg_attrs.as_ref())) - } - Type::Circle => { - let p = Path::new(shape_to_path::circle_segments(self)); - Some(p.to_skia_path(self.svg_attrs.as_ref())) - } - Type::Path(_) | Type::Bool(_) => { - let sk = self.get_skia_path()?; - Some(sk.make_transform(&self.shape_document_transform())) - } - _ => None, - } - } - - /// Whether this shape may use the backbuffer crop fast path during interactive drag. - /// - /// Conservative: only effects and fills that match what we snapshot and clip in - /// [`drag_crop_clip_path`](Self::drag_crop_clip_path). Text is never safe (glyph layout, - /// no `drag_crop_clip_path`). - pub fn is_safe_for_drag_crop_cache(&self, shapes_pool: ShapesPoolRef) -> bool { - if matches!(self.shape_type, Type::Text(_)) { - return false; - } - - // If a frame shows overflow (clip_content=false) and its visible content exceeds the - // frame bounds, a cached crop anchored to the frame can easily become incorrect while - // moving (children can extend beyond selrect). Be conservative and render live. - if matches!(self.shape_type, Type::Frame(_)) && !self.clip_content { - let extrect = self.extrect(shapes_pool, 1.0); - let sr = self.selrect; - let exceeds = extrect.left < sr.left - || extrect.top < sr.top - || extrect.right > sr.right - || extrect.bottom > sr.bottom; - if exceeds { - return false; - } - } - - self.blur.is_none() - && self.shadows.is_empty() - && (self.opacity - 1.0).abs() <= 1e-4 - && self.blend_mode().0 == skia::BlendMode::SrcOver - } - - /// Fill + visible strokes in **document space** for clipping interactive drag textures. - /// - /// The backbuffer crop uses an axis-aligned `extrect`; we clip the blit so backdrop pixels - /// outside the real silhouette (fill and stroke regions) are not smeared. Strokes use - /// [`stroke_to_path`](stroke_to_path) like the main renderer, then union with the fill path. - pub fn drag_crop_clip_path(&self) -> Option { - let mut acc = self.drag_crop_fill_clip_path_skia()?; - if !self.has_visible_strokes() { - return Some(acc); - } - - let shape_path = match &self.shape_type { - Type::Rect(r) => Path::new(shape_to_path::rect_segments(self, r.corners)), - Type::Circle => Path::new(shape_to_path::circle_segments(self)), - Type::Path(_) | Type::Bool(_) => self.shape_type.path()?.clone(), - _ => return Some(acc), - }; - - let path_transform = self.to_path_transform(); - let apply_doc_transform = path_transform.is_some(); - - for stroke in self.visible_strokes() { - let Some(stroke_region) = stroke_to_path( - stroke, - &shape_path, - path_transform.as_ref(), - &self.selrect, - self.svg_attrs.as_ref(), - true, - ) else { - continue; - }; - let mut sk = stroke_region.to_skia_path(self.svg_attrs.as_ref()); - if apply_doc_transform { - sk = sk.make_transform(&self.shape_document_transform()); - } - acc = acc.op(&sk, skia::PathOp::Union).unwrap_or(acc); - } - - Some(acc) - } - fn transform_selrect(&mut self, transform: &Matrix) { if math::is_move_only_matrix(transform) { let tx = transform.translate_x(); diff --git a/render-wasm/src/shapes/stroke_paths.rs b/render-wasm/src/shapes/stroke_paths.rs index 358be86141..a73c6bb020 100644 --- a/render-wasm/src/shapes/stroke_paths.rs +++ b/render-wasm/src/shapes/stroke_paths.rs @@ -12,8 +12,8 @@ use crate::math::Rect; /// `path_transform` maps from local shape coords to the drawing space (and back). /// /// When `solid_outline` is true, any dash/dot PathEffect is stripped so the result -/// is a continuous stroke region — useful for clipping (e.g. drag crop cache) where -/// dash gaps should not punch holes in the clip mask. +/// is a continuous stroke region — useful for clipping where dash gaps should not +/// punch holes in the clip mask. pub fn stroke_to_path( stroke: &Stroke, shape_path: &Path, diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index 0d32b1ae1c..429dca55bb 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -204,9 +204,12 @@ impl TileViewbox { } pub fn is_visible(&self, tile: &Tile) -> bool { - // TO CHECK self.interest_rect.contains(tile) self.visible_rect.contains(tile) } + + pub fn is_in_interest_area(&self, tile: &Tile) -> bool { + self.interest_rect.contains(tile) + } } pub const TILE_SIZE: f32 = 512.; @@ -357,47 +360,20 @@ impl TileSpiral { return; } - // Generate tiles in spiral order from center (same algorithm as before). - let mut cx = 0; - let mut cy = 0; + let cx = (columns / 2) as i32; + let cy = (rows / 2) as i32; - let ratio = (columns as f32 / rows as f32).ceil() as i32; - - let mut direction_current = 0; - let mut direction_total_x = ratio; - let mut direction_total_y = 1; - let mut direction = 0; - - self.offsets.push(Tile(cx, cy)); - while self.offsets.len() < total { - match direction { - 0 => cx += 1, - 1 => cy += 1, - 2 => cx -= 1, - 3 => cy -= 1, - _ => unreachable!("Invalid direction"), - } - - self.offsets.push(Tile(cx, cy)); - - direction_current += 1; - let direction_total = if direction % 2 == 0 { - direction_total_x - } else { - direction_total_y - }; - - if direction_current == direction_total { - if direction % 2 == 0 { - direction_total_x += 1; - } else { - direction_total_y += 1; - } - direction = (direction + 1) % 4; - direction_current = 0; + for j in 0..rows as i32 { + for i in 0..columns as i32 { + self.offsets.push(Tile(i - cx, j - cy)); } } + // Center-out priority: sort nearest-first by Manhattan distance, then + // reverse so the consumer (`PendingTiles::update` pushes in iter order, + // the render loop pops from the back) renders the nearest tiles first. + self.offsets + .sort_by_key(|t| t.0.unsigned_abs() + t.1.unsigned_abs()); self.offsets.reverse(); } } @@ -462,12 +438,10 @@ impl PendingTiles { self.interest_cached.clear(); self.interest_uncached.clear(); - // Compute the scheduling center explicitly (inclusive range). - // This avoids relying on `TileRect::center_x/center_y` semantics, which may be used - // elsewhere with different expectations. + // Scheduling center must match `TileSpiral::ensure`'s local center let center_tile = Tile( - (spiral_rect.x1() + spiral_rect.x2()) / 2, - (spiral_rect.y1() + spiral_rect.y2()) / 2, + spiral_rect.x1() + (columns / 2), + spiral_rect.y1() + (rows / 2), ); for spiral_tile in self.spiral.iter() { let tile = Tile(spiral_tile.0 + center_tile.0, spiral_tile.1 + center_tile.1);