Improve drag performance

This commit is contained in:
Elena Torro
2026-04-27 18:18:22 +02:00
parent f6bd991968
commit ca1e1dd806
8 changed files with 1346 additions and 190 deletions

View File

@@ -0,0 +1,221 @@
# Drag performance analysis — branch `superalex-improve-modifiers-flickering`
Date: 2026-04-24
Scope: per-rAF cost of `set_modifiers``start_render_loop` when dragging
a shape inside a dense frame (~1400 shapes in one 1:1 atlas tile).
## TL;DR
- One change kept in this branch: in `rebuild_modifier_tiles`, skip
`all_with_ancestors` during `is_interactive_transform()`. This removes a
wide ancestor-driven tile eviction that was triggering full tile
re-renders on every rAF.
- This helped for dragged shapes that do **not** themselves cover the
dense tile. For the user's test case (dragged shape's own bbox covers
tile (0,0) with ~1400 siblings), frames are still 400580 ms.
- Remaining cost is structural: ~0.3 ms × ~1400 Skia draws per tile =
~400 ms to render the one tile that holds the dense cluster. The
anti-flicker guard (render.rs:2043) forces the whole tile to complete
in a single rAF during interactive transform, so the cost can't be
amortised across frames without a visible regression.
- Options A (relax the anti-flicker guard), B/C (clip-based dirty-rect
narrowing), and drag-sprite resurrection all fail for this shape.
Remaining candidates are scroll-blit and sprite-of-dragged-shape,
both carry risk.
## The one change kept
`render-wasm/src/render.rs``rebuild_modifier_tiles`:
```rust
pub fn rebuild_modifier_tiles(
&mut self,
tree: ShapesPoolMutRef<'_>,
ids: Vec<Uuid>,
) -> Result<()> {
if self.options.is_interactive_transform() {
self.update_tiles_shapes(&ids, tree)?;
} else {
let ancestors = all_with_ancestors(&ids, tree, false);
self.update_tiles_shapes(&ancestors, tree)?;
}
Ok(())
}
```
### Why this helps
`all_with_ancestors` walks up from the dragged shape to the root. For a
shape inside a frame, that pulls in the frame itself. `update_shape_tiles`
on the frame computes the union of the frame's old and new extrects and
calls `remove_cached_tile_surface` on every atlas tile the frame covers —
which, for a frame that covers the whole viewport, is *every* visible
tile, including dense ones with hundreds of non-dragged siblings.
The anti-flicker guard then forces those tiles to complete in the same
rAF, so a 1 ms per-tile cost balloons into hundreds of tiles × their
individual shape draw costs.
Skipping the ancestor walk during interactive transform keeps eviction
bounded to the dragged shape's own tiles. Ancestor *extrect* caches are
still invalidated separately by `ShapesPool::set_modifiers`
(`state/shapes_pool.rs:214``all_with_ancestors(..., true)` on
`modified_shape_cache`), so layout metadata is correct. Tile-index
reconciliation happens post-gesture via `rebuild_touched_tiles` when the
commit path runs.
### Where this is not enough
The dragged shape's own bbox still evicts every tile it covers. If that
shape is, say, a 3000×1000 rectangle sitting on top of a cluster of 1400
small siblings inside tile (0,0), the dragged shape's extrect is tile
(0,0), so `update_shape_tiles(dragged)` evicts (0,0) on every rAF. The
next render of (0,0) re-draws all ~1400 siblings from scratch. That's
the 400 ms.
## Structural cost ceiling
Per traces captured before cleanup:
```
SLOW_TILE (0,0) 376ms shapes=1319
SLOW_TILE (0,0) 403ms shapes=1424
SLOW_TILE (0,0) 583ms shapes=1426
```
Arithmetic: ~0.3 ms per Skia draw × shape count in the tile ≈ the slow
frame time. Tile cost scales linearly with tile density. There is no
algorithmic reduction available as long as the full tile must be
re-rasterised from a cold atlas.
### Anti-flicker guard
`render.rs:2043` (commit `98c8bb1746`): during interactive transform,
`start_render_loop` iterates until all visible tiles complete in the
same rAF, bypassing the normal per-rAF budget. This exists because
`apply_render_to_final_canvas` is atomic — it only publishes to the
Target surface on full tile completion, not on EARLY_RETURN. A tile
left half-drawn is invisible; a tile evicted mid-gesture with no
replacement renders as background colour.
Removing or loosening the guard makes the dragged shape appear frozen
at its old position for however many rAFs the evicted tile needs to
complete. At 28 ms per rAF chunk and 15 chunks that's ~400 ms of
visual freeze — the same total cost, just visible.
## Why the obvious workarounds don't apply
### Option A — relax the guard, spread cost across rAFs
Would seem to amortise the tile re-render. Fails because the atomic
publish semantics mean Target doesn't update until the tile completes.
The dragged shape would visually freeze for the duration. Same total
cost, worse UX.
### Option B/C — clip-based dirty-rect re-rendering of the tile
The idea: instead of re-rasterising the whole tile, clip to the union
of `old_bbox new_bbox` and only re-render shapes intersecting the
dirty rect. Fails for translation of a wide shape: the union is
approximately the whole shape bbox, and in the user's test case the
shape bbox already covers the whole tile — so every sibling still
intersects and still draws. The clip doesn't narrow the work.
Clip-based narrowing is only beneficial when the dragged shape is
small relative to the tile. In that case the task-17 change already
suffices (ancestor eviction skipped, only the shape's own tiles are
touched, and the shape's tiles are small enough that full re-render
is cheap).
### Option D — resurrect the drag-sprite fast path
Documented as abandoned on 2026-04-24 in
`memory/drag-sprite-abandoned.md`. Summary: capturing the dragged
subtree into a GPU Image + rewriting the atlas with "scene minus
shape" worked in principle but was structurally fragile — every tile
eviction path (`rebuild_touched_tiles`, `with_current_shape_mut!`
`touch_current()`, any other `remove_cached_tile_surface` caller)
would need its own "is drag active?" guard. The sprite maintains a
parallel cache the rest of the renderer doesn't know about.
## Remaining workaround candidates
### 1. Scroll-blit for pure translation
If the modifier is a pure translation (no rotation, no scale, no
layout reflow, no fill change), the tile's pre-drag content is still
valid for all pixels except:
- the strip the shape is vacating (needs "scene minus shape" fill)
- the strip the shape is entering (needs old atlas + shape at new
position)
Implementation sketch: snapshot the tile's atlas at drag start, blit
it translated by `delta`, then redraw only the dragged shape at its
new position and redraw whatever siblings intersect the exposed
strips.
Risk: identical correctness fragility to the drag-sprite approach —
any atlas-touching code path during the drag invalidates the
snapshot. Plus it only handles pure translation; the moment the user
triggers a rotation or a layout change the fast path falls off.
### 2. Sprite-of-dragged-shape only (no atlas rewrite)
Capture just the dragged shape into a GPU Image at drag start.
Per rAF: re-render the tile normally from the atlas (shape drawn
at old position — which is still correct in the atlas since we
didn't evict it), then paint the sprite on top at the
modifier-transformed position, with the shape's atlas contribution
somehow masked out.
Problem: masking out the shape's own contribution to the atlas
without a separate "scene minus shape" capture is hard. If the
shape has transparency, strokes that extend beyond fills, blend
modes, or effects, there's no clean mask. You'd end up with the
shape painted twice — once at the old position (from the atlas)
and once at the new (sprite).
Same fragility as option D for any path that evicts the shape's
tiles from the atlas mid-drag.
### 3. Deferred render of non-dragged shapes
Render the dragged shape every frame, but only re-render the
surrounding siblings every N-th frame or after pointer idle. The
dragged shape stays responsive; the backdrop updates at a lower
rate. Visual artifact: siblings that overlap the dragged shape
will appear stale (stacking-order glitches).
Acceptable UX if the overlap is small, ugly if the shape is being
dragged across a dense cluster.
## Measurement hooks preserved
None in the current tree. The `performance::measure!("set_modifiers")`
wrapper referenced in `memory/drag-sprite-abandoned.md` was also
removed during cleanup. If re-adding instrumentation, the key stages
to time are:
- `set_modifiers` whole-function (main.rs, inside `with_state_mut!`)
- `rebuild_modifier_tiles` (render.rs:3375)
- per-tile cost inside the `start_render_loop` tile walk (render.rs,
search for `render_shape_tree_partial` and the tile iteration)
- `apply_render_to_final_canvas` (only fires on full-tile completion)
The dominant cost for this test case is the per-tile shape walk.
`rebuild_modifier_tiles` is sub-millisecond after task 17.
## Files
- `render-wasm/src/render.rs:3375` — the kept change
(`rebuild_modifier_tiles` branching on `is_interactive_transform()`)
- `render-wasm/src/render.rs:2043` — anti-flicker guard
(single-rAF completion during interactive transform)
- `render-wasm/src/render.rs:1716` — atlas backdrop composition during
interactive transform
- `render-wasm/src/state/shapes_pool.rs:214``ShapesPool::set_modifiers`
invalidates ancestor extrect caches (independent of the render-state
walk, so task 17 doesn't break layout metadata)
- `render-wasm/src/main.rs``set_modifiers` / `set_modifiers_end` (now
back to a clean minimum after drag-sprite removal)

View File

@@ -223,6 +223,7 @@ pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn render(_: i32) -> Result<()> {
let dx_t = crate::get_now!();
with_state_mut!(state, {
state.rebuild_touched_tiles();
// Drain the throttled modifier-tile invalidation accumulated
@@ -230,7 +231,15 @@ pub extern "C" fn render(_: i32) -> Result<()> {
// interactive_transform; we do it once here, with the current
// modifier set, so the cost is paid once per rAF rather than
// once per pointer move.
if state.render_state.options.is_interactive_transform() {
// Skip mid-drag tile invalidation when the drag-sprite fast
// path is active: rebuild_modifier_tiles would clear atlas
// pixels under the dragged shape, wiping the captured backdrop.
// The fast-path render composites atlas + sprite directly; on
// commit, set_modifiers_end clears the sprite and the next
// full render rebuilds the affected tiles.
if state.render_state.options.is_interactive_transform()
&& !state.render_state.drag_sprite_is_active()
{
let ids = state.shapes.modifier_ids();
if !ids.is_empty() {
state.rebuild_modifier_tiles(ids)?;
@@ -240,6 +249,13 @@ pub extern "C" fn render(_: i32) -> Result<()> {
.start_render_loop(performance::get_time())
.map_err(|_| Error::RecoverableError("Error rendering".to_string()))?;
});
let dx_dt = crate::get_now!() - dx_t;
if dx_dt > 16.0 {
crate::run_script!(format!(
"console.log('[wasm-entry] render took {:.1}ms')",
dx_dt
));
}
Ok(())
}
@@ -354,7 +370,16 @@ pub extern "C" fn render_loading_overlay() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn process_animation_frame(timestamp: i32) -> Result<()> {
let dx_t = crate::get_now!();
let result = with_state_mut!(state, { state.process_animation_frame(timestamp) });
let dx_dt = crate::get_now!() - dx_t;
if dx_dt > 16.0 {
crate::run_script!(format!(
"console.log('[wasm-entry] process_animation_frame took {:.1}ms')",
dx_dt
));
}
if let Err(err) = result {
eprintln!("process_animation_frame error: {}", err);
}
@@ -404,6 +429,9 @@ pub extern "C" fn set_view_start() -> Result<()> {
}
performance::begin_measure!("set_view_start");
state.render_state.options.set_fast_mode(true);
// If a previous two-pass rebuild was mid-flight, discard its
// intent — the new gesture supersedes it.
state.render_state.options.set_defer_effects(false);
performance::end_measure!("set_view_start");
});
Ok(())
@@ -438,6 +466,11 @@ 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();
// Start the progressive two-pass rebuild. Pass 1 renders
// tiles without blur/shadow for fast feedback; when it
// completes, process_animation_frame flips this off and
// kicks pass 2 which adds the effects back in place.
state.render_state.options.set_defer_effects(true);
} else {
// Pure pan at the same zoom level: tile contents have not
// changed — only the viewport position moved. Update the
@@ -462,8 +495,22 @@ pub extern "C" fn set_view_end() -> Result<()> {
pub extern "C" fn set_modifiers_start() -> Result<()> {
with_state_mut!(state, {
performance::begin_measure!("set_modifiers_start");
// Drain pending touched_ids accumulated before the gesture
// started (typically from the click/selection that initiated
// the drag). Without this, the first rAF's rebuild_touched_tiles
// would evict 1-2 tiles between sprite capture and the
// try_render_frame check, tripping the eviction-seq mismatch
// and disabling the fast path for the entire gesture.
state.rebuild_touched_tiles();
state.render_state.options.set_fast_mode(true);
state.render_state.options.set_interactive_transform(true);
state.render_state.drag_sprite_reset();
// Keep the default interest area during drag. Reducing it to 1
// caused ghost-shape artifacts: tiles invalidated mid-drag that
// moved outside the smaller interest area never re-rendered, so
// the atlas kept stale content. Default (3) keeps the queue
// larger but ensures everything that could be invalidated also
// gets re-rendered.
performance::end_measure!("set_modifiers_start");
});
Ok(())
@@ -478,8 +525,15 @@ pub extern "C" fn set_modifiers_start() -> Result<()> {
pub extern "C" fn set_modifiers_end() -> Result<()> {
with_state_mut!(state, {
performance::begin_measure!("set_modifiers_end");
state.render_state.drag_sprite_clear();
state.render_state.options.set_fast_mode(false);
state.render_state.options.set_interactive_transform(false);
// Drop atlas regions for every tile invalidated during the
// gesture. We kept them populated during drag (to avoid
// disappearing-tile flicker), but stale shape silhouettes
// along the drag's path would otherwise linger in tiles that
// don't immediately re-render after drop.
state.render_state.flush_drag_atlas_cleanup();
state.render_state.cancel_animation_frame();
performance::end_measure!("set_modifiers_end");
});
@@ -952,11 +1006,13 @@ pub extern "C" fn set_structure_modifiers() -> Result<()> {
pub extern "C" fn clean_modifiers() -> Result<()> {
with_state_mut!(state, {
let prev_modifier_ids = state.shapes.clean_all();
// Skip the tile-cache cleanup during interactive transform: the
// per-rAF `rebuild_modifier_tiles` in `render()` already evicts
// the same tiles for the active modifier set, so the eviction
// here is redundant and doubles the per-emission cost.
if !prev_modifier_ids.is_empty() && !state.render_state.options.is_interactive_transform() {
// Skip the tile-cache cleanup when drag-sprite is active: the
// sprite fast path doesn't read the dragged shape's tile cache
// and the eviction would wipe the captured atlas backdrop. The
// next `set_modifiers` will replace `state.shapes.modifiers`
// regardless, so the data layer stays consistent. Tiles are
// rebuilt at gesture commit (set_modifiers_end → next render).
if !prev_modifier_ids.is_empty() && !state.render_state.drag_sprite_is_active() {
state
.render_state
.update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?;
@@ -982,13 +1038,36 @@ pub extern "C" fn set_modifiers() -> Result<()> {
ids.push(entry.id);
}
let dx_t = crate::get_now!();
let dx_n = ids.len();
with_state_mut!(state, {
// Phase 3: capture sprite + backdrop BEFORE applying the
// modifier, so the captured image is at the pre-drag position.
// The state machine ensures the capture only runs on the first
// event of each gesture; subsequent calls are no-ops.
if state.render_state.options.is_interactive_transform() {
let ts = performance::get_time();
state
.render_state
.drag_sprite_try_capture(&ids, &state.shapes, ts)?;
}
state.set_modifiers(modifiers);
// TO CHECK
// Throttle: skip per-pointer-move tile invalidation. The render
// entry (`render`) drains the current modifier set once per rAF
// and calls rebuild_modifier_tiles then. With ~3 pointer moves
// per rAF, this cuts tile invalidations by 3× and removes the
// PAF backlog.
if !state.render_state.options.is_interactive_transform() {
state.rebuild_modifier_tiles(ids)?;
}
});
let dx_dt = crate::get_now!() - dx_t;
if dx_dt > 16.0 {
crate::run_script!(format!(
"console.log('[wasm-entry] set_modifiers ids={} took {:.1}ms')",
dx_n, dx_dt
));
}
Ok(())
}

View File

@@ -1,4 +1,5 @@
mod debug;
pub mod drag_sprite;
mod fills;
pub mod filters;
mod fonts;
@@ -372,6 +373,17 @@ 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,
/// One-shot flag consumed by `start_render_loop`. When set, the Cache
/// surface is NOT wiped for the upcoming pass — its current content
/// (typically a first-pass preview) stays visible while new tiles
/// overwrite it in place. Used by the progressive two-pass rebuild
/// after a zoom ends.
pub preserve_cache_this_render: bool,
// ---- Drag-perf diagnostic counters (read-only instrumentation) ----
// Cumulative `remove_cached_tile` calls since last PAF. Reset at end
// of each `process_animation_frame`. Tells us how aggressively tiles
// are being invalidated between PAF ticks.
pub dx_inval_since_paf: u32,
/// True iff the current tile had shapes assigned to it when we
/// started rendering it. Lets us distinguish a genuinely empty
/// tile (skip composite, just clear) from a tile whose walker
@@ -379,15 +391,51 @@ pub(crate) struct RenderState {
/// (must composite to present the work). Reset when current_tile
/// changes.
pub current_tile_had_shapes: bool,
/// During interactive transforms we keep `Target` between rAFs. Seed the
/// interactive backdrop exactly once per gesture (first rAF) so we don't
/// repeatedly overwrite tiles that have already been updated.
pub interactive_target_seeded: bool,
/// GPU crops from `Backbuffer` keyed by shape id. Filled on full-frame completion; during
/// drag, entries for the moved top-level selection are ensured here
/// Dormant. GPU crops from `Backbuffer` keyed by shape id, populated
/// after full-quality renders by `rebuild_backbuffer_crop_cache`. The
/// active drag-sprite path uses the persistent atlas as backdrop
/// instead and doesn't consult this cache; the field stays as a
/// scaffolding hook for re-enabling Alex's caching layer.
pub backbuffer_crop_cache: HashMap<Uuid, InteractiveDragCrop>,
/// Diagnostic: count of `mark_touched` calls since the last
/// `rebuild_touched_tiles` drain. High counts during drag indicate
/// CLJS is firing many shape mutations per pointer move.
pub dx_touch_calls: u32,
/// Pre-drag tile coverage for each shape that has a modifier
/// applied during the current gesture. Snapshotted on the first
/// `rebuild_modifier_tiles` per shape, invalidated at gesture end
/// (`clean_modifiers`). Without this, the shape's pre-drag atlas
/// region is never cleared because the per-rAF tile-index updates
/// during drag overwrite the original position before drop ever
/// gets a chance to invalidate it.
pub dx_pre_drag_tiles: HashMap<Uuid, Vec<tiles::Tile>>,
/// Every tile invalidated during the current interactive_transform
/// gesture. We skip clearing the atlas region on invalidation
/// during drag (to avoid disappearing-tile flicker), but tiles
/// that move outside the reduced interest area before they
/// re-render leave stale atlas content. At gesture end we drain
/// this set and force-clear all those atlas regions.
pub dx_drag_dirty_tiles: HashSet<tiles::Tile>,
/// Cumulative tile-eviction counter. Bumped by every
/// `remove_cached_tile` call. Survives across PAFs (unlike
/// `dx_inval_since_paf`), so the drag-sprite path can compare
/// "current seq" to "seq at capture time" to detect an unexpected
/// eviction mid-gesture.
pub tile_eviction_seq: u32,
/// Drag-sprite optimization state for the current gesture. See
/// `render/drag_sprite.rs`.
pub drag_sprite_state: drag_sprite::DragSpriteState,
/// Active during `capture_drag_backdrop`: the tile-render walker
/// consults this set at both root-level and child-recursion to
/// skip the dragged subtree, leaving the atlas with "scene minus
/// shape" pixels. `None` outside the brief backdrop-capture call.
pub drag_capture_exclude: Option<HashSet<Uuid>>,
}
/// Dormant scaffolding. Kept alongside `should_use_cached_top_level_during_interactive`
/// and `rebuild_backbuffer_crop_cache` for the alternative cached-crop drag
/// path. The active drag-sprite path doesn't consult these fields.
#[allow(dead_code)]
pub struct InteractiveDragCrop {
pub src_doc_bounds: Rect,
pub src_selrect: Rect,
@@ -420,6 +468,12 @@ impl RenderState {
/// because other transforms would require resampling and can diverge from the live render.
/// - **Other cached nodes**: if the moving bounds overlap this cached crop, invalidate it so
/// we don't show stale content while something moves over/inside it.
///
/// Currently dormant: kept as data-structure scaffolding alongside the
/// drag-sprite path, which uses the persistent atlas as backdrop and
/// doesn't consult this cache. Re-enable by re-adding the call site at
/// the top of the tile walker if the drag-sprite path is removed.
#[allow(dead_code)]
fn should_use_cached_top_level_during_interactive(
&mut self,
node_id: Uuid,
@@ -532,9 +586,16 @@ impl RenderState {
preview_mode: false,
export_context: None,
cache_cleared_this_render: false,
preserve_cache_this_render: false,
dx_inval_since_paf: 0,
current_tile_had_shapes: false,
interactive_target_seeded: false,
backbuffer_crop_cache: HashMap::default(),
dx_touch_calls: 0,
dx_pre_drag_tiles: HashMap::new(),
dx_drag_dirty_tiles: HashSet::new(),
tile_eviction_seq: 0,
drag_sprite_state: drag_sprite::DragSpriteState::default(),
drag_capture_exclude: None,
})
}
@@ -592,7 +653,7 @@ impl RenderState {
/// Must be called BEFORE any save_layer for the shape's own opacity/blend,
/// so that the backdrop blur is independent of the shape's visual properties.
fn render_background_blur(&mut self, shape: &Shape, target_surface: SurfaceId) {
if self.options.is_fast_mode() {
if self.options.should_skip_effects() {
return;
}
if matches!(shape.shape_type, Type::Text(_)) || matches!(shape.shape_type, Type::SVGRaw(_))
@@ -844,17 +905,8 @@ impl RenderState {
}
pub fn apply_render_to_final_canvas(&mut self, rect: skia::Rect) -> Result<()> {
// During interactive transforms we render tiles directly into Target; updating the cache
// (snapshot -> atlas blit -> tiles.add) can force GPU stalls. Defer cache rebuild until
// the interaction ends.
if self.options.is_interactive_transform() {
let tile_rect = self.get_current_aligned_tile_bounds()?;
self.surfaces
.draw_current_tile_direct_target_only(&tile_rect, self.background_color);
return Ok(());
}
let fast_mode = self.options.is_fast_mode();
let skip_atlas = self.options.is_defer_effects();
// Decide *now* (at the first real cache blit) whether we need to clear Cache.
// This avoids clearing Cache on renders that don't actually paint tiles (e.g. hover/UI),
// while still preventing stale pixels from surviving across full-quality renders.
@@ -875,6 +927,7 @@ impl RenderState {
&current_tile,
&tile_rect,
fast_mode,
skip_atlas,
self.render_area,
);
@@ -1009,13 +1062,10 @@ impl RenderState {
s.canvas().save();
});
}
let fast_mode = self.options.is_fast_mode();
// Skip anti-aliasing entirely during fast_mode (interactive
// gestures + pan/zoom). AA edge sampling is per-pixel and adds
// up across many shapes; reverts to full quality on commit.
let antialias = !fast_mode
&& shape.should_use_antialias(self.get_scale(), self.options.antialias_threshold);
let skip_effects = fast_mode;
let antialias =
shape.should_use_antialias(self.get_scale(), self.options.antialias_threshold);
let skip_effects = self.options.should_skip_effects();
let has_nested_fills = self
.nested_fills
@@ -1616,6 +1666,7 @@ impl RenderState {
}
}
#[allow(dead_code)]
fn rebuild_backbuffer_crop_cache(&mut self, tree: ShapesPoolRef) {
self.backbuffer_crop_cache.clear();
@@ -1903,6 +1954,14 @@ impl RenderState {
self.stats.clear();
let _start = performance::begin_timed_log!("start_render_loop");
// Phase 3: drag-sprite fast path. When the sprite is captured
// and validity checks pass, this composites atlas-backdrop +
// sprite at the modifier position and skips the slow tile walk.
if self.drag_sprite_try_render_frame(tree) {
return Ok(());
}
let scale = self.get_scale();
self.tile_viewbox.update(self.viewbox, scale);
@@ -1911,28 +1970,49 @@ impl RenderState {
performance::begin_measure!("render");
performance::begin_measure!("start_render_loop");
// When preserve_cache_this_render is set, pretend the Cache surface
// has already been cleared for this pass. The first-tile clear guards
// in apply_render_to_final_canvas keep their pass-1 content intact so
// pass-2 tiles overwrite in place (no flicker between passes).
self.cache_cleared_this_render = self.preserve_cache_this_render;
self.preserve_cache_this_render = false;
self.reset_canvas();
// Compute and set document-space bounds (1 unit == 1 doc px @ 100% zoom)
// to clamp atlas updates. This prevents zoom-out tiles from forcing atlas
// growth far beyond real content.
let doc_bounds = self.compute_document_bounds(base_object, tree);
self.surfaces.set_atlas_doc_bounds(doc_bounds);
self.cache_cleared_this_render = false;
if self.options.is_interactive_transform() {
// Keep `Target` as the previous frame and overwrite only the tiles
// that changed. This avoids clearing + redrawing an atlas backdrop
// every rAF during drag (a common source of GPU work/stalls).
// During an interactive shape transform (drag/resize/rotate) the
// Target is repainted tile-by-tile. If only a subset of the
// invalidated tiles finishes in this rAF the remaining area
// would either show stale content from the previous frame or,
// on buffer swaps, show blank pixels — either way the user
// perceives tiles appearing sequentially. Paint the persistent
// 1:1 atlas as a stable backdrop so every flush presents a
// coherent picture: unchanged tiles come from the atlas and
// invalidated tiles are overwritten on top as they finish.
//
// The same applies to the post-zoom pass 1 (`defer_effects`):
// tiles at the new scale are still being rebuilt, so paint the
// atlas as a scaled backdrop until they land. Unlike the
// interactive-transform case we do NOT clear Target first —
// during early-load gestures the atlas may only cover part of
// the viewport and clearing would flash bg-colored rectangles
// where pass-1 tiles haven't landed yet. Target already holds
// whatever `render_from_cache` drew during the gesture (or
// direct tile renders from a prior rAF), which is a strictly
// better backdrop than the raw background color.
if self.options.is_interactive_transform() && self.surfaces.has_atlas() {
self.surfaces.draw_atlas_to_target(
self.viewbox,
self.options.dpr(),
self.background_color,
);
} else if self.options.is_defer_effects() && self.surfaces.has_atlas() {
self.surfaces
.reset_interactive_transform(self.background_color);
if !self.interactive_target_seeded {
// Seed from the last presented frame; this is stable even when
// fast_mode skips cache updates and regardless of atlas coverage.
self.surfaces.seed_target_from_backbuffer();
self.interactive_target_seeded = true;
}
} else {
self.reset_canvas();
self.interactive_target_seeded = false;
.draw_atlas_over_target(self.viewbox, self.options.dpr());
}
let surface_ids = SurfaceId::Strokes as u32
@@ -2050,6 +2130,11 @@ impl RenderState {
timestamp: i32,
) -> Result<()> {
performance::begin_measure!("process_animation_frame");
let dx_t0 = crate::get_now!();
let dx_pending_in = self.pending_tiles.list.len();
let dx_inval_at_start = self.dx_inval_since_paf;
let dx_interactive = self.options.is_interactive_transform();
let dx_was_in_progress = self.render_in_progress;
if self.render_in_progress {
if tree.len() != 0 {
self.render_shape_tree_partial(base_object, tree, timestamp, true)?;
@@ -2071,18 +2156,57 @@ impl RenderState {
if self.render_in_progress {
self.cancel_animation_frame();
self.render_request_id = Some(wapi::request_animation_frame!());
} else if self.options.is_defer_effects() {
// Pass 1 just finished — tiles are on screen without
// blur/shadow. Immediately launch pass 2 to upgrade them
// in place. The tile texture cache is invalidated so
// every tile re-renders; the Cache surface is preserved
// so pass-1 content stays visible until each pass-2
// tile overwrites it.
self.options.set_defer_effects(false);
self.surfaces.invalidate_tile_cache();
self.preserve_cache_this_render = true;
self.start_render_loop(base_object, tree, timestamp, false)?;
} else {
// A full-quality frame is now complete. Refresh Backbuffer and regenerate
// the per-shape crop cache so interactive drags can reuse pixels.
if !self.options.is_fast_mode() && !self.options.is_interactive_transform() {
self.surfaces.copy_target_to_backbuffer();
self.rebuild_backbuffer_crop_cache(tree);
}
// A full-quality frame is now complete.
//
// Backbuffer-population call is intentionally omitted: the
// active drag path uses the persistent atlas as backdrop
// and doesn't read Backbuffer, so copying Target →
// Backbuffer on every full render would be a wasted
// viewport-sized GPU blit (very visible during pan, where
// full-quality renders fire continuously). Re-add
// `self.surfaces.copy_target_to_backbuffer()` here if any
// consumer is brought back.
wapi::notify_tiles_render_complete!();
performance::end_measure!("render");
}
}
performance::end_measure!("process_animation_frame");
// Per-PAF diagnostic. Emit only on slow PAFs (> 16ms breaks 60fps
// budget) or large invalidation bursts. Per-rAF logging with
// DevTools console open costs more than the work it measures.
let dx_pending_out = self.pending_tiles.list.len();
let dx_inval_in_paf = self.dx_inval_since_paf;
let dx_dt = crate::get_now!() - dx_t0;
if dx_dt > 16.0 || dx_inval_in_paf > 50 {
crate::run_script!(format!(
"console.log('[paf] dt={:.1}ms interactive={} render_in_progress={} pending_in={} pending_out={} inval_in_paf={}')",
dx_dt,
dx_interactive,
dx_was_in_progress,
dx_pending_in,
dx_pending_out,
dx_inval_in_paf
));
}
// Clear the per-PAF invalidation counter for the next tick.
// Note: invalidations that arrive AFTER this reset (e.g., from
// a `set_modifiers` call between PAFs) accumulate against the
// next PAF — exactly what we want to measure.
let _ = dx_inval_at_start; // (silence unused warning)
self.dx_inval_since_paf = 0;
Ok(())
}
@@ -2273,8 +2397,9 @@ impl RenderState {
paint.set_blend_mode(element.blend_mode().into());
paint.set_alpha_f(element.opacity());
// Skip frame-level blur in fast mode (pan/zoom).
if !self.options.is_fast_mode() {
// Skip frame-level blur in fast mode (pan/zoom) or during the
// post-gesture first rebuild pass.
if !self.options.should_skip_effects() {
if let Some(frame_blur) = Self::frame_clip_layer_blur(element) {
let scale = self.get_scale();
let sigma = radius_to_sigma(frame_blur.value * scale);
@@ -2878,29 +3003,6 @@ impl RenderState {
target_surface = SurfaceId::Export;
}
// During interactive transforms we compute the union of the current bounds of all
// modified shapes (doc-space @ 100% zoom, scale=1.0). This is used as a cheap overlap
// guard to decide when cached top-level crops are unsafe to reuse (something is moving
// over/inside them), without doing expensive ancestor walks per node.
let moved_bounds =
if self.options.is_interactive_transform() && !tree.modifier_ids().is_empty() {
let mut acc: Option<Rect> = None;
for id in tree.modifier_ids().iter() {
let Some(s) = tree.get(id) else { continue };
let r = self.get_cached_extrect(s, tree, 1.0);
acc = Some(match acc {
None => r,
Some(mut prev) => {
prev.join(r);
prev
}
});
}
acc
} else {
None
};
while let Some(node_render_state) = self.pending_nodes.pop() {
let node_id = node_render_state.id;
let visited_children = node_render_state.visited_children;
@@ -2971,64 +3073,6 @@ impl RenderState {
}
}
// Interactive drag cache: if this node is cacheable during interactive transform,
// draw it directly from Backbuffer crop on the current tile surface and skip
// traversing/rendering the subtree.
if self.options.is_interactive_transform() {
let use_cached = self.should_use_cached_top_level_during_interactive(
node_id,
tree,
&tree.modifier_ids(),
moved_bounds,
);
if use_cached {
if let Some(crop) = self.backbuffer_crop_cache.get(&node_id) {
let crop_image = &crop.image;
let crop_src_selrect = crop.src_selrect;
let crop_src_doc_bounds = crop.src_doc_bounds;
let cur_selrect = tree.get(&node_id).map(|s| s.selrect());
let (dx, dy) = match cur_selrect {
Some(cur) => (
cur.left - crop_src_selrect.left,
cur.top - crop_src_selrect.top,
),
None => (0.0, 0.0),
};
let dst_doc_rect = Rect::new(
crop_src_doc_bounds.left + dx,
crop_src_doc_bounds.top + dy,
crop_src_doc_bounds.right + dx,
crop_src_doc_bounds.bottom + dy,
);
let scale = self.get_scale();
let translation = self
.surfaces
.get_render_context_translation(self.render_area, scale);
let dst_tile_rect = skia::Rect::from_xywh(
(dst_doc_rect.left + translation.0) * scale,
(dst_doc_rect.top + translation.1) * scale,
dst_doc_rect.width() * scale,
dst_doc_rect.height() * scale,
);
// let canvas = self.surfaces.canvas_and_mark_dirty(target_surface);
let canvas = self.surfaces.canvas(target_surface);
canvas.save();
canvas.reset_matrix();
canvas.draw_image_rect(
crop_image,
None,
dst_tile_rect,
&skia::Paint::default(),
);
canvas.restore();
}
continue;
}
}
let can_flatten = element.can_flatten() && !self.focus_mode.should_focus(&element.id);
// Skip render_shape_enter/exit for flattened containers
@@ -3042,7 +3086,7 @@ impl RenderState {
// the layer blur (which would make it more diffused than without clipping)
let shadow_before_layer = !node_render_state.is_root()
&& self.focus_mode.is_active()
&& !self.options.is_fast_mode()
&& !self.options.should_skip_effects()
&& !matches!(element.shape_type, Type::Text(_))
&& Self::frame_clip_layer_blur(element).is_some()
&& element.drop_shadows_visible().next().is_some();
@@ -3078,8 +3122,9 @@ impl RenderState {
.surfaces
.get_render_context_translation(self.render_area, scale);
// Skip expensive drop shadow rendering in fast mode (during pan/zoom).
let skip_shadows = self.options.is_fast_mode();
// Skip expensive drop shadow rendering in fast mode (during
// pan/zoom) or during the post-gesture first rebuild pass.
let skip_shadows = self.options.should_skip_effects();
// Skip shadow block when already rendered before the layer (frame_clip_layer_blur)
let shadows_already_rendered = Self::frame_clip_layer_blur(element).is_some();
@@ -3175,6 +3220,16 @@ impl RenderState {
let children_ids = sort_z_index(tree, element, children_ids);
for child_id in children_ids.iter() {
// During drag-sprite backdrop capture, skip the
// dragged subtree so the atlas receives "scene
// minus shape" pixels.
if self
.drag_capture_exclude
.as_ref()
.is_some_and(|s| s.contains(child_id))
{
continue;
}
self.pending_nodes.push(NodeRenderState {
id: *child_id,
visited_children: false,
@@ -3218,17 +3273,12 @@ impl RenderState {
if let Some(current_tile) = self.current_tile {
if self.surfaces.has_cached_tile_surface(current_tile) {
performance::begin_measure!("render_shape_tree::cached");
// During interactive transforms, `Target` is preserved and seeded once
// from Backbuffer. Cached tiles are therefore already visible and
// re-blitting them costs extra GPU work.
let tile_rect = self.get_current_tile_bounds()?;
if !self.options.is_interactive_transform() {
self.surfaces.draw_cached_tile_surface(
current_tile,
tile_rect,
self.background_color,
);
}
self.surfaces.draw_cached_tile_surface(
current_tile,
tile_rect,
self.background_color,
);
// Also draw the cached tile to the Cache surface so
// render_from_cache (used during pan) has the full scene.
@@ -3266,19 +3316,18 @@ impl RenderState {
}
performance::end_measure!("render_shape_tree::uncached");
let tile_rect = self.get_current_tile_bounds()?;
// Composite if the walker did work in this PAF (`!is_empty`) OR
// the tile has unfinished work from a previous PAF
// (`current_tile_had_shapes` was set when we populated pending_nodes
// for this tile).
// Composite if the walker did work in this PAF
// (`!is_empty`) OR the tile has unfinished work from
// a previous PAF (`current_tile_had_shapes` was set
// when we populated pending_nodes for this tile).
// The explicit clear is reserved for tiles that
// genuinely have no shapes assigned to them —
// without this distinction, chunked-render
// resumption was painting completed tiles back to
// background, producing the disappearing-tile
// flicker during drag.
if !is_empty || self.current_tile_had_shapes {
if self.options.is_interactive_transform() {
// During drag, avoid snapshot-based caching. Draw Current directly
// into Target (and Cache) to reduce stalls.
self.surfaces
.draw_current_tile_direct(&tile_rect, self.background_color);
} else {
self.apply_render_to_final_canvas(tile_rect)?;
}
self.apply_render_to_final_canvas(tile_rect)?;
if self.options.is_debug_visible() {
debug::render_workspace_current_tile(
@@ -3294,7 +3343,6 @@ impl RenderState {
paint.set_color(self.background_color);
s.canvas().draw_rect(tile_rect, &paint);
});
// Keep Cache surface coherent for render_from_cache.
if !self.options.is_fast_mode() {
if !self.cache_cleared_this_render {
self.surfaces.clear_cache(self.background_color);
@@ -3339,28 +3387,26 @@ impl RenderState {
})
});
// We only need first level shapes, in the same order as the parent node.
//
// During interactive transforms we may invalidate only the modified shapes
// (to avoid massive ancestor eviction). However, we still composite full
// tiles (we clear the tile rect before drawing Current), so we must render
// all root shapes that can contribute to this tile; otherwise, unchanged
// siblings inside the same tile would disappear.
// We only need first level shapes, in the same order as the parent node
let mut valid_ids = Vec::with_capacity(ids.len());
if self.options.is_interactive_transform() || tile_has_bg_blur {
valid_ids.extend(root_ids.iter().copied());
} else {
for root_id in root_ids.iter() {
if ids.contains(root_id) {
valid_ids.push(*root_id);
}
for root_id in root_ids.iter() {
// During drag-sprite backdrop capture, skip
// root-level shapes in the excluded subtree.
if self
.drag_capture_exclude
.as_ref()
.is_some_and(|s| s.contains(root_id))
{
continue;
}
if tile_has_bg_blur || ids.contains(root_id) {
valid_ids.push(*root_id);
}
}
if !valid_ids.is_empty() {
self.current_tile_had_shapes = true;
}
self.pending_nodes.extend(valid_ids.into_iter().map(|id| {
NodeRenderState {
id,
@@ -3550,10 +3596,39 @@ impl RenderState {
}
pub fn remove_cached_tile(&mut self, tile: tiles::Tile) {
self.dx_inval_since_paf += 1;
self.drag_sprite_notify_eviction();
// Diagnostic: log every eviction that fires while a sprite is
// captured. These are the ones that trip the eviction-seq
// mismatch and disable the drag-sprite fast path. The tile
// coords plus surrounding context tell us which call site is
// sneaking in mid-gesture.
if matches!(
self.drag_sprite_state,
drag_sprite::DragSpriteState::Captured(_)
) {
crate::run_script!(format!(
"console.log('[evict_during_sprite] tile=({},{}) seq={}')",
tile.x(),
tile.y(),
self.tile_eviction_seq
));
}
self.surfaces
.remove_cached_tile_surface(&mut self.gpu_state, tile);
}
/// No-op shim: kept so main.rs::set_modifiers_end still compiles.
/// Atlas clearing is now done unconditionally in
/// `remove_cached_tile_surface`, the original behavior. The
/// interactive-transform-aware atlas-keep optimization caused
/// ghost-rect artifacts on heavy files; reverting to the simple
/// model.
pub fn flush_drag_atlas_cleanup(&mut self) {
let _ = self.dx_drag_dirty_tiles.drain();
let _ = self.dx_pre_drag_tiles.drain();
}
/// Rebuild the tile index (shape→tile mapping) for all top-level shapes.
/// This does NOT invalidate the tile texture cache — cached tile images
/// survive so that fast-mode renders during pan still show shadows/blur.
@@ -3641,6 +3716,26 @@ impl RenderState {
let mut all_tiles = HashSet::<tiles::Tile>::new();
let ids = std::mem::take(&mut self.touched_ids);
let dx_n_shapes = ids.len();
let dx_n_touch_calls = std::mem::take(&mut self.dx_touch_calls);
// Diagnostic: log when this fires with non-empty touched_ids
// while a sprite is captured. These touches sneak in between
// capture and the first frame check, tripping the eviction-seq
// mismatch and disabling the fast path.
if dx_n_shapes > 0
&& matches!(
self.drag_sprite_state,
drag_sprite::DragSpriteState::Captured(_)
)
{
let sample: Vec<String> = ids.iter().take(3).map(|u| u.to_string()).collect();
crate::run_script!(format!(
"console.log('[touched_during_sprite] n_shapes={} sample_ids={}')",
dx_n_shapes,
sample.join(",")
));
}
for shape_id in ids.iter() {
if let Some(shape) = tree.get(shape_id) {
@@ -3650,11 +3745,19 @@ impl RenderState {
}
}
let dx_n_tiles = all_tiles.len();
// Update the changed tiles
for tile in all_tiles {
self.remove_cached_tile(tile);
}
if dx_n_tiles > 20 || dx_n_touch_calls > 20 {
crate::run_script!(format!(
"console.log('[touched] mark_calls={} unique_shapes={} tiles_invalidated={}')",
dx_n_touch_calls, dx_n_shapes, dx_n_tiles
));
}
performance::end_measure!("rebuild_touched_tiles");
}
@@ -3696,16 +3799,38 @@ impl RenderState {
tree: ShapesPoolMutRef<'_>,
ids: Vec<Uuid>,
) -> Result<()> {
// During interactive transform, skip ancestor invalidation: walking up to the
// parent frame evicts every tile the frame covers, including dense tiles with
// many siblings. Ancestor extrect caches are already invalidated by
// `ShapesPool::set_modifiers`; the tile index is reconciled post-gesture by
// the committing code path (rebuild_touched_tiles).
if self.options.is_interactive_transform() {
// During interactive transform, skip ancestor invalidation: walking
// up to the parent frame evicts every tile the frame covers,
// including dense tiles with hundreds of siblings. The anti-flicker
// guard then forces all of them to re-render in a single frame.
// Ancestor extrect caches are already invalidated by
// `ShapesPool::set_modifiers`; the tile index is reconciled
// post-gesture by the committing code path (rebuild_touched_tiles).
let interactive = self.options.is_interactive_transform();
let inval_before = self.dx_inval_since_paf;
let processed_count = if interactive {
self.update_tiles_shapes(&ids, tree)?;
ids.len()
} else {
let ancestors = all_with_ancestors(&ids, tree, false);
let n = ancestors.len();
self.update_tiles_shapes(&ancestors, tree)?;
n
};
let inval_added = self.dx_inval_since_paf - inval_before;
// Only log when something unusual happens — per-rAF logging itself
// costs 0.5-2ms with DevTools console open, polluting the very
// measurement we care about. Spike threshold catches accidental
// ancestor-walk (interactive=false) or many tiles invalidated.
if !interactive || inval_added > 8 {
crate::run_script!(format!(
"console.log('[rebuild] interactive={} input_ids={} processed={} tiles_invalidated={}')",
interactive,
ids.len(),
processed_count,
inval_added
));
}
Ok(())
}
@@ -3728,6 +3853,7 @@ impl RenderState {
pub fn mark_touched(&mut self, uuid: Uuid) {
self.touched_ids.insert(uuid);
self.dx_touch_calls += 1;
}
#[allow(dead_code)]

View File

@@ -0,0 +1,649 @@
//! Drag-sprite optimization (Phase 3: backdrop + sprite + fast-path render).
//!
//! At gesture start (first `set_modifiers` of an interactive transform):
//! 1. Capture the affected tiles WITHOUT the dragged shape into the
//! persistent atlas — this is the "scene minus shape" backdrop.
//! 2. Capture the shape itself into a GPU image — the sprite.
//! 3. Snapshot the cumulative `tile_eviction_seq` AFTER both captures.
//!
//! Per rAF during drag:
//! - Check sprite is still valid: `tile_eviction_seq` matches snapshot,
//! modifier is translation-only, zoom hasn't changed. Mismatch → flip
//! to `Disabled` for the rest of the gesture (slow path resumes).
//! - Draw the atlas (which holds the backdrop) to the target.
//! - Blit the sprite at the modifier-transformed position.
//!
//! On `set_modifiers_end`: discard sprite and let the normal post-gesture
//! render rebuild the affected tiles with the shape at its committed
//! position.
use std::collections::HashSet;
use skia_safe::{self as skia, Rect};
use super::{ui, NodeRenderState, RenderState, SurfaceId};
use crate::error::Result;
use crate::performance;
use crate::state::ShapesPoolRef;
use crate::tiles::{self, TileRect};
use crate::uuid::Uuid;
/// State of the drag-sprite optimization for the current gesture.
#[derive(Default)]
pub enum DragSpriteState {
/// No active gesture, or capture not yet attempted.
#[default]
Idle,
/// Sprite captured at gesture start. Per-rAF compositing path is
/// active subject to per-frame validity checks.
Captured(DragSprite),
/// Capture was attempted and is invalid for the rest of this
/// gesture (preconditions not met, unexpected eviction, non-
/// translation modifier, zoom change, etc.). Slow path resumes.
Disabled,
}
/// Pre-rendered raster of the dragged shape, plus metadata used to
/// validate that the cached state is still safe to use each frame.
#[derive(Clone)]
pub struct DragSprite {
/// GPU-backed snapshot of the shape rendered into Export at the
/// pre-modifier position, with all fills/strokes/effects baked in.
pub image: skia::Image,
/// Shape's pre-drag extrect in doc space. The per-rAF blit uses
/// `modifier.map_rect(base_doc_rect)` to place the sprite.
pub base_doc_rect: Rect,
/// Render scale used when the sprite was captured. Mismatch with
/// the current zoom invalidates the sprite (would alias).
pub sprite_scale: f32,
/// Id of the shape this sprite represents.
pub shape_id: Uuid,
/// `RenderState::tile_eviction_seq` AFTER backdrop capture
/// completes. Any subsequent eviction bumps the live counter,
/// signalling the backdrop atlas is no longer trustworthy.
pub captured_eviction_seq: u32,
/// Viewport-sized capture of the shapes that render *after* the
/// dragged shape in document order (its z-order successors at every
/// ancestor level). Drawn on top of the sprite each rAF so shapes
/// that should overlap the dragged shape do, instead of the sprite
/// always sitting on top. `None` when the dragged shape is on top
/// at every level (no above-shapes).
pub above_image: Option<skia::Image>,
}
/// Returns true iff the matrix is a pure translation (within a small
/// epsilon to absorb floating-point noise from modifier composition).
/// Drag-sprite's per-rAF blit assumes the captured pixels are still
/// correct at the new position — so any rotate/scale/skew/perspective
/// requires falling back to the slow path.
///
/// We can't use `Matrix::is_translate` directly: it consults Skia's
/// type mask which is strict (any scale != exactly 1.0 sets the SCALE
/// bit). In practice modifier matrices arrive with sub-microscale
/// noise like `sx = 0.99999964` from upstream matrix composition,
/// which is visually pure translation but trips the strict check.
fn is_translation_only(m: &skia::Matrix) -> bool {
let eps = 1e-4_f32;
(m.scale_x() - 1.0).abs() < eps
&& (m.scale_y() - 1.0).abs() < eps
&& m.skew_x().abs() < eps
&& m.skew_y().abs() < eps
&& m.persp_x().abs() < eps
&& m.persp_y().abs() < eps
}
impl RenderState {
/// Idempotent. Called from `set_modifiers_start`.
pub fn drag_sprite_reset(&mut self) {
self.drag_sprite_state = DragSpriteState::Idle;
}
/// Drop any captured sprite. Called from `set_modifiers_end`. The
/// subsequent commit-time render will rebuild the affected tiles
/// with the shape at its final position.
pub fn drag_sprite_clear(&mut self) {
self.drag_sprite_state = DragSpriteState::Idle;
}
/// True iff the drag-sprite fast path is currently in `Captured`
/// state. Callers in `main.rs` use this to suppress mid-drag
/// `rebuild_modifier_tiles` (which would wipe the backdrop).
pub fn drag_sprite_is_active(&self) -> bool {
matches!(self.drag_sprite_state, DragSpriteState::Captured(_))
}
/// Try to capture the dragged shape's sprite + scene-minus-shape
/// backdrop. No-op if state is not `Idle` (already captured or
/// disabled). On any error, transitions to `Disabled`.
///
/// Must be called BEFORE `state.set_modifiers(modifiers)` on the
/// first event of a gesture so the shape is rendered at its
/// pre-drag position.
pub fn drag_sprite_try_capture(
&mut self,
ids: &[Uuid],
tree: ShapesPoolRef,
timestamp: i32,
) -> Result<()> {
if !matches!(self.drag_sprite_state, DragSpriteState::Idle) {
return Ok(());
}
if ids.len() != 1 {
// Multi-shape drag: out of Phase 3 scope.
self.drag_sprite_state = DragSpriteState::Disabled;
return Ok(());
}
let shape_id = ids[0];
match self.capture_drag_sprite_internal(&shape_id, tree, timestamp) {
Ok(sprite) => {
self.drag_sprite_state = DragSpriteState::Captured(sprite);
}
Err(_) => {
self.drag_sprite_state = DragSpriteState::Disabled;
}
}
Ok(())
}
/// Try the fast-path render. Returns true if the frame was rendered
/// (caller should skip the normal render loop). Returns false if
/// preconditions weren't met (state not Captured, eviction mismatch,
/// non-translation modifier, zoom change), in which case the slow
/// path runs.
///
/// Side effect: on validity failure, transitions state to `Disabled`
/// so the slow path runs for the rest of the gesture.
pub fn drag_sprite_try_render_frame(&mut self, tree: ShapesPoolRef) -> bool {
// Borrow-check dance: extract sprite by clone (skia::Image is
// Arc-like, so this is cheap), then we're free to mutate self.
let sprite = match &self.drag_sprite_state {
DragSpriteState::Captured(s) => s.clone(),
_ => return false,
};
// Validity check 1: eviction-sequence must match.
if sprite.captured_eviction_seq != self.tile_eviction_seq {
crate::run_script!(format!(
"console.log('[drag_sprite] disabled: eviction_seq mismatch (captured={} current={})')",
sprite.captured_eviction_seq, self.tile_eviction_seq
));
self.drag_sprite_state = DragSpriteState::Disabled;
return false;
}
// Validity check 2: zoom must match.
if (sprite.sprite_scale - self.get_scale()).abs() > 1e-5 {
crate::run_script!(format!(
"console.log('[drag_sprite] disabled: scale changed ({} -> {})')",
sprite.sprite_scale,
self.get_scale()
));
self.drag_sprite_state = DragSpriteState::Disabled;
return false;
}
// Validity check 3: modifier must be translation-only.
let modifier = tree.get_modifier(&sprite.shape_id);
if let Some(m) = modifier {
if !is_translation_only(m) {
crate::run_script!(format!(
"console.log('[drag_sprite] disabled: modifier not translation-only: sx={} sy={} kx={} ky={} tx={} ty={} px={} py={}')",
m.scale_x(),
m.scale_y(),
m.skew_x(),
m.skew_y(),
m.translate_x(),
m.translate_y(),
m.persp_x(),
m.persp_y()
));
self.drag_sprite_state = DragSpriteState::Disabled;
return false;
}
}
self.render_drag_sprite_frame_inner(&sprite, tree);
true
}
/// Bump the cumulative eviction counter. Called from
/// `remove_cached_tile`. The drag-sprite fast path uses this to
/// detect mid-gesture invalidation.
pub fn drag_sprite_notify_eviction(&mut self) {
self.tile_eviction_seq = self.tile_eviction_seq.wrapping_add(1);
}
/// Capture phase: backdrop + sprite. Mirrors commit `667b503505`'s
/// `capture_drag_sprite` but with the safety-net `eviction_seq`
/// snapshot taken AFTER both phases complete.
fn capture_drag_sprite_internal(
&mut self,
id: &Uuid,
tree: ShapesPoolRef,
timestamp: i32,
) -> Result<DragSprite> {
performance::begin_measure!("drag_sprite_capture");
let dx_t = crate::get_now!();
let scale = self.get_scale();
let Some(shape) = tree.get(id) else {
performance::end_measure!("drag_sprite_capture");
return Err(crate::error::Error::CriticalError(format!(
"drag_sprite: shape {} not found",
id
)));
};
let base_doc_rect = shape.extrect(tree, scale);
if base_doc_rect.is_empty() {
performance::end_measure!("drag_sprite_capture");
return Err(crate::error::Error::CriticalError(
"drag_sprite: empty extrect".to_string(),
));
}
// Phase 1: backdrop. Re-renders affected tiles with the dragged
// subtree excluded; result lands in the persistent atlas.
let backdrop_result = self.capture_drag_backdrop(id, tree, timestamp, base_doc_rect);
// Phase 2: sprite. Mirrors `render_shape_pixels` save/restore
// but returns a Skia Image instead of PNG bytes.
let target_surface = SurfaceId::Export;
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 mut render_rect = base_doc_rect;
self.export_context = Some((render_rect, scale));
let margins = self.surfaces.margins;
render_rect.offset((margins.width as f32 / scale, margins.height as f32 / scale));
self.surfaces.resize_export_surface(scale, render_rect);
self.render_area = render_rect;
self.render_area_with_margins = render_rect;
self.surfaces.update_render_context(render_rect, scale);
self.pending_nodes.push(NodeRenderState {
id: *id,
visited_children: false,
clip_bounds: None,
visited_mask: false,
mask: false,
flattened: false,
});
let sprite_result = 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);
}
// Surface errors AFTER the state restore above so a mid-render
// failure doesn't strand the renderer.
backdrop_result?;
sprite_result?;
// Phase 3: above-shapes overlay. Render the shapes that come
// AFTER the dragged shape in document order (z-order successors
// at every ancestor level) into a viewport-sized image. Per-rAF
// we blit it on top of the sprite so shapes meant to be above
// the dragged shape actually appear above it. `None` when the
// dragged shape is on top at every level.
let above_image = self.capture_drag_above(id, tree, timestamp).unwrap_or(None);
// Snapshot eviction seq AFTER all captures (each capture phase
// can evict tiles; we want to detect *additional* evictions
// that fire mid-gesture).
let captured_eviction_seq = self.tile_eviction_seq;
let dx_dt = crate::get_now!() - dx_t;
crate::run_script!(format!(
"console.log('[drag_sprite] captured shape={} extrect_w={:.0} extrect_h={:.0} scale={:.2} above={} took={:.1}ms')",
id,
base_doc_rect.width(),
base_doc_rect.height(),
scale,
above_image.is_some(),
dx_dt
));
performance::end_measure!("drag_sprite_capture");
Ok(DragSprite {
image,
base_doc_rect,
sprite_scale: scale,
shape_id: *id,
captured_eviction_seq,
above_image,
})
}
/// Walk from the dragged shape up to the root, collecting siblings
/// that come *before* the current node in `children_ids` at each
/// level — those are the shapes drawn ON TOP of it (z-order
/// successors). The returned list is the push-order for the
/// renderer's pending_nodes stack: outermost ancestor's above-
/// siblings come first so they pop last and render last (on top).
fn collect_above_shapes_push_order(&self, dragged_id: &Uuid, tree: ShapesPoolRef) -> Vec<Uuid> {
let mut levels: Vec<Vec<Uuid>> = Vec::new();
let mut current = *dragged_id;
loop {
let parent_id = tree
.get(&current)
.and_then(|s| s.parent_id)
.unwrap_or(Uuid::nil());
let Some(parent) = tree.get(&parent_id) else {
break;
};
let siblings = parent.children_ids(false);
if let Some(idx) = siblings.iter().position(|id| *id == current) {
// children_ids[..idx] are the siblings rendered AFTER
// `current` in document order. children_ids[0] is the
// top-most of those.
levels.push(siblings[..idx].to_vec());
}
if parent_id == Uuid::nil() {
break;
}
current = parent_id;
}
// Outer levels (highest z) first → push them first → they pop
// last → render last → on top.
let mut result = Vec::new();
for level in levels.iter().rev() {
result.extend_from_slice(level);
}
result
}
/// Render the above-set onto a viewport-sized image. Mirrors the
/// `render_shape_pixels` save/restore pattern but with multiple
/// shapes pushed onto `pending_nodes` and a viewport-scoped render
/// area. Returns `None` when there are no above-shapes (drag is on
/// top of the stack everywhere).
fn capture_drag_above(
&mut self,
dragged_id: &Uuid,
tree: ShapesPoolRef,
timestamp: i32,
) -> Result<Option<skia::Image>> {
let above_ids = self.collect_above_shapes_push_order(dragged_id, tree);
if above_ids.is_empty() {
return Ok(None);
}
let target_surface = SurfaceId::Export;
let scale = self.get_scale();
// Save state we're about to mutate.
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);
// Size Export to the workspace viewport (in doc space) plus the
// standard margin band — same convention as the sprite capture,
// so the per-rAF blit reuses the same translation math.
let viewport_doc = self.viewbox.area;
let mut render_rect = viewport_doc;
self.export_context = Some((render_rect, scale));
let margins = self.surfaces.margins;
render_rect.offset((margins.width as f32 / scale, margins.height as f32 / scale));
self.surfaces.resize_export_surface(scale, render_rect);
self.render_area = render_rect;
self.render_area_with_margins = render_rect;
self.surfaces.update_render_context(render_rect, scale);
for id in &above_ids {
self.pending_nodes.push(NodeRenderState {
id: *id,
visited_children: false,
clip_bounds: None,
visited_mask: false,
mask: false,
flattened: false,
});
}
let render_result = 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);
}
render_result?;
Ok(Some(image))
}
/// Compute the set of interest-area tiles covering a doc-space rect.
fn tiles_covering_doc_rect(&self, doc_rect: skia::Rect) -> HashSet<tiles::Tile> {
let scale = self.get_scale();
let tile_size = tiles::get_tile_size(scale);
let TileRect(x1, y1, x2, y2) = tiles::get_tiles_for_rect(doc_rect, tile_size);
let mut out = HashSet::new();
for tx in x1..=x2 {
for ty in y1..=y2 {
let tile = tiles::Tile::from(tx, ty);
if self.tile_viewbox.interest_rect.contains(&tile) {
out.insert(tile);
}
}
}
out
}
/// Re-render the tiles covering the dragged shape's pre-drag bounds
/// with that subtree excluded, leaving the atlas with pixel-correct
/// "scene minus shape" content. Texture cache for the affected tiles
/// is invalidated so the post-gesture render recomputes them with
/// the shape at its final position.
fn capture_drag_backdrop(
&mut self,
shape_id: &Uuid,
tree: ShapesPoolRef,
timestamp: i32,
base_doc_rect: skia::Rect,
) -> Result<()> {
performance::begin_measure!("drag_sprite_backdrop");
let Some(shape) = tree.get(shape_id) else {
performance::end_measure!("drag_sprite_backdrop");
return Ok(());
};
let mut exclude: HashSet<Uuid> = HashSet::new();
exclude.insert(*shape_id);
for child_id in shape.all_children_iter(tree, true, true) {
exclude.insert(child_id);
}
let capture_tiles = self.tiles_covering_doc_rect(base_doc_rect);
if capture_tiles.is_empty() {
performance::end_measure!("drag_sprite_backdrop");
return Ok(());
}
// Clear the atlas region under the dragged shape so the nested
// render's tile blits write into a blank area. Without this,
// SrcOver compositing could leak old shape pixels through the
// transparent gaps.
let _ = self
.surfaces
.clear_doc_rect_in_atlas(&mut self.gpu_state, base_doc_rect);
// Evict any cached tile textures for the affected tiles so the
// nested render is not short-circuited by the cache fast path.
for tile in &capture_tiles {
self.surfaces
.remove_cached_tile_surface(&mut self.gpu_state, *tile);
}
// Save state the nested render mutates.
let saved_pending_tiles = std::mem::take(&mut self.pending_tiles.list);
let saved_pending_nodes = std::mem::take(&mut self.pending_nodes);
let saved_current_tile = self.current_tile;
let saved_render_in_progress = self.render_in_progress;
self.pending_tiles.list = capture_tiles.iter().copied().collect();
self.current_tile = None;
self.render_in_progress = true;
self.drag_capture_exclude = Some(exclude);
self.surfaces
.canvas(SurfaceId::Current)
.clear(self.background_color);
let result = self.render_shape_tree_partial(None, tree, timestamp, false);
// Restore.
self.drag_capture_exclude = None;
self.pending_tiles.list = saved_pending_tiles;
self.pending_nodes = saved_pending_nodes;
self.current_tile = saved_current_tile;
self.render_in_progress = saved_render_in_progress;
self.surfaces
.canvas(SurfaceId::Current)
.clear(skia::Color::TRANSPARENT);
result?;
// Atlas now holds scene-minus-shape pixels for `capture_tiles`.
// Drop the tile textures so the post-gesture render recomputes
// them (the cached textures also lack the shape — same wrong
// content, just baked into the texture cache).
for tile in &capture_tiles {
self.surfaces.invalidate_tile_texture_only(*tile);
}
performance::end_measure!("drag_sprite_backdrop");
Ok(())
}
/// Per-rAF fast path: draw atlas as scene backdrop, then blit the
/// pre-rendered sprite at the modifier-transformed position.
fn render_drag_sprite_frame_inner(&mut self, sprite: &DragSprite, tree: ShapesPoolRef) {
performance::begin_measure!("drag_sprite_render_frame");
// Apply the (validated translation-only) modifier to the
// sprite's pre-drag rect.
let dst_doc_rect = if let Some(m) = tree.get_modifier(&sprite.shape_id) {
m.map_rect(sprite.base_doc_rect).0
} else {
sprite.base_doc_rect
};
// Draw the atlas (which holds the backdrop) to Target.
if self.surfaces.has_atlas() {
self.surfaces.draw_atlas_to_target(
self.viewbox,
self.options.dpr(),
self.background_color,
);
} else {
self.surfaces
.canvas(SurfaceId::Target)
.clear(self.background_color);
}
// Blit the sprite at the new position, in target pixel space.
let s = self.viewbox.zoom * self.options.dpr();
let dst = skia::Rect::from_xywh(
(dst_doc_rect.left + self.viewbox.pan_x) * s,
(dst_doc_rect.top + self.viewbox.pan_y) * s,
dst_doc_rect.width() * s,
dst_doc_rect.height() * s,
);
let canvas = self.surfaces.canvas(SurfaceId::Target);
canvas.save();
canvas.reset_matrix();
canvas.draw_image_rect(&sprite.image, None, dst, &skia::Paint::default());
canvas.restore();
// Above-shapes overlay: shapes drawn AFTER the dragged shape in
// document order go on top of the sprite, restoring correct
// z-order when they overlap. The above_image was rendered with
// canvas pixel (0,0) mapped to the viewport's top-left in doc
// space — exactly the same mapping as Target — so blit at (0,0).
if let Some(above) = sprite.above_image.as_ref() {
let canvas = self.surfaces.canvas(SurfaceId::Target);
canvas.save();
canvas.reset_matrix();
canvas.draw_image(above, (0, 0), Some(&skia::Paint::default()));
canvas.restore();
}
// Selection handles, snap guides, etc. ride on top.
ui::render(self, tree);
self.flush_and_submit();
performance::end_measure!("drag_sprite_render_frame");
}
}

View File

@@ -22,6 +22,11 @@ pub struct RenderOptions {
/// keeps per-frame flushing enabled (unlike pan/zoom, where
/// `render_from_cache` drives target presentation).
interactive_transform: bool,
/// Active during the first rebuild pass after a zoom ends. Skips
/// blur/shadow (like `fast_mode`) but renders tiles normally rather
/// than using the atlas backdrop, so the user sees a fast full-fidelity
/// shape preview before effects come in on a second pass.
defer_effects: 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,
@@ -37,6 +42,7 @@ impl Default for RenderOptions {
dpr: None,
fast_mode: false,
interactive_transform: false,
defer_effects: false,
antialias_threshold: ANTIALIAS_THRESHOLD,
viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD,
max_blocking_time_ms: MAX_BLOCKING_TIME_MS,
@@ -76,6 +82,23 @@ impl RenderOptions {
self.interactive_transform = enabled;
}
pub fn is_defer_effects(&self) -> bool {
self.defer_effects
}
pub fn set_defer_effects(&mut self, enabled: bool) {
self.defer_effects = enabled;
}
/// True when expensive per-shape effects (blur, shadow) should be
/// skipped. Covers both the active viewport gesture (`fast_mode`)
/// and the post-gesture first rebuild pass (`defer_effects`).
/// Do NOT use this to gate atlas-backdrop / cache-presentation logic
/// — those must key off `is_fast_mode()` specifically.
pub fn should_skip_effects(&self) -> bool {
self.fast_mode || self.defer_effects
}
/// 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

@@ -408,6 +408,26 @@ impl Surfaces {
/// Clears Target to `background` first so atlas-uncovered regions don't
/// show stale content when the atlas only partially covers the viewport.
pub fn draw_atlas_to_target(&mut self, viewbox: Viewbox, dpr: f32, background: skia::Color) {
self.draw_atlas_to_target_inner(viewbox, dpr, Some(background));
}
/// Same as `draw_atlas_to_target` but preserves whatever is already on
/// Target instead of clearing it to the background color first. Used
/// by the progressive pass-1 rebuild so that, when the atlas only
/// partially covers the current viewport, uncovered regions keep
/// their previous content (e.g. tiles rendered directly during an
/// earlier render) instead of flashing to the background color until
/// pass 1 catches up.
pub fn draw_atlas_over_target(&mut self, viewbox: Viewbox, dpr: f32) {
self.draw_atlas_to_target_inner(viewbox, dpr, None);
}
fn draw_atlas_to_target_inner(
&mut self,
viewbox: Viewbox,
dpr: f32,
background: Option<skia::Color>,
) {
if !self.has_atlas() {
return;
}
@@ -421,7 +441,9 @@ impl Surfaces {
None,
true,
);
canvas.clear(background);
if let Some(bg) = background {
canvas.clear(bg);
}
let s = viewbox.zoom * dpr;
let atlas_scale = self.atlas_scale.max(0.01);
@@ -944,6 +966,7 @@ impl Surfaces {
canvas.restore();
}
#[allow(clippy::too_many_arguments)]
pub fn cache_current_tile_texture(
&mut self,
gpu_state: &mut GpuState,
@@ -951,6 +974,7 @@ impl Surfaces {
tile: &Tile,
tile_rect: &skia::Rect,
skip_cache_surface: bool,
skip_atlas: bool,
tile_doc_rect: skia::Rect,
) {
let rect = IRect::from_xywh(
@@ -975,8 +999,13 @@ impl Surfaces {
// Incrementally update persistent 1:1 atlas in document space.
// `tile_doc_rect` is in world/document coordinates (1 unit == 1 px at 100%).
let _ = self.blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect);
self.atlas_tile_doc_rects.insert(*tile, tile_doc_rect);
// Skipped during the progressive pass 1 (defer_effects) so we do
// not contaminate the atlas with shape previews that lack blur
// or shadows — pass 2 will write the final full-quality tiles.
if !skip_atlas {
let _ = self.blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect);
self.atlas_tile_doc_rects.insert(*tile, tile_doc_rect);
}
self.tiles.add(tile_viewbox, tile, tile_image);
}
}
@@ -995,6 +1024,16 @@ impl Surfaces {
let _ = self.clear_tile_in_atlas(gpu_state, tile);
}
/// Evict a tile from the texture cache WITHOUT touching the atlas.
/// Used by the drag-sprite backdrop capture: after re-rendering
/// affected tiles with the dragged subtree excluded, the atlas
/// holds the "scene minus shape" pixels we want; we just need
/// subsequent renders to recompute the tile textures rather than
/// reuse the cached ones (which also lack the shape).
pub fn invalidate_tile_texture_only(&mut self, tile: Tile) {
self.tiles.remove(tile);
}
pub fn draw_cached_tile_surface(&mut self, tile: Tile, rect: skia::Rect, color: skia::Color) {
if let Some(image) = self.tiles.get(tile) {
let mut paint = skia::Paint::default();

View File

@@ -1015,6 +1015,13 @@ impl Shape {
}
pub fn calculate_extrect(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect {
// Extrect is a pure function of shape geometry (selrect, transform,
// path, strokes, shadows, blur, children) — it does not depend on
// `scale`. The parameter is threaded through for downstream callers
// that need it (e.g. visibility checks that multiply by scale), but
// the cache key must NOT include scale: different callers request
// the same extrect at different scales (render at viewbox.zoom*dpr,
// compute_document_bounds at 1.0) and should share cache entries.
if let Some(cached_extrect) = *self.extrect_cache.borrow() {
return cached_extrect;
}

View File

@@ -259,15 +259,27 @@ pub fn get_fill_shader(fill: &Fill, bounding_box: &Rect) -> Option<skia::Shader>
}
pub fn merge_fills(fills: &[Fill], bounding_box: Rect) -> skia::Paint {
let mut combined_shader: Option<skia::Shader> = None;
let mut fills_paint = skia::Paint::default();
if fills.is_empty() {
combined_shader = Some(skia::shaders::color(skia::Color::TRANSPARENT));
fills_paint.set_shader(combined_shader);
fills_paint.set_color(skia::Color::TRANSPARENT);
return fills_paint;
}
// Fast path: a single solid color fill is the overwhelmingly common
// case. Setting the paint's color directly uses Skia's optimized
// solid-fill GPU path (uniform color, no shader). The general
// shader path below builds a `shaders::color` shader and runs the
// fragment-shader pipeline for every pixel — that costs ~10 ms for
// a 595x435 rect at 2x DPR (≈1M pixels) when the rect is dragged.
if fills.len() == 1 {
if let Fill::Solid(SolidColor(color)) = &fills[0] {
fills_paint.set_color(*color);
return fills_paint;
}
}
let mut combined_shader: Option<skia::Shader> = None;
for fill in fills {
let shader = get_fill_shader(fill, &bounding_box);
@@ -287,7 +299,7 @@ pub fn merge_fills(fills: &[Fill], bounding_box: Rect) -> skia::Paint {
}
}
fills_paint.set_shader(combined_shader.clone());
fills_paint.set_shader(combined_shader);
fills_paint
}