diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index ddd30fe356..229dfffbea 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -366,9 +366,15 @@ pub extern "C" fn set_view_end() -> Result<()> { #[wasm_error] pub extern "C" fn set_modifiers_start() -> Result<()> { performance::begin_measure!("set_modifiers_start"); - let render_state = get_render_state(); - render_state.options.set_fast_mode(true); - render_state.options.set_interactive_transform(true); + with_state!(state, { + // Capture per-shape crops from the clean Backbuffer now, once per + // gesture, instead of speculatively on every Full frame (a full + // DocAtlas snapshot + per-shape copies was the dominant settle cost). + state.rebuild_drag_crop_cache(); + let render_state = get_render_state(); + render_state.options.set_fast_mode(true); + render_state.options.set_interactive_transform(true); + }); performance::end_measure!("set_modifiers_start"); Ok(()) } @@ -384,6 +390,11 @@ pub extern "C" fn set_modifiers_end() -> Result<()> { let render_state = get_render_state(); render_state.options.set_fast_mode(false); render_state.options.set_interactive_transform(false); + // The crop cache is only read during interactive transforms. Drop it now: + // entries can share the Backbuffer texture (full-bounds snapshots), and a + // live shared snapshot forces a copy-on-write of the Backbuffer on every + // later write — a per-frame tax on pan/zoom if the images outlive the drag. + render_state.backbuffer_crop_cache.clear(); performance::end_measure!("set_modifiers_end"); Ok(()) } diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index b613ec5586..1f0b2436cc 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -42,6 +42,8 @@ pub use images::*; type ClipStack = Vec<(Rect, Option, Matrix)>; +const MIN_TILES_PER_FRAME: i32 = 8; + #[repr(u8)] pub enum FrameType { None = 0, @@ -397,6 +399,12 @@ pub(crate) struct RenderState { /// (must composite to present the work). Reset when current_tile /// changes. pub current_tile_had_shapes: bool, + /// Count of uncached tiles composited in the current rAF. Reset at the top + /// of `render_shape_tree_partial`; checked against the adaptive per-frame + /// budget (`max(MIN_TILES_PER_FRAME, visible_tile_count)`) to yield (flush + + /// return Partial) so a single rAF doesn't hand the GPU one huge command + /// buffer. See `MIN_TILES_PER_FRAME`. + pub tiles_on_frame: i32, /// During interactive transforms we keep `Target` between rAFs. Seed the /// interactive backdrop exactly once per gesture (first rAF) so we don't /// repeatedly overwrite tiles that have already been updated. @@ -404,6 +412,10 @@ pub(crate) struct RenderState { /// GPU crops from `Backbuffer` or tile atlas keyed by shape id. Filled on full-frame completion; during /// drag, entries for the moved top-level selection are ensured here pub backbuffer_crop_cache: HashMap, + /// True while Backbuffer holds an untouched full-quality frame (set on + /// Full presents outside fast mode, cleared when any render pass or + /// preview writes Backbuffer again). Gates building the drag crop cache. + pub backbuffer_is_clean_full_frame: bool, } pub struct InteractiveDragCrop { @@ -583,8 +595,10 @@ impl RenderState { export_context: None, cache_cleared_this_render: false, current_tile_had_shapes: false, + tiles_on_frame: 0, interactive_target_seeded: false, backbuffer_crop_cache: HashMap::default(), + backbuffer_is_clean_full_frame: false, }) } @@ -847,10 +861,6 @@ impl RenderState { Ok(()) } - pub fn flush(&mut self) { - self.surfaces.flush(SurfaceId::Backbuffer); - } - pub fn flush_and_submit(&mut self) { self.surfaces.flush_and_submit(SurfaceId::Target); } @@ -913,6 +923,7 @@ impl RenderState { /// This is currently not being used, but it's set there for testing purposes on /// upcoming tasks pub fn render_loading_overlay(&mut self) { + self.backbuffer_is_clean_full_frame = false; let canvas = self.surfaces.canvas(SurfaceId::Backbuffer); let skia::ISize { width, height } = canvas.base_layer_size(); @@ -1422,7 +1433,6 @@ impl RenderState { let text_fill_inset = (count_inner_strokes > 0).then(|| 1.0 / self.get_scale()); let text_stroke_blur_outset = Stroke::max_bounds_width(shape.visible_strokes(), false); - let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); let stroke_kinds: Vec = shape.visible_strokes().rev().map(|s| s.kind).collect(); let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape @@ -1439,16 +1449,13 @@ impl RenderState { .unzip(); if skip_effects { // Fast path: render fills and strokes only (skip shadows/blur). - text::render( - Some(self), - None, + text::render_fill_cached( + self, &shape, - &mut paragraph_builders, + text_content, Some(fills_surface_id), None, - None, text_fill_inset, - None, )?; for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list @@ -1496,23 +1503,33 @@ impl RenderState { let inner_shadows = shape.inner_shadow_paints(); let blur_filter = shape.image_filter(1.); - let mut paragraphs_with_shadows = - text_content.paragraph_builder_group_from_text(Some(true)); + let needs_shadow_builders = parent_shadows.is_some() + || !drop_shadows.is_empty() + || !inner_shadows.is_empty(); + let mut paragraphs_with_shadows = if needs_shadow_builders { + text_content.paragraph_builder_group_from_text(Some(true)) + } else { + Vec::new() + }; let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): ( Vec<_>, Vec<_>, - ) = shape - .visible_strokes() - .rev() - .map(|stroke| { - text::stroke_paragraph_builder_group_from_text( - text_content, - stroke, - &shape.selrect(), - Some(true), - ) - }) - .unzip(); + ) = if needs_shadow_builders { + shape + .visible_strokes() + .rev() + .map(|stroke| { + text::stroke_paragraph_builder_group_from_text( + text_content, + stroke, + &shape.selrect(), + Some(true), + ) + }) + .unzip() + } else { + (Vec::new(), Vec::new()) + }; if let Some(parent_shadows) = parent_shadows { if !shape.has_visible_strokes() { @@ -1560,17 +1577,14 @@ impl RenderState { } } - // 2. Text fills - text::render( - Some(self), - None, + // 2. Text fills — reuse cached laid-out paragraphs (no per-tile reshape). + text::render_fill_cached( + self, &shape, - &mut paragraph_builders, + text_content, Some(fills_surface_id), - None, blur_filter.as_ref(), text_fill_inset, - None, )?; // 3. Stroke drop shadows @@ -1790,7 +1804,7 @@ impl RenderState { self.surfaces.update_render_context(self.render_area, scale); } - fn rebuild_backbuffer_crop_cache(&mut self, tree: ShapesPoolRef) { + pub fn rebuild_backbuffer_crop_cache(&mut self, tree: ShapesPoolRef) { self.backbuffer_crop_cache.clear(); // Collect candidate shapes that are "recortable" and visible in the current viewport. @@ -1992,6 +2006,9 @@ impl RenderState { pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) { let _start = performance::begin_timed_log!("render_from_cache"); performance::begin_measure!("render_from_cache"); + // Previews overwrite Backbuffer with scaled/atlas content; the drag + // crop cache must not be built from it. + self.backbuffer_is_clean_full_frame = false; let bg_color = self.background_color; // During fast mode (pan/zoom), if a previous full-quality render still has pending tiles, @@ -2159,6 +2176,9 @@ impl RenderState { sync_render: bool, ) -> Result { self.clear(tree); + // A new render pass will write Backbuffer; it is only "clean" again + // once the Full arm of continue_render_loop completes. + self.backbuffer_is_clean_full_frame = false; let _start = performance::begin_timed_log!("start_render_loop"); let scale = self.get_scale(); @@ -2298,30 +2318,32 @@ impl RenderState { performance::begin_measure!("continue_render_loop"); let frame_type = self.render_shape_tree_partial(base_object, tree, timestamp, true)?; - if !self.options.is_interactive_transform() { - self.surfaces.draw_tile_atlas_to_backbuffer( - &self.viewbox, - &self.tile_viewbox, - self.background_color, - ); - } - match frame_type { FrameType::None => { panic!("FrameType::None"); } FrameType::Partial => { - // Partial frame: just flush GPU work. The display shows the last - // fully submitted frame; no need to copy or draw UI overlays here. - self.flush(); + // Partial frame: flush AND submit so the GPU actually executes this + // chunk's draws now + self.surfaces.flush_and_submit(SurfaceId::Backbuffer); } FrameType::Full => { - // A full-quality frame is now complete. Rebuild the per-shape crop - // cache from the clean Backbuffer (no UI overlay yet) so that - // interactive drag backgrounds don't include the grid overlay. - if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { - self.rebuild_backbuffer_crop_cache(tree); + if !self.options.is_interactive_transform() { + self.surfaces.draw_tile_atlas_to_backbuffer( + &self.viewbox, + &self.tile_viewbox, + self.background_color, + ); } + // A full-quality frame is now complete and Backbuffer is clean + // (present_frame draws UI overlays on Target only). Don't rebuild + // the drag crop cache here — it takes a full DocAtlas snapshot + // (arming a whole-atlas COW on the next tile blit) plus per-shape + // Backbuffer copies, far too expensive to pay on every settle. + // Instead record that Backbuffer is usable; set_modifiers_start + // builds the cache from it when a drag actually begins. + self.backbuffer_is_clean_full_frame = + !self.options.is_fast_mode() && !self.options.is_interactive_transform(); // present_frame: copy clean Backbuffer → Target, draw UI/debug // overlays on Target only, then flush. Backbuffer stays overlay-free. self.present_frame(tree); @@ -3545,6 +3567,10 @@ impl RenderState { ) -> Result { let mut should_stop = false; self.viewer_render_root = base_object.copied(); + self.tiles_on_frame = 0; + // Never fewer than the currently-visible tile count, so a + // normal viewport composites all its visible tiles in one rAF + let tile_budget = MIN_TILES_PER_FRAME.max(self.tile_viewbox.visible_rect.len()); let root_ids = { if let Some(shape_id) = base_object { vec![*shape_id] @@ -3605,6 +3631,20 @@ impl RenderState { tile_rect, ); } + + // Cap how many such tiles we submit per rAF so the GPU doesn't get one + // giant command buffer. Skipped during interactive + // transforms + if allow_stop + && !self.options.is_interactive_transform() + && !self.viewer_masked_pass() + { + self.tiles_on_frame += 1; + if self.tiles_on_frame >= tile_budget { + self.viewer_render_root = None; + return Ok(FrameType::Partial); + } + } } } else if self.tiles.is_empty_at(current_tile) { self.surfaces.remove_cached_tile_surface(current_tile); @@ -3745,6 +3785,17 @@ impl RenderState { } } + pub fn get_tiles_for_shape_unclamped( + &mut self, + shape: &Shape, + tree: ShapesPoolRef, + ) -> TileRect { + let scale = self.get_scale(); + let extrect = self.get_cached_extrect(shape, tree, scale); + let tile_size = tiles::get_tile_size(scale); + tiles::get_tiles_for_rect(extrect, tile_size) + } + /* * Given a shape, check the indexes and update it's location in the tile set * returns the tiles that have changed in the process. @@ -3869,6 +3920,10 @@ impl RenderState { self.surfaces.remove_cached_tile_surface(tile); } + pub fn invalidate_cached_tile_content(&mut self, tile: tiles::Tile) { + self.surfaces.invalidate_cached_tile_surface(tile); + } + /// Rebuild the tile index (shape→tile mapping) for all top-level shapes. /// This does NOT invalidate the tile texture cache — cached tile images /// survive so that fast-mode renders during pan still show shadows/blur. @@ -3963,13 +4018,14 @@ impl RenderState { if let Some(shape) = tree.get(shape_id) { if shape_id != &Uuid::nil() { all_tiles.extend(self.update_shape_tiles(shape, tree)); + let unclamped = self.get_tiles_for_shape_unclamped(shape, tree); + all_tiles.extend(self.surfaces.cached_tiles_in_rect(&unclamped)); } } } - // Update the changed tiles for tile in all_tiles { - self.remove_cached_tile(tile); + self.invalidate_cached_tile_content(tile); } performance::end_measure!("rebuild_touched_tiles"); diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 867b657c7b..6324765886 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -11,7 +11,6 @@ use crate::math::Point; use base64::{engine::general_purpose, Engine as _}; use std::collections::{HashMap, HashSet}; -const TEXTURES_CACHE_CAPACITY: usize = 1024; const TEXTURES_BATCH_DELETE: usize = 256; // This is the amount of extra space we're going to give to all the surfaces to render shapes. @@ -166,12 +165,48 @@ impl DocAtlas { new_bottom = new_bottom.max(doc_rect.bottom.ceil()); } - // Add padding to reduce realloc frequency. + // Pad to reduce realloc frequency. A realloc copies the whole atlas + // (up to max_texture_size², hundreds of MB), so sides that actually + // grow get a geometric margin — half the current atlas span, at least + // a few tiles — making sustained pans cost O(log) reallocs instead of + // one per tile. let pad = tiles::TILE_SIZE; - new_left -= pad; - new_top -= pad; - new_right += pad; - new_bottom += pad; + if needs_init { + new_left -= pad; + new_top -= pad; + new_right += pad; + new_bottom += pad; + } else { + let grow_x = ((current_right - current_left) * 0.5).max(4.0 * tiles::TILE_SIZE); + let grow_y = ((current_bottom - current_top) * 0.5).max(4.0 * tiles::TILE_SIZE); + if new_left < current_left { + new_left -= grow_x; + } + if new_right > current_right { + new_right += grow_x; + } + if new_top < current_top { + new_top -= grow_y; + } + if new_bottom > current_bottom { + new_bottom += grow_y; + } + // Writes are clamped to doc_bounds, so texture past it is wasted; + // clip the margin to the document (but never below the rect that + // triggered this grow). + if let Some(bounds) = self.doc_bounds { + new_left = new_left.max((bounds.left - pad).min(doc_rect.left.floor())); + new_top = new_top.max((bounds.top - pad).min(doc_rect.top.floor())); + new_right = new_right.min((bounds.right + pad).max(doc_rect.right.ceil())); + new_bottom = new_bottom.min((bounds.bottom + pad).max(doc_rect.bottom.ceil())); + } + // Never shrink below current coverage: the old-atlas copy must fit, + // and shrinking would invite grow/shrink oscillation. + new_left = new_left.min(current_left); + new_top = new_top.min(current_top); + new_right = new_right.max(current_right); + new_bottom = new_bottom.max(current_bottom); + } let doc_w = (new_right - new_left).max(1.0); let doc_h = (new_bottom - new_top).max(1.0); @@ -525,19 +560,32 @@ impl Surfaces { background: skia::Color, ) { self.tiles.update(viewbox, tile_viewbox); - let atlas_image = self.tile_atlas.image_snapshot(); let canvas = self.backbuffer.canvas(); canvas.clear(background); - canvas.draw_atlas( - &atlas_image, - &self.tiles.transforms, - &self.tiles.textures, - None, - skia::BlendMode::SrcOver, - self.atlas_sampling_options, - None, - None, - ); + // Draw each tile via Surface::draw (clip + offset) instead of + // draw_atlas over a full image_snapshot(): the snapshot shares the + // atlas texture, so the first tile write after every present forced + // a copy-on-write of the whole (max_texture_size²) atlas. + for (transform, texture) in self.tiles.transforms.iter().zip(self.tiles.textures.iter()) { + if texture.is_empty() { + continue; + } + let dst = skia::Rect::from_xywh( + transform.tx, + transform.ty, + texture.width(), + texture.height(), + ); + canvas.save(); + canvas.clip_rect(dst, None, false); + self.tile_atlas.draw( + canvas, + (transform.tx - texture.left, transform.ty - texture.top), + self.atlas_sampling_options, + Some(&skia::Paint::default()), + ); + canvas.restore(); + } } /// Draw the persistent atlas onto the backbuffer using the current viewbox transform. @@ -681,12 +729,6 @@ impl Surfaces { self.dirty_surfaces = 0; } - pub fn flush(&mut self, id: SurfaceId) { - let gpu_state = get_gpu_state(); - let surface = self.get_mut(id); - gpu_state.context.flush_surface(surface); - } - pub fn flush_and_submit(&mut self, id: SurfaceId) { let gpu_state = get_gpu_state(); let surface = self.get_mut(id); @@ -1186,6 +1228,10 @@ impl Surfaces { self.tiles.has(tile) } + pub fn cached_tiles_in_rect(&self, rect: &TileRect) -> Vec { + self.tiles.cached_tiles_in_rect(rect) + } + /// Builds a 1:1 workspace-pixel snapshot for `src_doc_bounds` / `src_irect` into /// `scratch`, then returns the sub-region `[0, out_w) × [0, out_h)` as an image. /// @@ -1295,15 +1341,16 @@ impl Surfaces { pub fn remove_cached_tile_surface(&mut self, tile: Tile) { let gpu_state = get_gpu_state(); - // 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.atlas.clear_tile_in_atlas(gpu_state, tile); } + pub fn invalidate_cached_tile_surface(&mut self, tile: Tile) { + self.tiles.mark_stale(tile); + } + pub fn get_tile_image_from_tile_atlas(&mut self, tile: Tile) -> Option { let Some(tile_ref) = self.tiles.get(tile) else { panic!("Tile not found {}:{}", tile.0, tile.1); @@ -1319,11 +1366,30 @@ impl Surfaces { } pub fn draw_cached_tile_into_backbuffer(&mut self, tile: Tile, rect: &Rect) { - if let Some(image) = self.get_tile_image_from_tile_atlas(tile) { - // let rect = tile.get_rect_with_offset(&offset); - let backbuffer_canvas = self.backbuffer.canvas(); - backbuffer_canvas.draw_image_rect(&image, None, rect, &skia::Paint::default()); + // Draw the atlas region via Surface::draw (clip + transform) instead of + // image_snapshot_with_bounds + draw_image_rect: a subset snapshot is an + // eager GPU copy, paid per tile per frame on pan previews. + let Some(tile_ref) = self.tiles.get(tile) else { + panic!("Tile not found {}:{}", tile.0, tile.1); + }; + let src = tile_ref.rect; + if src.width() <= 0.0 || src.height() <= 0.0 { + return; } + let sampling_options = self.atlas_sampling_options; + let backbuffer_canvas = self.backbuffer.canvas(); + backbuffer_canvas.save(); + backbuffer_canvas.clip_rect(*rect, None, false); + backbuffer_canvas.translate((rect.left, rect.top)); + backbuffer_canvas.scale((rect.width() / src.width(), rect.height() / src.height())); + backbuffer_canvas.translate((-src.left, -src.top)); + self.tile_atlas.draw( + backbuffer_canvas, + (0.0, 0.0), + sampling_options, + Some(&skia::Paint::default()), + ); + backbuffer_canvas.restore(); } /// Draws the current tile directly to the backbuffer and cache surfaces without @@ -1526,6 +1592,7 @@ pub struct TileTextureCache { textures: Vec, grid: HashMap, removed: HashSet, + stale: HashSet, } impl TileTextureCache { @@ -1537,6 +1604,7 @@ impl TileTextureCache { textures: Vec::with_capacity(capacity), grid: HashMap::with_capacity(capacity), removed: HashSet::with_capacity(capacity), + stale: HashSet::with_capacity(capacity), } } @@ -1546,7 +1614,9 @@ impl TileTextureCache { if let Some(tile_ref) = self.grid.remove(tile) { self.provider.deallocate(tile_ref); } + self.stale.remove(tile); } + self.removed.clear(); } fn gc_non_visible(&mut self, tile_viewbox: &TileViewbox) { @@ -1554,7 +1624,7 @@ impl TileTextureCache { .grid .iter_mut() .filter_map(|(tile, _)| { - if !tile_viewbox.is_visible(tile) { + if !tile_viewbox.is_in_interest_area(tile) { Some(*tile) } else { None @@ -1567,6 +1637,7 @@ impl TileTextureCache { if let Some(tile_ref) = self.grid.remove(tile) { self.provider.deallocate(tile_ref); } + self.stale.remove(tile); } } @@ -1595,6 +1666,10 @@ impl TileTextureCache { for x in tile_viewbox.visible_rect.left()..=tile_viewbox.visible_rect.right() { let tile = Tile(x, y); + if self.removed.contains(&tile) { + continue; + } + let Some(tile_ref) = self.grid.get(&tile) else { continue; }; @@ -1615,19 +1690,30 @@ impl TileTextureCache { } pub fn has(&self, tile: Tile) -> bool { - self.grid.contains_key(&tile) && !self.removed.contains(&tile) + self.grid.contains_key(&tile) + && !self.removed.contains(&tile) + && !self.stale.contains(&tile) + } + + pub fn cached_tiles_in_rect(&self, rect: &TileRect) -> Vec { + self.grid + .keys() + .filter(|tile| !self.removed.contains(tile) && rect.contains(tile)) + .copied() + .collect() } pub fn add(&mut self, tile_viewbox: &TileViewbox, tile: &Tile) -> TileAtlasTextureRef { - if self.grid.len() > TEXTURES_CACHE_CAPACITY { + let capacity = self.provider.length; + if self.grid.len() >= capacity { // First we try to remove the obsolete tiles. self.gc(); } // If we still have a texture capacity problem, then // we try to remove all of those tiles that aren't - // visible. - if self.grid.len() > TEXTURES_CACHE_CAPACITY { + // in the interest area. + if self.grid.len() >= capacity { self.gc_non_visible(tile_viewbox); } @@ -1635,13 +1721,14 @@ impl TileTextureCache { panic!("Tile texture allocation failed {}:{}", tile.0, tile.1); }; - self.grid.insert(*tile, tile_ref.clone()); - - if self.removed.contains(tile) { - self.removed.remove(tile); + if let Some(old) = self.grid.insert(*tile, tile_ref.clone()) { + self.provider.deallocate(old); } - tile_ref.clone() + self.removed.remove(tile); + self.stale.remove(tile); + + tile_ref } pub fn get(&mut self, tile: Tile) -> Option<&TileAtlasTextureRef> { @@ -1652,17 +1739,20 @@ impl TileTextureCache { } pub fn remove(&mut self, tile: Tile) { - if let Some(tile_ref) = self.grid.get(&tile) { - if tile_ref.index < self.textures.len() { - self.textures[tile_ref.index].set_empty(); - } - } self.removed.insert(tile); } + pub fn mark_stale(&mut self, tile: Tile) { + if self.grid.contains_key(&tile) && !self.removed.contains(&tile) { + self.stale.insert(tile); + } + } + pub fn clear(&mut self) { for k in self.grid.keys() { self.removed.insert(*k); } + // `removed` supersedes `stale` everywhere; drop the now-moot marks. + self.stale.clear(); } } diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 1995ffe288..f5b3c96222 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -114,6 +114,18 @@ impl State { get_render_state().continue_render_loop(None, &self.shapes, timestamp) } + /// Build the drag crop cache at drag start, when Backbuffer still holds a + /// clean full-quality frame. Entries from a previous drag/viewport are + /// always dropped; if Backbuffer is mid-settle or holds a preview the drag + /// simply renders live (no cache). + pub fn rebuild_drag_crop_cache(&mut self) { + let render_state = get_render_state(); + render_state.backbuffer_crop_cache.clear(); + if render_state.backbuffer_is_clean_full_frame { + render_state.rebuild_backbuffer_crop_cache(&self.shapes); + } + } + pub fn clear_focus_mode(&mut self) { get_render_state().clear_focus_mode(); } diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index 0d32b1ae1c..429dca55bb 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -204,9 +204,12 @@ impl TileViewbox { } pub fn is_visible(&self, tile: &Tile) -> bool { - // TO CHECK self.interest_rect.contains(tile) self.visible_rect.contains(tile) } + + pub fn is_in_interest_area(&self, tile: &Tile) -> bool { + self.interest_rect.contains(tile) + } } pub const TILE_SIZE: f32 = 512.; @@ -357,47 +360,20 @@ impl TileSpiral { return; } - // Generate tiles in spiral order from center (same algorithm as before). - let mut cx = 0; - let mut cy = 0; + let cx = (columns / 2) as i32; + let cy = (rows / 2) as i32; - let ratio = (columns as f32 / rows as f32).ceil() as i32; - - let mut direction_current = 0; - let mut direction_total_x = ratio; - let mut direction_total_y = 1; - let mut direction = 0; - - self.offsets.push(Tile(cx, cy)); - while self.offsets.len() < total { - match direction { - 0 => cx += 1, - 1 => cy += 1, - 2 => cx -= 1, - 3 => cy -= 1, - _ => unreachable!("Invalid direction"), - } - - self.offsets.push(Tile(cx, cy)); - - direction_current += 1; - let direction_total = if direction % 2 == 0 { - direction_total_x - } else { - direction_total_y - }; - - if direction_current == direction_total { - if direction % 2 == 0 { - direction_total_x += 1; - } else { - direction_total_y += 1; - } - direction = (direction + 1) % 4; - direction_current = 0; + for j in 0..rows as i32 { + for i in 0..columns as i32 { + self.offsets.push(Tile(i - cx, j - cy)); } } + // Center-out priority: sort nearest-first by Manhattan distance, then + // reverse so the consumer (`PendingTiles::update` pushes in iter order, + // the render loop pops from the back) renders the nearest tiles first. + self.offsets + .sort_by_key(|t| t.0.unsigned_abs() + t.1.unsigned_abs()); self.offsets.reverse(); } } @@ -462,12 +438,10 @@ impl PendingTiles { self.interest_cached.clear(); self.interest_uncached.clear(); - // Compute the scheduling center explicitly (inclusive range). - // This avoids relying on `TileRect::center_x/center_y` semantics, which may be used - // elsewhere with different expectations. + // Scheduling center must match `TileSpiral::ensure`'s local center let center_tile = Tile( - (spiral_rect.x1() + spiral_rect.x2()) / 2, - (spiral_rect.y1() + spiral_rect.y2()) / 2, + spiral_rect.x1() + (columns / 2), + spiral_rect.y1() + (rows / 2), ); for spiral_tile in self.spiral.iter() { let tile = Tile(spiral_tile.0 + center_tile.0, spiral_tile.1 + center_tile.1);