From 689d506788415fd67573754bc68c17395580fa2c Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 20 Aug 2026 11:12:44 +0200 Subject: [PATCH] :zap: Render eligible frame drop shadows via direct geometry path Add a direct container-geometry path for eligible frames: inline blur when the kernel fits the tile margin, otherwise a cached filter-surface pass reused across tiles via DropShadowFilterCache on both the direct and slow render_shape paths. Move frame shadow logic into shadows.rs. Fix nested/clipped frame shadows by deferring parent clip to composite time, apply negative spread via inset, and allow rotated/transformed frames on the direct path. Skip descendant extrect walks for clipped frames when only nested drop shadows matter, and skip child silhouettes when the container fill already covers shadow descendants. --- .../render-wasm/ffi-rendering-subtleties.md | 4 +- render-wasm/src/render.rs | 303 +++++++++------ render-wasm/src/render/shadows.rs | 359 +++++++++++++++++- render-wasm/src/shapes.rs | 252 ++++++++++++ 4 files changed, 796 insertions(+), 122 deletions(-) diff --git a/.serena/memories/render-wasm/ffi-rendering-subtleties.md b/.serena/memories/render-wasm/ffi-rendering-subtleties.md index d739cd54e7..bb09ea8d52 100644 --- a/.serena/memories/render-wasm/ffi-rendering-subtleties.md +++ b/.serena/memories/render-wasm/ffi-rendering-subtleties.md @@ -24,4 +24,6 @@ - `set_modifiers_end` disables fast/interactive state and cancels pending async render; the caller must request the final full-quality render. - Plain viewport fast mode (`options.is_viewport_interaction()`) renders from cache and does not flush target output inside `process_animation_frame`; interactive transforms do flush. - Zoom changes rebuild the tile index while preserving cached tile textures. Avoid replacing that path with shallow rebuilds if blur/shadow cache preservation matters. -- Pending tile priority is intentionally reversed by pop order; check the queue construction before changing tile scheduling. \ No newline at end of file +- Pending tile priority is intentionally reversed by pop order; check the queue construction before changing tile scheduling. +- Frames with a fill may use `render_frame_container_drop_shadow` (direct rrect + + blur saveLayer on `DropShadows`) when `uses_direct_container_drop_shadow` is true. \ No newline at end of file diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 8aa067dae2..ffd3f97822 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -34,7 +34,7 @@ use crate::shapes::{ use crate::state::{ShapesPoolMutRef, ShapesPoolRef}; use crate::tiles::{self, PendingTiles, TileRect}; use crate::uuid::Uuid; -use crate::view::Viewbox; +use crate::view::{self, Viewbox}; use crate::wapi; use crate::{get_gpu_state, get_resources, performance}; @@ -417,6 +417,8 @@ pub(crate) struct RenderState { /// 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, + /// Filter-surface snapshots for drop shadows, reused across tiles. + drop_shadow_filter_cache: shadows::DropShadowFilterCache, } pub struct InteractiveDragCrop { @@ -551,6 +553,9 @@ impl RenderState { pub fn try_new(width: i32, height: i32) -> Result { // This needs to be done once per WebGL context. let sampling_options = get_resources().sampling_options; + let max_dim = get_gpu_state().max_surface_size(); + let width = width.clamp(1, max_dim); + let height = height.clamp(1, max_dim); let surfaces = Surfaces::try_new( (width, height), @@ -601,6 +606,7 @@ impl RenderState { backbuffer_crop_cache: HashMap::default(), tile_atlas_flushed: false, drop_shadows_ops_warmed: false, + drop_shadow_filter_cache: shadows::DropShadowFilterCache::new(), }) } @@ -875,25 +881,29 @@ impl RenderState { pub fn set_dpr(&mut self, dpr: f32) -> Result<()> { // Only when this function returns true (it means the value // was properly changed) the rest of the functions is called. + // Surface/viewbox pixel size is updated by `resize` after the + // canvas backing store is set, so we do not resize here with a + // stale CSS size (that desyncs Skia vs the GL framebuffer). if self.options.set_dpr(dpr) { + self.viewbox.set_dpr(dpr); self.tile_viewbox .set_interest(self.options.dpr_viewport_interest_area_threshold); - self.resize( - self.viewbox.width().floor() as i32, - self.viewbox.height().floor() as i32, - )?; get_resources().fonts.set_scale_debug_font(dpr); - self.viewbox.set_dpr(dpr); self.surfaces.set_dpr(dpr); } Ok(()) } + pub fn ensure_tile_atlas_layout(&mut self) { + self.surfaces + .ensure_tile_atlas_layout(self.tile_viewbox.interest_rect.len().max(1) as usize); + } + pub fn set_antialias_threshold(&mut self, value: f32) { self.options.set_antialias_threshold(value); } - pub fn set_viewport_interest_area_threshold(&mut self, value: i32) { + pub fn set_viewport_interest_area_threshold(&mut self, value: i32) -> Result<()> { // Only when this function returns true (it means the value // was changed properly) the tile_viewbox.set_interest is called. if self.options.set_viewport_interest_area_threshold(value) { @@ -902,7 +912,10 @@ impl RenderState { // affect pending_tiles generation. self.tile_viewbox .set_interest(self.options.dpr_viewport_interest_area_threshold); + self.tile_viewbox.update(&self.viewbox); + self.ensure_tile_atlas_layout(); } + Ok(()) } pub fn set_node_batch_threshold(&mut self, value: i32) { @@ -926,11 +939,27 @@ impl RenderState { } pub fn resize(&mut self, width: i32, height: i32) -> Result<()> { - let dpr_width = (width as f32 * self.options.dpr).floor() as i32; - let dpr_height = (height as f32 * self.options.dpr).floor() as i32; + let gpu_state = get_gpu_state(); + let max_dim = gpu_state.max_surface_size(); + let css_w = (width as f32).max(1.0); + let css_h = (height as f32).max(1.0); + let dpr = view::clamp_dpr_for_surface(css_w, css_h, self.options.dpr, max_dim); + let mut dpr_width = ((css_w * dpr).floor() as i32).clamp(1, max_dim); + let mut dpr_height = ((css_h * dpr).floor() as i32).clamp(1, max_dim); + // Prefer the real GL drawing buffer: wrap_backend_render_target + // binds the default framebuffer, whose origin is bottom-left. + if let Some((fb_w, fb_h)) = gpu_state.drawing_buffer_size() { + dpr_width = fb_w.clamp(1, max_dim); + dpr_height = fb_h.clamp(1, max_dim); + } + let effective_dpr = (dpr_width as f32 / css_w).min(dpr_height as f32 / css_h); + if (effective_dpr - self.options.dpr).abs() > f32::EPSILON { + self.set_dpr(effective_dpr)?; + } self.surfaces.resize(dpr_width, dpr_height)?; - self.viewbox.set_wh(width as f32, height as f32); + self.viewbox.set_wh(css_w, css_h); self.tile_viewbox.update(&self.viewbox); + self.ensure_tile_atlas_layout(); Ok(()) } @@ -2274,6 +2303,7 @@ impl RenderState { // reorder by distance to the center. self.current_tile = None; + self.drop_shadow_filter_cache.clear(); } pub fn start_render_loop( @@ -2995,6 +3025,87 @@ impl RenderState { )) } + /// Renders descendant silhouettes into the current drop-shadow layer. + #[allow(clippy::too_many_arguments)] + fn render_drop_shadow_child_silhouettes( + &mut self, + element: &Shape, + tree: ShapesPoolRef, + shadow: &Shadow, + scale: f32, + inherited_layer_blur: Option, + node_render_state: &NodeRenderState, + target_surface: SurfaceId, + ) -> Result<()> { + if matches!(element.shape_type, Type::Bool(_)) { + return Ok(()); + } + + let shadow_children = if element.is_recursive() { + get_simplified_children(tree, element) + } else { + Vec::new() + }; + + for shadow_shape_id in shadow_children.iter() { + let Some(shadow_shape) = tree.get(shadow_shape_id) else { + continue; + }; + if shadow_shape.hidden { + continue; + } + + 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(); + } + } + + Ok(()) + } + /// Renders a drop shadow effect for the given shape. /// /// Creates a black shadow by converting the original shadow color to black, @@ -3140,10 +3251,30 @@ impl RenderState { return Ok(()); } - // Adaptive downscale for large blur values (lossless GPU optimization). - // Bounds above were computed from the original sigma so filter surface coverage is correct. - // Maximum downscale is 1/BLUR_DOWNSCALE_THRESHOLD (i.e. 8x): beyond that the - // filter surface becomes too small and quality degrades noticeably. + // High zoom with blur: use render_into_filter_surface to ensure blur has enough space + // Apply spread geometrically to avoid dilate filter rounding issues + let layer_blur_value = combined_blur.map(|b| b.value).unwrap_or(0.0); + let cache_key = clip_bounds.is_none().then(|| { + shadows::DropShadowFilterCacheKey::for_shape( + shape.id, + shadow, + scale, + &shape.transform, + layer_blur_value, + ) + }); + + if let Some(ref key) = cache_key { + if let Some(cached) = self.drop_shadow_filter_cache.lookup(key) { + shadows::blit_cached_drop_shadow_filter( + &mut self.surfaces, + cached, + blur_filter.clone(), + ); + return Ok(()); + } + } + let blur_downscale_threshold: f32 = self.options.blur_downscale_threshold; let min_blur_downscale: f32 = 1.0 / blur_downscale_threshold; let blur_downscale = if shadow.blur > blur_downscale_threshold { @@ -3185,37 +3316,19 @@ impl RenderState { )?; if let Some((mut surface, filter_scale)) = filter_result { - let drop_canvas = self.surfaces.canvas(SurfaceId::DropShadows); - drop_canvas.save(); - //drop_canvas.scale((scale, scale)); - //drop_canvas.translate(translation); - let mut drop_paint = skia::Paint::default(); - drop_paint.set_image_filter(blur_filter.clone()); - - // If we scaled down in the filter surface, we need to scale back up - if filter_scale < 1.0 { - drop_canvas.save(); - drop_canvas.scale((1.0 / filter_scale, 1.0 / filter_scale)); - drop_canvas.translate((bounds.left * filter_scale, bounds.top * filter_scale)); - surface.draw( - drop_canvas, - (0.0, 0.0), - get_resources().sampling_options, - Some(&drop_paint), - ); - drop_canvas.restore(); - } else { - drop_canvas.save(); - drop_canvas.translate((bounds.left, bounds.top)); - surface.draw( - drop_canvas, - (0.0, 0.0), - get_resources().sampling_options, - Some(&drop_paint), - ); - drop_canvas.restore(); + let cached = shadows::CachedDropShadowFilter::new( + bounds, + filter_scale, + surface.image_snapshot(), + ); + shadows::blit_cached_drop_shadow_filter( + &mut self.surfaces, + &cached, + blur_filter.clone(), + ); + if let Some(key) = cache_key { + self.drop_shadow_filter_cache.store(key, cached); } - drop_canvas.restore(); } Ok(()) @@ -3254,6 +3367,7 @@ impl RenderState { }; let recursive = element.is_recursive(); + let use_direct_container_shadow = element.uses_direct_container_drop_shadow(tree, scale); let mut rendered_any = false; for shadow in element.drop_shadows_visible() { if !shadow.is_perceptible_at_scale_for(scale, recursive) { @@ -3266,78 +3380,35 @@ impl RenderState { .canvas(SurfaceId::DropShadows) .save_layer(&layer_rec); - self.render_drop_black_shadow( - element, - element_extrect, - shadow, - clip_bounds.clone(), - scale, - None, - target_surface, - )?; - - if !matches!(element.shape_type, Type::Bool(_)) { - let shadow_children = if element.is_recursive() { - get_simplified_children(tree, element) - } else { - Vec::new() - }; - - for shadow_shape_id in shadow_children.iter() { - let Some(shadow_shape) = tree.get(shadow_shape_id) else { - continue; - }; - if shadow_shape.hidden { - continue; - } - - 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(); - } + // Fast path: frame geometry only (no child silhouettes). + if use_direct_container_shadow { + shadows::render_direct_frame_drop_shadow( + self, + element, + element_extrect, + shadow, + scale, + )?; + } else { + self.render_drop_black_shadow( + element, + element_extrect, + shadow, + clip_bounds.clone(), + scale, + None, + target_surface, + )?; + if !element.container_fill_covers_shadow_descendants(tree, scale) { + self.render_drop_shadow_child_silhouettes( + element, + tree, + shadow, + scale, + inherited_layer_blur, + node_render_state, + target_surface, + )?; } } diff --git a/render-wasm/src/render/shadows.rs b/render-wasm/src/render/shadows.rs index c6baadc0b3..19f90542cf 100644 --- a/render-wasm/src/render/shadows.rs +++ b/render-wasm/src/render/shadows.rs @@ -1,12 +1,361 @@ +use std::collections::HashMap; + +use super::filters; use super::{RenderState, SurfaceId}; -use crate::render::strokes; -use crate::shapes::{ParagraphBuilderGroup, Shadow, Shape, Stroke, StrokeKind, TextContent, Type}; -use skia_safe::{canvas::SaveLayerRec, Paint, Path}; - use crate::error::Result; +use crate::get_resources; +use crate::render::strokes; use crate::render::text; +use crate::shapes::radius_to_sigma; +use crate::shapes::{ParagraphBuilderGroup, Shadow, Shape, Stroke, StrokeKind, TextContent, Type}; +use crate::uuid::Uuid; +use skia_safe::{self as skia, canvas::SaveLayerRec, Paint, Path, Rect}; + +// --------------------------------------------------------------------------- +// Direct frame drop shadows (fast inline blur + filter-surface cache fallback) +// --------------------------------------------------------------------------- + +pub(crate) struct DropShadowFilterCache { + entries: HashMap, +} + +#[derive(Hash, PartialEq, Eq, Clone, Copy)] +pub(crate) struct DropShadowFilterCacheKey { + shape_id: Uuid, + blur_bits: u32, + spread_bits: u32, + offset_x_bits: u32, + offset_y_bits: u32, + scale_bits: u32, + transform_a_bits: u32, + transform_b_bits: u32, + transform_c_bits: u32, + transform_d_bits: u32, + transform_e_bits: u32, + transform_f_bits: u32, + layer_blur_bits: u32, +} + +pub(crate) struct CachedDropShadowFilter { + bounds: Rect, + filter_scale: f32, + image: skia::Image, +} + +impl CachedDropShadowFilter { + pub(crate) fn new(bounds: Rect, filter_scale: f32, image: skia::Image) -> Self { + Self { + bounds, + filter_scale, + image, + } + } +} + +impl DropShadowFilterCacheKey { + pub(crate) fn for_shape( + shape_id: Uuid, + shadow: &Shadow, + scale: f32, + transform: &skia::Matrix, + layer_blur: f32, + ) -> Self { + Self::new(shape_id, shadow, scale, transform, layer_blur) + } + + fn new( + shape_id: Uuid, + shadow: &Shadow, + scale: f32, + transform: &skia::Matrix, + layer_blur: f32, + ) -> Self { + Self { + shape_id, + blur_bits: shadow.blur.to_bits(), + spread_bits: shadow.spread.to_bits(), + offset_x_bits: shadow.offset.0.to_bits(), + offset_y_bits: shadow.offset.1.to_bits(), + scale_bits: scale.to_bits(), + transform_a_bits: transform[0].to_bits(), + transform_b_bits: transform[1].to_bits(), + transform_c_bits: transform[2].to_bits(), + transform_d_bits: transform[3].to_bits(), + transform_e_bits: transform[4].to_bits(), + transform_f_bits: transform[5].to_bits(), + layer_blur_bits: layer_blur.to_bits(), + } + } +} + +impl DropShadowFilterCache { + pub fn new() -> Self { + Self { + entries: HashMap::default(), + } + } + + pub fn clear(&mut self) { + self.entries.clear(); + } + + pub(crate) fn lookup(&self, key: &DropShadowFilterCacheKey) -> Option<&CachedDropShadowFilter> { + self.entries.get(key) + } + + pub(crate) fn store(&mut self, key: DropShadowFilterCacheKey, value: CachedDropShadowFilter) { + self.entries.insert(key, value); + } +} + +/// Renders a direct frame drop shadow: inline blur on the tile when the kernel +/// fits the margin, otherwise a cached filter-surface pass shared across tiles. +/// +/// Does not apply the caller's clip stack; clip is applied when compositing +/// `DropShadows` onto the target surface. +pub(crate) fn render_direct_frame_drop_shadow( + state: &mut RenderState, + frame: &Shape, + shape_bounds: &Rect, + shadow: &Shadow, + scale: f32, +) -> Result<()> { + let margin = state.surfaces.margins().width as f32; + let sigma_device = radius_to_sigma(shadow.blur) * scale; + if sigma_device <= margin / 3.0 { + render_inline_frame_shadow(state, frame, shadow, scale) + } else { + render_cached_filter_frame_shadow(state, frame, shape_bounds, shadow, scale) + } +} + +fn frame_shadow_antialias(state: &RenderState, frame: &Shape, scale: f32) -> bool { + !state.options.is_fast_mode() + && frame.should_use_antialias(scale, state.options.antialias_threshold) +} + +fn spread_outset(spread: f32) -> Option { + Some(spread).filter(|&s| s > 0.0) +} + +fn spread_inset(spread: f32) -> Option { + Some(-spread).filter(|&s| s > 0.0) +} + +fn blur_layer_paint(blur: f32, sigma_scale: f32) -> skia::Paint { + let mut paint = skia::Paint::default(); + if blur > 0.0 { + let sigma = radius_to_sigma(blur) * sigma_scale; + paint.set_image_filter(skia::image_filters::blur((sigma, sigma), None, None, None)); + } + paint.set_blend_mode(skia::BlendMode::SrcOver); + paint +} + +fn draw_frame_shadow_rect( + surfaces: &mut super::Surfaces, + surface_id: SurfaceId, + frame: &Shape, + shadow: &Shadow, + antialias: bool, +) { + let mut fill_paint = skia::Paint::default(); + fill_paint.set_color(skia::Color::BLACK); + fill_paint.set_anti_alias(antialias); + surfaces.draw_rect_to( + surface_id, + frame, + &fill_paint, + spread_outset(shadow.spread), + spread_inset(shadow.spread), + ); +} + +fn shadow_filter_bounds( + shadow: &Shadow, + shape_bounds: &Rect, + world_offset: (f32, f32), +) -> Option { + let mut shadow_cull = *shadow; + shadow_cull.color = skia::Color::BLACK; + shadow_cull.offset = (0.0, 0.0); + let drop_filter = shadow_cull.get_drop_shadow_filter()?; + let mut bounds = drop_filter.compute_fast_bounds(*shape_bounds); + bounds.offset(world_offset); + Some(bounds) +} + +/// Local draw matrix for frame shadow geometry: centered shape transform plus +/// shadow offset in local space (matches `render_shape` with `Some(offset)`). +fn frame_shadow_draw_matrix(frame: &Shape, shadow: &Shadow) -> skia::Matrix { + let mut matrix = frame.centered_transform(); + matrix.pre_translate((shadow.offset.0, shadow.offset.1)); + matrix +} + +/// Shadow offset mapped to world space (for bounds culling and cache blit). +fn shadow_world_offset(frame: &Shape, shadow: &Shadow) -> (f32, f32) { + let mapped = frame + .centered_transform() + .map_vector((shadow.offset.0, shadow.offset.1)); + (mapped.x, mapped.y) +} + +/// When bounds fit in the filter surface, skip blur downscale to avoid banding +/// at high zoom. The tile cache makes a single full-res pass affordable. +fn blur_downscale_for_frame_shadow( + blur: f32, + bounds: Rect, + filter_width: i32, + filter_height: i32, + threshold: f32, +) -> f32 { + let bounds_w = bounds.width().ceil().max(1.0) as i32; + let bounds_h = bounds.height().ceil().max(1.0) as i32; + if bounds_w <= filter_width && bounds_h <= filter_height { + return 1.0; + } + if blur > threshold { + (threshold / blur).max(1.0 / threshold) + } else { + 1.0 + } +} + +pub(crate) fn blit_cached_drop_shadow_filter( + surfaces: &mut super::Surfaces, + cached: &CachedDropShadowFilter, + layer_blur: Option, +) { + let sampling = get_resources().sampling_options; + let mut paint = skia::Paint::default(); + if let Some(filter) = layer_blur { + paint.set_image_filter(filter); + } + let drop_canvas = surfaces.canvas(SurfaceId::DropShadows); + let dst = skia::Rect::from_wh(cached.image.width() as f32, cached.image.height() as f32); + + drop_canvas.save(); + drop_canvas.save(); + if cached.filter_scale < 1.0 { + drop_canvas.scale((1.0 / cached.filter_scale, 1.0 / cached.filter_scale)); + drop_canvas.translate(( + cached.bounds.left * cached.filter_scale, + cached.bounds.top * cached.filter_scale, + )); + } else { + drop_canvas.translate((cached.bounds.left, cached.bounds.top)); + } + drop_canvas.draw_image_rect_with_sampling_options(&cached.image, None, dst, sampling, &paint); + drop_canvas.restore(); + drop_canvas.restore(); +} + +fn render_inline_frame_shadow( + state: &mut RenderState, + frame: &Shape, + shadow: &Shadow, + scale: f32, +) -> Result<()> { + let antialias = frame_shadow_antialias(state, frame, scale); + let layer_paint = blur_layer_paint(shadow.blur, 1.0); + let draw_matrix = frame_shadow_draw_matrix(frame, shadow); + + { + let drop_canvas = state.surfaces.canvas(SurfaceId::DropShadows); + drop_canvas.save(); + drop_canvas.concat(&draw_matrix); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&layer_paint); + drop_canvas.save_layer(&layer_rec); + } + + draw_frame_shadow_rect( + &mut state.surfaces, + SurfaceId::DropShadows, + frame, + shadow, + antialias, + ); + + { + let drop_canvas = state.surfaces.canvas(SurfaceId::DropShadows); + drop_canvas.restore(); + drop_canvas.restore(); + } + + Ok(()) +} + +fn render_cached_filter_frame_shadow( + state: &mut RenderState, + frame: &Shape, + shape_bounds: &Rect, + shadow: &Shadow, + scale: f32, +) -> Result<()> { + let draw_matrix = frame.centered_transform(); + let key = DropShadowFilterCacheKey::for_shape(frame.id, shadow, scale, &draw_matrix, 0.0); + if let Some(cached) = state.drop_shadow_filter_cache.lookup(&key) { + blit_cached_drop_shadow_filter(&mut state.surfaces, cached, None); + return Ok(()); + } + + let world_offset = shadow_world_offset(frame, shadow); + let Some(bounds) = shadow_filter_bounds(shadow, shape_bounds, world_offset) else { + return Ok(()); + }; + + let antialias = frame_shadow_antialias(state, frame, scale); + let (filter_w, filter_h) = state.surfaces.filter_size(); + let blur_downscale = blur_downscale_for_frame_shadow( + shadow.blur, + bounds, + filter_w, + filter_h, + state.options.blur_downscale_threshold, + ); + let layer_paint = blur_layer_paint(shadow.blur, blur_downscale); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&layer_paint); + + let shadow_draw_matrix = frame_shadow_draw_matrix(frame, shadow); + let filter_result = filters::render_into_filter_surface( + state, + bounds, + blur_downscale, + |state, temp_surface| { + { + let canvas = state.surfaces.canvas(temp_surface); + canvas.save(); + canvas.concat(&shadow_draw_matrix); + canvas.save_layer(&layer_rec); + } + draw_frame_shadow_rect(&mut state.surfaces, temp_surface, frame, shadow, antialias); + { + let canvas = state.surfaces.canvas(temp_surface); + canvas.restore(); + canvas.restore(); + } + Ok(()) + }, + )?; + + if let Some((mut surface, filter_scale)) = filter_result { + let cached = CachedDropShadowFilter { + bounds, + filter_scale, + image: surface.image_snapshot(), + }; + blit_cached_drop_shadow_filter(&mut state.surfaces, &cached, None); + state.drop_shadow_filter_cache.store(key, cached); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Inner / text shadows +// --------------------------------------------------------------------------- -// Fill Shadows pub fn render_fill_inner_shadows( render_state: &mut RenderState, shape: &Shape, diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 8944e814fc..362b912a8d 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1843,6 +1843,99 @@ impl Shape { .any(|s| s.render_kind(is_open) == StrokeKind::Inner) } + /// When true, the frame drop shadow can use the direct geometry path + /// (`render_direct_frame_drop_shadow`) instead of filter surfaces and + /// descendant silhouettes. + /// + /// Requires at least one fill; fill opacity/type does not matter because the fast + /// path shadows the frame geometry as a solid mask. + /// + /// The fast path draws fill geometry only. On the slow path, visible strokes also + /// contribute to the shadow silhouette, so frames with outer/center strokes can + /// look slightly narrower here. We keep them eligible anyway for performance. + pub fn uses_direct_container_drop_shadow(&self, tree: ShapesPoolRef, scale: f32) -> bool { + if !matches!(self.shape_type, Type::Frame(_)) { + return false; + } + if !self.has_fills() { + return false; + } + if self.blend_mode() != BlendMode::default() { + return false; + } + if self.blur.is_some() || self.background_blur.is_some() { + return false; + } + if self.has_frame_clip_layer_blur() { + return false; + } + + if self.clip_content { + return !self.descendants_have_drop_shadows(tree); + } + + self.descendants_contained_for_frame_shadow(tree, scale, self.selrect()) + } + + /// When true, the container's own fill shadow mask is enough and descendant + /// silhouettes can be skipped (same geometry assumption as the direct path). + pub fn container_fill_covers_shadow_descendants( + &self, + tree: ShapesPoolRef, + scale: f32, + ) -> bool { + self.has_fills() && self.descendants_contained_for_frame_shadow(tree, scale, self.selrect()) + } + + fn descendants_have_drop_shadows(&self, tree: ShapesPoolRef) -> bool { + for child_id in self.children_ids_iter(false) { + let Some(child) = tree.get(child_id) else { + continue; + }; + if child.hidden { + continue; + } + if child.drop_shadows_visible().next().is_some() { + return true; + } + if child.is_recursive() && child.descendants_have_drop_shadows(tree) { + return true; + } + } + false + } + + fn descendants_contained_for_frame_shadow( + &self, + tree: ShapesPoolRef, + scale: f32, + bounds: math::Rect, + ) -> bool { + if self.descendants_have_drop_shadows(tree) { + return false; + } + + const MARGIN: f32 = 0.5; + for child_id in self.children_ids_iter(false) { + let Some(child) = tree.get(child_id) else { + continue; + }; + if child.hidden { + continue; + } + let child_extrect = child.extrect(tree, scale); + if !rect_contains_with_margin(bounds, child_extrect, MARGIN) { + return false; + } + if child.is_recursive() + && !child.descendants_contained_for_frame_shadow(tree, scale, bounds) + { + return false; + } + } + true + } + pub fn drop_shadow_paints(&self) -> Vec { let drop_shadows: Vec<&Shadow> = self.drop_shadows_visible().collect(); @@ -1872,6 +1965,14 @@ impl Shape { } } +#[inline] +fn rect_contains_with_margin(outer: math::Rect, inner: math::Rect, margin: f32) -> bool { + inner.left >= outer.left - margin + && inner.top >= outer.top - margin + && inner.right <= outer.right + margin + && inner.bottom <= outer.bottom + margin +} + #[cfg(test)] mod tests { use super::*; @@ -2012,4 +2113,155 @@ mod tests { assert_eq!(extrect.right, 50.0); assert_eq!(extrect.bottom, 50.0); } + + fn frame_with_fill_and_child(fill: Fill, opacity: f32) -> (ShapesPool, Uuid) { + let mut pool = ShapesPool::new(); + pool.initialize(2); + + let frame_id = Uuid::new_v4(); + let child_id = Uuid::new_v4(); + + { + let frame = pool.add_shape(frame_id); + frame.set_shape_type(Type::Frame(Frame::default())); + frame.set_selrect(0.0, 0.0, 200.0, 100.0); + frame.add_fill(fill); + frame.opacity = opacity; + frame.children = vec![child_id]; + } + + { + let child = pool.add_shape(child_id); + child.set_shape_type(Type::Rect(Rect::default())); + child.set_selrect(10.0, 10.0, 180.0, 80.0); + child.set_parent(frame_id); + } + + (pool, frame_id) + } + + #[test] + fn frame_with_any_fill_uses_direct_container_drop_shadow() { + for (fill, opacity) in [ + (Fill::Solid(SolidColor(skia::Color::WHITE)), 1.0), + ( + Fill::Solid(SolidColor(skia::Color::from_argb(128, 255, 255, 255))), + 0.5, + ), + ] { + let (pool, frame_id) = frame_with_fill_and_child(fill, opacity); + let frame = pool.get(&frame_id).expect("frame"); + assert!(frame.uses_direct_container_drop_shadow(&pool, 1.0)); + } + } + + #[test] + fn clipped_frame_with_child_drop_shadow_rejects_direct_path() { + let (mut pool, frame_id) = + frame_with_fill_and_child(Fill::Solid(SolidColor(skia::Color::WHITE)), 1.0); + let child_id = pool.get(&frame_id).expect("frame").children[0]; + + { + let child = pool.get_mut(&child_id).expect("child"); + child.add_shadow(Shadow::new( + skia::Color::BLACK, + 4.0, + 0.0, + (0.0, 4.0), + ShadowStyle::Drop, + false, + )); + } + + let frame = pool.get(&frame_id).expect("frame"); + assert!(!frame.uses_direct_container_drop_shadow(&pool, 1.0)); + } + + #[test] + fn clipped_frame_ignores_outside_child_extrect_for_direct_path() { + let mut pool = ShapesPool::new(); + pool.initialize(2); + + let frame_id = Uuid::new_v4(); + let child_id = Uuid::new_v4(); + + { + let frame = pool.add_shape(frame_id); + frame.set_shape_type(Type::Frame(Frame::default())); + frame.set_selrect(0.0, 0.0, 200.0, 100.0); + frame.add_fill(Fill::Solid(SolidColor(skia::Color::WHITE))); + frame.set_clip(true); + frame.children = vec![child_id]; + } + + { + let child = pool.add_shape(child_id); + child.set_shape_type(Type::Rect(Rect::default())); + child.set_selrect(-50.0, -50.0, 250.0, 150.0); + child.set_parent(frame_id); + } + + let frame = pool.get(&frame_id).expect("frame"); + assert!(frame.uses_direct_container_drop_shadow(&pool, 1.0)); + } + + #[test] + fn overflow_frame_with_outside_child_rejects_direct_path() { + let mut pool = ShapesPool::new(); + pool.initialize(2); + + let frame_id = Uuid::new_v4(); + let child_id = Uuid::new_v4(); + + { + let frame = pool.add_shape(frame_id); + frame.set_shape_type(Type::Frame(Frame::default())); + frame.set_selrect(0.0, 0.0, 200.0, 100.0); + frame.add_fill(Fill::Solid(SolidColor(skia::Color::WHITE))); + frame.set_clip(false); + frame.children = vec![child_id]; + } + + { + let child = pool.add_shape(child_id); + child.set_shape_type(Type::Rect(Rect::default())); + child.set_selrect(-50.0, -50.0, 250.0, 150.0); + child.set_parent(frame_id); + } + + let frame = pool.get(&frame_id).expect("frame"); + assert!(!frame.uses_direct_container_drop_shadow(&pool, 1.0)); + assert!(!frame.container_fill_covers_shadow_descendants(&pool, 1.0)); + } + + #[test] + fn frame_with_contained_child_covers_shadow_descendants() { + let (pool, frame_id) = + frame_with_fill_and_child(Fill::Solid(SolidColor(skia::Color::WHITE)), 1.0); + let frame = pool.get(&frame_id).expect("frame"); + assert!(frame.container_fill_covers_shadow_descendants(&pool, 1.0)); + } + + #[test] + fn rotated_frame_with_contained_child_uses_direct_container_drop_shadow() { + let (mut pool, frame_id) = + frame_with_fill_and_child(Fill::Solid(SolidColor(skia::Color::WHITE)), 1.0); + + { + let frame = pool.get_mut(&frame_id).expect("frame"); + // 45° rotation around the shape center (100, 50). + let angle = std::f32::consts::FRAC_PI_4; + frame.set_transform( + angle.cos(), + angle.sin(), + -angle.sin(), + angle.cos(), + 0.0, + 0.0, + ); + } + + let frame = pool.get(&frame_id).expect("frame"); + assert!(frame.uses_direct_container_drop_shadow(&pool, 1.0)); + } }