Expand direct shape painting and skip empty drop-shadow blits (#11100)

* ♻️ Extract apply_clip_stack_to_surfaces helper

Share the layered-path clip loop so the Current-surface direct
path can reuse the same hard-clip stack without duplication.

*  Expand direct shape painting onto Current

Allow clip stacks, frames, non-identity transforms, and SrcOver
opacity on the Current-surface fast path; skip empty non-masked
groups. Avoids Fills/Strokes blits for common shapes.

*  Skip empty drop-shadow blits; warm DropShadows once

Early-out drop-shadow composite when a shape has no visible
shadows, and touch DropShadows→Current once per tile instead
of per shape to keep flush_and_submit cheap.
This commit is contained in:
Alejandro Alonso 2026-08-06 12:31:49 +02:00 committed by GitHub
parent 10a2c19f92
commit 11fc090bc4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -413,6 +413,10 @@ pub(crate) struct RenderState {
/// a tile before its text glyph uploads complete (blank first/center tile). /// 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. /// One explicit flush warms the submit path for the rest of the pass.
pub tile_atlas_flushed: bool, 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 { pub struct InteractiveDragCrop {
@ -596,6 +600,7 @@ impl RenderState {
preserve_target_during_render: false, preserve_target_during_render: false,
backbuffer_crop_cache: HashMap::default(), backbuffer_crop_cache: HashMap::default(),
tile_atlas_flushed: false, tile_atlas_flushed: false,
drop_shadows_ops_warmed: false,
}) })
} }
@ -1248,121 +1253,17 @@ impl RenderState {
) )
} }
#[allow(clippy::too_many_arguments)] /// Apply frame clip stack in document space on the given surface bitmask.
pub fn render_shape( /// 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, &mut self,
shape: &Shape, clips: &ClipStack,
clip_bounds: Option<ClipStack>, surface_ids: u32,
fills_surface_id: SurfaceId, scale: f32,
strokes_surface_id: SurfaceId, debug_fill_surface: Option<SurfaceId>,
innershadows_surface_id: SurfaceId, ) {
text_drop_shadows_surface_id: SurfaceId,
apply_to_current_surface: bool,
offset: Option<(f32, f32)>,
parent_shadows: Option<Vec<skia_safe::Paint>>,
outset: Option<f32>,
target_surface: SurfaceId,
) -> Result<()> {
#[cfg(feature = "stats")]
self.stats.count(shape.id);
let surface_ids = fills_surface_id as u32
| strokes_surface_id as u32
| 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
// up across many shapes; reverts to full quality on commit.
let antialias = !fast_mode
&& shape.should_use_antialias(self.get_scale(), self.options.antialias_threshold);
let skip_effects = fast_mode;
let has_nested_fills = self
.nested_fills
.last()
.is_some_and(|fills| !fills.is_empty());
let has_inherited_blur = !self.ignore_nested_blurs
&& self.nested_blurs.iter().flatten().any(|blur| {
!blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.0
});
let can_render_directly = apply_to_current_surface
&& clip_bounds.is_none()
&& offset.is_none()
&& parent_shadows.is_none()
&& !shape.needs_layer()
&& 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(_)
)
&& !(shape.fills.is_empty() && has_nested_fills)
&& !shape
.svg_attrs
.as_ref()
.is_some_and(|attrs| attrs.fill_none)
&& 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);
self.surfaces.apply_mut(target_surface as u32, |s| {
let canvas = s.canvas();
canvas.save();
canvas.scale((scale, scale));
canvas.translate(translation);
});
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,
)?;
self.surfaces.apply_mut(target_surface as u32, |s| {
s.canvas().restore();
});
if self.options.is_debug_visible() {
let shape_selrect_bounds = self.get_shape_selrect_bounds(shape);
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(());
}
// set clipping
if let Some(clips) = clip_bounds.as_ref() {
let scale = self.get_scale();
for (mut bounds, corners, transform) in clips.iter() { for (mut bounds, corners, transform) in clips.iter() {
self.surfaces.apply_mut(surface_ids, |s| { self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().concat(transform); s.canvas().concat(transform);
@ -1389,6 +1290,7 @@ impl RenderState {
// This renders a red line around clipped // This renders a red line around clipped
// shapes (frames). // shapes (frames).
if self.options.is_debug_visible() { if self.options.is_debug_visible() {
if let Some(fills_surface_id) = debug_fill_surface {
let mut paint = skia::Paint::default(); let mut paint = skia::Paint::default();
paint.set_style(skia::PaintStyle::Stroke); paint.set_style(skia::PaintStyle::Stroke);
paint.set_color(skia::Color::from_argb(255, 255, 0, 0)); paint.set_color(skia::Color::from_argb(255, 255, 0, 0));
@ -1397,6 +1299,7 @@ impl RenderState {
.canvas(fills_surface_id) .canvas(fills_surface_id)
.draw_rect(bounds, &paint); .draw_rect(bounds, &paint);
} }
}
// Uncomment to debug the render_position_data // Uncomment to debug the render_position_data
// if let Type::Text(text_content) = &shape.shape_type { // if let Type::Text(text_content) = &shape.shape_type {
@ -1410,6 +1313,159 @@ impl RenderState {
} }
} }
#[allow(clippy::too_many_arguments)]
pub fn render_shape(
&mut self,
shape: &Shape,
clip_bounds: Option<ClipStack>,
fills_surface_id: SurfaceId,
strokes_surface_id: SurfaceId,
innershadows_surface_id: SurfaceId,
text_drop_shadows_surface_id: SurfaceId,
apply_to_current_surface: bool,
offset: Option<(f32, f32)>,
parent_shadows: Option<Vec<skia_safe::Paint>>,
outset: Option<f32>,
target_surface: SurfaceId,
) -> Result<()> {
#[cfg(feature = "stats")]
self.stats.count(shape.id);
let surface_ids = fills_surface_id as u32
| strokes_surface_id as u32
| innershadows_surface_id as u32
| text_drop_shadows_surface_id as u32;
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
// up across many shapes; reverts to full quality on commit.
let antialias = !fast_mode
&& shape.should_use_antialias(self.get_scale(), self.options.antialias_threshold);
let skip_effects = fast_mode;
let has_nested_fills = self
.nested_fills
.last()
.is_some_and(|fills| !fills.is_empty());
let has_inherited_blur = !self.ignore_nested_blurs
&& 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
&& offset.is_none()
&& parent_shadows.is_none()
&& 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()
&& 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)
&& 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);
self.surfaces.apply_mut(target_surface as u32, |s| {
let canvas = s.canvas();
canvas.save();
canvas.scale((scale, scale));
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)?;
// 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();
});
if self.options.is_debug_visible() {
let shape_selrect_bounds = self.get_shape_selrect_bounds(shape);
debug::render_debug_shape(self, Some(shape_selrect_bounds), None);
}
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();
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 // We don't want to change the value in the global state
let mut shape: Cow<Shape> = Cow::Borrowed(shape); let mut shape: Cow<Shape> = Cow::Borrowed(shape);
@ -3117,6 +3173,7 @@ impl RenderState {
/// Renders element drop shadows to DropShadows surface and composites to Current. /// 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). /// 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)] #[allow(clippy::too_many_arguments)]
fn render_element_drop_shadows_and_composite( fn render_element_drop_shadows_and_composite(
&mut self, &mut self,
@ -3127,7 +3184,14 @@ impl RenderState {
scale: f32, scale: f32,
node_render_state: &NodeRenderState, node_render_state: &NodeRenderState,
target_surface: SurfaceId, target_surface: SurfaceId,
) -> Result<()> { ) -> Result<bool> {
// 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 element_extrect = extrect.get_or_insert_with(|| element.extrect(tree, scale));
let inherited_layer_blur = match element.shape_type { let inherited_layer_blur = match element.shape_type {
Type::Frame(_) | Type::Group(_) => element.blur, Type::Frame(_) | Type::Group(_) => element.blur,
@ -3242,7 +3306,7 @@ impl RenderState {
self.surfaces self.surfaces
.canvas(SurfaceId::DropShadows) .canvas(SurfaceId::DropShadows)
.clear(skia::Color::TRANSPARENT); .clear(skia::Color::TRANSPARENT);
Ok(()) Ok(true)
} }
pub fn render_shape_tree_partial_uncached( pub fn render_shape_tree_partial_uncached(
@ -3482,8 +3546,8 @@ impl RenderState {
&& Self::frame_clip_layer_blur(element).is_some() && Self::frame_clip_layer_blur(element).is_some()
&& element.drop_shadows_visible().next().is_some(); && element.drop_shadows_visible().next().is_some();
if shadow_before_layer { if shadow_before_layer
self.render_element_drop_shadows_and_composite( && self.render_element_drop_shadows_and_composite(
element, element,
tree, tree,
&mut extrect, &mut extrect,
@ -3491,7 +3555,9 @@ impl RenderState {
scale, scale,
&node_render_state, &node_render_state,
target_surface, target_surface,
)?; )?
{
self.drop_shadows_ops_warmed = true;
} }
// Render background blur BEFORE save_layer so it modifies // Render background blur BEFORE save_layer so it modifies
@ -3514,8 +3580,7 @@ impl RenderState {
if !skip_shadows if !skip_shadows
&& !shadows_already_rendered && !shadows_already_rendered
&& !matches!(element.shape_type, Type::Text(_)) && !matches!(element.shape_type, Type::Text(_))
{ && self.render_element_drop_shadows_and_composite(
self.render_element_drop_shadows_and_composite(
element, element,
tree, tree,
&mut extrect, &mut extrect,
@ -3523,11 +3588,26 @@ impl RenderState {
scale, scale,
&node_render_state, &node_render_state,
target_surface, 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 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 // For frames without clip_content, inner strokes must render after children in
@ -3729,6 +3809,7 @@ impl RenderState {
// empty tile. // empty tile.
self.current_tile_had_shapes = false; self.current_tile_had_shapes = false;
self.tile_atlas_flushed = false; self.tile_atlas_flushed = false;
self.drop_shadows_ops_warmed = false;
let viewer_masked_pass = self.viewer_masked_pass(); let viewer_masked_pass = self.viewer_masked_pass();