From e1242831176f7e61db7ab721115f2d629c0c2245 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 21 Apr 2026 11:28:14 +0200 Subject: [PATCH] :tada: Retained mode --- frontend/src/app/render_wasm/api.cljs | 11 + frontend/src/debug.cljs | 11 + render-wasm/src/main.rs | 30 ++ render-wasm/src/render.rs | 483 +++++++++++++++++++++++++- render-wasm/src/render/options.rs | 25 ++ render-wasm/src/render/surfaces.rs | 10 + render-wasm/src/state.rs | 34 ++ render-wasm/src/state/shapes_pool.rs | 104 ++++++ 8 files changed, 707 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 0be80c8c8a..1dc0b1f660 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1534,6 +1534,17 @@ (when (and wasm/context-initialized? (not @wasm/context-lost?)) (h/call wasm/internal-module "_set_modifiers_end"))) +(defn set-retained-mode-enabled + "Toggle the experimental retained-mode compositor in the WASM + renderer. When enabled, top-level shapes are rasterized once to a + cache and recomposed every frame applying their modifier matrix as + a canvas transform, similar to how the SVG renderer composites + per-node in the browser. Intended for A/B testing drag performance + against the default tile pipeline." + [enabled?] + (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (h/call wasm/internal-module "_set_retained_mode_enabled" (boolean enabled?)))) + (defn set-modifiers [modifiers] diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index 65ac296895..5ee3e6a35e 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -31,6 +31,7 @@ [app.main.errors :as errors] [app.main.repo :as rp] [app.main.store :as st] + [app.render-wasm.api :as wasm.api] [app.render-wasm.helpers :as wasm.h] [app.render-wasm.mem :as wasm.mem] [app.render-wasm.wasm :as wasm] @@ -103,6 +104,16 @@ (reset! dbg/state #{}) (js* "app.main.reinit()")) +(defn ^:export set-retained-mode-enabled + "Toggle the experimental WASM retained-mode compositor (Figma-style + per-shape texture cache + canvas-transform compositing) for A/B + testing drag performance. Call from the devtools console as + `debug.setRetainedModeEnabled(true)` / `(false)` and trigger a + render to observe the effect." + [enabled?] + (wasm.api/set-retained-mode-enabled enabled?) + (js* "app.main.reinit()")) + (defn ^:export tap "Transducer function that can execute a side-effect `effect-fn` per input" [effect-fn] diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 7a030e114d..38495bffac 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -387,6 +387,16 @@ pub extern "C" fn set_view_end() -> Result<()> { // preview of the old content while new tiles render. state.render_state.rebuild_tile_index(&state.shapes); state.render_state.surfaces.invalidate_tile_cache(); + + // Retained-mode textures captured at the previous zoom + // are now resampled every frame (we let them drift + // during the gesture); drop the ones that no longer + // match the final scale so the next render recaptures + // at full resolution. Entries already at the GPU + // texture cap are preserved — we can't do any better — + // which also prevents an infinite recapture loop at + // extreme zoom levels. + state.render_state.evict_stale_scale_entries(scale); } else { // Pure pan at the same zoom level: tile contents have not // changed — only the viewport position moved. Update the @@ -437,6 +447,26 @@ pub extern "C" fn set_modifiers_end() -> Result<()> { Ok(()) } +/// Toggle the retained-mode compositor. When enabled, the render +/// loop takes a Figma-style "one texture per top-level shape" path +/// (`render_retained`) instead of the tile pipeline: dragging a shape +/// becomes a pure canvas transform on the cached texture, and no +/// re-rasterization happens until the shape itself is edited. +/// +/// Exposed as a boolean flag so the frontend can A/B test against the +/// existing renderer while the retained path is still experimental. +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_retained_mode_enabled(enabled: bool) -> Result<()> { + with_state_mut!(state, { + state.render_state.options.set_retained_mode(enabled); + if !enabled { + state.render_state.shape_cache.clear(); + } + }); + Ok(()) +} + #[no_mangle] #[wasm_error] pub extern "C" fn clear_focus_mode() -> Result<()> { diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 8684e0f112..769bf871a5 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -7,6 +7,7 @@ pub mod grid_layout; mod images; mod options; mod shadows; +mod shape_cache; mod strokes; mod surfaces; pub mod text; @@ -15,7 +16,7 @@ mod ui; use skia_safe::{self as skia, Matrix, RRect, Rect}; use std::borrow::Cow; -use std::collections::HashSet; +use std::collections::{HashSet, HashMap}; use gpu_state::GpuState; @@ -36,6 +37,7 @@ use crate::wapi; pub use fonts::*; pub use images::*; +pub use shape_cache::{ShapeCache, ShapeCacheEntry}; // This is the extra area used for tile rendering (tiles beyond viewport). // Higher values pre-render more tiles, reducing empty squares during pan but using more memory. @@ -343,6 +345,11 @@ pub(crate) struct RenderState { /// 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, + /// Retained-mode compositor cache: one entry per top-level shape + /// holding its rasterized subtree. Populated on demand by + /// `render_retained` and invalidated automatically by comparing + /// `captured_version` against the current `ShapesPool::shape_version`. + pub shape_cache: ShapeCache, } pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize { @@ -416,6 +423,7 @@ impl RenderState { preview_mode: false, export_context: None, cache_cleared_this_render: false, + shape_cache: ShapeCache::new(), }) } @@ -1907,6 +1915,479 @@ impl RenderState { Ok((data.as_bytes().to_vec(), width, height)) } + /// Capture scale we will actually use to rasterize a shape whose + /// extrect covers `rect` at a requested workspace scale of + /// `requested_scale`. It may be lower than `requested_scale` at + /// extreme zooms, so the allocated Skia surface fits within the + /// current GPU texture budget (`gl.MAX_TEXTURE_SIZE`). + /// + /// Callers use the same helper on both the capture side (to cap + /// the rasterization) and the cache-freshness side (so we don't + /// treat an already-clamped entry as stale just because the + /// requested scale is even higher). + pub fn effective_capture_scale(&self, rect: Rect, requested_scale: f32) -> f32 { + let margins = self.surfaces.margins; + let max_texture_dim = self.surfaces.max_texture_size() as f32; + let max_shape_px = + (max_texture_dim - (margins.width.max(margins.height) as f32) * 2.0).max(1.0); + let longest_side = rect.width().max(rect.height()).max(f32::EPSILON); + let max_scale_for_extrect = max_shape_px / longest_side; + requested_scale + .min(max_scale_for_extrect) + .max(f32::EPSILON) + } + + /// Drop cache entries whose `captured_scale` no longer matches + /// what we would pick *now* for the same shape. Uses + /// [`Self::effective_capture_scale`] so entries already clamped + /// to the GPU cap are preserved (we can't do any better than + /// what we already have), while entries captured at a lower + /// legitimate scale — e.g. before a zoom-in — are evicted so the + /// next render picks them up crisply again. + pub fn evict_stale_scale_entries(&mut self, current_scale: f32) { + let mut to_drop: Vec = Vec::new(); + for id in self.shape_cache.iter_ids().collect::>() { + let Some(entry) = self.shape_cache.get(&id) else { + continue; + }; + let expected = self.effective_capture_scale(entry.source_doc_rect, current_scale); + // Use a small absolute tolerance to avoid flapping from + // f32 rounding when the scale didn't really change. + if (entry.captured_scale - expected).abs() > 1e-3 { + to_drop.push(id); + } + } + for id in to_drop { + self.shape_cache.remove(&id); + } + } + + /// Rasterize `id` and its whole subtree into a standalone + /// `SkImage`, using the regular render pipeline against the Export + /// surface. Intended for the retained-mode shape cache: returns the + /// image plus the document-space rectangle it corresponds to, so + /// the compositor can blit it back with `draw_image_rect`. + /// + /// Mirrors `render_shape_pixels` but skips PNG encoding and does + /// not touch workspace-facing scale/context any more than strictly + /// necessary — every piece of mutable state it temporarily flips + /// is saved and restored before returning. + fn capture_shape_image( + &mut self, + id: &Uuid, + tree: ShapesPoolRef, + scale: f32, + clip_doc_rect: Option, + timestamp: i32, + ) -> Result> { + if tree.len() == 0 { + return Ok(None); + } + let Some(shape) = tree.get(id) else { + return Ok(None); + }; + + let target_surface = SurfaceId::Export; + + // Save every bit of state the regular pipeline uses so the + // capture is fully isolated from the workspace render. + let saved_focus_mode = self.focus_mode.clone(); + let saved_export_context = self.export_context; + let saved_render_area = self.render_area; + let saved_render_area_with_margins = self.render_area_with_margins; + let saved_current_tile = self.current_tile; + let saved_pending_nodes = std::mem::take(&mut self.pending_nodes); + let saved_nested_fills = std::mem::take(&mut self.nested_fills); + let saved_nested_blurs = std::mem::take(&mut self.nested_blurs); + let saved_nested_shadows = std::mem::take(&mut self.nested_shadows); + let saved_ignore_nested_blurs = self.ignore_nested_blurs; + let saved_preview_mode = self.preview_mode; + + self.focus_mode.clear(); + self.surfaces + .canvas(target_surface) + .clear(skia::Color::TRANSPARENT); + + let full_extrect = shape.extrect(tree, scale); + // When a clip rect is provided (retained-mode ephemeral + // capture of a big shape at high zoom), shrink the capture + // rect to `extrect ∩ clip`. That keeps the allocated surface + // within the GPU texture budget without clamping the capture + // scale — the parts of the shape outside the clip simply + // aren't needed because they're off-screen anyway. + let extrect = match clip_doc_rect { + Some(clip) => { + let mut r = full_extrect; + if r.intersect(clip) { r } else { Rect::new_empty() } + } + None => full_extrect, + }; + // Bail on degenerate geometry to avoid trying to resize a + // zero-area Export surface. + if extrect.is_empty() { + // Restore everything and report "nothing to cache". + self.focus_mode = saved_focus_mode; + self.export_context = saved_export_context; + self.render_area = saved_render_area; + self.render_area_with_margins = saved_render_area_with_margins; + self.current_tile = saved_current_tile; + self.pending_nodes = saved_pending_nodes; + self.nested_fills = saved_nested_fills; + self.nested_blurs = saved_nested_blurs; + self.nested_shadows = saved_nested_shadows; + self.ignore_nested_blurs = saved_ignore_nested_blurs; + self.preview_mode = saved_preview_mode; + return Ok(None); + } + + // Clamp the capture resolution so the allocated Skia surface + // never exceeds the GPU's texture size budget. Without this, + // zooming deep in explodes the capture texture past + // `GL_MAX_TEXTURE_SIZE`, `new_surface_with_dimensions` returns + // `None` and the whole wasm module aborts from inside + // `resize_export_surface`. + // + // The tile pipeline never hits this because it slices the + // scene into 512×512 tiles; retained-mode captures the entire + // subtree in one go, so at extreme zoom we must fall back to a + // lower-resolution snapshot and let Skia upscale it when + // compositing (slightly blurrier but correct). Callers that + // can't tolerate the softening pass a `clip_doc_rect` so the + // `extrect` shrinks to the visible portion and the clamp + // never needs to kick in. + let effective_scale = self.effective_capture_scale(extrect, scale); + let margins = self.surfaces.margins; + + self.export_context = Some((extrect, effective_scale)); + // `render_shape_pixels` offsets the render area by the margins + // so the intermediate surfaces (Fills/Strokes/shadows) have + // headroom for background-blur sampling. The offset is NOT a + // relocation of the shape in document space — the resulting + // Export image still covers the shape's original extrect; the + // offset only feeds `update_render_context`'s internal + // translation so pixels land centered inside the surface. + // + // Keep `source_doc_rect = extrect` (without the offset) so the + // retained compositor blits the texture at the exact location + // the shape occupies in document space — otherwise every + // top-level shape appears shifted by `margins / scale` units. + let offset_extrect = { + let mut r = extrect; + r.offset(( + margins.width as f32 / effective_scale, + margins.height as f32 / effective_scale, + )); + r + }; + + self.surfaces + .resize_export_surface(effective_scale, offset_extrect); + self.render_area = offset_extrect; + self.render_area_with_margins = offset_extrect; + self.surfaces + .update_render_context(offset_extrect, effective_scale); + + self.pending_nodes.push(NodeRenderState { + id: *id, + visited_children: false, + clip_bounds: None, + visited_mask: false, + mask: false, + flattened: false, + }); + self.render_shape_tree_partial_uncached(tree, timestamp, false, true)?; + + self.export_context = None; + self.surfaces + .flush_and_submit(&mut self.gpu_state, target_surface); + let image = self.surfaces.snapshot(target_surface); + + // Restore workspace state. + self.focus_mode = saved_focus_mode; + self.export_context = saved_export_context; + self.render_area = saved_render_area; + self.render_area_with_margins = saved_render_area_with_margins; + self.current_tile = saved_current_tile; + self.pending_nodes = saved_pending_nodes; + self.nested_fills = saved_nested_fills; + self.nested_blurs = saved_nested_blurs; + self.nested_shadows = saved_nested_shadows; + self.ignore_nested_blurs = saved_ignore_nested_blurs; + self.preview_mode = saved_preview_mode; + + let workspace_scale = self.get_scale(); + if let Some(tile) = self.current_tile { + self.update_render_context(tile); + } else if !self.render_area.is_empty() { + self.surfaces + .update_render_context(self.render_area, workspace_scale); + } + + Ok(Some((image, extrect, effective_scale))) + } + + /// Retained-mode render loop: for every top-level shape (direct + /// child of the root) in paint order, make sure its cached + /// subtree image is fresh (capture-on-miss), then blit it on the + /// Target surface applying the shape's modifier matrix on top. + /// + /// This path completely bypasses the tile pipeline and is the + /// Penpot analogue of how the SVG renderer composites per-node in + /// the browser: each shape is effectively a GPU layer, and + /// drag/resize/rotate is just a canvas transform change — no + /// rasterization happens until the shape itself is mutated. + /// + /// The function temporarily suppresses modifiers on `tree` while + /// capturing so the snapshot is taken at the shape's base + /// position; modifiers are reinstalled before compositing and + /// re-applied there as canvas transforms. + pub fn render_retained( + &mut self, + tree: ShapesPoolMutRef, + timestamp: i32, + ) -> Result<()> { + let _start = performance::begin_timed_log!("render_retained"); + let scale = self.get_scale(); + + self.reset_canvas(); + let dpr = self.options.dpr(); + let zoom = self.viewbox.zoom * dpr; + let pan = (self.viewbox.pan_x * dpr, self.viewbox.pan_y * dpr); + + // Snapshot the list of top-level ids up-front: anything + // reachable as a direct child of the root is considered a + // retained layer. Order matches paint order. + let top_level_ids: Vec = match tree.get(&Uuid::nil()) { + Some(root) => root.children_ids_iter_forward(true).copied().collect(), + None => return Ok(()), + }; + + // Evict entries whose shape disappeared from the pool. + let live: HashSet = top_level_ids.iter().copied().collect(); + let stale: Vec = self + .shape_cache + .iter_ids() + .filter(|id| !live.contains(id)) + .collect(); + for id in stale { + self.shape_cache.remove(&id); + } + + // While a pan/zoom gesture is in flight we deliberately avoid + // re-capturing on scale changes: skia will resample the + // existing textures during compose (slightly softer for a + // few frames) and we reserve the cost of a fresh capture for + // after the gesture settles — same strategy the tile + // pipeline uses with its cache surface. + let allow_scale_drift = self.options.is_fast_mode(); + + // Classify each top-level shape into "cacheable" (fits the + // GPU texture budget at workspace scale, so the capture is + // pixel-accurate and worth persisting) vs "ephemeral" (would + // trigger the scale clamp — we capture just the visible + // viewport slice at full resolution and discard afterwards). + // + // We compare against the per-shape *effective* capture scale + // (which may be clamped at extreme zoom to fit the GPU + // texture budget) instead of the raw workspace scale. Without + // this we'd re-capture every frame at high zoom: the capture + // returns a clamped scale < workspace scale, `is_fresh` then + // sees a scale mismatch, we recapture and get the same clamp, + // ad infinitum. + let (cacheable_to_capture, ephemeral_to_capture): (Vec<(Uuid, u64)>, Vec<(Uuid, u64)>) = { + // Reborrow `tree` as an immutable view so we can call + // `extrect` (which takes `ShapesPoolRef`) without fighting + // the outer `&mut` binding. + let pool: ShapesPoolRef = &*tree; + let mut cacheable = Vec::new(); + let mut ephemeral = Vec::new(); + for id in &top_level_ids { + let v = pool.shape_version(id).unwrap_or(0); + let (effective_scale, is_clamped) = match pool.get(id) { + Some(shape) => { + let eff = self.effective_capture_scale(shape.extrect(pool, scale), scale); + // Treat anything below the workspace scale as + // clamped — even tiny drifts would otherwise + // leave the shape looking permanently soft + // once Skia upscales the blit. + (eff, eff < scale - 1e-3) + } + None => (scale, false), + }; + if is_clamped { + // Ephemeral path only runs outside of interactive + // gestures: during pan/zoom/drag we let the old + // (possibly stale) cache entry stretch so the + // gesture stays responsive, and we re-capture on + // release. + if !allow_scale_drift { + ephemeral.push((*id, v)); + } + } else if !self + .shape_cache + .is_fresh(id, v, effective_scale, allow_scale_drift) + { + cacheable.push((*id, v)); + } + } + (cacheable, ephemeral) + }; + + // Drop any persistent entry for shapes that just became + // ephemeral — the stored texture was captured at a different + // (lower, clamped) resolution and would otherwise leak into + // the viewport-clipped compose path. + for (id, _) in &ephemeral_to_capture { + self.shape_cache.remove(id); + } + + // Capture phase — suppress modifiers so snapshots are taken at + // each shape's base position. If there was an active gesture + // we put the modifiers back immediately afterwards so the + // compositor below sees them. + let needs_capture = + !cacheable_to_capture.is_empty() || !ephemeral_to_capture.is_empty(); + let saved_modifiers = if needs_capture { + tree.take_modifiers() + } else { + HashMap::new() + }; + + for (id, version) in &cacheable_to_capture { + // `capture_shape_image` borrows `tree` immutably; we're + // holding a `ShapesPoolMutRef` so reborrow as &* for the + // call. + let capture = self.capture_shape_image(id, &*tree, scale, None, timestamp)?; + match capture { + // `captured_scale` is the *effective* scale used inside + // `capture_shape_image`; for cacheable shapes it + // equals the workspace scale by construction. + Some((image, source_doc_rect, captured_scale)) => { + self.shape_cache.insert( + *id, + ShapeCacheEntry { + image, + captured_version: *version, + captured_scale, + source_doc_rect, + }, + ); + } + None => { + self.shape_cache.remove(id); + } + } + } + + // Ephemeral captures: only the `extrect ∩ viewport` slice is + // rendered, which keeps the texture inside the GPU budget + // even at extreme zoom levels. We reuse `ShapeCache` as the + // hand-off channel into the compose loop and evict the entry + // immediately after compositing so the next frame (which may + // have a different pan) can capture afresh. + let viewport_doc = self.viewbox.area; + let mut ephemeral_capture_ids: Vec = Vec::new(); + if !ephemeral_to_capture.is_empty() && !viewport_doc.is_empty() { + for (id, version) in &ephemeral_to_capture { + let capture = + self.capture_shape_image(id, &*tree, scale, Some(viewport_doc), timestamp)?; + if let Some((image, source_doc_rect, captured_scale)) = capture { + self.shape_cache.insert( + *id, + ShapeCacheEntry { + image, + captured_version: *version, + captured_scale, + source_doc_rect, + }, + ); + ephemeral_capture_ids.push(*id); + } + } + } + + if needs_capture { + tree.set_modifiers(saved_modifiers); + } + + // Compose on the Target surface in document space. All cached + // images already carry the workspace scale baked in, so after + // applying zoom*dpr the blit lands at the right size. + let canvas = self.surfaces.canvas(SurfaceId::Target); + canvas.save(); + canvas.reset_matrix(); + // `reset_canvas` clears every intermediate surface but leaves + // Target alone; wipe it here so the previous frame's pixels + // don't leak through when shapes change position. + canvas.clear(self.background_color); + canvas.scale((zoom, zoom)); + canvas.translate(pan); + + // Background fill covering the document viewport (idempotent + // after the full-surface clear, but cheap and keeps the code + // symmetric with the tile pipeline). + let mut bg_paint = skia::Paint::default(); + bg_paint.set_color(self.background_color); + bg_paint.set_style(skia::PaintStyle::Fill); + let viewport_rect = self.viewbox.area; + if !viewport_rect.is_empty() { + canvas.draw_rect(viewport_rect, &bg_paint); + } + + let sampling = self.sampling_options; + let blit_paint = skia::Paint::default(); + for id in &top_level_ids { + let Some(entry) = self.shape_cache.get(id) else { + continue; + }; + let Some(shape) = tree.get(id) else { continue }; + if shape.hidden() { + continue; + } + + let modifier = tree.modifier_of(id); + + canvas.save(); + if let Some(m) = modifier { + canvas.concat(&m); + } + canvas.draw_image_rect_with_sampling_options( + &entry.image, + None, + entry.source_doc_rect, + sampling, + &blit_paint, + ); + canvas.restore(); + } + + canvas.restore(); + self.flush_and_submit(); + + // Drop the short-lived ephemeral entries now that they've been + // blitted. Keeping them would break panning at extreme zoom: + // the stored `source_doc_rect` only covers the previous + // viewport slice, so the next frame would show stale content + // where the viewport moved. + for id in &ephemeral_capture_ids { + self.shape_cache.remove(id); + } + + // The frontend installs a blurred "page transition" overlay on + // file open / page switch and keeps it on screen until WASM + // dispatches `penpot:wasm:tiles-complete` (see + // `start-initial-load-transition!` in `api.cljs`). The legacy + // tile pipeline fires that event from `process_animation_frame` + // once the last pending tile is flushed; in retained mode we + // composite the scene in a single pass here, so we must emit + // the same signal ourselves — otherwise the overlay stays up + // forever and the canvas looks empty even though the real + // render succeeded. + wapi::notify_tiles_render_complete!(); + + Ok(()) + } + #[inline] pub fn should_stop_rendering(&self, iteration: i32, timestamp: i32) -> bool { if iteration % NODE_BATCH_THRESHOLD != 0 { diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index f6072f964f..62d21bd04b 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -14,6 +14,12 @@ pub struct RenderOptions { /// keeps per-frame flushing enabled (unlike pan/zoom, where /// `render_from_cache` drives target presentation). interactive_transform: bool, + /// Opt-in switch for the retained-mode rendering path: when + /// enabled, top-level shapes are rasterized once to a `ShapeCache` + /// and recomposed every frame applying their modifier matrix as a + /// canvas transform, instead of being re-rasterized from scratch. + /// Behaves like the SVG compositor in the browser. + retained_mode: bool, /// Minimum on-screen size (CSS px at 1:1 zoom) above which vector antialiasing is enabled. pub antialias_threshold: f32, } @@ -25,6 +31,14 @@ impl Default for RenderOptions { dpr: None, fast_mode: false, interactive_transform: false, + // Retained-mode (Figma-style per-top-level-shape texture + // cache) is ON by default: drag/resize/rotate become + // pure canvas transforms over cached textures and + // pan/zoom reuses those same textures instead of going + // through the tile atlas pipeline. Set to `false` or + // call `set_retained_mode_enabled(false)` to fall back to + // the legacy tile pipeline for A/B comparisons. + retained_mode: true, antialias_threshold: 7.0, } } @@ -60,6 +74,17 @@ impl RenderOptions { self.interactive_transform = enabled; } + /// Returns `true` when the retained-mode compositor should own the + /// render loop. Mirrors the Figma-style "one texture per top-level + /// shape" approach. + pub fn is_retained_mode(&self) -> bool { + self.retained_mode + } + + pub fn set_retained_mode(&mut self, enabled: bool) { + self.retained_mode = enabled; + } + /// True only when the viewport is the one being moved (pan/zoom) /// and the dedicated `render_from_cache` path owns Target /// presentation. In this mode `process_animation_frame` must not diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index e3a9512e08..2d060895a5 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -162,6 +162,16 @@ impl Surfaces { self.max_atlas_texture_size = max_px.clamp(TILE_SIZE as i32, MAX_ATLAS_TEXTURE_SIZE); } + /// Current per-side pixel budget for GPU surfaces (same limit the atlas + /// uses). Callers that allocate Skia surfaces sized from a document + /// rect × scale — notably the retained-mode shape capture — must clamp + /// against this to avoid overflowing `GL_MAX_TEXTURE_SIZE` at high + /// zoom, which otherwise makes `new_surface_with_dimensions` return + /// `None` and panics downstream. + pub fn max_texture_size(&self) -> i32 { + self.max_atlas_texture_size + } + fn ensure_atlas_contains( &mut self, gpu_state: &mut GpuState, diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index c624dad43d..13fd4ba098 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -90,15 +90,38 @@ impl State { } pub fn render_from_cache(&mut self) { + if self.render_state.options.is_retained_mode() { + // `render_from_cache` is the pan/zoom fast path of the + // tile pipeline: it paints the cached atlas/cache surface + // under the current viewbox transform. In retained mode + // those surfaces are never populated (we don't run the + // tile pipeline at all) so what they hold is either empty + // or stale from a pre-retained render — either way it + // makes shapes appear shifted while panning/zooming. + // + // Re-composing via `render_retained` is cheap in steady + // state: as long as shape versions and scale haven't + // changed the cache just blits the pre-rasterized + // textures with the new viewbox transform, which is + // exactly what render_from_cache tries to approximate. + let _ = self.render_state.render_retained(&mut self.shapes, 0); + return; + } self.render_state.render_from_cache(&self.shapes); } pub fn render_sync(&mut self, timestamp: i32) -> Result<()> { + if self.render_state.options.is_retained_mode() { + return self.render_state.render_retained(&mut self.shapes, timestamp); + } self.render_state .start_render_loop(None, &self.shapes, timestamp, true) } pub fn render_sync_shape(&mut self, id: &Uuid, timestamp: i32) -> Result<()> { + if self.render_state.options.is_retained_mode() { + return self.render_state.render_retained(&mut self.shapes, timestamp); + } self.render_state .start_render_loop(Some(id), &self.shapes, timestamp, true) } @@ -114,6 +137,10 @@ impl State { } pub fn start_render_loop(&mut self, timestamp: i32) -> Result<()> { + if self.render_state.options.is_retained_mode() { + return self.render_state.render_retained(&mut self.shapes, timestamp); + } + // If zoom changed (e.g. interrupted zoom render followed by pan), the // tile index may be stale for the new viewport position. Rebuild the // index so shapes are mapped to the correct tiles. We use @@ -129,6 +156,13 @@ impl State { } pub fn process_animation_frame(&mut self, timestamp: i32) -> Result<()> { + if self.render_state.options.is_retained_mode() { + // In retained mode there is no tile pipeline driving + // incremental progress: every frame is composed in a + // single pass inside `render_retained`, so animation + // frames are a no-op. + return Ok(()); + } self.render_state .process_animation_frame(None, &self.shapes, timestamp) } diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index 7e03befa01..2ab6a53a0c 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -53,6 +53,20 @@ pub struct ShapesPoolImpl { structure: HashMap>, /// Scale content values, keyed by index scale_content: HashMap, + + /// Monotonically increasing version number per shape index. + /// + /// Bumped every time a shape is accessed mutably via `get_mut`, and + /// the increment is propagated up the parent chain so the + /// retained-mode renderer can detect "this shape or any of its + /// descendants changed" by comparing the version of a top-level + /// ancestor against the version cached when its texture was + /// captured. + /// + /// Modifiers are intentionally *not* tracked here: they are applied + /// as a canvas transform by the retained compositor and must not + /// invalidate the cached texture. + shape_versions: Vec, } // Type aliases - no longer need lifetimes! @@ -71,6 +85,7 @@ impl ShapesPoolImpl { modifiers: HashMap::default(), structure: HashMap::default(), scale_content: HashMap::default(), + shape_versions: vec![], } } @@ -91,6 +106,7 @@ impl ShapesPoolImpl { self.shapes .extend(iter::repeat_with(|| Shape::new(Uuid::nil())).take(additional as usize)); + self.shape_versions.resize(self.shapes.len(), 0); performance::end_measure!("shapes_pool_initialize"); } @@ -113,6 +129,9 @@ impl ShapesPoolImpl { self.shapes .extend(iter::repeat_with(|| Shape::new(Uuid::nil())).take(additional)); } + if self.shape_versions.len() < self.shapes.len() { + self.shape_versions.resize(self.shapes.len(), 0); + } let idx = self.counter; let new_shape = &mut self.shapes[idx]; @@ -122,6 +141,12 @@ impl ShapesPoolImpl { self.uuid_to_idx.insert(id, idx); self.counter += 1; + // Bump version so a late retained-mode render doesn't serve a + // stale texture from a shape uuid that has been reused. + if let Some(v) = self.shape_versions.get_mut(idx) { + *v = v.wrapping_add(1); + } + &mut self.shapes[idx] } // No longer needed! Index-based storage means no references to rebuild. @@ -137,9 +162,86 @@ impl ShapesPoolImpl { pub fn get_mut(&mut self, id: &Uuid) -> Option<&mut Shape> { let idx = *self.uuid_to_idx.get(id)?; + self.bump_version_with_ancestors(idx); Some(&mut self.shapes[idx]) } + /// Increment the version counter of `idx` and walk up its parent + /// chain bumping each ancestor as well. The retained-mode cache + /// stores textures per top-level shape (direct child of the root); + /// propagating lets a deep mutation correctly invalidate whichever + /// ancestor owns the cached subtree. + fn bump_version_with_ancestors(&mut self, idx: usize) { + if let Some(v) = self.shape_versions.get_mut(idx) { + *v = v.wrapping_add(1); + } + + // The root (Uuid::nil) has no parent; walking stops naturally + // via `parent_id == None`. + let mut current_idx = idx; + loop { + let parent_id = match self.shapes.get(current_idx).and_then(|s| s.parent_id) { + Some(pid) => pid, + None => break, + }; + let Some(parent_idx) = self.uuid_to_idx.get(&parent_id).copied() else { + break; + }; + if parent_idx == current_idx { + // Defensive: a self-parent would loop forever. + break; + } + if let Some(v) = self.shape_versions.get_mut(parent_idx) { + *v = v.wrapping_add(1); + } + current_idx = parent_idx; + } + } + + /// Current version of a shape. Used by the retained-mode texture + /// cache to detect invalidated entries: captured_version != current + /// ⇒ re-rasterize. + /// + /// Returns `None` for uuids that are not (or no longer) registered + /// in the pool. + pub fn shape_version(&self, id: &Uuid) -> Option { + let idx = *self.uuid_to_idx.get(id)?; + self.shape_versions.get(idx).copied() + } + + /// Raw transform modifier associated with `id`, without applying + /// it to the shape. The retained-mode compositor uses this to + /// concatenate the modifier onto the canvas before blitting the + /// cached texture, so the workspace behaves like the SVG renderer + /// (texture stays put, layer transform moves). + pub fn modifier_of(&self, id: &Uuid) -> Option { + let idx = *self.uuid_to_idx.get(id)?; + self.modifiers.get(&idx).copied() + } + + /// Removes every active modifier and returns them keyed by Uuid, + /// ready to be handed back to `set_modifiers`. Used by the + /// retained-mode capture step to ensure the snapshot is taken at + /// the shape's base position — modifiers are re-applied later as + /// canvas transforms, so capturing with them applied would + /// double-transform the shape. + pub fn take_modifiers(&mut self) -> HashMap { + if self.modifiers.is_empty() { + return HashMap::new(); + } + let mut out = HashMap::with_capacity(self.modifiers.len()); + for (idx, matrix) in self.modifiers.drain() { + if let Some(shape) = self.shapes.get(idx) { + out.insert(shape.id, matrix); + } + } + // Modified-shape cache entries baked in the taken modifiers + // become stale; drop them so subsequent `get()` calls return + // the un-transformed shape. + self.modified_shape_cache.clear(); + out + } + /// Get a shape by UUID. Returns the modified shape if modifiers/structure /// are applied, otherwise returns the base shape. pub fn get(&self, id: &Uuid) -> Option<&Shape> { @@ -327,6 +429,7 @@ impl ShapesPoolImpl { new_idx += 1; } + let shape_versions = vec![0; shapes.len()]; ShapesPoolImpl { shapes, counter: new_idx, @@ -335,6 +438,7 @@ impl ShapesPoolImpl { modifiers: HashMap::default(), structure: HashMap::default(), scale_content: HashMap::default(), + shape_versions, } }