diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 82fb16ebaa..0f9d15bbeb 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -946,15 +946,32 @@ (:y position))] (= result 1))) +(defn- tile-rebuild-loop + "Process tile rebuild in chunks via requestAnimationFrame, rendering + on each frame so partial results are visible. The generation counter + ensures stale loops (from a previous zoom/pan) stop immediately." + [generation] + (js/requestAnimationFrame + (fn [ts] + (when wasm/context-initialized? + (let [has-more (h/call wasm/internal-module "_tile_rebuild_step" ts generation)] + (render ts) + (when (pos? has-more) + (tile-rebuild-loop generation))))))) + (def render-finish - (letfn [(do-render [ts] + (letfn [(do-render [_ts] ;; Check if context is still initialized before executing ;; to prevent errors when navigating quickly (when wasm/context-initialized? (perf/begin-measure "render-finish") - (h/call wasm/internal-module "_set_view_end") - (render ts) - (perf/end-measure "render-finish")))] + (let [generation (h/call wasm/internal-module "_set_view_end")] + (perf/end-measure "render-finish") + (if (pos? generation) + ;; Async rebuild started — drive it with rAF loop + (tile-rebuild-loop generation) + ;; Profile mode did sync rebuild — just render once + (render (js/performance.now))))))] (fns/debounce do-render DEBOUNCE_DELAY_MS))) (def render-pan diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 6e519cc249..493fb7f734 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -305,45 +305,55 @@ pub extern "C" fn set_view_start() -> Result<()> { Ok(()) } +/// Uses chunked tile rebuild to avoid blocking +/// the main thread. Prepares the view state and starts the async +/// tile rebuild process. Call `tile_rebuild_step` in a rAF loop after this. +/// +/// Returns a generation counter (> 0) when async rebuild was started, +/// or 0 when profile mode used sync rebuild (no loop needed). #[no_mangle] #[wasm_error] -pub extern "C" fn set_view_end() -> Result<()> { - with_state_mut!(state, { - let _end_start = performance::begin_timed_log!("set_view_end"); +pub extern "C" fn set_view_end() -> Result { + let generation = with_state_mut!(state, { performance::begin_measure!("set_view_end"); state.render_state.options.set_fast_mode(false); state.render_state.cancel_animation_frame(); - // Update tile_viewbox first so that get_tiles_for_shape uses the correct interest area - // This is critical because we limit tiles to the interest area for optimization let scale = state.render_state.get_scale(); state .render_state .tile_viewbox .update(state.render_state.viewbox, scale); - // We rebuild the tile index on both pan and zoom because `get_tiles_for_shape` - // clips each shape to the current `TileViewbox::interest_rect` (viewport-dependent). - let _rebuild_start = performance::begin_timed_log!("rebuild_tiles"); - performance::begin_measure!("set_view_end::rebuild_tiles"); - if state.render_state.options.is_profile_rebuild_tiles() { + let gen = if state.render_state.options.is_profile_rebuild_tiles() { + // Profile mode uses sync rebuild — no async loop needed state.rebuild_tiles(); + 0i32 } else { - state.rebuild_tiles_shallow(); - } - performance::end_measure!("set_view_end::rebuild_tiles"); - performance::end_timed_log!("rebuild_tiles", _rebuild_start); + state.start_tile_rebuild() as i32 + }; state.render_state.sync_cached_viewbox(); performance::end_measure!("set_view_end"); - performance::end_timed_log!("set_view_end", _end_start); - #[cfg(feature = "profile-macros")] - { - let total_time = performance::get_time() - unsafe { VIEW_INTERACTION_START }; - performance::console_log!("[PERF] view_interaction: {}ms", total_time); + gen + }); + Ok(generation) +} + +/// Process a chunk of the tile rebuild. Returns 1 if more work remains, 0 if done. +/// `generation` must match the value returned by `set_view_end`; stale +/// loops from a previous rebuild are stopped immediately. +#[no_mangle] +#[wasm_error] +pub extern "C" fn tile_rebuild_step(timestamp: i32, generation: i32) -> Result { + let result = with_state_mut!(state, { + if state.process_tile_rebuild_step(timestamp, generation as u32) { + 1 + } else { + 0 } }); - Ok(()) + Ok(result) } #[no_mangle] diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index ce5781ca63..d9c26b8f4e 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -300,6 +300,11 @@ pub(crate) struct RenderState { pub ignore_nested_blurs: bool, /// Preview render mode - when true, uses simplified rendering for progressive loading pub preview_mode: bool, + /// State for chunked tile rebuild across animation frames + tile_rebuild_pending_nodes: Vec, + tile_rebuild_zoom_changed: bool, + /// Generation counter to detect stale rebuild loops after re-entrant zoom/pan + tile_rebuild_generation: u32, } pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize { @@ -372,6 +377,9 @@ impl RenderState { touched_ids: HashSet::default(), ignore_nested_blurs: false, preview_mode: false, + tile_rebuild_pending_nodes: vec![], + tile_rebuild_zoom_changed: false, + tile_rebuild_generation: 0, } } @@ -2514,17 +2522,14 @@ impl RenderState { // because tiles are rendered at specific zoom levels let zoom_changed = self.zoom_changed(); - let mut tiles_to_invalidate = HashSet::::new(); let mut nodes = vec![Uuid::nil()]; while let Some(shape_id) = nodes.pop() { if let Some(shape) = tree.get(&shape_id) { if shape_id != Uuid::nil() { if zoom_changed { - // Zoom changed: use full update that tracks all affected tiles - tiles_to_invalidate.extend(self.update_shape_tiles(shape, tree)); + let _ = self.update_shape_tiles(shape, tree); } else { - // Pan only: use incremental update that preserves valid cached tiles - self.update_shape_tiles_incremental(shape, tree); + let _ = self.update_shape_tiles_incremental(shape, tree); } } else { // We only need to rebuild tiles from the first level. @@ -2541,6 +2546,72 @@ impl RenderState { performance::end_measure!("rebuild_tiles_shallow"); } + /// Initialize a chunked tile rebuild that can be spread across multiple + /// animation frames to avoid blocking the main thread. + /// Returns the generation counter so the caller can detect stale loops. + pub fn start_tile_rebuild(&mut self, tree: ShapesPoolRef) -> u32 { + self.tile_rebuild_zoom_changed = self.zoom_changed(); + self.tile_rebuild_generation = self.tile_rebuild_generation.wrapping_add(1); + // Skip 0 — the CLJS caller uses 0 to mean "sync rebuild, no loop needed" + if self.tile_rebuild_generation == 0 { + self.tile_rebuild_generation = 1; + } + + // Collect top-level shape ids (children of the root node) + let mut nodes = Vec::new(); + if let Some(root) = tree.get(&Uuid::nil()) { + for child_id in root.children_ids_iter(false) { + nodes.push(*child_id); + } + } + + self.tile_rebuild_pending_nodes = nodes; + self.tile_rebuild_generation + } + + /// Process a batch of shapes for tile rebuild within a time budget. + /// Returns true if there is more work remaining. Returns false if done + /// or if `generation` doesn't match (stale loop from a previous rebuild). + pub fn process_tile_rebuild_step( + &mut self, + tree: ShapesPoolRef, + timestamp: i32, + generation: u32, + ) -> bool { + if generation != self.tile_rebuild_generation { + return false; + } + + performance::begin_measure!("process_tile_rebuild_step"); + + let mut iteration = 0i32; + while let Some(shape_id) = self.tile_rebuild_pending_nodes.pop() { + if let Some(shape) = tree.get(&shape_id) { + if self.tile_rebuild_zoom_changed { + // Return value intentionally discarded — invalidation happens + // in bulk via remove_cached_tiles once all shapes are processed. + let _ = self.update_shape_tiles(shape, tree); + } else { + let _ = self.update_shape_tiles_incremental(shape, tree); + } + } + + iteration += 1; + if iteration % NODE_BATCH_THRESHOLD == 0 + && performance::get_time() - timestamp > MAX_BLOCKING_TIME_MS + { + performance::end_measure!("process_tile_rebuild_step"); + return true; + } + } + + // All shapes processed — finalize + self.surfaces.remove_cached_tiles(self.background_color); + + performance::end_measure!("process_tile_rebuild_step"); + false + } + pub fn rebuild_tiles_from(&mut self, tree: ShapesPoolRef, base_id: Option<&Uuid>) { performance::begin_measure!("rebuild_tiles"); diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index b99b768334..68a0e23a2c 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -211,6 +211,15 @@ impl State { self.render_state.rebuild_tiles_shallow(&self.shapes); } + pub fn start_tile_rebuild(&mut self) -> u32 { + self.render_state.start_tile_rebuild(&self.shapes) + } + + pub fn process_tile_rebuild_step(&mut self, timestamp: i32, generation: u32) -> bool { + self.render_state + .process_tile_rebuild_step(&self.shapes, timestamp, generation) + } + pub fn rebuild_tiles(&mut self) { self.render_state.rebuild_tiles_from(&self.shapes, None); }