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)); + } }