From b06aa2ba3e3ab01c0e629f555749f5f127673e98 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Mon, 27 Apr 2026 11:57:23 +0200 Subject: [PATCH] wip --- render-wasm/src/main.rs | 70 ++++++++++++-- render-wasm/src/render.rs | 133 ++++++++++++++++++++++++--- render-wasm/src/render/surfaces.rs | 32 +++++-- render-wasm/src/shapes/fills.rs | 20 +++- render-wasm/src/state/shapes_pool.rs | 19 ++++ render-wasm/src/tiles.rs | 4 + 6 files changed, 245 insertions(+), 33 deletions(-) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 498fc4376b..6b3cbe2648 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -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); @@ -461,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(()) @@ -478,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"); }); @@ -979,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(()) } diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 4eae0d84be..a01fd10ea3 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -340,6 +340,27 @@ pub(crate) struct RenderState { /// 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, + /// 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 { @@ -414,6 +435,10 @@ impl RenderState { 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, }) } @@ -631,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) { @@ -1837,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)?; @@ -1879,6 +1913,30 @@ impl RenderState { } } 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(()) } @@ -2011,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 } @@ -2977,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() { @@ -2994,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); @@ -3019,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) { @@ -3042,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, @@ -3231,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. @@ -3322,6 +3395,8 @@ impl RenderState { let mut all_tiles = HashSet::::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) { @@ -3331,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"); } @@ -3384,11 +3467,30 @@ impl RenderState { // Ancestor extrect caches are already invalidated by // `ShapesPool::set_modifiers`; the tile index is reconciled // post-gesture by the committing code path (rebuild_touched_tiles). - if self.options.is_interactive_transform() { + 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(()) } @@ -3411,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)] diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 5d29c9a1d5..47190430fe 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -928,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) { diff --git a/render-wasm/src/shapes/fills.rs b/render-wasm/src/shapes/fills.rs index 5b61b3ee2a..6519b44a10 100644 --- a/render-wasm/src/shapes/fills.rs +++ b/render-wasm/src/shapes/fills.rs @@ -259,15 +259,27 @@ pub fn get_fill_shader(fill: &Fill, bounding_box: &Rect) -> Option } pub fn merge_fills(fills: &[Fill], bounding_box: Rect) -> skia::Paint { - let mut combined_shader: Option = 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 = 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 } diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index 8b3cd98e7f..1a433c2a29 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -320,6 +320,25 @@ impl ShapesPoolImpl { 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 { + if self.modifiers.is_empty() { + return Vec::new(); + } + let mut idx_to_uuid: HashMap = + 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"); diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index 13ed4c1aeb..003ca52fdb 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -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)