mirror of
https://github.com/penpot/penpot.git
synced 2026-08-01 10:56:20 +00:00
⚡ Improve drag performance
This commit is contained in:
parent
f6bd991968
commit
ca1e1dd806
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]
|
#[no_mangle]
|
||||||
#[wasm_error]
|
#[wasm_error]
|
||||||
pub extern "C" fn render(_: i32) -> Result<()> {
|
pub extern "C" fn render(_: i32) -> Result<()> {
|
||||||
|
let dx_t = crate::get_now!();
|
||||||
with_state_mut!(state, {
|
with_state_mut!(state, {
|
||||||
state.rebuild_touched_tiles();
|
state.rebuild_touched_tiles();
|
||||||
// Drain the throttled modifier-tile invalidation accumulated
|
// 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
|
// interactive_transform; we do it once here, with the current
|
||||||
// modifier set, so the cost is paid once per rAF rather than
|
// modifier set, so the cost is paid once per rAF rather than
|
||||||
// once per pointer move.
|
// 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();
|
let ids = state.shapes.modifier_ids();
|
||||||
if !ids.is_empty() {
|
if !ids.is_empty() {
|
||||||
state.rebuild_modifier_tiles(ids)?;
|
state.rebuild_modifier_tiles(ids)?;
|
||||||
@ -240,6 +249,13 @@ pub extern "C" fn render(_: i32) -> Result<()> {
|
|||||||
.start_render_loop(performance::get_time())
|
.start_render_loop(performance::get_time())
|
||||||
.map_err(|_| Error::RecoverableError("Error rendering".to_string()))?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -354,7 +370,16 @@ pub extern "C" fn render_loading_overlay() -> Result<()> {
|
|||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
#[wasm_error]
|
#[wasm_error]
|
||||||
pub extern "C" fn process_animation_frame(timestamp: i32) -> Result<()> {
|
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 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 {
|
if let Err(err) = result {
|
||||||
eprintln!("process_animation_frame error: {}", err);
|
eprintln!("process_animation_frame error: {}", err);
|
||||||
}
|
}
|
||||||
@ -404,6 +429,9 @@ pub extern "C" fn set_view_start() -> Result<()> {
|
|||||||
}
|
}
|
||||||
performance::begin_measure!("set_view_start");
|
performance::begin_measure!("set_view_start");
|
||||||
state.render_state.options.set_fast_mode(true);
|
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");
|
performance::end_measure!("set_view_start");
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -438,6 +466,11 @@ pub extern "C" fn set_view_end() -> Result<()> {
|
|||||||
// preview of the old content while new tiles render.
|
// preview of the old content while new tiles render.
|
||||||
state.render_state.rebuild_tile_index(&state.shapes);
|
state.render_state.rebuild_tile_index(&state.shapes);
|
||||||
state.render_state.surfaces.invalidate_tile_cache();
|
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 {
|
} else {
|
||||||
// Pure pan at the same zoom level: tile contents have not
|
// Pure pan at the same zoom level: tile contents have not
|
||||||
// changed — only the viewport position moved. Update the
|
// 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<()> {
|
pub extern "C" fn set_modifiers_start() -> Result<()> {
|
||||||
with_state_mut!(state, {
|
with_state_mut!(state, {
|
||||||
performance::begin_measure!("set_modifiers_start");
|
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_fast_mode(true);
|
||||||
state.render_state.options.set_interactive_transform(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");
|
performance::end_measure!("set_modifiers_start");
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -478,8 +525,15 @@ pub extern "C" fn set_modifiers_start() -> Result<()> {
|
|||||||
pub extern "C" fn set_modifiers_end() -> Result<()> {
|
pub extern "C" fn set_modifiers_end() -> Result<()> {
|
||||||
with_state_mut!(state, {
|
with_state_mut!(state, {
|
||||||
performance::begin_measure!("set_modifiers_end");
|
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_fast_mode(false);
|
||||||
state.render_state.options.set_interactive_transform(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();
|
state.render_state.cancel_animation_frame();
|
||||||
performance::end_measure!("set_modifiers_end");
|
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<()> {
|
pub extern "C" fn clean_modifiers() -> Result<()> {
|
||||||
with_state_mut!(state, {
|
with_state_mut!(state, {
|
||||||
let prev_modifier_ids = state.shapes.clean_all();
|
let prev_modifier_ids = state.shapes.clean_all();
|
||||||
// Skip the tile-cache cleanup during interactive transform: the
|
// Skip the tile-cache cleanup when drag-sprite is active: the
|
||||||
// per-rAF `rebuild_modifier_tiles` in `render()` already evicts
|
// sprite fast path doesn't read the dragged shape's tile cache
|
||||||
// the same tiles for the active modifier set, so the eviction
|
// and the eviction would wipe the captured atlas backdrop. The
|
||||||
// here is redundant and doubles the per-emission cost.
|
// next `set_modifiers` will replace `state.shapes.modifiers`
|
||||||
if !prev_modifier_ids.is_empty() && !state.render_state.options.is_interactive_transform() {
|
// 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
|
state
|
||||||
.render_state
|
.render_state
|
||||||
.update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?;
|
.update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?;
|
||||||
@ -982,13 +1038,36 @@ pub extern "C" fn set_modifiers() -> Result<()> {
|
|||||||
ids.push(entry.id);
|
ids.push(entry.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let dx_t = crate::get_now!();
|
||||||
|
let dx_n = ids.len();
|
||||||
with_state_mut!(state, {
|
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);
|
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() {
|
if !state.render_state.options.is_interactive_transform() {
|
||||||
state.rebuild_modifier_tiles(ids)?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
mod debug;
|
mod debug;
|
||||||
|
pub mod drag_sprite;
|
||||||
mod fills;
|
mod fills;
|
||||||
pub mod filters;
|
pub mod filters;
|
||||||
mod fonts;
|
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
|
/// 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.
|
/// time we are about to blit a tile into Cache for this pass.
|
||||||
pub cache_cleared_this_render: bool,
|
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
|
/// True iff the current tile had shapes assigned to it when we
|
||||||
/// started rendering it. Lets us distinguish a genuinely empty
|
/// started rendering it. Lets us distinguish a genuinely empty
|
||||||
/// tile (skip composite, just clear) from a tile whose walker
|
/// 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
|
/// (must composite to present the work). Reset when current_tile
|
||||||
/// changes.
|
/// changes.
|
||||||
pub current_tile_had_shapes: bool,
|
pub current_tile_had_shapes: bool,
|
||||||
/// During interactive transforms we keep `Target` between rAFs. Seed the
|
/// Dormant. GPU crops from `Backbuffer` keyed by shape id, populated
|
||||||
/// interactive backdrop exactly once per gesture (first rAF) so we don't
|
/// after full-quality renders by `rebuild_backbuffer_crop_cache`. The
|
||||||
/// repeatedly overwrite tiles that have already been updated.
|
/// active drag-sprite path uses the persistent atlas as backdrop
|
||||||
pub interactive_target_seeded: bool,
|
/// instead and doesn't consult this cache; the field stays as a
|
||||||
/// GPU crops from `Backbuffer` keyed by shape id. Filled on full-frame completion; during
|
/// scaffolding hook for re-enabling Alex's caching layer.
|
||||||
/// drag, entries for the moved top-level selection are ensured here
|
|
||||||
pub backbuffer_crop_cache: HashMap<Uuid, InteractiveDragCrop>,
|
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 struct InteractiveDragCrop {
|
||||||
pub src_doc_bounds: Rect,
|
pub src_doc_bounds: Rect,
|
||||||
pub src_selrect: Rect,
|
pub src_selrect: Rect,
|
||||||
@ -420,6 +468,12 @@ impl RenderState {
|
|||||||
/// because other transforms would require resampling and can diverge from the live render.
|
/// 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
|
/// - **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.
|
/// 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(
|
fn should_use_cached_top_level_during_interactive(
|
||||||
&mut self,
|
&mut self,
|
||||||
node_id: Uuid,
|
node_id: Uuid,
|
||||||
@ -532,9 +586,16 @@ impl RenderState {
|
|||||||
preview_mode: false,
|
preview_mode: false,
|
||||||
export_context: None,
|
export_context: None,
|
||||||
cache_cleared_this_render: false,
|
cache_cleared_this_render: false,
|
||||||
|
preserve_cache_this_render: false,
|
||||||
|
dx_inval_since_paf: 0,
|
||||||
current_tile_had_shapes: false,
|
current_tile_had_shapes: false,
|
||||||
interactive_target_seeded: false,
|
|
||||||
backbuffer_crop_cache: HashMap::default(),
|
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,
|
/// 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.
|
/// so that the backdrop blur is independent of the shape's visual properties.
|
||||||
fn render_background_blur(&mut self, shape: &Shape, target_surface: SurfaceId) {
|
fn render_background_blur(&mut self, shape: &Shape, target_surface: SurfaceId) {
|
||||||
if self.options.is_fast_mode() {
|
if self.options.should_skip_effects() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if matches!(shape.shape_type, Type::Text(_)) || matches!(shape.shape_type, Type::SVGRaw(_))
|
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<()> {
|
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 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.
|
// 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),
|
// 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.
|
// while still preventing stale pixels from surviving across full-quality renders.
|
||||||
@ -875,6 +927,7 @@ impl RenderState {
|
|||||||
¤t_tile,
|
¤t_tile,
|
||||||
&tile_rect,
|
&tile_rect,
|
||||||
fast_mode,
|
fast_mode,
|
||||||
|
skip_atlas,
|
||||||
self.render_area,
|
self.render_area,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1009,13 +1062,10 @@ impl RenderState {
|
|||||||
s.canvas().save();
|
s.canvas().save();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let fast_mode = self.options.is_fast_mode();
|
|
||||||
// Skip anti-aliasing entirely during fast_mode (interactive
|
let antialias =
|
||||||
// gestures + pan/zoom). AA edge sampling is per-pixel and adds
|
shape.should_use_antialias(self.get_scale(), self.options.antialias_threshold);
|
||||||
// up across many shapes; reverts to full quality on commit.
|
let skip_effects = self.options.should_skip_effects();
|
||||||
let antialias = !fast_mode
|
|
||||||
&& shape.should_use_antialias(self.get_scale(), self.options.antialias_threshold);
|
|
||||||
let skip_effects = fast_mode;
|
|
||||||
|
|
||||||
let has_nested_fills = self
|
let has_nested_fills = self
|
||||||
.nested_fills
|
.nested_fills
|
||||||
@ -1616,6 +1666,7 @@ impl RenderState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
fn rebuild_backbuffer_crop_cache(&mut self, tree: ShapesPoolRef) {
|
fn rebuild_backbuffer_crop_cache(&mut self, tree: ShapesPoolRef) {
|
||||||
self.backbuffer_crop_cache.clear();
|
self.backbuffer_crop_cache.clear();
|
||||||
|
|
||||||
@ -1903,6 +1954,14 @@ impl RenderState {
|
|||||||
self.stats.clear();
|
self.stats.clear();
|
||||||
|
|
||||||
let _start = performance::begin_timed_log!("start_render_loop");
|
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();
|
let scale = self.get_scale();
|
||||||
|
|
||||||
self.tile_viewbox.update(self.viewbox, scale);
|
self.tile_viewbox.update(self.viewbox, scale);
|
||||||
@ -1911,28 +1970,49 @@ impl RenderState {
|
|||||||
performance::begin_measure!("render");
|
performance::begin_measure!("render");
|
||||||
performance::begin_measure!("start_render_loop");
|
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)
|
// 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
|
// to clamp atlas updates. This prevents zoom-out tiles from forcing atlas
|
||||||
// growth far beyond real content.
|
// growth far beyond real content.
|
||||||
let doc_bounds = self.compute_document_bounds(base_object, tree);
|
let doc_bounds = self.compute_document_bounds(base_object, tree);
|
||||||
self.surfaces.set_atlas_doc_bounds(doc_bounds);
|
self.surfaces.set_atlas_doc_bounds(doc_bounds);
|
||||||
|
|
||||||
self.cache_cleared_this_render = false;
|
// During an interactive shape transform (drag/resize/rotate) the
|
||||||
if self.options.is_interactive_transform() {
|
// Target is repainted tile-by-tile. If only a subset of the
|
||||||
// Keep `Target` as the previous frame and overwrite only the tiles
|
// invalidated tiles finishes in this rAF the remaining area
|
||||||
// that changed. This avoids clearing + redrawing an atlas backdrop
|
// would either show stale content from the previous frame or,
|
||||||
// every rAF during drag (a common source of GPU work/stalls).
|
// 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
|
self.surfaces
|
||||||
.reset_interactive_transform(self.background_color);
|
.draw_atlas_over_target(self.viewbox, self.options.dpr());
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let surface_ids = SurfaceId::Strokes as u32
|
let surface_ids = SurfaceId::Strokes as u32
|
||||||
@ -2050,6 +2130,11 @@ impl RenderState {
|
|||||||
timestamp: i32,
|
timestamp: i32,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
performance::begin_measure!("process_animation_frame");
|
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 self.render_in_progress {
|
||||||
if tree.len() != 0 {
|
if tree.len() != 0 {
|
||||||
self.render_shape_tree_partial(base_object, tree, timestamp, true)?;
|
self.render_shape_tree_partial(base_object, tree, timestamp, true)?;
|
||||||
@ -2071,18 +2156,57 @@ impl RenderState {
|
|||||||
if self.render_in_progress {
|
if self.render_in_progress {
|
||||||
self.cancel_animation_frame();
|
self.cancel_animation_frame();
|
||||||
self.render_request_id = Some(wapi::request_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 {
|
} else {
|
||||||
// A full-quality frame is now complete. Refresh Backbuffer and regenerate
|
// A full-quality frame is now complete.
|
||||||
// the per-shape crop cache so interactive drags can reuse pixels.
|
//
|
||||||
if !self.options.is_fast_mode() && !self.options.is_interactive_transform() {
|
// Backbuffer-population call is intentionally omitted: the
|
||||||
self.surfaces.copy_target_to_backbuffer();
|
// active drag path uses the persistent atlas as backdrop
|
||||||
self.rebuild_backbuffer_crop_cache(tree);
|
// 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!();
|
wapi::notify_tiles_render_complete!();
|
||||||
performance::end_measure!("render");
|
performance::end_measure!("render");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
performance::end_measure!("process_animation_frame");
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2273,8 +2397,9 @@ impl RenderState {
|
|||||||
paint.set_blend_mode(element.blend_mode().into());
|
paint.set_blend_mode(element.blend_mode().into());
|
||||||
paint.set_alpha_f(element.opacity());
|
paint.set_alpha_f(element.opacity());
|
||||||
|
|
||||||
// Skip frame-level blur in fast mode (pan/zoom).
|
// Skip frame-level blur in fast mode (pan/zoom) or during the
|
||||||
if !self.options.is_fast_mode() {
|
// post-gesture first rebuild pass.
|
||||||
|
if !self.options.should_skip_effects() {
|
||||||
if let Some(frame_blur) = Self::frame_clip_layer_blur(element) {
|
if let Some(frame_blur) = Self::frame_clip_layer_blur(element) {
|
||||||
let scale = self.get_scale();
|
let scale = self.get_scale();
|
||||||
let sigma = radius_to_sigma(frame_blur.value * scale);
|
let sigma = radius_to_sigma(frame_blur.value * scale);
|
||||||
@ -2878,29 +3003,6 @@ impl RenderState {
|
|||||||
target_surface = SurfaceId::Export;
|
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() {
|
while let Some(node_render_state) = self.pending_nodes.pop() {
|
||||||
let node_id = node_render_state.id;
|
let node_id = node_render_state.id;
|
||||||
let visited_children = node_render_state.visited_children;
|
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);
|
let can_flatten = element.can_flatten() && !self.focus_mode.should_focus(&element.id);
|
||||||
|
|
||||||
// Skip render_shape_enter/exit for flattened containers
|
// 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)
|
// the layer blur (which would make it more diffused than without clipping)
|
||||||
let shadow_before_layer = !node_render_state.is_root()
|
let shadow_before_layer = !node_render_state.is_root()
|
||||||
&& self.focus_mode.is_active()
|
&& self.focus_mode.is_active()
|
||||||
&& !self.options.is_fast_mode()
|
&& !self.options.should_skip_effects()
|
||||||
&& !matches!(element.shape_type, Type::Text(_))
|
&& !matches!(element.shape_type, Type::Text(_))
|
||||||
&& Self::frame_clip_layer_blur(element).is_some()
|
&& Self::frame_clip_layer_blur(element).is_some()
|
||||||
&& element.drop_shadows_visible().next().is_some();
|
&& element.drop_shadows_visible().next().is_some();
|
||||||
@ -3078,8 +3122,9 @@ impl RenderState {
|
|||||||
.surfaces
|
.surfaces
|
||||||
.get_render_context_translation(self.render_area, scale);
|
.get_render_context_translation(self.render_area, scale);
|
||||||
|
|
||||||
// Skip expensive drop shadow rendering in fast mode (during pan/zoom).
|
// Skip expensive drop shadow rendering in fast mode (during
|
||||||
let skip_shadows = self.options.is_fast_mode();
|
// 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)
|
// 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();
|
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);
|
let children_ids = sort_z_index(tree, element, children_ids);
|
||||||
|
|
||||||
for child_id in children_ids.iter() {
|
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 {
|
self.pending_nodes.push(NodeRenderState {
|
||||||
id: *child_id,
|
id: *child_id,
|
||||||
visited_children: false,
|
visited_children: false,
|
||||||
@ -3218,17 +3273,12 @@ impl RenderState {
|
|||||||
if let Some(current_tile) = self.current_tile {
|
if let Some(current_tile) = self.current_tile {
|
||||||
if self.surfaces.has_cached_tile_surface(current_tile) {
|
if self.surfaces.has_cached_tile_surface(current_tile) {
|
||||||
performance::begin_measure!("render_shape_tree::cached");
|
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()?;
|
let tile_rect = self.get_current_tile_bounds()?;
|
||||||
if !self.options.is_interactive_transform() {
|
self.surfaces.draw_cached_tile_surface(
|
||||||
self.surfaces.draw_cached_tile_surface(
|
current_tile,
|
||||||
current_tile,
|
tile_rect,
|
||||||
tile_rect,
|
self.background_color,
|
||||||
self.background_color,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also draw the cached tile to the Cache surface so
|
// Also draw the cached tile to the Cache surface so
|
||||||
// render_from_cache (used during pan) has the full scene.
|
// render_from_cache (used during pan) has the full scene.
|
||||||
@ -3266,19 +3316,18 @@ impl RenderState {
|
|||||||
}
|
}
|
||||||
performance::end_measure!("render_shape_tree::uncached");
|
performance::end_measure!("render_shape_tree::uncached");
|
||||||
let tile_rect = self.get_current_tile_bounds()?;
|
let tile_rect = self.get_current_tile_bounds()?;
|
||||||
// Composite if the walker did work in this PAF (`!is_empty`) OR
|
// Composite if the walker did work in this PAF
|
||||||
// the tile has unfinished work from a previous PAF
|
// (`!is_empty`) OR the tile has unfinished work from
|
||||||
// (`current_tile_had_shapes` was set when we populated pending_nodes
|
// a previous PAF (`current_tile_had_shapes` was set
|
||||||
// for this tile).
|
// 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 !is_empty || self.current_tile_had_shapes {
|
||||||
if self.options.is_interactive_transform() {
|
self.apply_render_to_final_canvas(tile_rect)?;
|
||||||
// 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)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.options.is_debug_visible() {
|
if self.options.is_debug_visible() {
|
||||||
debug::render_workspace_current_tile(
|
debug::render_workspace_current_tile(
|
||||||
@ -3294,7 +3343,6 @@ impl RenderState {
|
|||||||
paint.set_color(self.background_color);
|
paint.set_color(self.background_color);
|
||||||
s.canvas().draw_rect(tile_rect, &paint);
|
s.canvas().draw_rect(tile_rect, &paint);
|
||||||
});
|
});
|
||||||
// Keep Cache surface coherent for render_from_cache.
|
|
||||||
if !self.options.is_fast_mode() {
|
if !self.options.is_fast_mode() {
|
||||||
if !self.cache_cleared_this_render {
|
if !self.cache_cleared_this_render {
|
||||||
self.surfaces.clear_cache(self.background_color);
|
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.
|
// 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.
|
|
||||||
let mut valid_ids = Vec::with_capacity(ids.len());
|
let mut valid_ids = Vec::with_capacity(ids.len());
|
||||||
if self.options.is_interactive_transform() || tile_has_bg_blur {
|
for root_id in root_ids.iter() {
|
||||||
valid_ids.extend(root_ids.iter().copied());
|
// During drag-sprite backdrop capture, skip
|
||||||
} else {
|
// root-level shapes in the excluded subtree.
|
||||||
for root_id in root_ids.iter() {
|
if self
|
||||||
if ids.contains(root_id) {
|
.drag_capture_exclude
|
||||||
valid_ids.push(*root_id);
|
.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() {
|
if !valid_ids.is_empty() {
|
||||||
self.current_tile_had_shapes = true;
|
self.current_tile_had_shapes = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.pending_nodes.extend(valid_ids.into_iter().map(|id| {
|
self.pending_nodes.extend(valid_ids.into_iter().map(|id| {
|
||||||
NodeRenderState {
|
NodeRenderState {
|
||||||
id,
|
id,
|
||||||
@ -3550,10 +3596,39 @@ impl RenderState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_cached_tile(&mut self, tile: tiles::Tile) {
|
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
|
self.surfaces
|
||||||
.remove_cached_tile_surface(&mut self.gpu_state, tile);
|
.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.
|
/// Rebuild the tile index (shape→tile mapping) for all top-level shapes.
|
||||||
/// This does NOT invalidate the tile texture cache — cached tile images
|
/// This does NOT invalidate the tile texture cache — cached tile images
|
||||||
/// survive so that fast-mode renders during pan still show shadows/blur.
|
/// 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 mut all_tiles = HashSet::<tiles::Tile>::new();
|
||||||
|
|
||||||
let ids = std::mem::take(&mut self.touched_ids);
|
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() {
|
for shape_id in ids.iter() {
|
||||||
if let Some(shape) = tree.get(shape_id) {
|
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
|
// Update the changed tiles
|
||||||
for tile in all_tiles {
|
for tile in all_tiles {
|
||||||
self.remove_cached_tile(tile);
|
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");
|
performance::end_measure!("rebuild_touched_tiles");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3696,16 +3799,38 @@ impl RenderState {
|
|||||||
tree: ShapesPoolMutRef<'_>,
|
tree: ShapesPoolMutRef<'_>,
|
||||||
ids: Vec<Uuid>,
|
ids: Vec<Uuid>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// During interactive transform, skip ancestor invalidation: walking up to the
|
// During interactive transform, skip ancestor invalidation: walking
|
||||||
// parent frame evicts every tile the frame covers, including dense tiles with
|
// up to the parent frame evicts every tile the frame covers,
|
||||||
// many siblings. Ancestor extrect caches are already invalidated by
|
// including dense tiles with hundreds of siblings. The anti-flicker
|
||||||
// `ShapesPool::set_modifiers`; the tile index is reconciled post-gesture by
|
// guard then forces all of them to re-render in a single frame.
|
||||||
// the committing code path (rebuild_touched_tiles).
|
// Ancestor extrect caches are already invalidated by
|
||||||
if self.options.is_interactive_transform() {
|
// `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)?;
|
self.update_tiles_shapes(&ids, tree)?;
|
||||||
|
ids.len()
|
||||||
} else {
|
} else {
|
||||||
let ancestors = all_with_ancestors(&ids, tree, false);
|
let ancestors = all_with_ancestors(&ids, tree, false);
|
||||||
|
let n = ancestors.len();
|
||||||
self.update_tiles_shapes(&ancestors, tree)?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -3728,6 +3853,7 @@ impl RenderState {
|
|||||||
|
|
||||||
pub fn mark_touched(&mut self, uuid: Uuid) {
|
pub fn mark_touched(&mut self, uuid: Uuid) {
|
||||||
self.touched_ids.insert(uuid);
|
self.touched_ids.insert(uuid);
|
||||||
|
self.dx_touch_calls += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[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
|
/// keeps per-frame flushing enabled (unlike pan/zoom, where
|
||||||
/// `render_from_cache` drives target presentation).
|
/// `render_from_cache` drives target presentation).
|
||||||
interactive_transform: bool,
|
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.
|
/// Minimum on-screen size (CSS px at 1:1 zoom) above which vector antialiasing is enabled.
|
||||||
pub antialias_threshold: f32,
|
pub antialias_threshold: f32,
|
||||||
pub viewport_interest_area_threshold: i32,
|
pub viewport_interest_area_threshold: i32,
|
||||||
@ -37,6 +42,7 @@ impl Default for RenderOptions {
|
|||||||
dpr: None,
|
dpr: None,
|
||||||
fast_mode: false,
|
fast_mode: false,
|
||||||
interactive_transform: false,
|
interactive_transform: false,
|
||||||
|
defer_effects: false,
|
||||||
antialias_threshold: ANTIALIAS_THRESHOLD,
|
antialias_threshold: ANTIALIAS_THRESHOLD,
|
||||||
viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD,
|
viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD,
|
||||||
max_blocking_time_ms: MAX_BLOCKING_TIME_MS,
|
max_blocking_time_ms: MAX_BLOCKING_TIME_MS,
|
||||||
@ -76,6 +82,23 @@ impl RenderOptions {
|
|||||||
self.interactive_transform = enabled;
|
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)
|
/// True only when the viewport is the one being moved (pan/zoom)
|
||||||
/// and the dedicated `render_from_cache` path owns Target
|
/// and the dedicated `render_from_cache` path owns Target
|
||||||
/// presentation. In this mode `process_animation_frame` must not
|
/// 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
|
/// Clears Target to `background` first so atlas-uncovered regions don't
|
||||||
/// show stale content when the atlas only partially covers the viewport.
|
/// 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) {
|
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() {
|
if !self.has_atlas() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -421,7 +441,9 @@ impl Surfaces {
|
|||||||
None,
|
None,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
canvas.clear(background);
|
if let Some(bg) = background {
|
||||||
|
canvas.clear(bg);
|
||||||
|
}
|
||||||
|
|
||||||
let s = viewbox.zoom * dpr;
|
let s = viewbox.zoom * dpr;
|
||||||
let atlas_scale = self.atlas_scale.max(0.01);
|
let atlas_scale = self.atlas_scale.max(0.01);
|
||||||
@ -944,6 +966,7 @@ impl Surfaces {
|
|||||||
canvas.restore();
|
canvas.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn cache_current_tile_texture(
|
pub fn cache_current_tile_texture(
|
||||||
&mut self,
|
&mut self,
|
||||||
gpu_state: &mut GpuState,
|
gpu_state: &mut GpuState,
|
||||||
@ -951,6 +974,7 @@ impl Surfaces {
|
|||||||
tile: &Tile,
|
tile: &Tile,
|
||||||
tile_rect: &skia::Rect,
|
tile_rect: &skia::Rect,
|
||||||
skip_cache_surface: bool,
|
skip_cache_surface: bool,
|
||||||
|
skip_atlas: bool,
|
||||||
tile_doc_rect: skia::Rect,
|
tile_doc_rect: skia::Rect,
|
||||||
) {
|
) {
|
||||||
let rect = IRect::from_xywh(
|
let rect = IRect::from_xywh(
|
||||||
@ -975,8 +999,13 @@ impl Surfaces {
|
|||||||
|
|
||||||
// Incrementally update persistent 1:1 atlas in document space.
|
// Incrementally update persistent 1:1 atlas in document space.
|
||||||
// `tile_doc_rect` is in world/document coordinates (1 unit == 1 px at 100%).
|
// `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);
|
// Skipped during the progressive pass 1 (defer_effects) so we do
|
||||||
self.atlas_tile_doc_rects.insert(*tile, tile_doc_rect);
|
// 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);
|
self.tiles.add(tile_viewbox, tile, tile_image);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -995,6 +1024,16 @@ impl Surfaces {
|
|||||||
let _ = self.clear_tile_in_atlas(gpu_state, tile);
|
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) {
|
pub fn draw_cached_tile_surface(&mut self, tile: Tile, rect: skia::Rect, color: skia::Color) {
|
||||||
if let Some(image) = self.tiles.get(tile) {
|
if let Some(image) = self.tiles.get(tile) {
|
||||||
let mut paint = skia::Paint::default();
|
let mut paint = skia::Paint::default();
|
||||||
|
|||||||
@ -1015,6 +1015,13 @@ impl Shape {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn calculate_extrect(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect {
|
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() {
|
if let Some(cached_extrect) = *self.extrect_cache.borrow() {
|
||||||
return cached_extrect;
|
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 {
|
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();
|
let mut fills_paint = skia::Paint::default();
|
||||||
|
|
||||||
if fills.is_empty() {
|
if fills.is_empty() {
|
||||||
combined_shader = Some(skia::shaders::color(skia::Color::TRANSPARENT));
|
fills_paint.set_color(skia::Color::TRANSPARENT);
|
||||||
fills_paint.set_shader(combined_shader);
|
|
||||||
return fills_paint;
|
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 {
|
for fill in fills {
|
||||||
let shader = get_fill_shader(fill, &bounding_box);
|
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
|
fills_paint
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user