🔧 Split the tile rebuild into a chunked async loop

This commit is contained in:
Elena Torro 2026-03-09 11:59:15 +01:00
parent c76985abee
commit 996f973317
4 changed files with 136 additions and 29 deletions

View File

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

View File

@ -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<i32> {
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<i32> {
let result = with_state_mut!(state, {
if state.process_tile_rebuild_step(timestamp, generation as u32) {
1
} else {
0
}
});
Ok(())
Ok(result)
}
#[no_mangle]

View File

@ -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<Uuid>,
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::<tiles::Tile>::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");

View File

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