diff --git a/.serena/memories/render-wasm/ffi-rendering-subtleties.md b/.serena/memories/render-wasm/ffi-rendering-subtleties.md index a7d760f2a1..099e05cc35 100644 --- a/.serena/memories/render-wasm/ffi-rendering-subtleties.md +++ b/.serena/memories/render-wasm/ffi-rendering-subtleties.md @@ -31,8 +31,8 @@ work; crop-cache rebuild is deferred to the later `Full` so the soft→sharp snap is compose+present only. - Drop-shadow cadence: walker soft-drains every N tree nodes; recursive frame shadows - also soft-drain every M silhouette/filter passes (`partial_gpu_drain_shadow_every_n`, - default 2) because one frame node can trigger many child re-renders. + flush Filter/DropShadows/TextDropShadows every silhouette pass (default every 1) + because one frame node can trigger many child re-renders with blur filter surfaces. - Interactive transforms are distinct from viewport fast mode. `set_modifiers_start` enables fast mode and interactive transform; interactive transform still flushes each animation frame. - During interactive transform, modifier tile invalidation is deferred to `render()` once per rAF. Outside interactive transform, `set_modifiers` rebuilds modifier tiles immediately. - `set_modifiers_end` disables fast/interactive state and cancels pending async render; the caller must request the final full-quality render. diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 9e6bac299a..afdf9e5209 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -64,7 +64,7 @@ pub enum RenderFlag { SyncTiles = 4, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct NodeRenderState { pub id: Uuid, // We use this bool to keep that we've traversed all the children inside this node. @@ -77,6 +77,42 @@ pub struct NodeRenderState { mask: bool, // True when this container was flattened (enter/exit skipped). flattened: bool, + /// Drop shadows for this node were finished by a deferred progressive pass. + drop_shadow_done: bool, + /// Skip enter/blur when resuming after progressive shadows that yielded post-enter. + skip_shape_enter: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DropShadowPhase { + Children, + Colorize, + Composite, +} + +/// Progressive drop-shadow work split across rAF partial frames. +#[derive(Debug, Clone)] +struct InProgressDropShadow { + node_render_state: NodeRenderState, + element_id: Uuid, + pending_shadows: Vec, + current_shadow: Shadow, + child_ids: Vec, + child_index: usize, + phase: DropShadowPhase, + clip_bounds: Option, + target_surface: SurfaceId, + scale: f32, + element_extrect: Rect, + inherited_layer_blur: Option, + rendered_any: bool, + /// True when progressive shadow work started after `render_shape_enter`. + shape_enter_done: bool, +} + +enum DropShadowResult { + Complete(bool), + Yielded, } /// Get simplified children of a container, flattening nested flattened containers @@ -426,6 +462,12 @@ pub(crate) struct RenderState { pub viewport_presented: bool, /// Drop-shadow silhouette ops since the last shadow cadence drain (reset per tile). shadow_gpu_ops_since_drain: i32, + /// True while `render_shape_tree_partial_uncached` runs with `allow_stop`. + progressive_render: bool, + /// Wall-clock anchor for progressive yield checks during shadow passes. + render_budget_timestamp: i32, + /// Drop-shadow pass resumed on the next partial frame. + in_progress_drop_shadow: Option, } pub struct InteractiveDragCrop { @@ -612,6 +654,9 @@ impl RenderState { drop_shadows_ops_warmed: false, viewport_presented: false, shadow_gpu_ops_since_drain: 0, + progressive_render: false, + render_budget_timestamp: 0, + in_progress_drop_shadow: None, }) } @@ -1120,7 +1165,11 @@ impl RenderState { .ok_or(Error::CriticalError("Current tile not found".to_string()))?; if self.tile_atlas_flushed { - crate::get_gpu_state().context.flush_and_submit(); + if self.progressive_render { + crate::get_gpu_state().context.flush(None); + } else { + crate::get_gpu_state().context.flush_and_submit(); + } } self.surfaces.draw_current_tile_into_tile_atlas( @@ -2302,6 +2351,8 @@ impl RenderState { self.current_tile = None; self.viewport_presented = false; self.shadow_gpu_ops_since_drain = 0; + self.in_progress_drop_shadow = None; + self.progressive_render = false; } pub fn start_render_loop( @@ -2626,6 +2677,8 @@ impl RenderState { visited_mask: false, mask: false, flattened: false, + drop_shadow_done: false, + skip_shape_enter: false, }); self.render_shape_tree_partial_uncached(tree, timestamp, false, true)?; } @@ -2723,6 +2776,17 @@ impl RenderState { crate::get_gpu_state().context.flush(None); } + /// Flush the offscreen surfaces used by drop-shadow passes, then drain the + /// GrContext. Shadow blur/filter work records into Filter and DropShadows; + /// a context-only flush leaves those op chains unbounded on WebGL. + #[inline] + pub(crate) fn drain_shadow_gpu_soft(&mut self) { + self.surfaces.flush(SurfaceId::Filter); + self.surfaces.flush(SurfaceId::DropShadows); + self.surfaces.flush(SurfaceId::TextDropShadows); + Self::drain_partial_gpu_soft(); + } + /// Count one expensive drop-shadow GPU pass and soft-drain on the shadow /// cadence. Frame/group shadows re-render many children inside a single /// walker node, so they need tighter flushing than [`Self::drain_partial_gpu_soft`] @@ -2736,10 +2800,27 @@ impl RenderState { self.shadow_gpu_ops_since_drain += 1; if self.shadow_gpu_ops_since_drain >= every { self.shadow_gpu_ops_since_drain = 0; - Self::drain_partial_gpu_soft(); + self.drain_shadow_gpu_soft(); } } + /// Text silhouettes in a shadow pass run a full `render_shape`; flush + /// immediately so glyph/filter ops do not pile up behind one counter tick. + #[inline] + fn should_yield_render_budget(&self) -> bool { + if !self.progressive_render { + return false; + } + performance::get_time() - self.render_budget_timestamp + > self.options.max_blocking_time_ms + } + + #[inline] + fn drain_shadow_gpu_after_text_pass(&mut self) { + self.shadow_gpu_ops_since_drain = 0; + self.drain_shadow_gpu_soft(); + } + /// Skip all drop/inner shadows in fast mode, or when even a large design-space /// shadow would be subpixel. Otherwise filter per shadow via /// [`Shadow::is_perceptible_at_scale_for`] (stricter for recursive shapes). @@ -2918,6 +2999,8 @@ impl RenderState { visited_mask: true, mask: false, flattened: false, + drop_shadow_done: false, + skip_shape_enter: false, }); if let Some(&mask_id) = element.mask_id() { self.pending_nodes.push(NodeRenderState { @@ -2927,6 +3010,8 @@ impl RenderState { visited_mask: false, mask: true, flattened: false, + drop_shadow_done: false, + skip_shape_enter: false, }); } } @@ -3283,15 +3368,13 @@ impl RenderState { drop_canvas.restore(); } - self.maybe_drain_after_shadow_gpu_op(); + self.drain_shadow_gpu_soft(); Ok(()) } - /// Renders element drop shadows to DropShadows surface and composites to Current. - /// Used for both normal shadow rendering and pre-layer rendering (frame_clip_layer_blur). - /// Returns `true` when at least one visible drop shadow was composited. + /// Synchronous drop-shadow render (exports, sync tiles, non-recursive shapes). #[allow(clippy::too_many_arguments)] - fn render_element_drop_shadows_and_composite( + fn render_element_drop_shadows_sync( &mut self, element: &Shape, tree: ShapesPoolRef, @@ -3301,10 +3384,6 @@ impl RenderState { node_render_state: &NodeRenderState, target_surface: SurfaceId, ) -> Result { - // Avoid a blank DropShadows→Current blit + clear when nothing will paint - // (no shadows, fast/overview skip, or all footprints subpixel). Callers - // must still touch DropShadows once per tile when this returns false - // (see `drop_shadows_ops_warmed`). if self.should_skip_drop_shadows() || !element .drop_shadows_visible() @@ -3403,7 +3482,7 @@ impl RenderState { ) })?; self.surfaces.canvas(SurfaceId::DropShadows).restore(); - self.maybe_drain_after_shadow_gpu_op(); + self.drain_shadow_gpu_after_text_pass(); } } } @@ -3416,12 +3495,24 @@ impl RenderState { .draw_paint(&paint); self.surfaces.canvas(SurfaceId::DropShadows).restore(); + self.drain_shadow_gpu_soft(); } if !rendered_any { return Ok(false); } + self.composite_drop_shadows_to_target(element, clip_bounds, scale, target_surface); + Ok(true) + } + + fn composite_drop_shadows_to_target( + &mut self, + element: &Shape, + clip_bounds: Option, + scale: f32, + target_surface: SurfaceId, + ) { if let Some(clips) = clip_bounds.as_ref() { let antialias = !self.options.is_fast_mode() && element.should_use_antialias(scale, self.options.antialias_threshold); @@ -3438,8 +3529,339 @@ impl RenderState { self.surfaces .canvas(SurfaceId::DropShadows) .clear(skia::Color::TRANSPARENT); - self.maybe_drain_after_shadow_gpu_op(); - Ok(true) + self.drain_shadow_gpu_soft(); + } + + fn render_drop_shadow_child_silhouette( + &mut self, + element: &Shape, + shadow_shape_id: &Uuid, + shadow: &Shadow, + node_render_state: &NodeRenderState, + scale: f32, + inherited_layer_blur: Option, + target_surface: SurfaceId, + tree: ShapesPoolRef, + ) -> Result<()> { + let Some(shadow_shape) = tree.get(shadow_shape_id) else { + return Ok(()); + }; + if shadow_shape.hidden { + return Ok(()); + } + + let nested_clip_bounds = node_render_state.get_nested_shadow_clip_bounds(element, shadow); + + if !matches!(shadow_shape.shape_type, Type::Text(_)) { + self.render_drop_black_shadow( + shadow_shape, + &shadow_shape.extrect(tree, scale), + shadow, + nested_clip_bounds, + scale, + inherited_layer_blur, + target_surface, + ) + } else { + let paint = skia::Paint::default(); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + self.surfaces + .canvas(SurfaceId::DropShadows) + .save_layer(&layer_rec); + + let mut transformed_shadow: Cow = Cow::Borrowed(shadow); + transformed_shadow.to_mut().color = skia::Color::BLACK; + transformed_shadow.to_mut().blur = transformed_shadow.blur; + transformed_shadow.to_mut().spread = transformed_shadow.spread; + + let mut new_shadow_paint = skia::Paint::default(); + new_shadow_paint.set_image_filter(transformed_shadow.get_drop_shadow_filter()); + new_shadow_paint.set_blend_mode(skia::BlendMode::SrcOver); + + self.with_nested_blurs_suppressed(|state| { + state.render_shape( + shadow_shape, + nested_clip_bounds, + SurfaceId::DropShadows, + SurfaceId::DropShadows, + SurfaceId::DropShadows, + SurfaceId::DropShadows, + true, + None, + Some(vec![new_shadow_paint.clone()]), + None, + target_surface, + ) + })?; + self.surfaces.canvas(SurfaceId::DropShadows).restore(); + self.drain_shadow_gpu_after_text_pass(); + Ok(()) + } + } + + fn begin_drop_shadow_progressive( + &mut self, + element: &Shape, + tree: ShapesPoolRef, + extrect: &mut Option, + clip_bounds: Option, + scale: f32, + node_render_state: &NodeRenderState, + target_surface: SurfaceId, + shape_enter_done: bool, + ) -> Result { + let element_extrect = extrect.get_or_insert_with(|| element.extrect(tree, scale)); + let inherited_layer_blur = match element.shape_type { + Type::Frame(_) | Type::Group(_) => element.blur, + _ => None, + }; + + let mut shadows: Vec = element + .drop_shadows_visible() + .filter(|s| s.is_perceptible_at_scale_for(scale, element.is_recursive())) + .cloned() + .collect(); + if shadows.is_empty() { + return Ok(DropShadowResult::Complete(false)); + } + + let current_shadow = shadows.remove(0); + let paint = skia::Paint::default(); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + self.surfaces + .canvas(SurfaceId::DropShadows) + .save_layer(&layer_rec); + + self.render_drop_black_shadow( + element, + element_extrect, + ¤t_shadow, + clip_bounds.clone(), + scale, + None, + target_surface, + )?; + self.drain_shadow_gpu_soft(); + + let child_ids = get_simplified_children(tree, element); + let progress = InProgressDropShadow { + node_render_state: node_render_state.clone(), + element_id: element.id, + pending_shadows: shadows, + current_shadow, + child_ids, + child_index: 0, + phase: DropShadowPhase::Children, + clip_bounds, + target_surface, + scale, + element_extrect: *element_extrect, + inherited_layer_blur, + rendered_any: true, + shape_enter_done, + }; + + self.in_progress_drop_shadow = Some(progress); + self.advance_drop_shadow_progressive(element, tree, false) + } + + fn advance_drop_shadow_progressive( + &mut self, + element: &Shape, + tree: ShapesPoolRef, + requeue_on_complete: bool, + ) -> Result { + let Some(mut progress) = self.in_progress_drop_shadow.take() else { + return Ok(DropShadowResult::Complete(false)); + }; + + let node_render_state = progress.node_render_state.clone(); + let result = self.advance_drop_shadow_progressive_inner( + element, + tree, + &node_render_state, + &mut progress, + ); + + match result { + Ok(DropShadowResult::Yielded) => { + self.in_progress_drop_shadow = Some(progress); + Ok(DropShadowResult::Yielded) + } + Ok(DropShadowResult::Complete(rendered)) => { + self.in_progress_drop_shadow = None; + if requeue_on_complete { + let mut ns = progress.node_render_state; + ns.drop_shadow_done = true; + ns.skip_shape_enter = progress.shape_enter_done; + self.pending_nodes.push(ns); + } + Ok(DropShadowResult::Complete(rendered)) + } + Err(e) => { + self.in_progress_drop_shadow = Some(progress); + Err(e) + } + } + } + + fn advance_drop_shadow_progressive_inner( + &mut self, + element: &Shape, + tree: ShapesPoolRef, + node_render_state: &NodeRenderState, + progress: &mut InProgressDropShadow, + ) -> Result { + loop { + match progress.phase { + DropShadowPhase::Children => { + while progress.child_index < progress.child_ids.len() { + let child_id = progress.child_ids[progress.child_index]; + progress.child_index += 1; + self.render_drop_shadow_child_silhouette( + element, + &child_id, + &progress.current_shadow, + node_render_state, + progress.scale, + progress.inherited_layer_blur, + progress.target_surface, + tree, + )?; + self.drain_shadow_gpu_soft(); + if self.should_yield_render_budget() { + return Ok(DropShadowResult::Yielded); + } + } + progress.phase = DropShadowPhase::Colorize; + } + DropShadowPhase::Colorize => { + let mut paint = skia::Paint::default(); + paint.set_color(progress.current_shadow.color); + paint.set_blend_mode(skia::BlendMode::SrcIn); + self.surfaces + .canvas(SurfaceId::DropShadows) + .draw_paint(&paint); + self.surfaces.canvas(SurfaceId::DropShadows).restore(); + self.drain_shadow_gpu_soft(); + + if progress.pending_shadows.is_empty() { + progress.phase = DropShadowPhase::Composite; + } else { + progress.current_shadow = progress.pending_shadows.remove(0); + progress.child_index = 0; + progress.phase = DropShadowPhase::Children; + + let paint = skia::Paint::default(); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + self.surfaces + .canvas(SurfaceId::DropShadows) + .save_layer(&layer_rec); + self.render_drop_black_shadow( + element, + &progress.element_extrect, + &progress.current_shadow, + progress.clip_bounds.clone(), + progress.scale, + None, + progress.target_surface, + )?; + self.drain_shadow_gpu_soft(); + if self.should_yield_render_budget() { + return Ok(DropShadowResult::Yielded); + } + } + } + DropShadowPhase::Composite => { + self.composite_drop_shadows_to_target( + element, + progress.clip_bounds.clone(), + progress.scale, + progress.target_surface, + ); + return Ok(DropShadowResult::Complete(progress.rendered_any)); + } + } + } + } + + fn resume_drop_shadow_progressive( + &mut self, + tree: ShapesPoolRef, + ) -> Result { + let element_id = self + .in_progress_drop_shadow + .as_ref() + .map(|p| p.element_id) + .ok_or_else(|| Error::CriticalError("Missing in-progress drop shadow".into()))?; + let Some(element) = tree.get(&element_id) else { + self.in_progress_drop_shadow = None; + return Ok(DropShadowResult::Complete(false)); + }; + self.advance_drop_shadow_progressive(element, tree, true) + } + + /// Renders element drop shadows to DropShadows surface and composites to Current. + /// Used for both normal shadow rendering and pre-layer rendering (frame_clip_layer_blur). + #[allow(clippy::too_many_arguments)] + fn render_element_drop_shadows_and_composite( + &mut self, + element: &Shape, + tree: ShapesPoolRef, + extrect: &mut Option, + clip_bounds: Option, + scale: f32, + node_render_state: &NodeRenderState, + target_surface: SurfaceId, + shape_enter_done: bool, + ) -> Result { + if self.should_skip_drop_shadows() + || !element + .drop_shadows_visible() + .any(|s| s.is_perceptible_at_scale_for(scale, element.is_recursive())) + { + return Ok(DropShadowResult::Complete(false)); + } + + if node_render_state.drop_shadow_done { + return Ok(DropShadowResult::Complete(false)); + } + + let use_progressive = self.progressive_render + && element.is_recursive() + && !matches!(element.shape_type, Type::Bool(_)) + && !get_simplified_children(tree, element).is_empty(); + + if use_progressive { + if self + .in_progress_drop_shadow + .as_ref() + .is_some_and(|p| p.element_id == element.id) + { + return self.advance_drop_shadow_progressive(element, tree, false); + } + return self.begin_drop_shadow_progressive( + element, + tree, + extrect, + clip_bounds, + scale, + node_render_state, + target_surface, + shape_enter_done, + ); + } + + let rendered = self.render_element_drop_shadows_sync( + element, + tree, + extrect, + clip_bounds, + scale, + node_render_state, + target_surface, + )?; + Ok(DropShadowResult::Complete(rendered)) } pub fn render_shape_tree_partial_uncached( @@ -3452,6 +3874,9 @@ impl RenderState { let mut iteration = 0; let mut is_empty = true; + self.progressive_render = allow_stop; + self.render_budget_timestamp = performance::get_time(); + let mut target_surface = SurfaceId::Current; if export { target_surface = SurfaceId::Export; @@ -3499,6 +3924,16 @@ impl RenderState { None }; + if self.in_progress_drop_shadow.is_some() { + match self.resume_drop_shadow_progressive(tree)? { + DropShadowResult::Yielded => return Ok((false, true)), + DropShadowResult::Complete(true) => { + self.drop_shadows_ops_warmed = true; + } + DropShadowResult::Complete(false) => {} + } + } + while let Some(node_render_state) = self.pending_nodes.pop() { let node_id = node_render_state.id; let visited_children = node_render_state.visited_children; @@ -3557,6 +3992,8 @@ impl RenderState { visited_mask: false, mask: false, flattened: false, + drop_shadow_done: false, + skip_shape_enter: false, }); } } @@ -3681,8 +4118,8 @@ impl RenderState { .drop_shadows_visible() .any(|s| s.is_perceptible_at_scale_for(scale, element.is_recursive())); - if shadow_before_layer - && self.render_element_drop_shadows_and_composite( + if shadow_before_layer && !node_render_state.drop_shadow_done { + match self.render_element_drop_shadows_and_composite( element, tree, &mut extrect, @@ -3690,18 +4127,28 @@ impl RenderState { scale, &node_render_state, target_surface, - )? - { - self.drop_shadows_ops_warmed = true; + false, + )? { + DropShadowResult::Yielded => return Ok((is_empty, true)), + DropShadowResult::Complete(true) => { + self.drop_shadows_ops_warmed = true; + } + DropShadowResult::Complete(false) => {} + } } // Render background blur BEFORE save_layer so it modifies // the backdrop independently of the shape's opacity. - if !node_render_state.is_root() && self.focus_mode.is_active() { + if !node_render_state.skip_shape_enter + && !node_render_state.is_root() + && self.focus_mode.is_active() + { self.render_background_blur(element, target_surface); } - self.render_shape_enter(element, mask, clip_bounds.as_ref(), target_surface); + if !node_render_state.skip_shape_enter { + self.render_shape_enter(element, mask, clip_bounds.as_ref(), target_surface); + } } if !node_render_state.is_root() && self.focus_mode.is_active() { @@ -3709,13 +4156,16 @@ impl RenderState { let skip_shadows = self.should_skip_drop_shadows(); // Skip shadow block when already rendered before the layer (frame_clip_layer_blur) - let shadows_already_rendered = Self::frame_clip_layer_blur(element).is_some(); + // or when a deferred progressive pass finished shadows for this node. + let shadows_already_rendered = node_render_state.drop_shadow_done + || Self::frame_clip_layer_blur(element).is_some(); // For text shapes, render drop shadow using text rendering logic if !skip_shadows && !shadows_already_rendered && !matches!(element.shape_type, Type::Text(_)) - && self.render_element_drop_shadows_and_composite( + { + match self.render_element_drop_shadows_and_composite( element, tree, &mut extrect, @@ -3723,10 +4173,14 @@ impl RenderState { scale, &node_render_state, target_surface, - )? - { - // Real shadow composite already clears DropShadows. - self.drop_shadows_ops_warmed = true; + true, + )? { + DropShadowResult::Yielded => return Ok((is_empty, true)), + DropShadowResult::Complete(true) => { + self.drop_shadows_ops_warmed = true; + } + DropShadowResult::Complete(false) => {} + } } if !self.drop_shadows_ops_warmed { @@ -3807,6 +4261,8 @@ impl RenderState { visited_mask: false, mask, flattened: can_flatten, + drop_shadow_done: false, + skip_shape_enter: false, }); if element.is_recursive() { @@ -3841,6 +4297,8 @@ impl RenderState { visited_mask: false, mask: false, flattened: false, + drop_shadow_done: false, + skip_shape_enter: false, }); } } @@ -3952,6 +4410,7 @@ impl RenderState { self.tile_atlas_flushed = false; self.drop_shadows_ops_warmed = false; self.shadow_gpu_ops_since_drain = 0; + self.in_progress_drop_shadow = None; let viewer_masked_pass = self.viewer_masked_pass(); @@ -4006,6 +4465,8 @@ impl RenderState { visited_mask: false, mask: false, flattened: false, + drop_shadow_done: false, + skip_shape_enter: false, })); } else { // Visible tiles finished. Promote deferred interest-ring work diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 2d90be49e1..b7816f97fb 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -16,8 +16,8 @@ const NODE_BATCH_THRESHOLD: i32 = 3; const PARTIAL_GPU_DRAIN_EVERY_N: i32 = 64; /// Soft-drain during recursive drop-shadow passes (each silhouette or filter /// surface). The walker counts tree nodes, not shadow children re-rendered -/// inside one frame, so frame shadows need a tighter cadence. -const PARTIAL_GPU_DRAIN_SHADOW_EVERY_N: i32 = 2; +/// inside one frame, so frame shadows drain after every heavy pass by default. +const PARTIAL_GPU_DRAIN_SHADOW_EVERY_N: i32 = 1; const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0; const ANTIALIAS_THRESHOLD: f32 = 7.0; #[derive(Debug, Copy, Clone, PartialEq)]