mirror of
https://github.com/penpot/penpot.git
synced 2026-04-25 11:18:36 +00:00
🎉 Retained mode
This commit is contained in:
parent
0b49c1f3e9
commit
e124283117
@ -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]
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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<()> {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user