mirror of
https://github.com/penpot/penpot.git
synced 2026-05-24 00:19:29 -04:00
⚡ Improve drag performance
This commit is contained in:
221
render-wasm/DRAG_PERF_ANALYSIS.md
Normal file
221
render-wasm/DRAG_PERF_ANALYSIS.md
Normal 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 400–580 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)
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
¤t_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)]
|
||||
|
||||
649
render-wasm/src/render/drag_sprite.rs
Normal file
649
render-wasm/src/render/drag_sprite.rs
Normal 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(¤t)
|
||||
.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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user