diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index 496cdea0..048b3c81 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -356,17 +356,28 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u } } + // Hoisted from step 8 below: the layer-bounds computation right after + // this needs it too, to decide whether descendant ink painted outside + // the border-box (legitimate under `overflow: visible`) must stay + // reachable by the opacity/filter layer opened next. + let overflow = node.css.overflow.unwrap_or(Overflow::Visible); + // 4. opacity / filter layer — one shared layer carries both the group // alpha and the CSS `filter` chain (applies to the node and its - // subtree). Bounded to the node's own box (padded by the filter chain's - // blur/drop-shadow bleed so those still bleed past the edge, unclipped): - // an unbounded `SaveLayerRec` sizes the layer against the current clip — - // usually the whole viewport — so every faded/filtered node allocates - // and composites a full-frame layer regardless of how small it is - // (measured on this repo's release binary, 1080x1920/60 frames, 30 small - // `opacity: 0.5` shapes, `--threads 1`: ~42-60s wall time unbounded vs. - // ~0.5s bounded — roughly two orders of magnitude, not a rounding - // error; cost scales with viewport area, not node size). + // subtree). Bounded to the node's own box, padded by: the filter + // chain's blur/drop-shadow bleed, this node's own outset box-shadow + // extent (painted inside this same layer at step 5, outside the + // border-box), and — when `overflow` leaves descendant ink free to + // paint past the border-box — the union of the whole subtree's layout + // boxes. An unbounded `SaveLayerRec` sizes the layer against the + // current clip — usually the whole viewport — so every faded/filtered + // node allocates and composites a full-frame layer regardless of how + // small it is (measured on this repo's release binary, 1080x1920/60 + // frames, 30 small `opacity: 0.5` shapes, `--threads 1`: ~42-60s wall + // time unbounded vs. ~0.5s bounded — roughly two orders of magnitude, + // not a rounding error; cost scales with viewport area, not node + // size), so the bound stays tight to the content that can actually + // paint rather than falling back to the viewport. let opacity = node.css.opacity.unwrap_or(1.0).clamp(0.0, 1.0); let content_filter = node .css @@ -381,18 +392,30 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u if let Some(filter) = content_filter { paint.set_image_filter(filter); } - let bleed = node + let filter_bleed_px = node .css .filter .as_deref() .map(|list| filter_bleed(list, &length_ctx)) .unwrap_or(0.0); - let bounds = Rect::from_xywh( + let shadow_bleed_px = node + .css + .box_shadow + .as_deref() + .map(|shadows| box_shadow_bleed(shadows, &length_ctx)) + .unwrap_or(0.0); + let bleed = filter_bleed_px.max(shadow_bleed_px); + let mut bounds = Rect::from_xywh( box_layout.x - bleed, box_layout.y - bleed, box_layout.width + bleed * 2.0, box_layout.height + bleed * 2.0, ); + if overflow == Overflow::Visible { + if let Some(descendants) = subtree_layout_bounds(node, ctx.layout) { + bounds = Rect::join2(bounds, descendants); + } + } let rec = SaveLayerRec::default().paint(&paint).bounds(&bounds); canvas.save_layer(&rec); true @@ -448,8 +471,8 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u // 8. clip overflow:hidden / clip — scoped to this node's own content and // its children only (see step 5-7's comment for why the box's own - // decorations must stay outside this clip). - let overflow = node.css.overflow.unwrap_or(Overflow::Visible); + // decorations must stay outside this clip). `overflow` was hoisted + // above step 4. let opened_overflow_clip = if matches!( overflow, Overflow::Hidden | Overflow::Clip | Overflow::Scroll | Overflow::Auto @@ -632,6 +655,65 @@ fn filter_bleed(list: &[crate::css::style::FilterFn], ctx: &LengthContext) -> f3 bleed } +/// Conservative outward bleed (px) a node's own outset `box_shadow` list +/// paints beyond its border-box — the same role `filter_bleed` plays for +/// `filter`, and sized the same way (offset + spread pushes the shadow rect +/// out, `1.5x` blur radius covers the Gaussian falloff). Inset shadows are +/// clipped to the padding-box by `paint_box_shadow` and never bleed outward, +/// so they are skipped here. +fn box_shadow_bleed(shadows: &[BoxShadow], ctx: &LengthContext) -> f32 { + let mut bleed = 0.0f32; + for shadow in shadows { + if shadow.inset.unwrap_or(false) { + continue; + } + let offset = shadow + .offset_x + .resolve(ctx) + .abs() + .max(shadow.offset_y.resolve(ctx).abs()); + let spread = shadow + .spread + .as_ref() + .map(|s| s.resolve(ctx).max(0.0)) + .unwrap_or(0.0); + let blur_bleed = shadow + .blur + .as_ref() + .map(|b| b.resolve(ctx).max(0.0) * 1.5) + .unwrap_or(0.0); + bleed = bleed.max(offset + spread + blur_bleed); + } + bleed +} + +/// Bounding box (viewport coordinates) of every descendant's own layout box, +/// recursively — the same "leave the layer big enough to hold what can +/// legitimately paint outside the border-box" contract as `filter_bleed`, +/// applied to `overflow: visible` subtrees instead of a filter chain. Each +/// descendant contributes only its plain layout rect (not its own +/// filter/shadow bleed or transform): a tight bound for the common cases — +/// absolutely-positioned children, `marquee`, a taller-than-parent flow — +/// without walking the whole subtree's CSS. +fn subtree_layout_bounds(node: &BoxNode, layout: &LayoutResult) -> Option { + let mut bounds: Option = None; + for child in &node.children { + if let Some(child_layout) = layout.get(child.id) { + let rect = Rect::from_xywh( + child_layout.x, + child_layout.y, + child_layout.width, + child_layout.height, + ); + bounds = Some(bounds.map_or(rect, |b| Rect::join2(b, rect))); + } + if let Some(child_bounds) = subtree_layout_bounds(child, layout) { + bounds = Some(bounds.map_or(child_bounds, |b| Rect::join2(b, child_bounds))); + } + } + bounds +} + // ---- CSS filters ---- /// Build a Skia `ImageFilter` chain from a CSS `filter`/`backdrop-filter` diff --git a/crates/rustmotion-core/tests/audit_ws_a.rs b/crates/rustmotion-core/tests/audit_ws_a.rs new file mode 100644 index 00000000..0eb5f7e5 --- /dev/null +++ b/crates/rustmotion-core/tests/audit_ws_a.rs @@ -0,0 +1,133 @@ +//! Regression tests for the workstream A (animation & paint) audit findings +//! tracked in issue #220. + +use rustmotion_core::css::style::{ + Background, BoxShadow, Color as CssColor, CssStyle, Display, FlexDirection, Position, + Size as CSize, +}; +use rustmotion_core::css::taffy_bridge::ConversionContext; +use rustmotion_core::css::units::{Length, LengthPercentage as CLP}; +use rustmotion_core::engine::box_tree::{BoxKind, BoxNode}; +use rustmotion_core::engine::layout_pass::run_layout; +use rustmotion_core::engine::paint_pass::{paint_tree, NoopDispatcher, PaintFrame}; + +fn test_frame(w: u32, h: u32) -> PaintFrame { + PaintFrame { + time: 0.0, + scenario_time: 0.0, + frame_index: 0, + fps: 30, + video_width: w, + video_height: h, + scene_duration: 1.0, + camera: None, + } +} + +fn render_pixels(root: &mut BoxNode, w: u32, h: u32) -> Vec { + root.assign_ids(0); + let layout = run_layout(root, (w as f32, h as f32), &ConversionContext::default()); + let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap(); + paint_tree( + surface.canvas(), + root, + &layout, + &test_frame(w, h), + &NoopDispatcher, + ); + let info = skia_safe::ImageInfo::new( + (w as i32, h as i32), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; (w * h * 4) as usize]; + surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0)); + buf +} + +fn root_node(w: f32, h: f32, background: &str, children: Vec) -> BoxNode { + BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + display: Some(Display::Flex), + flex_direction: Some(FlexDirection::Column), + width: Some(CSize::Length(CLP::Px(w))), + height: Some(CSize::Length(CLP::Px(h))), + background: Some(Background::Color(CssColor::String(background.to_string()))), + ..Default::default() + }, + children, + intrinsic: None, + source_path: None, + window: None, + } +} + +fn probe(buf: &[u8], w: u32, x: usize, y: usize) -> (u8, u8, u8) { + let i = (y * w as usize + x) * 4; + (buf[i], buf[i + 1], buf[i + 2]) +} + +// ---- opacity layer must not clip the node's own outset box-shadow ---- + +fn card_with_shadow(opacity: Option) -> BoxNode { + let css = CssStyle { + position: Some(Position::Absolute), + left: Some(CLP::Px(50.0)), + top: Some(CLP::Px(50.0)), + width: Some(CSize::Length(CLP::Px(100.0))), + height: Some(CSize::Length(CLP::Px(100.0))), + background: Some(Background::Color(CssColor::String("#ffffff".into()))), + box_shadow: Some(vec![BoxShadow { + offset_x: Length::Px(0.0), + offset_y: Length::Px(0.0), + blur: None, + spread: Some(Length::Px(20.0)), + color: Some(CssColor::String("#ff0000".into())), + inset: None, + }]), + opacity, + ..Default::default() + }; + BoxNode { + id: 0, + kind: BoxKind::Container, + css, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + } +} + +#[test] +fn opacity_layer_does_not_clip_own_outset_box_shadow() { + // 100x100 white card at (50,50) on a 200x200 black canvas, outset + // box-shadow (red, spread 20, blur 0 -> hard-edged halo rect from + // (30,30) to (170,170)). Probe point (100,45) sits in the halo band + // above the card, outside its own border-box. `opacity: 0.999` forces + // the opacity/filter SaveLayerRec open without visibly dimming the + // probed color. + let opaque = { + let mut root = root_node(200.0, 200.0, "#000000", vec![card_with_shadow(None)]); + render_pixels(&mut root, 200, 200) + }; + let faded = { + let mut root = root_node(200.0, 200.0, "#000000", vec![card_with_shadow(Some(0.999))]); + render_pixels(&mut root, 200, 200) + }; + + let above_opaque = probe(&opaque, 200, 100, 45); + assert!( + above_opaque.0 > 200 && above_opaque.1 < 50, + "sanity: shadow halo must be visible without an opacity layer, got {above_opaque:?}" + ); + + let above_faded = probe(&faded, 200, 100, 45); + assert!( + above_faded.0 > 200 && above_faded.1 < 50, + "an opacity<1 layer must not clip the node's own outset box-shadow, got {above_faded:?}" + ); +}