mirror of
https://github.com/penpot/penpot.git
synced 2026-04-25 11:18:36 +00:00
leaf cache
This commit is contained in:
parent
02238a9f1e
commit
458da0fbbf
@ -502,8 +502,26 @@ pub extern "C" fn set_modifiers_end() -> Result<()> {
|
||||
pub extern "C" fn set_retained_mode_enabled(enabled: bool) -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
state.render_state.options.set_retained_mode(enabled);
|
||||
// Do NOT wipe `shape_cache` when toggling retained-mode: the
|
||||
// same store is also used by the leaf-cache path for the
|
||||
// default (tile-based) pipeline. Clearing it here would
|
||||
// throw away perfectly valid leaf entries every time the
|
||||
// A/B switch is flipped.
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Toggle the per-leaf texture cache used by the tile walker. When
|
||||
/// disabled the walker always rasterizes leaves from scratch — mostly
|
||||
/// useful as an A/B kill switch from the frontend.
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn set_leaf_cache_enabled(enabled: bool) -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
state.render_state.options.set_leaf_cache(enabled);
|
||||
if !enabled {
|
||||
state.render_state.shape_cache.clear();
|
||||
state.render_state.pending_leaf_captures.clear();
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
|
||||
@ -336,11 +336,19 @@ 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`.
|
||||
/// Per-leaf texture cache. Entries are populated on-demand by the
|
||||
/// tile walker (`render_shape_tree_partial_uncached`) when
|
||||
/// `options.leaf_cache` is enabled — one entry per leaf shape
|
||||
/// (`!shape.is_recursive()`) — and blitted back on subsequent
|
||||
/// frames instead of re-rasterizing, turning edit/drag repaints
|
||||
/// into single GPU blits.
|
||||
pub shape_cache: ShapeCache,
|
||||
/// Leaf shapes the walker just intercepted with a cache miss,
|
||||
/// queued for off-walker capture once the current frame finishes.
|
||||
/// Tuples are `(shape_id, shape_version)` so `flush_pending_leaf_captures`
|
||||
/// can validate the queued version against the current pool state
|
||||
/// before paying for the capture.
|
||||
pub pending_leaf_captures: Vec<(Uuid, u64)>,
|
||||
}
|
||||
|
||||
pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize {
|
||||
@ -415,6 +423,7 @@ impl RenderState {
|
||||
export_context: None,
|
||||
cache_cleared_this_render: false,
|
||||
shape_cache: ShapeCache::new(),
|
||||
pending_leaf_captures: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@ -1932,6 +1941,21 @@ impl RenderState {
|
||||
Ok((data.as_bytes().to_vec(), width, height))
|
||||
}
|
||||
|
||||
/// `true` when `shape` is a leaf whose rasterization we can legally
|
||||
/// swap for a cached bitmap blit. We deliberately stay conservative
|
||||
/// for the MVP: any shape whose final compositing depends on the
|
||||
/// destination (non-default blend modes, opacity layer, frame-clip
|
||||
/// layer blur, masked groups) goes through the regular pipeline so
|
||||
/// we don't have to re-implement blend math on top of
|
||||
/// `draw_image_rect`.
|
||||
///
|
||||
/// Groups/frames/bools return `false` here — we only cache leaves so
|
||||
/// children can keep invalidating tiles through the normal flow.
|
||||
#[inline]
|
||||
fn is_leaf_cacheable(shape: &Shape) -> bool {
|
||||
!shape.is_recursive() && !shape.hidden && !shape.needs_layer()
|
||||
}
|
||||
|
||||
/// 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
|
||||
@ -1979,6 +2003,89 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain `pending_leaf_captures` (populated by the walker's cache
|
||||
/// miss branch) and capture up to `cap` leaves into
|
||||
/// `shape_cache`. Invariants:
|
||||
///
|
||||
/// - We only capture shapes that are still a leaf and whose
|
||||
/// `shape_version` hasn't moved since the walker queued them;
|
||||
/// anything else is silently dropped and will be re-queued on the
|
||||
/// next render if it's still visible.
|
||||
/// - We skip captures while a pan/zoom gesture is in flight
|
||||
/// (`options.is_fast_mode`): the effective scale is still moving,
|
||||
/// any capture taken now would be invalidated by
|
||||
/// `evict_stale_scale_entries` at `set_view_end`.
|
||||
///
|
||||
/// Capping the number of captures per frame keeps the worst-case
|
||||
/// added latency bounded when a page full of previously-unseen
|
||||
/// leaves scrolls into view.
|
||||
pub fn flush_pending_leaf_captures(
|
||||
&mut self,
|
||||
tree: ShapesPoolRef,
|
||||
timestamp: i32,
|
||||
cap: usize,
|
||||
) -> Result<()> {
|
||||
if !self.options.is_leaf_cache() || self.pending_leaf_captures.is_empty() {
|
||||
self.pending_leaf_captures.clear();
|
||||
return Ok(());
|
||||
}
|
||||
if self.options.is_fast_mode() {
|
||||
self.pending_leaf_captures.clear();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let scale = self.get_scale();
|
||||
let mut queue = std::mem::take(&mut self.pending_leaf_captures);
|
||||
let mut processed = 0usize;
|
||||
for (id, queued_version) in queue.drain(..) {
|
||||
if processed >= cap {
|
||||
break;
|
||||
}
|
||||
let Some(shape) = tree.get(&id) else { continue };
|
||||
if !Self::is_leaf_cacheable(shape) {
|
||||
continue;
|
||||
}
|
||||
let current_version = tree.shape_version(&id).unwrap_or(0);
|
||||
if current_version != queued_version {
|
||||
// Stale queue entry — the shape mutated since the
|
||||
// walker saw it; next render will re-queue.
|
||||
continue;
|
||||
}
|
||||
// If another tile walker iteration already filled the
|
||||
// cache in the same frame (dedup sometimes misses across
|
||||
// the margin-padded re-entries), skip.
|
||||
let effective_scale =
|
||||
self.effective_capture_scale(shape.extrect(tree, scale), scale);
|
||||
if self
|
||||
.shape_cache
|
||||
.is_fresh(&id, current_version, effective_scale, false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let capture = self.capture_shape_image(&id, tree, scale, None, timestamp)?;
|
||||
match capture {
|
||||
Some((image, source_doc_rect, captured_scale)) => {
|
||||
self.shape_cache.insert(
|
||||
id,
|
||||
ShapeCacheEntry {
|
||||
image,
|
||||
captured_version: current_version,
|
||||
captured_scale,
|
||||
source_doc_rect,
|
||||
},
|
||||
);
|
||||
}
|
||||
None => {
|
||||
self.shape_cache.remove(&id);
|
||||
}
|
||||
}
|
||||
processed += 1;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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
|
||||
@ -3146,6 +3253,104 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
// Leaf-cache intercept. When `options.leaf_cache` is
|
||||
// enabled we try to short-circuit the per-shape
|
||||
// rasterization by blitting a previously captured image
|
||||
// for this leaf. Any miss queues the shape for a deferred
|
||||
// capture (handled after the walker finishes), and we fall
|
||||
// through to the normal rendering path so the frame is
|
||||
// correct even on first sight.
|
||||
if !export
|
||||
&& !node_render_state.is_root()
|
||||
&& !mask
|
||||
&& clip_bounds.is_none()
|
||||
&& self.options.is_leaf_cache()
|
||||
&& self.focus_mode.should_focus(&element.id)
|
||||
&& !self.options.is_debug_visible()
|
||||
&& Self::is_leaf_cacheable(element)
|
||||
{
|
||||
let element_extrect =
|
||||
*extrect.get_or_insert_with(|| element.extrect(tree, scale));
|
||||
let effective_scale =
|
||||
self.effective_capture_scale(element_extrect, scale);
|
||||
let version = tree.shape_version(&node_id).unwrap_or(0);
|
||||
// During pan/zoom gestures we allow a slightly stale
|
||||
// scale so we keep blitting old textures instead of
|
||||
// invalidating them mid-gesture — mirrors what the
|
||||
// atlas/tile pipeline does.
|
||||
let allow_scale_drift = self.options.is_fast_mode();
|
||||
let is_fresh = self.shape_cache.is_fresh(
|
||||
&node_id,
|
||||
version,
|
||||
effective_scale,
|
||||
allow_scale_drift,
|
||||
);
|
||||
|
||||
if is_fresh {
|
||||
if let Some(entry) = self.shape_cache.get(&node_id) {
|
||||
let image = entry.image.clone();
|
||||
let source_doc_rect = entry.source_doc_rect;
|
||||
let modifier = tree.modifier_of(&node_id);
|
||||
let fills_surface = SurfaceId::Fills;
|
||||
let sampling = self.sampling_options;
|
||||
|
||||
// Fills already has `scale + translate` baked
|
||||
// in via `update_render_context`, so drawing
|
||||
// the image at its captured document-space
|
||||
// rect lands at the correct screen position.
|
||||
// The modifier (if any) is concatenated on top
|
||||
// so drag/resize of the leaf turns into a pure
|
||||
// transform of the blit.
|
||||
let canvas = self.surfaces.canvas(fills_surface);
|
||||
canvas.save();
|
||||
if let Some(m) = modifier {
|
||||
canvas.concat(&m);
|
||||
}
|
||||
let paint = skia::Paint::default();
|
||||
canvas.draw_image_rect_with_sampling_options(
|
||||
&image,
|
||||
None,
|
||||
source_doc_rect,
|
||||
sampling,
|
||||
&paint,
|
||||
);
|
||||
canvas.restore();
|
||||
self.surfaces.mark_dirty(fills_surface);
|
||||
|
||||
// Composite Fills -> target (the `Current`
|
||||
// tile surface in the online path) so the
|
||||
// blit is visible this frame, matching what
|
||||
// `render_shape` + `apply_drawing_to_render_canvas`
|
||||
// would have produced.
|
||||
self.apply_drawing_to_render_canvas(
|
||||
Some(element),
|
||||
target_surface,
|
||||
);
|
||||
|
||||
iteration += 1;
|
||||
if allow_stop && self.should_stop_rendering(iteration, timestamp) {
|
||||
return Ok((is_empty, true));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Cache miss: let the normal pipeline render this
|
||||
// frame and record the leaf for a deferred capture
|
||||
// so the next frame becomes a cheap blit. Only
|
||||
// queue under non-drift conditions — while the user
|
||||
// is actively panning/zooming the scale is moving
|
||||
// so captures would be thrown away immediately.
|
||||
if !allow_scale_drift
|
||||
&& !self
|
||||
.pending_leaf_captures
|
||||
.iter()
|
||||
.any(|(id, _)| id == &node_id)
|
||||
{
|
||||
self.pending_leaf_captures.push((node_id, version));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let can_flatten = element.can_flatten() && !self.focus_mode.should_focus(&element.id);
|
||||
|
||||
// Skip render_shape_enter/exit for flattened containers
|
||||
@ -3467,6 +3672,14 @@ impl RenderState {
|
||||
|
||||
self.render_in_progress = false;
|
||||
|
||||
// Leaf-cache deferred captures. Populated by the tile walker
|
||||
// whenever it hits a cache miss on a leaf; handled here so the
|
||||
// per-shape Export pass doesn't contend with the per-tile
|
||||
// render budget. Capped at a small batch per frame to keep the
|
||||
// extra work bounded during heavy scrolling; whatever doesn't
|
||||
// fit stays queued for the next render tick.
|
||||
self.flush_pending_leaf_captures(tree, timestamp, 8)?;
|
||||
|
||||
self.surfaces.gc();
|
||||
|
||||
// Mark cache as valid for render_from_cache.
|
||||
|
||||
@ -28,6 +28,16 @@ pub struct RenderOptions {
|
||||
/// canvas transform, instead of being re-rasterized from scratch.
|
||||
/// Behaves like the SVG compositor in the browser.
|
||||
retained_mode: bool,
|
||||
/// Per-leaf texture cache. When enabled the tile walker blits the
|
||||
/// cached image of a leaf shape (any shape where `!is_recursive()`)
|
||||
/// instead of re-rasterizing it, as long as the cached entry is
|
||||
/// fresh (same `shape_version`, same effective capture scale).
|
||||
/// Leaves that are visible but not yet cached are captured in a
|
||||
/// post-walker pass, with a per-frame cap, so the first frame is
|
||||
/// paid for once and subsequent frames become GPU blits. Unlike
|
||||
/// `retained_mode` this keeps the tile + atlas pipeline as the
|
||||
/// main driver, so pan/zoom caching behaviour is unchanged.
|
||||
leaf_cache: bool,
|
||||
/// Minimum on-screen size (CSS px at 1:1 zoom) above which vector antialiasing is enabled.
|
||||
pub antialias_threshold: f32,
|
||||
pub viewport_interest_area_threshold: i32,
|
||||
@ -49,13 +59,19 @@ impl Default for RenderOptions {
|
||||
node_batch_threshold: NODE_BATCH_THRESHOLD,
|
||||
blur_downscale_threshold: BLUR_DOWNSCALE_THRESHOLD,
|
||||
// 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,
|
||||
// cache) is OFF by default: the tile + atlas pipeline
|
||||
// owns the render loop and we accelerate edits through
|
||||
// `leaf_cache` instead, which blits cached textures for
|
||||
// leaf shapes from inside the tile walker. Retained mode
|
||||
// is still available for A/B comparisons via
|
||||
// `set_retained_mode(true)`.
|
||||
retained_mode: false,
|
||||
// Per-leaf texture cache is ON by default: while the tile
|
||||
// walker traverses the scene it blits the cached texture
|
||||
// of any leaf shape instead of re-rasterizing it,
|
||||
// dramatically cutting the cost of repeated frames during
|
||||
// edits.
|
||||
leaf_cache: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -101,6 +117,16 @@ impl RenderOptions {
|
||||
self.retained_mode = enabled;
|
||||
}
|
||||
|
||||
/// Returns `true` when the per-leaf texture cache should be
|
||||
/// consulted by the tile walker.
|
||||
pub fn is_leaf_cache(&self) -> bool {
|
||||
self.leaf_cache
|
||||
}
|
||||
|
||||
pub fn set_leaf_cache(&mut self, enabled: bool) {
|
||||
self.leaf_cache = 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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user