diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index abbbc2486b..ca850cc3f2 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -44,7 +44,7 @@ pub(crate) use resources::RenderResources; type ClipStack = Vec<(Rect, Option, Matrix)>; -#[derive(Clone, Copy)] +#[derive(Clone, Copy, Debug)] #[repr(u8)] pub enum FrameType { None = 0, @@ -420,6 +420,9 @@ pub(crate) struct RenderState { pub drop_shadows_ops_warmed: bool, /// Multi-tile paint-once into Current, then crop to atlas slots. paint_region: Option, + /// After cropping a paint-once region, yield one Partial before Full + /// present so the GPU can drain without blocking the Full flush. + defer_full_after_paint_region: bool, } /// Active paint-once region (visible viewport tiles or interest ring). @@ -611,6 +614,7 @@ impl RenderState { tile_atlas_flushed: false, drop_shadows_ops_warmed: false, paint_region: None, + defer_full_after_paint_region: false, }) } @@ -2296,6 +2300,7 @@ impl RenderState { // reorder by distance to the center. self.current_tile = None; self.paint_region = None; + self.defer_full_after_paint_region = false; } pub fn start_render_loop( @@ -2310,6 +2315,7 @@ impl RenderState { let _start = performance::begin_timed_log!("start_render_loop"); let scale = self.get_scale(); + let t_loop = performance::get_time(); self.tile_viewbox.update(&self.viewbox); self.focus_mode.reset(); @@ -2320,17 +2326,25 @@ impl RenderState { // Compute and set document-space bounds (1 unit == 1 doc px @ 100% zoom) // to clamp atlas updates. This prevents zoom-out tiles from forcing atlas // growth far beyond real content. - let t_bounds = performance::get_time(); + let t0 = performance::get_time(); let doc_bounds = self.compute_document_bounds(base_object, tree); self.surfaces.atlas.set_doc_bounds(doc_bounds); + println!( + "[PAINT-PERF] start doc_bounds={}ms", + performance::get_time() - t0 + ); self.cache_cleared_this_render = false; let preserve_target = self.preserve_target_during_render; self.preserve_target_during_render = false; if preserve_target && self.options.is_fast_mode() { - let t_idx = performance::get_time(); + let t0 = performance::get_time(); self.rebuild_tile_index(tree); + println!( + "[PAINT-PERF] start rebuild_tile_index={}ms", + performance::get_time() - t0 + ); } if self.options.is_interactive_transform() { @@ -2359,8 +2373,13 @@ impl RenderState { // instead of blanking until the first full `present_frame`. // Skip on sync renders (thumbnails/exports) if !sync_render { + let t0 = performance::get_time(); ui::render(self, tree); self.flush_and_submit(); + println!( + "[PAINT-PERF] start ui+flush={}ms", + performance::get_time() - t0 + ); } } @@ -2373,11 +2392,16 @@ impl RenderState { s.canvas().scale((scale, scale)); }); + let t0 = performance::get_time(); self.surfaces.resize_cache_from_viewbox( &self.viewbox, &self.cached_viewbox, self.options.dpr_viewport_interest_area_threshold, )?; + println!( + "[PAINT-PERF] start resize_cache={}ms", + performance::get_time() - t0 + ); // FIXME - review debug // debug::render_debug_tiles_for_viewbox(self); @@ -2385,10 +2409,15 @@ impl RenderState { let _tile_start = performance::begin_timed_log!("tile_cache_update"); performance::begin_measure!("tile_cache"); - let t_pending = performance::get_time(); + let t0 = performance::get_time(); let only_visible = self.options.is_interactive_transform(); self.pending_tiles .update(&self.tile_viewbox, &self.surfaces, only_visible); + println!( + "[PAINT-PERF] start pending_tiles={}ms pending={}", + performance::get_time() - t0, + self.pending_tiles.list.len() + ); performance::end_measure!("tile_cache"); performance::end_timed_log!("tile_cache_update", _tile_start); @@ -2404,8 +2433,14 @@ impl RenderState { // stable viewbox (e.g. recoloring) which renders in one frame. let allow_stop = !preserve_target || self.zoom_changed() || self.options.is_interactive_transform(); - let t_cont = performance::get_time(); + let t0 = performance::get_time(); frame_type = self.continue_render_loop(base_object, tree, timestamp, allow_stop)?; + println!( + "[PAINT-PERF] start continue={}ms frame={:?} total={}ms", + performance::get_time() - t0, + frame_type, + performance::get_time() - t_loop + ); // This is an option to debug frames. if self.options.capture_frames > 0 { @@ -2469,43 +2504,84 @@ impl RenderState { ) -> Result { performance::begin_measure!("continue_render_loop"); let timestamp = self.render_budget_start(timestamp); + let t0 = performance::get_time(); + let pending_before = self.pending_tiles.list.len(); let frame_type = self.render_shape_tree_partial(base_object, tree, timestamp, allow_stop)?; + let tree_ms = performance::get_time() - t0; // `draw_atlas` needs a snapshot of the tile atlas. Partial frames are not // presented (only flushed), so defer composition to the final frame and // avoid re-snapshotting up to 4096² on every rAF during async tile work. + let mut compose_ms = 0; if !self.options.is_interactive_transform() && matches!(frame_type, FrameType::Full) { + let t1 = performance::get_time(); self.surfaces.draw_tile_atlas_to_backbuffer( &self.viewbox, &self.tile_viewbox, self.background_color, ); + compose_ms = performance::get_time() - t1; } + let mut tail_ms = 0; match frame_type { FrameType::None => { panic!("FrameType::None"); } FrameType::Partial => { - // Final soft drain for this yield (mid-walk also drains; see - // `drain_partial_gpu_soft`). Full still submits via present_frame. - Self::drain_partial_gpu_soft(); + let t1 = performance::get_time(); + if self.defer_full_after_paint_region && self.paint_region.is_none() { + // Dedicated rAF to sync the paint-once GPU backlog before + // Full compose/present (avoids a long hang on zoom settle). + crate::get_gpu_state().context.flush_and_submit(); + self.defer_full_after_paint_region = false; + println!( + "[PAINT-PERF] post-region GPU submit={}ms", + performance::get_time() - t1 + ); + } else { + // Final soft drain for this yield (mid-walk also drains; see + // `drain_partial_gpu_soft`). Full still submits via present_frame. + Self::drain_partial_gpu_soft(); + } + tail_ms = performance::get_time() - t1; } FrameType::Full => { + let t1 = performance::get_time(); // 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. + let mut crop_ms = 0; if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { + let t2 = performance::get_time(); self.rebuild_backbuffer_crop_cache(tree); + crop_ms = performance::get_time() - t2; } // present_frame: copy clean Backbuffer → Target, draw UI/debug // overlays on Target only, then flush. Backbuffer stays overlay-free. + let t2 = performance::get_time(); self.present_frame(tree); + let present_ms = performance::get_time() - t2; wapi::notify_tiles_render_complete!(); performance::end_measure!("render"); + tail_ms = performance::get_time() - t1; + println!( + "[PAINT-PERF] continue Full crop={}ms present={}ms tail={}ms", + crop_ms, present_ms, tail_ms + ); } } + println!( + "[PAINT-PERF] continue {:?} tree={}ms compose={}ms flush/present={}ms pending {}→{} total={}ms", + frame_type, + tree_ms, + compose_ms, + tail_ms, + pending_before, + self.pending_tiles.list.len(), + performance::get_time() - t0 + ); performance::end_measure!("continue_render_loop"); Ok(frame_type) } @@ -3835,37 +3911,9 @@ impl RenderState { self.surfaces.update_render_context(self.render_area, scale); } - /// Drain pending uncached tiles into a paint-once region when safe. - /// Returns true when `paint_region` was started and nodes were seeded. - fn try_begin_paint_region(&mut self, root_ids: &[Uuid], tree: ShapesPoolRef) -> Result { - if self.viewer_masked_pass() || self.options.is_interactive_transform() { - return Ok(false); - } - if self.paint_region.is_some() || !self.pending_nodes.is_empty() { - return Ok(false); - } - - let mut region_tiles = Vec::new(); - let mut remaining = Vec::new(); - for tile in self.pending_tiles.list.drain(..) { - if self.surfaces.has_cached_tile_surface(tile) { - continue; - } - if self.tiles.is_empty_at(tile) { - remaining.push(tile); - continue; - } - region_tiles.push(tile); - } - self.pending_tiles.list = remaining; - - if region_tiles.is_empty() { - return Ok(false); - } - - let scale = self.get_scale(); + fn tiles_doc_area(tiles: &[tiles::Tile], scale: f32) -> Rect { let mut area = Rect::new_empty(); - for tile in ®ion_tiles { + for tile in tiles { let r = tiles::get_tile_rect(*tile, scale); if area.is_empty() { area = r; @@ -3873,19 +3921,173 @@ impl RenderState { area.join(r); } } + area + } - if region_tiles.len() == 1 || !self.surfaces.region_fits_paint_surface(area, scale) { + /// Peel outer columns/rows from `tiles` until the AABB fits + /// `max_w`×`max_h` (+1px ceil slack). Returns deferred tiles. + fn shrink_tiles_to_paint_fit( + tiles: &mut Vec, + scale: f32, + margins: skia::ISize, + max_w: i32, + max_h: i32, + ) -> Vec { + let mut deferred = Vec::new(); + let cx = tiles.iter().map(|t| t.x()).sum::() as f32 / tiles.len() as f32; + let cy = tiles.iter().map(|t| t.y()).sum::() as f32 / tiles.len() as f32; + let slack_w = max_w.saturating_add(1); + let slack_h = max_h.saturating_add(1); + + while tiles.len() > 1 { + let area = Self::tiles_doc_area(tiles, scale); + let need_w = (area.width() * scale).ceil() as i32 + 2 * margins.width; + let need_h = (area.height() * scale).ceil() as i32 + 2 * margins.height; + if need_w <= slack_w && need_h <= slack_h { + break; + } + + let min_x = tiles.iter().map(|t| t.x()).min().unwrap(); + let max_x = tiles.iter().map(|t| t.x()).max().unwrap(); + let min_y = tiles.iter().map(|t| t.y()).min().unwrap(); + let max_y = tiles.iter().map(|t| t.y()).max().unwrap(); + + // Prefer shrinking the oversize axis; drop the edge farthest from centroid. + let drop_x = need_w > slack_w || (max_x - min_x) >= (max_y - min_y); + let drop_lo = if drop_x { + (cx - min_x as f32) >= (max_x as f32 - cx) + } else { + (cy - min_y as f32) >= (max_y as f32 - cy) + }; + + let edge_x = if drop_lo { min_x } else { max_x }; + let edge_y = if drop_lo { min_y } else { max_y }; + + let mut kept = Vec::with_capacity(tiles.len()); + for tile in tiles.drain(..) { + let on_edge = if drop_x { + tile.x() == edge_x + } else { + tile.y() == edge_y + }; + if on_edge { + deferred.push(tile); + } else { + kept.push(tile); + } + } + *tiles = kept; + if tiles.is_empty() { + if let Some(t) = deferred.pop() { + tiles.push(t); + } + break; + } + } + deferred + } + + /// Drain pending uncached tiles into a paint-once region when safe. + /// Returns true when `paint_region` was started and nodes were seeded. + fn try_begin_paint_region(&mut self, root_ids: &[Uuid], tree: ShapesPoolRef) -> Result { + if self.viewer_masked_pass() || self.options.is_interactive_transform() { + println!( + "[PAINT-PERF] skip_paint_region viewer_mask={} interactive={}", + self.viewer_masked_pass(), + self.options.is_interactive_transform() + ); + return Ok(false); + } + if self.paint_region.is_some() || !self.pending_nodes.is_empty() { + return Ok(false); + } + + let pending_n = self.pending_tiles.list.len(); + let mut region_tiles = Vec::new(); + let mut cached_n = 0usize; + for tile in self.pending_tiles.list.drain(..) { + // Cached tiles are composed from the atlas; drop them from pending. + if self.surfaces.has_cached_tile_surface(tile) { + cached_n += 1; + continue; + } + // Include empty tiles too: the region AABB is the interest set, and + // the walker seeds roots (below) so shapes still get indexed/painted + // even when the tile grid is stale or sparse. + region_tiles.push(tile); + } + + if region_tiles.is_empty() { + println!( + "[PAINT-PERF] skip_paint_region reason=no_uncached pending={} cached={}", + pending_n, cached_n + ); + return Ok(false); + } + + let empty_n = region_tiles + .iter() + .filter(|t| self.tiles.is_empty_at(**t)) + .count(); + + let scale = self.get_scale(); + let mut area = Self::tiles_doc_area(®ion_tiles, scale); + let mut need = self.surfaces.paint_region_need_dims(area, scale); + let have = self.surfaces.paint_surface_size(); + + // Band to the current viewport-sized Current (do not grow to GPU max — + // that packed too much GPU work and hung the browser on Full present). + if region_tiles.len() > 1 && !self.surfaces.region_fits_paint_surface(area, scale) { + let before = region_tiles.len(); + let deferred = Self::shrink_tiles_to_paint_fit( + &mut region_tiles, + scale, + self.surfaces.margins(), + have.width, + have.height, + ); + self.pending_tiles.list.extend(deferred); + area = Self::tiles_doc_area(®ion_tiles, scale); + need = self.surfaces.paint_region_need_dims(area, scale); + println!( + "[PAINT-PERF] band_paint_region {}→{} tiles need={}x{} have={}x{}", + before, + region_tiles.len(), + need.width, + need.height, + have.width, + have.height + ); + } + + let fits = self.surfaces.region_fits_paint_surface(area, scale); + + if region_tiles.len() == 1 || !fits { + let reason = if region_tiles.len() == 1 { + "single_tile" + } else { + "too_big" + }; + println!( + "[PAINT-PERF] skip_paint_region reason={} tiles={} empty≈{} area=({:.0}x{:.0})@{:.3} need={}x{} have={}x{} pending={} cached={}", + reason, + region_tiles.len(), + empty_n, + area.width(), + area.height(), + scale, + need.width, + need.height, + have.width, + have.height, + pending_n, + cached_n + ); // Restore for the single-tile path (pop from end). self.pending_tiles.list.extend(region_tiles); return Ok(false); } - self.update_render_context_for_area(area); - self.current_tile = Some(region_tiles[0]); - self.current_tile_had_shapes = true; - self.tile_atlas_flushed = false; - self.drop_shadows_ops_warmed = false; - let mut shape_ids: HashSet = HashSet::default(); let mut region_has_bg_blur = false; for tile in ®ion_tiles { @@ -3902,7 +4104,9 @@ impl RenderState { } let mut valid_ids = Vec::new(); - if region_has_bg_blur { + // Empty/stale tile index, or any bg-blur in the region: walk all roots + // so shapes are (re)indexed and painted into the region in one pass. + if shape_ids.is_empty() || region_has_bg_blur { valid_ids.extend(root_ids.iter().copied()); } else { for root_id in root_ids { @@ -3913,10 +4117,22 @@ impl RenderState { } if valid_ids.is_empty() { + println!( + "[PAINT-PERF] skip_paint_region reason=no_root_ids tiles={} shapes_in_tiles={}", + region_tiles.len(), + shape_ids.len() + ); self.pending_tiles.list.extend(region_tiles); return Ok(false); } + // Current already covers viewport+interest; region was banded to fit. + self.update_render_context_for_area(area); + self.current_tile = Some(region_tiles[0]); + self.current_tile_had_shapes = true; + self.tile_atlas_flushed = false; + self.drop_shadows_ops_warmed = false; + self.pending_nodes .extend(valid_ids.into_iter().map(|id| NodeRenderState { id, @@ -3927,16 +4143,31 @@ impl RenderState { flattened: false, })); + let n_tiles = region_tiles.len(); self.paint_region = Some(PaintRegion { tiles: region_tiles, }); + println!( + "[PAINT-PERF] begin_paint_region tiles={} area=({:.0},{:.0},{:.0}x{:.0}) scale={:.3} need={}x{} have={}x{}", + n_tiles, + area.left, + area.top, + area.width(), + area.height(), + scale, + need.width, + need.height, + have.width, + have.height + ); Ok(true) } fn apply_paint_region_to_atlas(&mut self, region: &PaintRegion) -> Result<()> { - if self.tile_atlas_flushed { - crate::get_gpu_state().context.flush_and_submit(); - } + let t0 = performance::get_time(); + // Soft-drain only: hard flush_and_submit here waited on the whole + // paint-once backlog and hung the browser after zoom. + Self::drain_partial_gpu_soft(); self.cache_cleared_this_render = true; let scale = self.get_scale(); let render_area = self.render_area; @@ -3956,6 +4187,14 @@ impl RenderState { src, ); } + println!( + "[PAINT-PERF] apply_paint_region tiles={} ms={}", + region.tiles.len(), + performance::get_time() - t0 + ); + // Yield one Partial before Full present so GPU work from this region + // is not synced in the same rAF as compose+present. + self.defer_full_after_paint_region = true; Ok(()) } @@ -3987,9 +4226,17 @@ impl RenderState { // a previous pass; otherwise pass-1 pixels can leak into pass 2. if self.viewer_masked_pass() || !self.surfaces.has_cached_tile_surface(current_tile) { + let paint_once = self.paint_region.is_some(); + let region_tiles = self + .paint_region + .as_ref() + .map(|r| r.tiles.len()) + .unwrap_or(1); performance::begin_measure!("render_shape_tree::uncached"); + let t_walk = performance::get_time(); let (is_empty, early_return) = self .render_shape_tree_partial_uncached(tree, timestamp, allow_stop, false)?; + let walk_ms = performance::get_time() - t_walk; #[cfg(target_arch = "wasm32")] if self.options.capture_frames > 0 { @@ -3997,16 +4244,27 @@ impl RenderState { } if early_return { + println!( + "[PAINT-PERF] walker yield paint_once={} tiles≈{} walk={}ms", + paint_once, region_tiles, walk_ms + ); self.viewer_render_root = None; return Ok(FrameType::Partial); } performance::end_measure!("render_shape_tree::uncached"); if let Some(region) = self.paint_region.take() { + println!( + "[PAINT-PERF] walker done paint_once tiles={} walk={}ms empty={}", + region.tiles.len(), + walk_ms, + is_empty + ); if !is_empty || self.current_tile_had_shapes { self.apply_paint_region_to_atlas(®ion)?; } } else { + let t_apply = performance::get_time(); let tile_rect = self.get_current_tile_bounds()?; // Composite if the walker did work in this PAF (`!is_empty`) OR // the tile has unfinished work from a previous PAF @@ -4034,6 +4292,12 @@ impl RenderState { ); } } + println!( + "[PAINT-PERF] walker done single_tile walk={}ms apply={}ms empty={}", + walk_ms, + performance::get_time() - t_apply, + is_empty + ); } } else if self.tiles.is_empty_at(current_tile) { self.surfaces.remove_cached_tile_surface(current_tile); @@ -4126,6 +4390,13 @@ impl RenderState { self.viewer_render_root = None; + // After cropping a paint-once region, yield before Full present so + // compose+flush_and_submit do not sync the whole region in one rAF. + if self.defer_full_after_paint_region { + println!("[PAINT-PERF] defer Full after paint-region apply"); + return Ok(FrameType::Partial); + } + // Mark cache as valid for render_from_cache. // Only update for full-quality renders (non-fast mode). // An async render can complete while fast mode is active diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 84e8cd7064..3ccfe68ea3 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -690,7 +690,8 @@ impl Surfaces { } /// Recreate Current + layer surfaces for the viewport (+ interest pad). - /// Called only from window resize / init — not from zoom HQ passes. + /// Called from window resize / init, and to restore after a paint-once + /// region resize. pub fn resize_paint_surfaces( &mut self, viewport_w: i32, @@ -699,6 +700,18 @@ impl Surfaces { ) -> Result<()> { let max_texture_size = get_gpu_state().max_texture_size(); let dims = paint_surface_dims(viewport_w, viewport_h, interest_tiles, max_texture_size); + self.resize_paint_surfaces_to(dims) + } + + /// Resize Current + layer surfaces to exact `dims` (clamped to GPU max). + /// Used by paint-once so the region can exceed the viewport-sized surface. + pub fn resize_paint_surfaces_to(&mut self, dims: skia::ISize) -> Result<()> { + let max_texture_size = get_gpu_state().max_texture_size(); + let min_dim = TILE_SIZE + 2 * TILE_MARGIN_SIZE; + let dims = skia::ISize::new( + dims.width.clamp(min_dim, max_texture_size), + dims.height.clamp(min_dim, max_texture_size), + ); if dims == self.extra_tile_dims { return Ok(()); } @@ -729,6 +742,12 @@ impl Surfaces { self.extra_tile_dims } + pub fn paint_region_need_dims(&self, render_area: skia::Rect, scale: f32) -> skia::ISize { + let need_w = (render_area.width() * scale).ceil() as i32 + 2 * self.margins.width; + let need_h = (render_area.height() * scale).ceil() as i32 + 2 * self.margins.height; + skia::ISize::new(need_w, need_h) + } + pub fn tile_margin_size() -> i32 { TILE_MARGIN_SIZE } @@ -1329,11 +1348,16 @@ impl Surfaces { } } - /// Whether a doc-space region (with blur margins) fits in Current at `scale`. + /// Whether a doc-space region fits in the **current** paint surface at + /// `scale`. Paint-once bands to this size instead of growing Current up to + /// the GPU max — a 4096² region packs too much GPU work and stalls the + /// browser on Full present after zoom. + /// + /// Allows 1px of ceil slack from `paint_region_need_dims`. pub fn region_fits_paint_surface(&self, render_area: skia::Rect, scale: f32) -> bool { - let need_w = (render_area.width() * scale).ceil() as i32 + 2 * self.margins.width; - let need_h = (render_area.height() * scale).ceil() as i32 + 2 * self.margins.height; - need_w <= self.current.width() && need_h <= self.current.height() + let need = self.paint_region_need_dims(render_area, scale); + need.width <= self.current.width().saturating_add(1) + && need.height <= self.current.height().saturating_add(1) } /// Pixel rect inside Current for a tile given the region `render_area` and scale.