Compare commits

...

9 Commits

Author SHA1 Message Date
Elena Torro
8480dd8b8b 🔧 Simplify view interaction log message
Remove zoom_changed from log output as it's no longer needed
for debugging after the tile optimization changes.
2026-02-04 11:08:05 +01:00
Elena Torro
d8143963fd 🔧 Optimize pan/zoom tile handling
- Add incremental tile update that preserves cache during pan
- Only invalidate tile cache when zoom changes
- Force visible tiles to render synchronously (no yielding)
- Increase interest area threshold from 2 to 3 tiles
2026-02-04 11:08:05 +01:00
Elena Torro
5243a34e14 🔧 Prioritize visible tiles over interest-area tiles
Partition pending tiles into 4 groups by visibility and cache status.
Visible tiles are processed first to eliminate empty squares during
pan/zoom. Cached tiles within each group are processed before uncached.
2026-02-04 11:08:05 +01:00
Elena Torro
743b9d22c2 🔧 Use HashSet for grid layout children lookup
HashSet provides O(1) contains() vs Vec's O(n), improving
child lookup performance in grid cell data creation.
2026-02-04 11:08:05 +01:00
Elena Torro
452269d0fc 🔧 Use swap_remove in flex layout distribution
swap_remove is O(1) vs remove's O(n) when order doesn't matter.
These loops iterate backwards, so swap_remove is safe.
2026-02-04 11:08:05 +01:00
Elena Torro
0d726df9a8 🔧 Prevent duplicate layout calculations
Use HashSet for layout_reflows to avoid processing the same
layout multiple times. Also use std::mem::take instead of
creating a new Vec on each iteration.
2026-02-04 11:08:05 +01:00
Elena Torro
111c9a480b 🔧 Add forward children iterator for flex layout
Avoid Vec allocation + reverse for reversed flex layouts.
The new children_ids_iter_forward returns children in original order,
eliminating the need to collect and reverse.
2026-02-04 11:08:05 +01:00
Elena Torro
271515904e 🔧 Return HashSet from update_shape_tiles
Avoid final collect() allocation by returning HashSet directly.
Callers already use extend() which works with both types.
2026-02-04 11:08:05 +01:00
Elena Torro
b0b09b7acc 🔧 Avoid clone in rebuild_touched_tiles
Use std::mem::take instead of clone to avoid HashSet allocation.
The set was cleared anyway by clean_touched(), so take() is safe.
2026-02-04 11:08:05 +01:00
7 changed files with 178 additions and 62 deletions

View File

