This commit is contained in:
Elena Torro 2026-04-27 11:57:23 +02:00
parent 0d062449d4
commit b06aa2ba3e
6 changed files with 245 additions and 33 deletions

View File

@ -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(())
}

View File

@ -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<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 {
@ -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 7001500 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::<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) {
@ -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)]

View File

@ -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) {

View File

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

View File

@ -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<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");

View File

@ -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)