leaf cache

This commit is contained in:
Alejandro Alonso 2026-04-22 07:44:52 +02:00
parent 02238a9f1e
commit 458da0fbbf
3 changed files with 268 additions and 11 deletions

View File

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

View File

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

View File

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