@@ -302,8 +302,7 @@ pub extern "C" fn set_view_end() {
{
let total_time = performance::get_time() - unsafe { VIEW_INTERACTION_START };
performance::console_log!(
"[PERF] view_interaction (zoom_changed={}): {}ms",
zoom_changed,
"[PERF] view_interaction: {}ms",
total_time
);
}

View File

@@ -33,8 +33,9 @@ use crate::wapi;
pub use fonts::*;
pub use images::*;
// This is the extra are used for tile rendering.
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 2;
// This is the extra area used for tile rendering (tiles beyond viewport).
// Higher values pre-render more tiles, reducing empty squares during pan but using more memory.
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3;
const MAX_BLOCKING_TIME_MS: i32 = 32;
const NODE_BATCH_THRESHOLD: i32 = 3;
@@ -2063,8 +2064,13 @@ impl RenderState {
}
} else {
performance::begin_measure!("render_shape_tree::uncached");
// Only allow stopping (yielding) if the current tile is NOT visible.
// This ensures all visible tiles render synchronously before showing,
// eliminating empty squares during zoom. Interest-area tiles can still yield.
let tile_is_visible = self.tile_viewbox.is_visible(&current_tile);
let can_stop = allow_stop && !tile_is_visible;
let (is_empty, early_return) =
self.render_shape_tree_partial_uncached(tree, timestamp, allow_stop)?;
self.render_shape_tree_partial_uncached(tree, timestamp, can_stop)?;
if early_return {
return Ok(());
@@ -2189,17 +2195,16 @@ impl RenderState {
* Given a shape, check the indexes and update it's location in the tile set
* returns the tiles that have changed in the process.
*/
pub fn update_shape_tiles(&mut self, shape: &Shape, tree: ShapesPoolRef) -> Vec<tiles::Tile> {
pub fn update_shape_tiles(&mut self, shape: &Shape, tree: ShapesPoolRef) -> HashSet<tiles::Tile> {
let TileRect(rsx, rsy, rex, rey) = self.get_tiles_for_shape(shape, tree);
let old_tiles = self
// Collect old tiles to avoid borrow conflict with remove_shape_at
let old_tiles: Vec<_> = self
.tiles
.get_tiles_of(shape.id)
.map_or(Vec::new(), |tiles| tiles.iter().copied().collect());
.map_or(Vec::new(), |t| t.iter().copied().collect());
let new_tiles = (rsx..=rex).flat_map(|x| (rsy..=rey).map(move |y| tiles::Tile::from(x, y)));
let mut result = HashSet::<tiles::Tile>::new();
let mut result = HashSet::<tiles::Tile>::with_capacity(old_tiles.len());
// First, remove the shape from all tiles where it was previously located
for tile in old_tiles {
@@ -2208,12 +2213,66 @@ impl RenderState {
}
// Then, add the shape to the new tiles
for tile in new_tiles {
for tile in (rsx..=rex).flat_map(|x| (rsy..=rey).map(move |y| tiles::Tile::from(x, y))) {
self.tiles.add_shape_at(tile, shape.id);
result.insert(tile);
}
result.iter().copied().collect()
result
}
/*
* Incremental version of update_shape_tiles for pan/zoom operations.
* Updates the tile index and returns ONLY tiles that need cache invalidation.
*
* During pan operations, shapes don't move in world coordinates. The interest
* area (viewport) moves, which changes which tiles we track in the index, but
* tiles that were already cached don't need re-rendering just because the
* viewport moved.
*
* This function:
* 1. Updates the tile index (adds/removes shapes from tiles based on interest area)
* 2. Returns empty vec for cache invalidation (pan doesn't change tile content)
*
* Tile cache invalidation only happens when shapes actually move or change,
* which is handled by rebuild_touched_tiles, not during pan/zoom.
*/
pub fn update_shape_tiles_incremental(
&mut self,
shape: &Shape,
tree: ShapesPoolRef,
) -> Vec<tiles::Tile> {
let TileRect(rsx, rsy, rex, rey) = self.get_tiles_for_shape(shape, tree);
let old_tiles: HashSet<tiles::Tile> = self
.tiles
.get_tiles_of(shape.id)
.map_or(HashSet::new(), |tiles| tiles.iter().copied().collect());
let new_tiles: HashSet<tiles::Tile> = (rsx..=rex)
.flat_map(|x| (rsy..=rey).map(move |y| tiles::Tile::from(x, y)))
.collect();
// Tiles where shape is being removed from index (left interest area)
let removed: Vec<_> = old_tiles.difference(&new_tiles).copied().collect();
// Tiles where shape is being added to index (entered interest area)
let added: Vec<_> = new_tiles.difference(&old_tiles).copied().collect();
// Update the index: remove from old tiles
for tile in &removed {
self.tiles.remove_shape_at(*tile, shape.id);
}
// Update the index: add to new tiles
for tile in &added {
self.tiles.add_shape_at(*tile, shape.id);
}
// Don't invalidate cache for pan/zoom - the tile content hasn't changed,
// only the interest area moved. Tiles that were cached are still valid.
// New tiles that entered the interest area will be rendered fresh since
// they weren't in the cache anyway.
Vec::new()
}
/*
@@ -2239,12 +2298,22 @@ impl RenderState {
pub fn rebuild_tiles_shallow(&mut self, tree: ShapesPoolRef) {
performance::begin_measure!("rebuild_tiles_shallow");
let mut all_tiles = HashSet::<tiles::Tile>::new();
// Check if zoom changed - if so, we need full cache invalidation
// 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() {
all_tiles.extend(self.update_shape_tiles(shape, tree));
if zoom_changed {
// Zoom changed: use full update that tracks all affected tiles
tiles_to_invalidate.extend(self.update_shape_tiles(shape, tree));
} else {
// Pan only: use incremental update that preserves valid cached tiles
self.update_shape_tiles_incremental(shape, tree);
}
} else {
// We only need to rebuild tiles from the first level.
for child_id in shape.children_ids_iter(false) {
@@ -2254,11 +2323,11 @@ impl RenderState {
}
}
// Invalidate changed tiles - old content stays visible until new tiles render
self.surfaces.remove_cached_tiles(self.background_color);
for tile in all_tiles {
self.remove_cached_tile(tile);
if zoom_changed {
// Zoom changed: clear all cached tiles since they're at wrong zoom level
self.surfaces.remove_cached_tiles(self.background_color);
}
// Pan only: no cache invalidation needed - tiles content unchanged
performance::end_measure!("rebuild_tiles_shallow");
}
@@ -2307,7 +2376,7 @@ impl RenderState {
let mut all_tiles = HashSet::<tiles::Tile>::new();
let ids = self.touched_ids.clone();
let ids = std::mem::take(&mut self.touched_ids);
for shape_id in ids.iter() {
if let Some(shape) = tree.get(shape_id) {
@@ -2322,8 +2391,6 @@ impl RenderState {
self.remove_cached_tile(tile);
}
self.clean_touched();
performance::end_measure!("rebuild_touched_tiles");
}

View File

@@ -1115,6 +1115,25 @@ impl Shape {
}
}
/// Returns children in forward (non-reversed) order - useful for layout calculations
pub fn children_ids_iter_forward(&self, include_hidden: bool) -> Box<dyn Iterator<Item = &Uuid> + '_> {
if include_hidden {
return Box::new(self.children.iter());
}
if let Type::Bool(_) = self.shape_type {
Box::new([].iter())
} else if let Type::Group(group) = self.shape_type {
if group.masked {
Box::new(self.children.iter().skip(1))
} else {
Box::new(self.children.iter())
}
} else {
Box::new(self.children.iter())
}
}
pub fn all_children(
&self,
shapes: ShapesPoolRef,

View File

@@ -282,7 +282,7 @@ fn propagate_reflow(
state: &State,
entries: &mut VecDeque<Modifier>,
bounds: &mut HashMap<Uuid, Bounds>,
layout_reflows: &mut Vec<Uuid>,
layout_reflows: &mut HashSet<Uuid>,
reflown: &mut HashSet<Uuid>,
modifiers: &HashMap<Uuid, Matrix>,
) {
@@ -312,7 +312,7 @@ fn propagate_reflow(
}
if !skip_reflow {
layout_reflows.push(*id);
layout_reflows.insert(*id);
}
}
Type::Group(Group { masked: true }) => {
@@ -394,7 +394,7 @@ pub fn propagate_modifiers(
let mut modifiers = HashMap::<Uuid, Matrix>::new();
let mut bounds = HashMap::<Uuid, Bounds>::new();
let mut reflown = HashSet::<Uuid>::new();
let mut layout_reflows = Vec::<Uuid>::new();
let mut layout_reflows = HashSet::<Uuid>::new();
// We first propagate the transforms to the children and then after
// recalculate the layouts. The layout can create further transforms that
@@ -424,13 +424,12 @@ pub fn propagate_modifiers(
}
}
for id in layout_reflows.iter() {
if reflown.contains(id) {
for id in std::mem::take(&mut layout_reflows) {
if reflown.contains(&id) {
continue;
}
reflow_shape(id, state, &mut reflown, &mut entries, &mut bounds);
reflow_shape(&id, state, &mut reflown, &mut entries, &mut bounds);
}
layout_reflows = Vec::new();
}
modifiers

View File

@@ -183,15 +183,18 @@ fn initialize_tracks(
) -> Vec<TrackData> {
let mut tracks = Vec::<TrackData>::new();
let mut current_track = TrackData::default();
let mut children = shape.children_ids(true);
let mut first = true;
if flex_data.is_reverse() {
children.reverse();
}
// When is_reverse() is true, we need forward order (children_ids_iter_forward).
// When is_reverse() is false, we need reversed order (children_ids_iter).
let children_iter: Box<dyn Iterator<Item = Uuid>> = if flex_data.is_reverse() {
Box::new(shape.children_ids_iter_forward(true).copied())
} else {
Box::new(shape.children_ids_iter(true).copied())
};
for child_id in children.iter() {
let Some(child) = shapes.get(child_id) else {
for child_id in children_iter {
let Some(child) = shapes.get(&child_id) else {
continue;
};
@@ -292,7 +295,7 @@ fn distribute_fill_main_space(layout_axis: &LayoutAxis, tracks: &mut [TrackData]
track.main_size += delta;
if (child.main_size - child.max_main_size).abs() < MIN_SIZE {
to_resize_children.remove(i);
to_resize_children.swap_remove(i);
}
}
}
@@ -329,7 +332,7 @@ fn distribute_fill_across_space(layout_axis: &LayoutAxis, tracks: &mut [TrackDat
left_space -= delta;
if (track.across_size - track.max_across_size).abs() < MIN_SIZE {
to_resize_tracks.remove(i);
to_resize_tracks.swap_remove(i);
}
}
}

View File

@@ -6,7 +6,7 @@ use crate::shapes::{
};
use crate::state::ShapesPoolRef;
use crate::uuid::Uuid;
use std::collections::{HashMap, VecDeque};
use std::collections::{HashMap, HashSet, VecDeque};
use super::common::GetBounds;
@@ -537,7 +537,7 @@ fn cell_bounds(
pub fn create_cell_data<'a>(
layout_bounds: &Bounds,
children: &[Uuid],
children: &HashSet<Uuid>,
shapes: ShapesPoolRef<'a>,
cells: &Vec<GridCell>,
column_tracks: &[TrackData],
@@ -614,7 +614,7 @@ pub fn grid_cell_data<'a>(
let bounds = &mut HashMap::<Uuid, Bounds>::new();
let layout_bounds = shape.bounds();
let children = shape.children_ids(false);
let children: HashSet<Uuid> = shape.children_ids_iter(false).copied().collect();
let column_tracks = calculate_tracks(
true,
@@ -707,7 +707,7 @@ pub fn reflow_grid_layout(
) -> VecDeque<Modifier> {
let mut result = VecDeque::new();
let layout_bounds = bounds.find(shape);
let children = shape.children_ids(true);
let children: HashSet<Uuid> = shape.children_ids_iter(true).copied().collect();
let column_tracks = calculate_tracks(
true,

View File

@@ -209,16 +209,19 @@ impl PendingTiles {
}
}
pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces) {
self.list.clear();
let columns = tile_viewbox.interest_rect.width();
let rows = tile_viewbox.interest_rect.height();
// Generate tiles in spiral order from center
fn generate_spiral(rect: &TileRect) -> Vec<Tile> {
let columns = rect.width();
let rows = rect.height();
let total = columns * rows;
let mut cx = tile_viewbox.interest_rect.center_x();
let mut cy = tile_viewbox.interest_rect.center_y();
if total <= 0 {
return Vec::new();
}
let mut result = Vec::with_capacity(total as usize);
let mut cx = rect.center_x();
let mut cy = rect.center_y();
let ratio = (columns as f32 / rows as f32).ceil() as i32;
@@ -228,7 +231,7 @@ impl PendingTiles {
let mut direction = 0;
let mut current = 0;
self.list.push(Tile(cx, cy));
result.push(Tile(cx, cy));
while current < total {
match direction {
0 => cx += 1,
@@ -238,7 +241,7 @@ impl PendingTiles {
_ => unreachable!("Invalid direction"),
}
self.list.push(Tile(cx, cy));
result.push(Tile(cx, cy));
direction_current += 1;
let direction_total = if direction % 2 == 0 {
@@ -258,18 +261,44 @@ impl PendingTiles {
}
current += 1;
}
self.list.reverse();
result.reverse();
result
}
// Create a new list where the cached tiles go first
let iter1 = self
.list
.iter()
.filter(|t| surfaces.has_cached_tile_surface(**t));
let iter2 = self
.list
.iter()
.filter(|t| !surfaces.has_cached_tile_surface(**t));
self.list = iter1.chain(iter2).copied().collect();
pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces) {
self.list.clear();
// Generate spiral for the interest area (viewport + margin)
let spiral = Self::generate_spiral(&tile_viewbox.interest_rect);
// Partition tiles into 4 priority groups (highest priority = processed last due to pop()):
// 1. visible + cached (fastest - just blit from cache)
// 2. visible + uncached (user sees these, render next)
// 3. interest + cached (pre-rendered area, blit from cache)
// 4. interest + uncached (lowest priority - background pre-render)
let mut visible_cached = Vec::new();
let mut visible_uncached = Vec::new();
let mut interest_cached = Vec::new();
let mut interest_uncached = Vec::new();
for tile in spiral {
let is_visible = tile_viewbox.visible_rect.contains(&tile);
let is_cached = surfaces.has_cached_tile_surface(tile);
match (is_visible, is_cached) {
(true, true) => visible_cached.push(tile),
(true, false) => visible_uncached.push(tile),
(false, true) => interest_cached.push(tile),
(false, false) => interest_uncached.push(tile),
}
}
// Build final list with lowest priority first (they get popped last)
// Order: interest_uncached, interest_cached, visible_uncached, visible_cached
self.list.extend(interest_uncached);
self.list.extend(interest_cached);
self.list.extend(visible_uncached);
self.list.extend(visible_cached);
}
pub fn pop(&mut self) -> Option<Tile> {