Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 95 additions & 13 deletions crates/rustmotion-core/src/engine/paint_pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Rect> {
let mut bounds: Option<Rect> = 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`
Expand Down
133 changes: 133 additions & 0 deletions crates/rustmotion-core/tests/audit_ws_a.rs
Original file line number Diff line number Diff line change
@@ -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<u8> {
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 {
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<f32>) -> 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:?}"
);
}
Loading