🎉 Retained mode

This commit is contained in:
Alejandro Alonso 2026-04-21 11:28:14 +02:00
parent 0b49c1f3e9
commit e124283117
8 changed files with 707 additions and 1 deletions

View File

@ -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]

View File

@ -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]

View File

@ -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<()> {

View File

@ -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<Uuid> = Vec::new();
for id in self.shape_cache.iter_ids().collect::<Vec<_>>() {
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<Rect>,
timestamp: i32,
) -> Result<Option<(skia::Image, Rect, f32)>> {
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<Uuid> = 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<Uuid> = top_level_ids.iter().copied().collect();
let stale: Vec<Uuid> = 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<Uuid> = 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 {

View File

@ -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

View File

@ -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,

View File

@ -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)
}

View File

@ -53,6 +53,20 @@ pub struct ShapesPoolImpl {
structure: HashMap<usize, Vec<StructureEntry>>,
/// Scale content values, keyed by index
scale_content: HashMap<usize, f32>,
/// 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<u64>,
}
// 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<u64> {
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<skia::Matrix> {
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<Uuid, skia::Matrix> {
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,
}
}