diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index d1c4228343..5574816b3a 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -413,6 +413,10 @@ pub(crate) struct RenderState { /// a tile before its text glyph uploads complete (blank first/center tile). /// One explicit flush warms the submit path for the rest of the pass. pub tile_atlas_flushed: bool, + /// DropShadows→Current touch once per tile when no shape composites a real + /// shadow. A full skip made flush_and_submit very slow (Skia ops-task + /// ordering); doing it per shape was wasted GPU work. + pub drop_shadows_ops_warmed: bool, } pub struct InteractiveDragCrop { @@ -596,6 +600,7 @@ impl RenderState { preserve_target_during_render: false, backbuffer_crop_cache: HashMap::default(), tile_atlas_flushed: false, + drop_shadows_ops_warmed: false, }) } @@ -1248,6 +1253,66 @@ impl RenderState { ) } + /// Apply frame clip stack in document space on the given surface bitmask. + /// Caller must already have those surfaces in doc transform (Fills-style + /// scale + tile translation, or Current after the same). Hard (non-AA) + /// clips avoid alpha seams on semi-transparent overflow. + fn apply_clip_stack_to_surfaces( + &mut self, + clips: &ClipStack, + surface_ids: u32, + scale: f32, + debug_fill_surface: Option, + ) { + for (mut bounds, corners, transform) in clips.iter() { + self.surfaces.apply_mut(surface_ids, |s| { + s.canvas().concat(transform); + }); + + // Outset clip by ~0.5 to include edge pixels that + // aliased clip misclassifies as outside (causing artifacts). + let outset = 0.5 / scale; + bounds.outset((outset, outset)); + + // Hard clip edge (antialias = false) to avoid alpha seam when clipping + // semi-transparent content larger than the frame. + if let Some(corners) = corners { + let rrect = RRect::new_rect_radii(bounds, corners); + self.surfaces.apply_mut(surface_ids, |s| { + s.canvas().clip_rrect(rrect, skia::ClipOp::Intersect, false); + }); + } else { + self.surfaces.apply_mut(surface_ids, |s| { + s.canvas().clip_rect(bounds, skia::ClipOp::Intersect, false); + }); + } + + // This renders a red line around clipped + // shapes (frames). + if self.options.is_debug_visible() { + if let Some(fills_surface_id) = debug_fill_surface { + let mut paint = skia::Paint::default(); + paint.set_style(skia::PaintStyle::Stroke); + paint.set_color(skia::Color::from_argb(255, 255, 0, 0)); + paint.set_stroke_width(4.); + self.surfaces + .canvas(fills_surface_id) + .draw_rect(bounds, &paint); + } + } + + // Uncomment to debug the render_position_data + // if let Type::Text(text_content) = &shape.shape_type { + // text::render_position_data(self, fills_surface_id, &shape, text_content); + // } + + self.surfaces.apply_mut(surface_ids, |s| { + s.canvas() + .concat(&transform.invert().unwrap_or(Matrix::default())); + }); + } + } + #[allow(clippy::too_many_arguments)] pub fn render_shape( &mut self, @@ -1271,16 +1336,6 @@ impl RenderState { | innershadows_surface_id as u32 | text_drop_shadows_surface_id as u32; - // 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 fast_mode = self.options.is_fast_mode(); // Skip anti-aliasing entirely during fast_mode (interactive // gestures + pan/zoom). AA edge sampling is per-pixel and adds @@ -1297,19 +1352,39 @@ impl RenderState { && self.nested_blurs.iter().flatten().any(|blur| { !blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.0 }); + + // Empty non-masked groups paint nothing here (children are separate walker + // nodes). Skip the layered Fills/Strokes path entirely. + if matches!(shape.shape_type, Type::Group(g) if !g.masked) + && shape.fills.is_empty() + && !shape.has_visible_strokes() + && shape.shadows.is_empty() + && shape.blur.is_none() + && shape.background_blur.is_none() + && !has_inherited_blur + && parent_shadows.is_none() + { + return Ok(()); + } + + // 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. let can_render_directly = apply_to_current_surface - && clip_bounds.is_none() && offset.is_none() && parent_shadows.is_none() - && !shape.needs_layer() + && shape.blend_mode().0 == skia::BlendMode::SrcOver + && !shape.has_frame_clip_layer_blur() + && !matches!(shape.shape_type, Type::Group(g) if g.masked) && shape.blur.is_none() && shape.background_blur.is_none() && !has_inherited_blur && shape.shadows.is_empty() - && shape.transform.is_identity() && matches!( shape.shape_type, - Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) + Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) | Type::Frame(_) ) && !(shape.fills.is_empty() && has_nested_fills) && !shape @@ -1331,17 +1406,36 @@ impl RenderState { canvas.translate(translation); }); + if let Some(clips) = clip_bounds.as_ref() { + self.apply_clip_stack_to_surfaces(clips, target_surface as u32, scale, None); + } + + if !shape.transform.is_identity() { + let center = shape.center(); + let mut matrix = shape.transform; + matrix.post_translate(center); + matrix.pre_translate(-center); + self.surfaces.apply_mut(target_surface as u32, |s| { + s.canvas().concat(&matrix); + }); + } + fills::render(self, shape, &shape.fills, antialias, target_surface, None)?; - // Pass strokes in natural order; stroke merging handles top-most ordering internally. - let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect(); - strokes::render( - self, - shape, - &visible_strokes, - Some(target_surface), - antialias, - outset, - )?; + + // Clipped frames draw strokes in render_shape_exit over children. + let skip_strokes = matches!(shape.shape_type, Type::Frame(_)) && shape.clip_content; + if !skip_strokes { + // Pass strokes in natural order; stroke merging handles top-most ordering internally. + let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect(); + strokes::render( + self, + shape, + &visible_strokes, + Some(target_surface), + antialias, + outset, + )?; + } self.surfaces.apply_mut(target_surface as u32, |s| { s.canvas().restore(); @@ -1352,62 +1446,24 @@ impl RenderState { debug::render_debug_shape(self, Some(shape_selrect_bounds), None); } - if needs_save { - self.surfaces.apply_mut(surface_ids, |s| { - s.canvas().restore(); - }); - } return Ok(()); } + // 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(); + }); + } + // set clipping if let Some(clips) = clip_bounds.as_ref() { let scale = self.get_scale(); - for (mut bounds, corners, transform) in clips.iter() { - self.surfaces.apply_mut(surface_ids, |s| { - s.canvas().concat(transform); - }); - - // Outset clip by ~0.5 to include edge pixels that - // aliased clip misclassifies as outside (causing artifacts). - let outset = 0.5 / scale; - bounds.outset((outset, outset)); - - // Hard clip edge (antialias = false) to avoid alpha seam when clipping - // semi-transparent content larger than the frame. - if let Some(corners) = corners { - let rrect = RRect::new_rect_radii(bounds, corners); - self.surfaces.apply_mut(surface_ids, |s| { - s.canvas().clip_rrect(rrect, skia::ClipOp::Intersect, false); - }); - } else { - self.surfaces.apply_mut(surface_ids, |s| { - s.canvas().clip_rect(bounds, skia::ClipOp::Intersect, false); - }); - } - - // This renders a red line around clipped - // shapes (frames). - if self.options.is_debug_visible() { - let mut paint = skia::Paint::default(); - paint.set_style(skia::PaintStyle::Stroke); - paint.set_color(skia::Color::from_argb(255, 255, 0, 0)); - paint.set_stroke_width(4.); - self.surfaces - .canvas(fills_surface_id) - .draw_rect(bounds, &paint); - } - - // Uncomment to debug the render_position_data - // if let Type::Text(text_content) = &shape.shape_type { - // text::render_position_data(self, fills_surface_id, &shape, text_content); - // } - - self.surfaces.apply_mut(surface_ids, |s| { - s.canvas() - .concat(&transform.invert().unwrap_or(Matrix::default())); - }); - } + self.apply_clip_stack_to_surfaces(clips, surface_ids, scale, Some(fills_surface_id)); } // We don't want to change the value in the global state @@ -3117,6 +3173,7 @@ impl RenderState { /// 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. #[allow(clippy::too_many_arguments)] fn render_element_drop_shadows_and_composite( &mut self, @@ -3127,7 +3184,14 @@ impl RenderState { scale: f32, node_render_state: &NodeRenderState, target_surface: SurfaceId, - ) -> Result<()> { + ) -> Result { + // Avoid a blank DropShadows→Current blit + clear on every shape without + // shadows. Callers must still touch DropShadows once per tile when this + // returns false (see `drop_shadows_ops_warmed`). + if element.drop_shadows_visible().next().is_none() { + return Ok(false); + } + 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, @@ -3242,7 +3306,7 @@ impl RenderState { self.surfaces .canvas(SurfaceId::DropShadows) .clear(skia::Color::TRANSPARENT); - Ok(()) + Ok(true) } pub fn render_shape_tree_partial_uncached( @@ -3482,8 +3546,8 @@ impl RenderState { && Self::frame_clip_layer_blur(element).is_some() && element.drop_shadows_visible().next().is_some(); - if shadow_before_layer { - self.render_element_drop_shadows_and_composite( + if shadow_before_layer + && self.render_element_drop_shadows_and_composite( element, tree, &mut extrect, @@ -3491,7 +3555,9 @@ impl RenderState { scale, &node_render_state, target_surface, - )?; + )? + { + self.drop_shadows_ops_warmed = true; } // Render background blur BEFORE save_layer so it modifies @@ -3514,8 +3580,7 @@ impl RenderState { if !skip_shadows && !shadows_already_rendered && !matches!(element.shape_type, Type::Text(_)) - { - self.render_element_drop_shadows_and_composite( + && self.render_element_drop_shadows_and_composite( element, tree, &mut extrect, @@ -3523,11 +3588,26 @@ impl RenderState { scale, &node_render_state, target_surface, - )?; - } else { - // This is necessary or the later flush_and_submit will be very slow + )? + { + // Real shadow composite already clears DropShadows. + self.drop_shadows_ops_warmed = true; + } + + if !self.drop_shadows_ops_warmed { + // Touch DropShadows→Current once per tile when no shape has + // composited real shadows yet. Omitting this entirely made + // flush_and_submit very slow (ops-task ordering); repeating + // it per shape was waste. + self.surfaces.draw_into( + SurfaceId::DropShadows, + target_surface, + Some(&skia::Paint::default()), + ); self.surfaces - .draw_into(SurfaceId::DropShadows, target_surface, None); + .canvas(SurfaceId::DropShadows) + .clear(skia::Color::TRANSPARENT); + self.drop_shadows_ops_warmed = true; } // For frames without clip_content, inner strokes must render after children in @@ -3729,6 +3809,7 @@ impl RenderState { // empty tile. self.current_tile_had_shapes = false; self.tile_atlas_flushed = false; + self.drop_shadows_ops_warmed = false; let viewer_masked_pass = self.viewer_masked_pass();