From 18381f3e09fed21ce9441044aab474e0c8ef5625 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 3 Aug 2026 11:27:07 +0200 Subject: [PATCH] :zap: Prefer direct painting when effects are imperceptible Skip the Fills/Strokes layered path when drop/inner shadows would not paint at the current scale, and allow stroke-only shapes (fills_none) on the direct path. Apply the same footprint LOD to inner-shadow painting. Raise the render budget slightly during multi-tile paint regions. --- render-wasm/src/render.rs | 44 +++++++++++++++++++++---------- render-wasm/src/render/shadows.rs | 42 ++++++++++++++++++----------- 2 files changed, 57 insertions(+), 29 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 838ef520e0..322f9c8dc8 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1540,11 +1540,29 @@ impl RenderState { return Ok(()); } + // Only perceptible shadows need the layered Fills/Strokes path. Use the + // same footprint LOD as when painting drop and inner shadows. + let scale = self.get_scale(); + let shadows_need_layered = !skip_drop_shadows + && (shape + .drop_shadows_visible() + .any(|s| s.is_perceptible_at_scale_for(scale, shape.is_recursive())) + || shape + .inner_shadows_visible() + .any(|s| s.is_perceptible_at_scale_for(scale, shape.is_recursive()))); + // Clip is allowed: we apply the same stack on Current after scale+translate. // Opacity < 1 with SrcOver is OK: render_shape_enter already opened a // save_layer on Current; painting fills/strokes into that layer matches // the layered path without Fills/Strokes blits. // Non-SrcOver blend, frame clip blur, and masked groups stay layered. + // Stroke-only (fills_none) can go direct: empty fills are a no-op and + // strokes paint into Current. + let type_ok = matches!( + shape.shape_type, + Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) | Type::Frame(_) + ); + let needs_nested_fills = shape.fills.is_empty() && has_nested_fills; let can_render_directly = apply_to_current_surface && offset.is_none() && parent_shadows.is_none() @@ -1554,20 +1572,12 @@ impl RenderState { && shape.blur.is_none() && shape.background_blur.is_none() && !has_inherited_blur - && shape.shadows.is_empty() - && matches!( - shape.shape_type, - Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) | Type::Frame(_) - ) - && !(shape.fills.is_empty() && has_nested_fills) - && !shape - .svg_attrs - .as_ref() - .is_some_and(|attrs| attrs.fill_none) + && !shadows_need_layered + && type_ok + && !needs_nested_fills && target_surface != SurfaceId::Export; if can_render_directly { - let scale = self.get_scale(); let translation = self .surfaces .get_render_context_translation(self.render_area, scale); @@ -2976,7 +2986,13 @@ impl RenderState { if iteration % self.options.node_batch_threshold != 0 { return false; } - if performance::get_time() - timestamp <= self.options.max_blocking_time_ms { + // Multi-tile paint regions need fewer yields to finish visible HQ work. + let budget = if self.paint_region.is_some() { + self.options.max_blocking_time_ms.max(48) + } else { + self.options.max_blocking_time_ms + }; + if performance::get_time() - timestamp <= budget { return false; } @@ -2995,11 +3011,11 @@ impl RenderState { true } - /// Skip all drop shadows in fast mode, or when even a large design-space + /// 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). #[inline] - fn should_skip_drop_shadows(&self) -> bool { + pub(crate) fn should_skip_drop_shadows(&self) -> bool { if self.options.is_fast_mode() { return true; } diff --git a/render-wasm/src/render/shadows.rs b/render-wasm/src/render/shadows.rs index c3de172ad8..c6baadc0b3 100644 --- a/render-wasm/src/render/shadows.rs +++ b/render-wasm/src/render/shadows.rs @@ -13,10 +13,16 @@ pub fn render_fill_inner_shadows( antialias: bool, surface_id: SurfaceId, ) { - if shape.has_fills() { - for shadow in shape.inner_shadows_visible() { - render_fill_inner_shadow(render_state, shape, shadow, antialias, surface_id); + if !shape.has_fills() || render_state.should_skip_drop_shadows() { + return; + } + let scale = render_state.get_scale(); + let recursive = shape.is_recursive(); + for shadow in shape.inner_shadows_visible() { + if !shadow.is_perceptible_at_scale_for(scale, recursive) { + continue; } + render_fill_inner_shadow(render_state, shape, shadow, antialias, surface_id); } } @@ -38,19 +44,25 @@ pub fn render_stroke_inner_shadows( antialias: bool, surface_id: SurfaceId, ) -> Result<()> { - if !shape.has_fills() { - for shadow in shape.inner_shadows_visible() { - let filter = shadow.get_inner_shadow_filter(); - strokes::render_single( - render_state, - shape, - stroke, - Some(surface_id), - filter.as_ref(), - antialias, - None, // Inner shadows don't use spread - )?; + if shape.has_fills() || render_state.should_skip_drop_shadows() { + return Ok(()); + } + let scale = render_state.get_scale(); + let recursive = shape.is_recursive(); + for shadow in shape.inner_shadows_visible() { + if !shadow.is_perceptible_at_scale_for(scale, recursive) { + continue; } + let filter = shadow.get_inner_shadow_filter(); + strokes::render_single( + render_state, + shape, + stroke, + Some(surface_id), + filter.as_ref(), + antialias, + None, // Inner shadows don't use spread + )?; } Ok(()) }