mirror of
https://github.com/penpot/penpot.git
synced 2026-09-12 05:39:59 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b06aa2ba3e | ||
|
|
0d062449d4 |
No files matched your search
+71
-7
@@ -223,12 +223,31 @@ pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> {
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn render(_: i32) -> Result<()> {
|
||||
let dx_t = crate::get_now!();
|
||||
with_state_mut!(state, {
|
||||
state.rebuild_touched_tiles();
|
||||
// Drain the throttled modifier-tile invalidation accumulated
|
||||
// since the previous rAF. set_modifiers skips this work during
|
||||
// interactive_transform; we do it once here, with the current
|
||||
// modifier set, so the cost is paid once per rAF rather than
|
||||
// once per pointer move.
|
||||
if state.render_state.options.is_interactive_transform() {
|
||||
let ids = state.shapes.modifier_ids();
|
||||
if !ids.is_empty() {
|
||||
state.rebuild_modifier_tiles(ids)?;
|
||||
}
|
||||
}
|
||||
state
|
||||
.start_render_loop(performance::get_time())
|
||||
.map_err(|_| Error::RecoverableError("Error rendering".to_string()))?;
|
||||
});
|
||||
let dx_dt = crate::get_now!() - dx_t;
|
||||
if dx_dt > 16.0 {
|
||||
crate::run_script!(format!(
|
||||
"console.log('[wasm-entry] render took {:.1}ms')",
|
||||
dx_dt
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -343,7 +362,15 @@ pub extern "C" fn render_loading_overlay() -> Result<()> {
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn process_animation_frame(timestamp: i32) -> Result<()> {
|
||||
let dx_t = crate::get_now!();
|
||||
let result = with_state_mut!(state, { state.process_animation_frame(timestamp) });
|
||||
let dx_dt = crate::get_now!() - dx_t;
|
||||
if dx_dt > 16.0 {
|
||||
crate::run_script!(format!(
|
||||
"console.log('[wasm-entry] process_animation_frame took {:.1}ms')",
|
||||
dx_dt
|
||||
));
|
||||
}
|
||||
|
||||
if let Err(err) = result {
|
||||
eprintln!("process_animation_frame error: {}", err);
|
||||
@@ -395,6 +422,9 @@ pub extern "C" fn set_view_start() -> Result<()> {
|
||||
}
|
||||
performance::begin_measure!("set_view_start");
|
||||
state.render_state.options.set_fast_mode(true);
|
||||
// If a previous two-pass rebuild was mid-flight, discard its
|
||||
// intent — the new gesture supersedes it.
|
||||
state.render_state.options.set_defer_effects(false);
|
||||
performance::end_measure!("set_view_start");
|
||||
});
|
||||
Ok(())
|
||||
@@ -429,6 +459,11 @@ pub extern "C" fn set_view_end() -> Result<()> {
|
||||
// preview of the old content while new tiles render.
|
||||
state.render_state.rebuild_tile_index(&state.shapes);
|
||||
state.render_state.surfaces.invalidate_tile_cache();
|
||||
// Start the progressive two-pass rebuild. Pass 1 renders
|
||||
// tiles without blur/shadow for fast feedback; when it
|
||||
// completes, process_animation_frame flips this off and
|
||||
// kicks pass 2 which adds the effects back in place.
|
||||
state.render_state.options.set_defer_effects(true);
|
||||
} else {
|
||||
// Pure pan at the same zoom level: tile contents have not
|
||||
// changed — only the viewport position moved. Update the
|
||||
@@ -453,9 +488,18 @@ pub extern "C" fn set_view_end() -> Result<()> {
|
||||
pub extern "C" fn set_modifiers_start() -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
performance::begin_measure!("set_modifiers_start");
|
||||
let opts = &mut state.render_state.options;
|
||||
opts.set_fast_mode(true);
|
||||
opts.set_interactive_transform(true);
|
||||
state.render_state.options.set_fast_mode(true);
|
||||
state.render_state.options.set_interactive_transform(true);
|
||||
// Drop interest-area pre-rendering to a minimum (1 ring of tiles
|
||||
// beyond visible) for the duration of the gesture. The default
|
||||
// (3) puts ~81 tiles in the queue and made every PAF expensive;
|
||||
// 0 caused the dragged shape to disappear at tile boundaries
|
||||
// because the next-row tile wasn't pre-rendered. 1 keeps the
|
||||
// immediate neighbor tiles pre-rendered so the shape stays
|
||||
// visible when crossing a boundary, without inflating the queue.
|
||||
let prev = state.render_state.options.viewport_interest_area_threshold;
|
||||
state.render_state.dx_saved_interest_area = Some(prev);
|
||||
state.render_state.set_viewport_interest_area_threshold(1);
|
||||
performance::end_measure!("set_modifiers_start");
|
||||
});
|
||||
Ok(())
|
||||
@@ -470,9 +514,13 @@ pub extern "C" fn set_modifiers_start() -> Result<()> {
|
||||
pub extern "C" fn set_modifiers_end() -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
performance::begin_measure!("set_modifiers_end");
|
||||
let opts = &mut state.render_state.options;
|
||||
opts.set_fast_mode(false);
|
||||
opts.set_interactive_transform(false);
|
||||
state.render_state.options.set_fast_mode(false);
|
||||
state.render_state.options.set_interactive_transform(false);
|
||||
// Restore interest-area pre-rendering for normal post-drag
|
||||
// behavior (smooth pan, etc.).
|
||||
if let Some(prev) = state.render_state.dx_saved_interest_area.take() {
|
||||
state.render_state.set_viewport_interest_area_threshold(prev);
|
||||
}
|
||||
state.render_state.cancel_animation_frame();
|
||||
performance::end_measure!("set_modifiers_end");
|
||||
});
|
||||
@@ -971,10 +1019,26 @@ pub extern "C" fn set_modifiers() -> Result<()> {
|
||||
ids.push(entry.id);
|
||||
}
|
||||
|
||||
let dx_t = crate::get_now!();
|
||||
let dx_n = ids.len();
|
||||
with_state_mut!(state, {
|
||||
state.set_modifiers(modifiers);
|
||||
state.rebuild_modifier_tiles(ids)?;
|
||||
// Throttle: skip per-pointer-move tile invalidation. The render
|
||||
// entry (`render`) drains the current modifier set once per rAF
|
||||
// and calls rebuild_modifier_tiles then. With ~3 pointer moves
|
||||
// per rAF, this cuts tile invalidations by 3× and removes the
|
||||
// PAF backlog.
|
||||
if !state.render_state.options.is_interactive_transform() {
|
||||
state.rebuild_modifier_tiles(ids)?;
|
||||
}
|
||||
});
|
||||
let dx_dt = crate::get_now!() - dx_t;
|
||||
if dx_dt > 16.0 {
|
||||
crate::run_script!(format!(
|
||||
"console.log('[wasm-entry] set_modifiers ids={} took {:.1}ms')",
|
||||
dx_n, dx_dt
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+185
-30
@@ -334,6 +334,33 @@ pub(crate) struct RenderState {
|
||||
/// Cleared at the beginning of a render pass; set to true after we clear Cache the first
|
||||
/// time we are about to blit a tile into Cache for this pass.
|
||||
pub cache_cleared_this_render: bool,
|
||||
/// One-shot flag consumed by `start_render_loop`. When set, the Cache
|
||||
/// surface is NOT wiped for the upcoming pass — its current content
|
||||
/// (typically a first-pass preview) stays visible while new tiles
|
||||
/// overwrite it in place. Used by the progressive two-pass rebuild
|
||||
/// after a zoom ends.
|
||||
pub preserve_cache_this_render: bool,
|
||||
// ---- Drag-perf diagnostic counters (read-only instrumentation) ----
|
||||
// Cumulative `remove_cached_tile` calls since last PAF. Reset at end
|
||||
// of each `process_animation_frame`. Tells us how aggressively tiles
|
||||
// are being invalidated between PAF ticks.
|
||||
pub dx_inval_since_paf: u32,
|
||||
/// `viewport_interest_area_threshold` value to restore at gesture
|
||||
/// end. While dragging we drop interest to 1 (visible + 1 ring) to
|
||||
/// keep the queue small without making the dragged shape disappear
|
||||
/// when crossing tile boundaries.
|
||||
pub dx_saved_interest_area: Option<i32>,
|
||||
/// True iff the current tile had shapes assigned to it when we
|
||||
/// started rendering it. Lets us distinguish a genuinely empty
|
||||
/// tile (skip composite, just clear) from a tile whose walker
|
||||
/// finished its work in a previous PAF and is now being resumed
|
||||
/// (must composite to present the work). Reset when current_tile
|
||||
/// changes.
|
||||
pub current_tile_had_shapes: bool,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize {
|
||||
@@ -407,6 +434,11 @@ impl RenderState {
|
||||
preview_mode: false,
|
||||
export_context: None,
|
||||
cache_cleared_this_render: false,
|
||||
preserve_cache_this_render: false,
|
||||
dx_inval_since_paf: 0,
|
||||
dx_saved_interest_area: None,
|
||||
current_tile_had_shapes: false,
|
||||
dx_touch_calls: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -464,7 +496,7 @@ impl RenderState {
|
||||
/// Must be called BEFORE any save_layer for the shape's own opacity/blend,
|
||||
/// so that the backdrop blur is independent of the shape's visual properties.
|
||||
fn render_background_blur(&mut self, shape: &Shape, target_surface: SurfaceId) {
|
||||
if self.options.is_fast_mode() {
|
||||
if self.options.should_skip_effects() {
|
||||
return;
|
||||
}
|
||||
if matches!(shape.shape_type, Type::Text(_)) || matches!(shape.shape_type, Type::SVGRaw(_))
|
||||
@@ -624,6 +656,10 @@ impl RenderState {
|
||||
|
||||
pub fn set_viewport_interest_area_threshold(&mut self, value: i32) {
|
||||
self.options.set_viewport_interest_area_threshold(value);
|
||||
// The TileViewbox stores its own copy of `interest` (set at
|
||||
// construction). Without propagating, options change wouldn't
|
||||
// affect pending_tiles generation.
|
||||
self.tile_viewbox.set_interest(value);
|
||||
}
|
||||
|
||||
pub fn set_node_batch_threshold(&mut self, value: i32) {
|
||||
@@ -713,6 +749,7 @@ impl RenderState {
|
||||
|
||||
pub fn apply_render_to_final_canvas(&mut self, rect: skia::Rect) -> Result<()> {
|
||||
let fast_mode = self.options.is_fast_mode();
|
||||
let skip_atlas = self.options.is_defer_effects();
|
||||
// Decide *now* (at the first real cache blit) whether we need to clear Cache.
|
||||
// This avoids clearing Cache on renders that don't actually paint tiles (e.g. hover/UI),
|
||||
// while still preventing stale pixels from surviving across full-quality renders.
|
||||
@@ -733,6 +770,7 @@ impl RenderState {
|
||||
¤t_tile,
|
||||
&tile_rect,
|
||||
fast_mode,
|
||||
skip_atlas,
|
||||
self.render_area,
|
||||
);
|
||||
|
||||
@@ -867,7 +905,7 @@ impl RenderState {
|
||||
|
||||
let antialias =
|
||||
shape.should_use_antialias(self.get_scale(), self.options.antialias_threshold);
|
||||
let fast_mode = self.options.is_fast_mode();
|
||||
let skip_effects = self.options.should_skip_effects();
|
||||
let has_nested_fills = self
|
||||
.nested_fills
|
||||
.last()
|
||||
@@ -995,7 +1033,7 @@ impl RenderState {
|
||||
// Remove background blur from the shape so it doesn't get processed
|
||||
// as a layer blur. The actual rendering is done before the save_layer
|
||||
// in render_background_blur() so it's independent of shape opacity.
|
||||
if !fast_mode
|
||||
if !skip_effects
|
||||
&& apply_to_current_surface
|
||||
&& fills_surface_id == SurfaceId::Fills
|
||||
&& !matches!(shape.shape_type, Type::Text(_))
|
||||
@@ -1021,14 +1059,14 @@ impl RenderState {
|
||||
} else if shape_has_blur {
|
||||
shape.to_mut().set_blur(None);
|
||||
}
|
||||
if fast_mode {
|
||||
if skip_effects {
|
||||
shape.to_mut().set_blur(None);
|
||||
}
|
||||
|
||||
// For non-text, non-SVG shapes in the normal rendering path, apply blur
|
||||
// via a single save_layer on each render surface
|
||||
// Clip correctness is preserved
|
||||
let blur_sigma_for_layers: Option<f32> = if !fast_mode
|
||||
let blur_sigma_for_layers: Option<f32> = if !skip_effects
|
||||
&& apply_to_current_surface
|
||||
&& fills_surface_id == SurfaceId::Fills
|
||||
&& !matches!(shape.shape_type, Type::Text(_))
|
||||
@@ -1107,7 +1145,7 @@ impl RenderState {
|
||||
)
|
||||
})
|
||||
.unzip();
|
||||
if fast_mode {
|
||||
if skip_effects {
|
||||
// Fast path: render fills and strokes only (skip shadows/blur).
|
||||
text::render(
|
||||
Some(self),
|
||||
@@ -1396,7 +1434,7 @@ impl RenderState {
|
||||
antialias,
|
||||
outset,
|
||||
)?;
|
||||
if !fast_mode {
|
||||
if !skip_effects {
|
||||
for stroke in &visible_strokes {
|
||||
shadows::render_stroke_inner_shadows(
|
||||
self,
|
||||
@@ -1409,7 +1447,7 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
if !fast_mode {
|
||||
if !skip_effects {
|
||||
shadows::render_fill_inner_shadows(
|
||||
self,
|
||||
shape,
|
||||
@@ -1669,7 +1707,12 @@ impl RenderState {
|
||||
performance::begin_measure!("render");
|
||||
performance::begin_measure!("start_render_loop");
|
||||
|
||||
self.cache_cleared_this_render = false;
|
||||
// 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)
|
||||
@@ -1687,12 +1730,26 @@ impl RenderState {
|
||||
// 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
|
||||
.draw_atlas_over_target(self.viewbox, self.options.dpr());
|
||||
}
|
||||
|
||||
let surface_ids = SurfaceId::Strokes as u32
|
||||
@@ -1809,6 +1866,11 @@ impl RenderState {
|
||||
timestamp: i32,
|
||||
) -> Result<()> {
|
||||
performance::begin_measure!("process_animation_frame");
|
||||
let dx_t0 = crate::get_now!();
|
||||
let dx_pending_in = self.pending_tiles.list.len();
|
||||
let dx_inval_at_start = self.dx_inval_since_paf;
|
||||
let dx_interactive = self.options.is_interactive_transform();
|
||||
let dx_was_in_progress = self.render_in_progress;
|
||||
if self.render_in_progress {
|
||||
if tree.len() != 0 {
|
||||
self.render_shape_tree_partial(base_object, tree, timestamp, true)?;
|
||||
@@ -1834,12 +1896,47 @@ impl RenderState {
|
||||
if self.render_in_progress {
|
||||
self.cancel_animation_frame();
|
||||
self.render_request_id = Some(wapi::request_animation_frame!());
|
||||
} else if self.options.is_defer_effects() {
|
||||
// Pass 1 just finished — tiles are on screen without
|
||||
// blur/shadow. Immediately launch pass 2 to upgrade them
|
||||
// in place. The tile texture cache is invalidated so
|
||||
// every tile re-renders; the Cache surface is preserved
|
||||
// so pass-1 content stays visible until each pass-2
|
||||
// tile overwrites it.
|
||||
self.options.set_defer_effects(false);
|
||||
self.surfaces.invalidate_tile_cache();
|
||||
self.preserve_cache_this_render = true;
|
||||
self.start_render_loop(base_object, tree, timestamp, false)?;
|
||||
} else {
|
||||
wapi::notify_tiles_render_complete!();
|
||||
performance::end_measure!("render");
|
||||
}
|
||||
}
|
||||
performance::end_measure!("process_animation_frame");
|
||||
|
||||
// Per-PAF diagnostic. Emit only on slow PAFs (> 16ms breaks 60fps
|
||||
// budget) or large invalidation bursts. Per-rAF logging with
|
||||
// DevTools console open costs more than the work it measures.
|
||||
let dx_pending_out = self.pending_tiles.list.len();
|
||||
let dx_inval_in_paf = self.dx_inval_since_paf;
|
||||
let dx_dt = crate::get_now!() - dx_t0;
|
||||
if dx_dt > 16.0 || dx_inval_in_paf > 50 {
|
||||
crate::run_script!(format!(
|
||||
"console.log('[paf] dt={:.1}ms interactive={} render_in_progress={} pending_in={} pending_out={} inval_in_paf={}')",
|
||||
dx_dt,
|
||||
dx_interactive,
|
||||
dx_was_in_progress,
|
||||
dx_pending_in,
|
||||
dx_pending_out,
|
||||
dx_inval_in_paf
|
||||
));
|
||||
}
|
||||
// Clear the per-PAF invalidation counter for the next tick.
|
||||
// Note: invalidations that arrive AFTER this reset (e.g., from
|
||||
// a `set_modifiers` call between PAFs) accumulate against the
|
||||
// next PAF — exactly what we want to measure.
|
||||
let _ = dx_inval_at_start; // (silence unused warning)
|
||||
self.dx_inval_since_paf = 0;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1972,17 +2069,13 @@ impl RenderState {
|
||||
return false;
|
||||
}
|
||||
|
||||
// During interactive shape transforms we must complete every
|
||||
// visible tile in a single rAF so the user never sees tiles
|
||||
// popping in sequentially. Only yield once all visible work is
|
||||
// done and we are processing the interest-area pre-render.
|
||||
if self.options.is_interactive_transform() {
|
||||
if let Some(tile) = self.current_tile {
|
||||
if self.tile_viewbox.is_visible(&tile) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The previous version forced every visible tile to complete in
|
||||
// a single rAF during interactive_transform to avoid sequential
|
||||
// tile pop-in. With dense scenes that produced 700–1500 ms PAFs
|
||||
// because all dirty tiles got rendered synchronously. Yielding
|
||||
// back to the rAF budget makes drag responsive at the cost of
|
||||
// tiles appearing one-by-one — acceptable during a gesture, the
|
||||
// user is focused on the cursor, not the periphery.
|
||||
|
||||
true
|
||||
}
|
||||
@@ -2030,8 +2123,9 @@ impl RenderState {
|
||||
paint.set_blend_mode(element.blend_mode().into());
|
||||
paint.set_alpha_f(element.opacity());
|
||||
|
||||
// Skip frame-level blur in fast mode (pan/zoom)
|
||||
if !self.options.is_fast_mode() {
|
||||
// Skip frame-level blur in fast mode (pan/zoom) or during the
|
||||
// post-gesture first rebuild pass.
|
||||
if !self.options.should_skip_effects() {
|
||||
if let Some(frame_blur) = Self::frame_clip_layer_blur(element) {
|
||||
let scale = self.get_scale();
|
||||
let sigma = radius_to_sigma(frame_blur.value * scale);
|
||||
@@ -2717,7 +2811,7 @@ impl RenderState {
|
||||
// the layer blur (which would make it more diffused than without clipping)
|
||||
let shadow_before_layer = !node_render_state.is_root()
|
||||
&& self.focus_mode.is_active()
|
||||
&& !self.options.is_fast_mode()
|
||||
&& !self.options.should_skip_effects()
|
||||
&& !matches!(element.shape_type, Type::Text(_))
|
||||
&& Self::frame_clip_layer_blur(element).is_some()
|
||||
&& element.drop_shadows_visible().next().is_some();
|
||||
@@ -2753,8 +2847,9 @@ impl RenderState {
|
||||
.surfaces
|
||||
.get_render_context_translation(self.render_area, scale);
|
||||
|
||||
// Skip expensive drop shadow rendering in fast mode (during pan/zoom)
|
||||
let skip_shadows = self.options.is_fast_mode();
|
||||
// Skip expensive drop shadow rendering in fast mode (during
|
||||
// pan/zoom) or during the post-gesture first rebuild pass.
|
||||
let skip_shadows = self.options.should_skip_effects();
|
||||
|
||||
// Skip shadow block when already rendered before the layer (frame_clip_layer_blur)
|
||||
let shadows_already_rendered = Self::frame_clip_layer_blur(element).is_some();
|
||||
@@ -2936,7 +3031,17 @@ impl RenderState {
|
||||
}
|
||||
performance::end_measure!("render_shape_tree::uncached");
|
||||
let tile_rect = self.get_current_tile_bounds()?;
|
||||
if !is_empty {
|
||||
// Composite if the walker did work in this PAF
|
||||
// (`!is_empty`) OR the tile has unfinished work from
|
||||
// a previous PAF (`current_tile_had_shapes` was set
|
||||
// when we populated pending_nodes for this tile).
|
||||
// The explicit clear is reserved for tiles that
|
||||
// genuinely have no shapes assigned to them —
|
||||
// without this distinction, chunked-render
|
||||
// resumption was painting completed tiles back to
|
||||
// background, producing the disappearing-tile
|
||||
// flicker during drag.
|
||||
if !is_empty || self.current_tile_had_shapes {
|
||||
self.apply_render_to_final_canvas(tile_rect)?;
|
||||
|
||||
if self.options.is_debug_visible() {
|
||||
@@ -2953,7 +3058,6 @@ impl RenderState {
|
||||
paint.set_color(self.background_color);
|
||||
s.canvas().draw_rect(tile_rect, &paint);
|
||||
});
|
||||
// Keep Cache surface coherent for render_from_cache.
|
||||
if !self.options.is_fast_mode() {
|
||||
if !self.cache_cleared_this_render {
|
||||
self.surfaces.clear_cache(self.background_color);
|
||||
@@ -2978,6 +3082,11 @@ impl RenderState {
|
||||
// let's check if there are more pending nodes
|
||||
if let Some(next_tile) = self.pending_tiles.pop() {
|
||||
self.update_render_context(next_tile);
|
||||
// Reset for the new tile. We'll flip it to true if the
|
||||
// tile has shapes, so a later "is_empty=true" reflects
|
||||
// a resumed-from-yield case rather than a genuinely
|
||||
// empty tile.
|
||||
self.current_tile_had_shapes = false;
|
||||
|
||||
if !self.surfaces.has_cached_tile_surface(next_tile) {
|
||||
if let Some(ids) = self.tiles.get_shapes_at(next_tile) {
|
||||
@@ -3001,6 +3110,9 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
if !valid_ids.is_empty() {
|
||||
self.current_tile_had_shapes = true;
|
||||
}
|
||||
self.pending_nodes.extend(valid_ids.into_iter().map(|id| {
|
||||
NodeRenderState {
|
||||
id,
|
||||
@@ -3190,8 +3302,10 @@ impl RenderState {
|
||||
}
|
||||
|
||||
pub fn remove_cached_tile(&mut self, tile: tiles::Tile) {
|
||||
self.dx_inval_since_paf += 1;
|
||||
let keep_atlas = self.options.is_interactive_transform();
|
||||
self.surfaces
|
||||
.remove_cached_tile_surface(&mut self.gpu_state, tile);
|
||||
.remove_cached_tile_surface(&mut self.gpu_state, tile, keep_atlas);
|
||||
}
|
||||
|
||||
/// Rebuild the tile index (shape→tile mapping) for all top-level shapes.
|
||||
@@ -3281,6 +3395,8 @@ impl RenderState {
|
||||
let mut all_tiles = HashSet::<tiles::Tile>::new();
|
||||
|
||||
let ids = std::mem::take(&mut self.touched_ids);
|
||||
let dx_n_shapes = ids.len();
|
||||
let dx_n_touch_calls = std::mem::take(&mut self.dx_touch_calls);
|
||||
|
||||
for shape_id in ids.iter() {
|
||||
if let Some(shape) = tree.get(shape_id) {
|
||||
@@ -3290,11 +3406,19 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
let dx_n_tiles = all_tiles.len();
|
||||
// Update the changed tiles
|
||||
for tile in all_tiles {
|
||||
self.remove_cached_tile(tile);
|
||||
}
|
||||
|
||||
if dx_n_tiles > 20 || dx_n_touch_calls > 20 {
|
||||
crate::run_script!(format!(
|
||||
"console.log('[touched] mark_calls={} unique_shapes={} tiles_invalidated={}')",
|
||||
dx_n_touch_calls, dx_n_shapes, dx_n_tiles
|
||||
));
|
||||
}
|
||||
|
||||
performance::end_measure!("rebuild_touched_tiles");
|
||||
}
|
||||
|
||||
@@ -3336,8 +3460,38 @@ impl RenderState {
|
||||
tree: ShapesPoolMutRef<'_>,
|
||||
ids: Vec<Uuid>,
|
||||
) -> Result<()> {
|
||||
let ancestors = all_with_ancestors(&ids, tree, false);
|
||||
self.update_tiles_shapes(&ancestors, tree)?;
|
||||
// During interactive transform, skip ancestor invalidation: walking
|
||||
// up to the parent frame evicts every tile the frame covers,
|
||||
// including dense tiles with hundreds of siblings. The anti-flicker
|
||||
// guard then forces all of them to re-render in a single frame.
|
||||
// Ancestor extrect caches are already invalidated by
|
||||
// `ShapesPool::set_modifiers`; the tile index is reconciled
|
||||
// post-gesture by the committing code path (rebuild_touched_tiles).
|
||||
let interactive = self.options.is_interactive_transform();
|
||||
let inval_before = self.dx_inval_since_paf;
|
||||
let processed_count = if interactive {
|
||||
self.update_tiles_shapes(&ids, tree)?;
|
||||
ids.len()
|
||||
} else {
|
||||
let ancestors = all_with_ancestors(&ids, tree, false);
|
||||
let n = ancestors.len();
|
||||
self.update_tiles_shapes(&ancestors, tree)?;
|
||||
n
|
||||
};
|
||||
let inval_added = self.dx_inval_since_paf - inval_before;
|
||||
// Only log when something unusual happens — per-rAF logging itself
|
||||
// costs 0.5-2ms with DevTools console open, polluting the very
|
||||
// measurement we care about. Spike threshold catches accidental
|
||||
// ancestor-walk (interactive=false) or many tiles invalidated.
|
||||
if !interactive || inval_added > 8 {
|
||||
crate::run_script!(format!(
|
||||
"console.log('[rebuild] interactive={} input_ids={} processed={} tiles_invalidated={}')",
|
||||
interactive,
|
||||
ids.len(),
|
||||
processed_count,
|
||||
inval_added
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3359,6 +3513,7 @@ impl RenderState {
|
||||
|
||||
pub fn mark_touched(&mut self, uuid: Uuid) {
|
||||
self.touched_ids.insert(uuid);
|
||||
self.dx_touch_calls += 1;
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -22,6 +22,11 @@ pub struct RenderOptions {
|
||||
/// keeps per-frame flushing enabled (unlike pan/zoom, where
|
||||
/// `render_from_cache` drives target presentation).
|
||||
interactive_transform: bool,
|
||||
/// Active during the first rebuild pass after a zoom ends. Skips
|
||||
/// blur/shadow (like `fast_mode`) but renders tiles normally rather
|
||||
/// than using the atlas backdrop, so the user sees a fast full-fidelity
|
||||
/// shape preview before effects come in on a second pass.
|
||||
defer_effects: bool,
|
||||
/// Minimum on-screen size (CSS px at 1:1 zoom) above which vector antialiasing is enabled.
|
||||
pub antialias_threshold: f32,
|
||||
pub viewport_interest_area_threshold: i32,
|
||||
@@ -37,6 +42,7 @@ impl Default for RenderOptions {
|
||||
dpr: None,
|
||||
fast_mode: false,
|
||||
interactive_transform: false,
|
||||
defer_effects: false,
|
||||
antialias_threshold: ANTIALIAS_THRESHOLD,
|
||||
viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD,
|
||||
max_blocking_time_ms: MAX_BLOCKING_TIME_MS,
|
||||
@@ -76,6 +82,23 @@ impl RenderOptions {
|
||||
self.interactive_transform = enabled;
|
||||
}
|
||||
|
||||
pub fn is_defer_effects(&self) -> bool {
|
||||
self.defer_effects
|
||||
}
|
||||
|
||||
pub fn set_defer_effects(&mut self, enabled: bool) {
|
||||
self.defer_effects = enabled;
|
||||
}
|
||||
|
||||
/// True when expensive per-shape effects (blur, shadow) should be
|
||||
/// skipped. Covers both the active viewport gesture (`fast_mode`)
|
||||
/// and the post-gesture first rebuild pass (`defer_effects`).
|
||||
/// Do NOT use this to gate atlas-backdrop / cache-presentation logic
|
||||
/// — those must key off `is_fast_mode()` specifically.
|
||||
pub fn should_skip_effects(&self) -> bool {
|
||||
self.fast_mode || self.defer_effects
|
||||
}
|
||||
|
||||
/// True only when the viewport is the one being moved (pan/zoom)
|
||||
/// and the dedicated `render_from_cache` path owns Target
|
||||
/// presentation. In this mode `process_animation_frame` must not
|
||||
|
||||
@@ -400,6 +400,26 @@ impl Surfaces {
|
||||
/// Draw the persistent atlas onto the target using the current viewbox transform.
|
||||
/// Intended for fast pan/zoom-out previews (avoids per-tile composition).
|
||||
pub fn draw_atlas_to_target(&mut self, viewbox: Viewbox, dpr: f32, background: skia::Color) {
|
||||
self.draw_atlas_to_target_inner(viewbox, dpr, Some(background));
|
||||
}
|
||||
|
||||
/// Same as `draw_atlas_to_target` but preserves whatever is already on
|
||||
/// Target instead of clearing it to the background color first. Used
|
||||
/// by the progressive pass-1 rebuild so that, when the atlas only
|
||||
/// partially covers the current viewport, uncovered regions keep
|
||||
/// their previous content (e.g. tiles rendered directly during an
|
||||
/// earlier render) instead of flashing to the background color until
|
||||
/// pass 1 catches up.
|
||||
pub fn draw_atlas_over_target(&mut self, viewbox: Viewbox, dpr: f32) {
|
||||
self.draw_atlas_to_target_inner(viewbox, dpr, None);
|
||||
}
|
||||
|
||||
fn draw_atlas_to_target_inner(
|
||||
&mut self,
|
||||
viewbox: Viewbox,
|
||||
dpr: f32,
|
||||
background: Option<skia::Color>,
|
||||
) {
|
||||
if !self.has_atlas() {
|
||||
return;
|
||||
};
|
||||
@@ -417,7 +437,9 @@ impl Surfaces {
|
||||
let s = viewbox.zoom * dpr;
|
||||
let atlas_scale = self.atlas_scale.max(0.01);
|
||||
|
||||
canvas.clear(background);
|
||||
if let Some(bg) = background {
|
||||
canvas.clear(bg);
|
||||
}
|
||||
canvas.translate((
|
||||
(self.atlas_origin.x + viewbox.pan_x) * s,
|
||||
(self.atlas_origin.y + viewbox.pan_y) * s,
|
||||
@@ -858,6 +880,7 @@ impl Surfaces {
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn cache_current_tile_texture(
|
||||
&mut self,
|
||||
gpu_state: &mut GpuState,
|
||||
@@ -865,6 +888,7 @@ impl Surfaces {
|
||||
tile: &Tile,
|
||||
tile_rect: &skia::Rect,
|
||||
skip_cache_surface: bool,
|
||||
skip_atlas: bool,
|
||||
tile_doc_rect: skia::Rect,
|
||||
) {
|
||||
let rect = IRect::from_xywh(
|
||||
@@ -889,8 +913,13 @@ impl Surfaces {
|
||||
|
||||
// Incrementally update persistent 1:1 atlas in document space.
|
||||
// `tile_doc_rect` is in world/document coordinates (1 unit == 1 px at 100%).
|
||||
let _ = self.blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect);
|
||||
self.atlas_tile_doc_rects.insert(*tile, tile_doc_rect);
|
||||
// Skipped during the progressive pass 1 (defer_effects) so we do
|
||||
// not contaminate the atlas with shape previews that lack blur
|
||||
// or shadows — pass 2 will write the final full-quality tiles.
|
||||
if !skip_atlas {
|
||||
let _ = self.blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect);
|
||||
self.atlas_tile_doc_rects.insert(*tile, tile_doc_rect);
|
||||
}
|
||||
self.tiles.add(tile_viewbox, tile, tile_image);
|
||||
}
|
||||
}
|
||||
@@ -899,14 +928,32 @@ impl Surfaces {
|
||||
self.tiles.has(tile)
|
||||
}
|
||||
|
||||
pub fn remove_cached_tile_surface(&mut self, gpu_state: &mut GpuState, tile: Tile) {
|
||||
// Mark tile as invalid
|
||||
// Old content stays visible until new tile overwrites it atomically,
|
||||
// preventing flickering during tile re-renders.
|
||||
pub fn remove_cached_tile_surface(
|
||||
&mut self,
|
||||
gpu_state: &mut GpuState,
|
||||
tile: Tile,
|
||||
keep_atlas: bool,
|
||||
) {
|
||||
// Mark tile as invalid. Old content stays visible until new
|
||||
// tile overwrites it atomically, preventing flickering during
|
||||
// tile re-renders.
|
||||
self.tiles.remove(tile);
|
||||
// Also clear the corresponding region in the persistent atlas to avoid
|
||||
// leaving stale pixels when shapes move/delete.
|
||||
let _ = self.clear_tile_in_atlas(gpu_state, tile);
|
||||
// Atlas eviction policy:
|
||||
// - During interactive transform (`keep_atlas=true`):
|
||||
// `draw_atlas_to_target` clears Target then blits the atlas
|
||||
// on top. Clearing the atlas region here would make that
|
||||
// region appear as background until re-render completes —
|
||||
// the disappearing-tile flicker. Keep the old atlas content
|
||||
// so the shape shows briefly at its prior position (the
|
||||
// walker overwrites it when the tile re-renders).
|
||||
// - Outside interactive transform (`keep_atlas=false`): clear
|
||||
// the atlas region. Otherwise stale content (e.g. a shape's
|
||||
// silhouette at its pre-drag position) lingers in the atlas
|
||||
// until the tile re-renders — and if the shape moved away,
|
||||
// nothing forces a re-render of the *old* tile region.
|
||||
if !keep_atlas {
|
||||
let _ = self.clear_tile_in_atlas(gpu_state, tile);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_cached_tile_surface(&mut self, tile: Tile, rect: skia::Rect, color: skia::Color) {
|
||||
|
||||
@@ -195,7 +195,7 @@ pub struct Shape {
|
||||
pub shadows: Vec<Shadow>,
|
||||
pub layout_item: Option<LayoutItem>,
|
||||
pub bounds: OnceCell<math::Bounds>,
|
||||
pub extrect_cache: RefCell<Option<(math::Rect, u32)>>,
|
||||
pub extrect_cache: RefCell<Option<math::Rect>>,
|
||||
pub svg_transform: Option<Matrix>,
|
||||
pub ignore_constraints: bool,
|
||||
deleted: bool,
|
||||
@@ -1015,17 +1015,20 @@ impl Shape {
|
||||
}
|
||||
|
||||
pub fn calculate_extrect(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect {
|
||||
let scale_key = (scale * 1000.0).round() as u32;
|
||||
|
||||
if let Some((cached_extrect, cached_scale)) = *self.extrect_cache.borrow() {
|
||||
if cached_scale == scale_key {
|
||||
return cached_extrect;
|
||||
}
|
||||
// Extrect is a pure function of shape geometry (selrect, transform,
|
||||
// path, strokes, shadows, blur, children) — it does not depend on
|
||||
// `scale`. The parameter is threaded through for downstream callers
|
||||
// that need it (e.g. visibility checks that multiply by scale), but
|
||||
// the cache key must NOT include scale: different callers request
|
||||
// the same extrect at different scales (render at viewbox.zoom*dpr,
|
||||
// compute_document_bounds at 1.0) and should share cache entries.
|
||||
if let Some(cached_extrect) = *self.extrect_cache.borrow() {
|
||||
return cached_extrect;
|
||||
}
|
||||
|
||||
let extrect = self.calculate_extrect_uncached(shapes_pool, scale);
|
||||
|
||||
*self.extrect_cache.borrow_mut() = Some((extrect, scale_key));
|
||||
*self.extrect_cache.borrow_mut() = Some(extrect);
|
||||
extrect
|
||||
}
|
||||
|
||||
|
||||
@@ -259,15 +259,27 @@ pub fn get_fill_shader(fill: &Fill, bounding_box: &Rect) -> Option<skia::Shader>
|
||||
}
|
||||
|
||||
pub fn merge_fills(fills: &[Fill], bounding_box: Rect) -> skia::Paint {
|
||||
let mut combined_shader: Option<skia::Shader> = None;
|
||||
let mut fills_paint = skia::Paint::default();
|
||||
|
||||
if fills.is_empty() {
|
||||
combined_shader = Some(skia::shaders::color(skia::Color::TRANSPARENT));
|
||||
fills_paint.set_shader(combined_shader);
|
||||
fills_paint.set_color(skia::Color::TRANSPARENT);
|
||||
return fills_paint;
|
||||
}
|
||||
|
||||
// Fast path: a single solid color fill is the overwhelmingly common
|
||||
// case. Setting the paint's color directly uses Skia's optimized
|
||||
// solid-fill GPU path (uniform color, no shader). The general
|
||||
// shader path below builds a `shaders::color` shader and runs the
|
||||
// fragment-shader pipeline for every pixel — that costs ~10 ms for
|
||||
// a 595x435 rect at 2x DPR (≈1M pixels) when the rect is dragged.
|
||||
if fills.len() == 1 {
|
||||
if let Fill::Solid(SolidColor(color)) = &fills[0] {
|
||||
fills_paint.set_color(*color);
|
||||
return fills_paint;
|
||||
}
|
||||
}
|
||||
|
||||
let mut combined_shader: Option<skia::Shader> = None;
|
||||
for fill in fills {
|
||||
let shader = get_fill_shader(fill, &bounding_box);
|
||||
|
||||
@@ -287,7 +299,7 @@ pub fn merge_fills(fills: &[Fill], bounding_box: Rect) -> skia::Paint {
|
||||
}
|
||||
}
|
||||
|
||||
fills_paint.set_shader(combined_shader.clone());
|
||||
fills_paint.set_shader(combined_shader);
|
||||
fills_paint
|
||||
}
|
||||
|
||||
|
||||
@@ -309,6 +309,36 @@ impl ShapesPoolImpl {
|
||||
modified_uuids
|
||||
}
|
||||
|
||||
/// Returns the current transform modifier for a shape, if any.
|
||||
pub fn get_modifier(&self, id: &Uuid) -> Option<&skia::Matrix> {
|
||||
let idx = self.uuid_to_idx.get(id)?;
|
||||
self.modifiers.get(idx)
|
||||
}
|
||||
|
||||
/// Number of shapes currently under a modifier.
|
||||
pub fn modifier_count(&self) -> usize {
|
||||
self.modifiers.len()
|
||||
}
|
||||
|
||||
/// UUIDs of all shapes that currently have a transform modifier.
|
||||
/// Used by the throttled drag path so per-rAF tile invalidation can
|
||||
/// be done once with the current modifier set instead of once per
|
||||
/// pointer move.
|
||||
pub fn modifier_ids(&self) -> Vec<Uuid> {
|
||||
if self.modifiers.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut idx_to_uuid: HashMap<usize, Uuid> =
|
||||
HashMap::with_capacity(self.uuid_to_idx.len());
|
||||
for (uuid, idx) in self.uuid_to_idx.iter() {
|
||||
idx_to_uuid.insert(*idx, *uuid);
|
||||
}
|
||||
self.modifiers
|
||||
.keys()
|
||||
.filter_map(|idx| idx_to_uuid.get(idx).copied())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn subtree(&self, id: &Uuid) -> ShapesPoolImpl {
|
||||
let Some(shape) = self.get(id) else {
|
||||
panic!("Subtree not found");
|
||||
|
||||
@@ -86,6 +86,10 @@ impl TileViewbox {
|
||||
self.center = get_tile_center_for_viewbox(viewbox, scale);
|
||||
}
|
||||
|
||||
pub fn set_interest(&mut self, interest: i32) {
|
||||
self.interest = interest;
|
||||
}
|
||||
|
||||
pub fn is_visible(&self, tile: &Tile) -> bool {
|
||||
// TO CHECK self.interest_rect.contains(tile)
|
||||
self.visible_rect.contains(tile)
|
||||
|
||||
Reference in new issue
Block a user