From 10e145da3592e10ae2a0b311361b32592264c367 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 9 Jan 2026 13:33:08 +0100 Subject: [PATCH 1/3] :bug: Fix wasm playgrounds --- frontend/resources/wasm-playground/clips.html | 2 +- frontend/resources/wasm-playground/masks.html | 2 +- frontend/resources/wasm-playground/paths.html | 2 +- frontend/resources/wasm-playground/plus.html | 2 +- frontend/resources/wasm-playground/rects.html | 2 +- frontend/resources/wasm-playground/texts.html | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/resources/wasm-playground/clips.html b/frontend/resources/wasm-playground/clips.html index e4f8bef576..ba1951e56a 100644 --- a/frontend/resources/wasm-playground/clips.html +++ b/frontend/resources/wasm-playground/clips.html @@ -23,7 +23,7 @@ - \ No newline at end of file + From 9ea5e21870eaf4571b717c49cd49b893f1abb105 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 9 Jan 2026 13:33:38 +0100 Subject: [PATCH 2/3] :tada: Avoid unnecesary saves and restores --- render-wasm/src/render.rs | 127 +++++++++++++++++++++++++------------- render-wasm/src/shapes.rs | 107 +++++++++++++++++++++++++++++++- 2 files changed, 190 insertions(+), 44 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index a0763e6d40..12092c8f30 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -398,12 +398,7 @@ impl RenderState { } fn frame_clip_layer_blur(shape: &Shape) -> Option { - match shape.shape_type { - Type::Frame(_) if shape.clip() => shape.blur.filter(|blur| { - !blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0. - }), - _ => None, - } + shape.frame_clip_layer_blur() } /// Runs `f` with `ignore_nested_blurs` temporarily forced to `true`. @@ -605,9 +600,17 @@ impl RenderState { | strokes_surface_id as u32 | innershadows_surface_id as u32 | text_drop_shadows_surface_id as u32; - self.surfaces.apply_mut(surface_ids, |s| { - s.canvas().save(); - }); + + // Only save canvas state if we have clipping or transforms + // For simple shapes without clipping, skip expensive save/restore + let needs_save = + clip_bounds.is_some() || offset.is_some() || !shape.transform.is_identity(); + + if needs_save { + self.surfaces.apply_mut(surface_ids, |s| { + s.canvas().save(); + }); + } let antialias = shape.should_use_antialias(self.get_scale()); @@ -908,9 +911,13 @@ impl RenderState { if apply_to_current_surface { self.apply_drawing_to_render_canvas(Some(&shape)); } - self.surfaces.apply_mut(surface_ids, |s| { - s.canvas().restore(); - }); + + // Only restore if we saved (optimization for simple shapes) + if needs_save { + self.surfaces.apply_mut(surface_ids, |s| { + s.canvas().restore(); + }); + } } pub fn update_render_context(&mut self, tile: tiles::Tile) { @@ -1117,35 +1124,41 @@ impl RenderState { self.nested_fills.push(Vec::new()); } - let mut paint = skia::Paint::default(); - paint.set_blend_mode(element.blend_mode().into()); - paint.set_alpha_f(element.opacity()); + // Only create save_layer if actually needed + // For simple shapes with default opacity and blend mode, skip expensive save_layer + let needs_layer = element.needs_layer() || mask; - if let Some(frame_blur) = Self::frame_clip_layer_blur(element) { - let scale = self.get_scale(); - let sigma = frame_blur.value * scale; - if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) { - paint.set_image_filter(filter); + if needs_layer { + let mut paint = skia::Paint::default(); + paint.set_blend_mode(element.blend_mode().into()); + paint.set_alpha_f(element.opacity()); + + if let Some(frame_blur) = Self::frame_clip_layer_blur(element) { + let scale = self.get_scale(); + let sigma = frame_blur.value * scale; + if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) { + paint.set_image_filter(filter); + } } - } - // When we're rendering the mask shape we need to set a special blend mode - // called 'destination-in' that keeps the drawn content within the mask. - // @see https://skia.org/docs/user/api/skblendmode_overview/ - if mask { - let mut mask_paint = skia::Paint::default(); - mask_paint.set_blend_mode(skia::BlendMode::DstIn); - let mask_rec = skia::canvas::SaveLayerRec::default().paint(&mask_paint); + // When we're rendering the mask shape we need to set a special blend mode + // called 'destination-in' that keeps the drawn content within the mask. + // @see https://skia.org/docs/user/api/skblendmode_overview/ + if mask { + let mut mask_paint = skia::Paint::default(); + mask_paint.set_blend_mode(skia::BlendMode::DstIn); + let mask_rec = skia::canvas::SaveLayerRec::default().paint(&mask_paint); + self.surfaces + .canvas(SurfaceId::Current) + .save_layer(&mask_rec); + } + + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); self.surfaces .canvas(SurfaceId::Current) - .save_layer(&mask_rec); + .save_layer(&layer_rec); } - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); - self.surfaces - .canvas(SurfaceId::Current) - .save_layer(&layer_rec); - self.focus_mode.enter(&element.id); } @@ -1217,7 +1230,15 @@ impl RenderState { ); } - self.surfaces.canvas(SurfaceId::Current).restore(); + // Only restore if we created a layer (optimization for simple shapes) + let needs_layer = element.needs_layer() + || (matches!(element.shape_type, Type::Group(_)) + && matches!(element.shape_type, Type::Group(g) if g.masked)); + + if needs_layer { + self.surfaces.canvas(SurfaceId::Current).restore(); + } + self.focus_mode.exit(&element.id); } @@ -1463,12 +1484,31 @@ impl RenderState { if !node_render_state.is_root() { let transformed_element: Cow = Cow::Borrowed(element); - let scale = self.get_scale(); - let extrect = transformed_element.extrect(tree, scale); - let is_visible = extrect.intersects(self.render_area) - && !transformed_element.hidden - && !transformed_element.visually_insignificant(scale, tree); + // Aggressive early exit: check hidden and selrect first (fastest checks) + if transformed_element.hidden { + continue; + } + + let selrect = transformed_element.selrect(); + if !selrect.intersects(self.render_area) { + continue; + } + + // For simple shapes without effects, selrect check is sufficient + // Only calculate expensive extrect for shapes with effects that might extend bounds + let scale = self.get_scale(); + let has_effects = transformed_element.has_effects_that_extend_bounds(); + + let is_visible = if !has_effects { + // Simple shape: selrect check is sufficient, skip expensive extrect + !transformed_element.visually_insignificant(scale, tree) + } else { + // Shape with effects: need extrect for accurate bounds + let extrect = transformed_element.extrect(tree, scale); + extrect.intersects(self.render_area) + && !transformed_element.visually_insignificant(scale, tree) + }; if self.options.is_debug_visible() { let shape_extrect_bounds = @@ -1515,6 +1555,8 @@ impl RenderState { _ => None, }; + let element_extrect = element.extrect(tree, scale); + for shadow in element.drop_shadows_visible() { let paint = skia::Paint::default(); let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); @@ -1526,7 +1568,7 @@ impl RenderState { // First pass: Render shadow in black to establish alpha mask self.render_drop_black_shadow( element, - &element.extrect(tree, scale), + &element_extrect, shadow, clip_bounds.clone(), scale, @@ -1546,9 +1588,10 @@ impl RenderState { .get_nested_shadow_clip_bounds(element, shadow); if !matches!(shadow_shape.shape_type, Type::Text(_)) { + let shadow_extrect = shadow_shape.extrect(tree, scale); self.render_drop_black_shadow( shadow_shape, - &shadow_shape.extrect(tree, scale), + &shadow_extrect, shadow, clip_bounds, scale, diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index cb334a6f00..be37043a37 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -920,10 +920,27 @@ impl Shape { } Type::Group(_) | Type::Frame(_) if !self.clip_content => { + // Use selrect as a fast approximation first, then calculate + // extrect only if needed. This avoids expensive recursive extrect calculations + // for children that don't significantly expand the bounds. for child_id in self.children_ids_iter(false) { if let Some(child_shape) = shapes_pool.get(child_id) { - let child_extrect = child_shape.calculate_extrect(shapes_pool, scale); - rect.join(child_extrect); + // Fast path: check if child has effects that might expand bounds + // If no effects, selrect is likely sufficient + let has_effects = !child_shape.shadows.is_empty() + || child_shape.blur.is_some() + || !child_shape.strokes.is_empty() + || matches!(child_shape.shape_type, Type::Group(_) | Type::Frame(_)); + + if has_effects { + // Calculate full extrect for shapes with effects + let child_extrect = child_shape.calculate_extrect(shapes_pool, scale); + rect.join(child_extrect); + } else { + // No effects, selrect is sufficient (much faster) + let child_selrect = child_shape.selrect(); + rect.join(child_selrect); + } } } } @@ -1419,6 +1436,92 @@ impl Shape { !self.fills.is_empty() } + /// Determines if this frame or group can be flattened (doesn't affect children visually) + /// A container can be flattened if it has no visual effects that affect its children + /// and doesn't render its own content (no fills/strokes) + pub fn can_flatten(&self) -> bool { + // Only frames and groups can be flattened + if !matches!(self.shape_type, Type::Frame(_) | Type::Group(_)) { + return false; + } + + // Cannot flatten if it has visual effects that affect children: + + if self.clip_content { + return false; + } + + if !self.transform.is_identity() { + return false; + } + + if self.opacity != 1.0 { + return false; + } + + if self.blend_mode() != BlendMode::default() { + return false; + } + + if self.blur.is_some() { + return false; + } + + if !self.shadows.is_empty() { + return false; + } + + if let Type::Group(group) = &self.shape_type { + if group.masked { + return false; + } + } + + // If the container itself has fills/strokes, it renders something visible + // We cannot flatten containers that render their own background/border + // because they need to be rendered even if they don't affect children + if self.has_fills() || self.has_visible_strokes() { + return false; + } + + true + } + + /// Checks if this shape needs a layer for rendering due to visual effects + /// (opacity < 1.0, non-default blend mode, or frame clip layer blur) + pub fn needs_layer(&self) -> bool { + self.opacity() < 1.0 + || self.blend_mode().0 != skia::BlendMode::SrcOver + || self.has_frame_clip_layer_blur() + } + + /// Checks if this frame has clip layer blur (affects children) + /// A frame has clip layer blur if it clips content and has layer blur + pub fn has_frame_clip_layer_blur(&self) -> bool { + self.frame_clip_layer_blur().is_some() + } + + /// Returns the frame clip layer blur if this frame has one + /// A frame has clip layer blur if it clips content and has layer blur + pub fn frame_clip_layer_blur(&self) -> Option { + use crate::shapes::BlurType; + match self.shape_type { + Type::Frame(_) if self.clip_content => self.blur.filter(|blur| { + !blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.0 + }), + _ => None, + } + } + + /// Checks if this shape has visual effects that might extend its bounds beyond selrect + /// Shapes with these effects require expensive extrect calculation for accurate visibility checks + pub fn has_effects_that_extend_bounds(&self) -> bool { + !self.shadows.is_empty() + || self.blur.is_some() + || !self.strokes.is_empty() + || matches!(self.shape_type, Type::Group(_) | Type::Frame(_)) + } + pub fn count_visible_inner_strokes(&self) -> usize { self.visible_strokes() .filter(|s| s.kind == StrokeKind::Inner) From 919b961348482a3cc8e04c1a311d2fe08494d308 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 8 Jan 2026 13:13:39 +0100 Subject: [PATCH 3/3] :tada: Ignore frames and groups when they have no visual extra information --- render-wasm/src/render.rs | 74 ++++++++++++++++++++++++++++++--------- 1 file changed, 58 insertions(+), 16 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 12092c8f30..b6d068fbea 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -10,7 +10,6 @@ mod shadows; mod strokes; mod surfaces; pub mod text; - mod ui; use skia_safe::{self as skia, Matrix, RRect, Rect}; @@ -53,6 +52,25 @@ pub struct NodeRenderState { mask: bool, } +/// Get simplified children of a container, flattening nested flattened containers +fn get_simplified_children<'a>(tree: ShapesPoolRef<'a>, shape: &'a Shape) -> Vec { + let mut result = Vec::new(); + + for child_id in shape.children_ids_iter(false) { + if let Some(child) = tree.get(child_id) { + if child.can_flatten() { + // Child is flattened: recursively get its simplified children + result.extend(get_simplified_children(tree, child)); + } else { + // Child is not flattened: add it directly + result.push(*child_id); + } + } + } + + result +} + impl NodeRenderState { pub fn is_root(&self) -> bool { self.id.is_nil() @@ -1038,6 +1056,7 @@ impl RenderState { // reorder by distance to the center. self.current_tile = None; self.render_in_progress = true; + self.apply_drawing_to_render_canvas(None); if sync_render { @@ -1478,7 +1497,10 @@ impl RenderState { } if visited_children { - self.render_shape_exit(element, visited_mask); + // Skip render_shape_exit for flattened containers + if !element.can_flatten() { + self.render_shape_exit(element, visited_mask); + } continue; } @@ -1521,7 +1543,12 @@ impl RenderState { } } - self.render_shape_enter(element, mask); + // Skip render_shape_enter/exit for flattened containers + // If a container was flattened, it doesn't affect children visually, so we skip + // the expensive enter/exit operations and process children directly + if !element.can_flatten() { + self.render_shape_enter(element, mask); + } if !node_render_state.is_root() && self.focus_mode.is_active() { let scale: f32 = self.get_scale(); @@ -1725,14 +1752,18 @@ impl RenderState { self.apply_drawing_to_render_canvas(Some(element)); } - match element.shape_type { - Type::Frame(_) if Self::frame_clip_layer_blur(element).is_some() => { - self.nested_blurs.push(None); + // Skip nested state updates for flattened containers + // Flattened containers don't affect children, so we don't need to track their state + if !element.can_flatten() { + match element.shape_type { + Type::Frame(_) if Self::frame_clip_layer_blur(element).is_some() => { + self.nested_blurs.push(None); + } + Type::Frame(_) | Type::Group(_) => { + self.nested_blurs.push(element.blur); + } + _ => {} } - Type::Frame(_) | Type::Group(_) => { - self.nested_blurs.push(element.blur); - } - _ => {} } // Set the node as visited_children before processing children @@ -1747,24 +1778,35 @@ impl RenderState { if element.is_recursive() { let children_clip_bounds = node_render_state.get_children_clip_bounds(element, None); - let mut children_ids: Vec<_> = element.children_ids_iter(false).collect(); + + let children_ids: Vec<_> = if element.can_flatten() { + // Container was flattened: get simplified children (which skip this level) + get_simplified_children(tree, element) + } else { + // Container not flattened: use original children + element.children_ids_iter(false).copied().collect() + }; // Z-index ordering on Layouts - if element.has_layout() { + let children_ids = if element.has_layout() { + let mut ids = children_ids; if element.is_flex() && !element.is_flex_reverse() { - children_ids.reverse(); + ids.reverse(); } - children_ids.sort_by(|id1, id2| { + ids.sort_by(|id1, id2| { let z1 = tree.get(id1).map(|s| s.z_index()).unwrap_or(0); let z2 = tree.get(id2).map(|s| s.z_index()).unwrap_or(0); z2.cmp(&z1) }); - } + ids + } else { + children_ids + }; for child_id in children_ids.iter() { self.pending_nodes.push(NodeRenderState { - id: **child_id, + id: *child_id, visited_children: false, clip_bounds: children_clip_bounds.clone(), visited_mask: false,