Skip to content

Commit b6b7ec5

Browse files
vello: make Scene clip/layers honor fill rule (even-odd clips)
Scene layer APIs now take a `peniko::StyleRef` for the clip shape (`push_layer`, `push_clip_layer`, `push_luminance_mask_layer`), fixing the long-standing hardcoded `Fill::NonZero` clip behavior. This is required for correctness in CSS/SVG semantics (e.g. `clip-rule`, `fill-rule`, and masking/clipPath content that relies on even-odd holes). Using `StyleRef` also enables clipping to a stroke (pass `&Stroke`), so clips can be defined by a stroked outline as well as a filled interior. To make even-odd clipping work end-to-end, plumb the fill rule through the clip pipeline: - Give `BEGIN_CLIP` a 1-word info slot so clip draw flags can be stored. - Populate clip `draw_flags` for `BEGIN_CLIP` in `draw_leaf`. - In `clip_leaf`, propagate `info_offset` from `BEGIN_CLIP` to the corresponding `END_CLIP`. - In coarse, apply clip layers with the correct fill rule by propagating `BEGIN_CLIP` draw flags through to `END_CLIP`, and by updating the “clip-zero” fast path to respect even-odd backdrop parity (GPU + CPU paths). Existing callsites can continue passing `Fill::NonZero` to preserve prior behavior; `Fill::EvenOdd` selects even-odd clips.
1 parent c15340d commit b6b7ec5

File tree

14 files changed

+222
-30
lines changed

14 files changed

+222
-30
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ This release has an [MSRV][] of 1.88.
1818
### Changed
1919

2020
- Breaking change: wgpu has been updated to wgpu 27. ([#1280][] by [@theoparis][])
21+
- Breaking change: Make `Scene` clip / layers honor fill rule (even-odd clips). ([#1332][] by [@waywardmonkeys][])
22+
When pushing a layer, you should use `Fill::NonZero` as the clip fill rule to achieve the same behavior as previous versions.
2123

2224
### Fixed
2325

@@ -383,6 +385,7 @@ This release has an [MSRV][] of 1.75.
383385
[#1273]: https://github.com/linebender/vello/pull/1273
384386
[#1280]: https://github.com/linebender/vello/pull/1280
385387
[#1323]: https://github.com/linebender/vello/pull/1323
388+
[#1332]: https://github.com/linebender/vello/pull/1332
386389

387390
<!-- Note that this still comparing against 0.5.0, because 0.5.1 is a cherry-picked patch -->
388391
[Unreleased]: https://github.com/linebender/vello/compare/v0.5.0...HEAD

examples/scenes/src/test_scenes.rs

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -769,7 +769,7 @@ mod impls {
769769
&rect,
770770
);
771771
let alpha = params.time.sin() as f32 * 0.5 + 0.5;
772-
scene.push_layer(Mix::Normal, alpha, Affine::IDENTITY, &rect);
772+
scene.push_layer(Fill::NonZero, Mix::Normal, alpha, Affine::IDENTITY, &rect);
773773
scene.fill(
774774
Fill::NonZero,
775775
Affine::translate((100.0, 100.0)) * Affine::scale(0.2),
@@ -1125,6 +1125,7 @@ mod impls {
11251125
let mut depth = 0;
11261126
for (width, color) in &options[..params.complexity.min(options.len() - 1)] {
11271127
scene.push_layer(
1128+
Fill::NonZero,
11281129
Mix::Normal,
11291130
0.9,
11301131
Affine::IDENTITY,
@@ -1152,7 +1153,7 @@ mod impls {
11521153
const CLIPS_PER_FILL: usize = 3;
11531154
for _ in 0..CLIPS_PER_FILL {
11541155
let rot = Affine::rotate(rng.random_range(0.0..PI));
1155-
scene.push_clip_layer(translate * rot, &base_tri);
1156+
scene.push_clip_layer(Fill::NonZero, translate * rot, &base_tri);
11561157
}
11571158
let rot = Affine::rotate(rng.random_range(0.0..PI));
11581159
let color = Color::new([rng.random(), rng.random(), rng.random(), 1.]);
@@ -1212,7 +1213,7 @@ mod impls {
12121213
PathEl::LineTo((X0, Y1).into()),
12131214
PathEl::ClosePath,
12141215
];
1215-
scene.push_clip_layer(Affine::IDENTITY, &path);
1216+
scene.push_clip_layer(Fill::NonZero, Affine::IDENTITY, &path);
12161217
}
12171218
let rect = Rect::new(X0, Y0, X1, Y1);
12181219
scene.fill(
@@ -1243,7 +1244,11 @@ mod impls {
12431244
None,
12441245
&make_diamond(1024.0, 125.0),
12451246
);
1246-
scene.push_clip_layer(Affine::IDENTITY, &make_diamond(1024.0, 150.0));
1247+
scene.push_clip_layer(
1248+
Fill::NonZero,
1249+
Affine::IDENTITY,
1250+
&make_diamond(1024.0, 150.0),
1251+
);
12471252
scene.fill(
12481253
Fill::NonZero,
12491254
Affine::IDENTITY,
@@ -1271,11 +1276,11 @@ mod impls {
12711276
scene.fill(Fill::NonZero, transform, &radial, None, &rect);
12721277
}
12731278
const COLORS: &[Color] = &[palette::css::RED, palette::css::LIME, palette::css::BLUE];
1274-
scene.push_layer(Mix::Normal, 1.0, transform, &rect);
1279+
scene.push_layer(Fill::NonZero, Mix::Normal, 1.0, transform, &rect);
12751280
for (i, c) in COLORS.iter().enumerate() {
12761281
let linear = Gradient::new_linear((0.0, 0.0), (0.0, 200.0))
12771282
.with_stops([palette::css::WHITE, *c]);
1278-
scene.push_layer(blend, 1.0, transform, &rect);
1283+
scene.push_layer(Fill::NonZero, blend, 1.0, transform, &rect);
12791284
// squash the ellipse
12801285
let a = transform
12811286
* Affine::translate((100., 100.))
@@ -1579,7 +1584,7 @@ mod impls {
15791584
PathEl::ClosePath,
15801585
]
15811586
};
1582-
scene.push_clip_layer(Affine::IDENTITY, &clip);
1587+
scene.push_clip_layer(Fill::NonZero, Affine::IDENTITY, &clip);
15831588
{
15841589
let text_size = 60.0 + 40.0 * (params.time as f32).sin();
15851590
let s = "Some clipped text!";
@@ -1594,6 +1599,80 @@ mod impls {
15941599
}
15951600
scene.pop_layer();
15961601

1602+
// Even-odd clip-layer demo: a self-intersecting star ("pentagram") has different results
1603+
// under non-zero vs even-odd fill rules (even-odd produces a hole).
1604+
let demo_rect = Rect::new(250.0, 20.0, 450.0, 220.0);
1605+
scene.fill(
1606+
Fill::NonZero,
1607+
Affine::IDENTITY,
1608+
palette::css::BLUE,
1609+
None,
1610+
&demo_rect,
1611+
);
1612+
let mut star = BezPath::new();
1613+
let center = Point::new(350.0, 120.0);
1614+
let outer_r = 90.0;
1615+
let start_angle = -std::f64::consts::FRAC_PI_2;
1616+
let pts: [Point; 5] = core::array::from_fn(|i| {
1617+
let a = start_angle + (i as f64) * (2.0 * std::f64::consts::PI / 5.0);
1618+
center + Vec2::new(a.cos() * outer_r, a.sin() * outer_r)
1619+
});
1620+
let order = [0_usize, 2, 4, 1, 3];
1621+
star.move_to(pts[order[0]]);
1622+
for &idx in &order[1..] {
1623+
star.line_to(pts[idx]);
1624+
}
1625+
star.close_path();
1626+
1627+
scene.push_clip_layer(Fill::EvenOdd, Affine::IDENTITY, &star);
1628+
scene.fill(
1629+
Fill::NonZero,
1630+
Affine::IDENTITY,
1631+
palette::css::RED,
1632+
None,
1633+
&demo_rect,
1634+
);
1635+
scene.pop_layer();
1636+
1637+
// Stroke clip demo: clip to the stroked outline of a path.
1638+
let stroke_demo_rect = Rect::new(250.0, 240.0, 450.0, 440.0);
1639+
scene.fill(
1640+
Fill::NonZero,
1641+
Affine::IDENTITY,
1642+
palette::css::SLATE_GRAY,
1643+
None,
1644+
&stroke_demo_rect,
1645+
);
1646+
let mut stroke_star = BezPath::new();
1647+
let center = Point::new(350.0, 340.0);
1648+
let outer_r = 85.0;
1649+
let start_angle = -std::f64::consts::FRAC_PI_2;
1650+
let pts: [Point; 5] = core::array::from_fn(|i| {
1651+
let a = start_angle + (i as f64) * (2.0 * std::f64::consts::PI / 5.0);
1652+
center + Vec2::new(a.cos() * outer_r, a.sin() * outer_r)
1653+
});
1654+
let order = [0_usize, 2, 4, 1, 3];
1655+
stroke_star.move_to(pts[order[0]]);
1656+
for &idx in &order[1..] {
1657+
stroke_star.line_to(pts[idx]);
1658+
}
1659+
stroke_star.close_path();
1660+
let mut stroke = Stroke::new(18.0);
1661+
stroke.join = Join::Round;
1662+
stroke.start_cap = Cap::Round;
1663+
stroke.end_cap = Cap::Round;
1664+
scene.push_clip_layer(&stroke, Affine::IDENTITY, &stroke_star);
1665+
let grad = Gradient::new_linear((250.0, 240.0), (450.0, 440.0))
1666+
.with_stops([palette::css::MAGENTA, palette::css::CYAN]);
1667+
scene.fill(
1668+
Fill::NonZero,
1669+
Affine::IDENTITY,
1670+
&grad,
1671+
None,
1672+
&stroke_demo_rect,
1673+
);
1674+
scene.pop_layer();
1675+
15971676
let large_background_rect = Rect::new(-1000.0, -1000.0, 2000.0, 2000.0);
15981677
let inside_clip_rect = Rect::new(11.0, 13.399999999999999, 59.0, 56.6);
15991678
let outside_clip_rect = Rect::new(
@@ -1606,6 +1685,7 @@ mod impls {
16061685
let scale = 2.0;
16071686

16081687
scene.push_layer(
1688+
Fill::NonZero,
16091689
BlendMode {
16101690
mix: Mix::Normal,
16111691
compose: Compose::SrcOver,
@@ -1926,6 +2006,7 @@ mod impls {
19262006
},
19272007
);
19282008
scene.push_layer(
2009+
Fill::NonZero,
19292010
BlendMode::new(Mix::Normal, Compose::SrcOver),
19302011
1.0,
19312012
Affine::IDENTITY,
@@ -1949,6 +2030,7 @@ mod impls {
19492030
},
19502031
);
19512032
scene.push_luminance_mask_layer(
2033+
Fill::NonZero,
19522034
1.0,
19532035
Affine::IDENTITY,
19542036
&Rect {
@@ -1993,6 +2075,7 @@ mod impls {
19932075
.unwrap();
19942076
// HACK: Porter-Duff "over" the base color, restoring full alpha
19952077
scene.push_layer(
2078+
Fill::NonZero,
19962079
BlendMode::new(Mix::Normal, Compose::SrcOver),
19972080
1.0,
19982081
Affine::IDENTITY,
@@ -2028,6 +2111,7 @@ mod impls {
20282111
},
20292112
);
20302113
scene.push_luminance_mask_layer(
2114+
Fill::NonZero,
20312115
1.0,
20322116
Affine::IDENTITY,
20332117
&Rect {

vello/src/scene.rs

Lines changed: 84 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ impl Scene {
8282
/// Pushes a new layer clipped by the specified shape and composed with
8383
/// previous layers using the specified blend mode.
8484
///
85+
/// The `clip_style` controls how the `clip` shape is interpreted.
86+
///
87+
/// - Use [`Fill`] to clip to the interior of the shape, with the chosen fill rule.
88+
/// - Use [`Stroke`] (via `&Stroke`) to clip to the stroked outline of the shape.
89+
///
8590
/// Every drawing command after this call will be clipped by the shape
8691
/// until the layer is [popped](Self::pop_layer).
8792
/// For layers which are only added for clipping, you should
@@ -90,9 +95,14 @@ impl Scene {
9095
/// **However, the transforms are *not* saved or modified by the layer stack.**
9196
/// That is, the `transform` argument to this function only applies a transform to the `clip` shape.
9297
#[expect(deprecated, reason = "Provided by the user, need to handle correctly.")]
98+
#[expect(
99+
single_use_lifetimes,
100+
reason = "False positive: https://github.com/rust-lang/rust/issues/129255"
101+
)]
93102
#[track_caller]
94-
pub fn push_layer(
103+
pub fn push_layer<'a>(
95104
&mut self,
105+
clip_style: impl Into<StyleRef<'a>>,
96106
blend: impl Into<BlendMode>,
97107
alpha: f32,
98108
transform: Affine,
@@ -107,6 +117,7 @@ impl Scene {
107117
}
108118
self.push_layer_inner(
109119
DrawBeginClip::new(blend, alpha.clamp(0.0, 1.0)),
120+
clip_style.into(),
110121
transform,
111122
clip,
112123
);
@@ -118,6 +129,11 @@ impl Scene {
118129
/// That is, content drawn between this and the matching `pop_layer` call will serve
119130
/// as a luminance mask for the prior content in this layer.
120131
///
132+
/// The `clip_style` controls how the `clip` shape is interpreted.
133+
///
134+
/// - Use [`Fill`] to clip to the interior of the shape, with the chosen fill rule.
135+
/// - Use [`Stroke`] (via `&Stroke`) to clip to the stroked outline of the shape.
136+
///
121137
/// Every drawing command after this call will be clipped by the shape
122138
/// until the layer is [popped](Self::pop_layer).
123139
///
@@ -135,16 +151,32 @@ impl Scene {
135151
/// This issue only occurs if there are no intermediate opaque layers, so can be worked around
136152
/// by drawing something opaque (or having an opaque `base_color`), then putting a layer around your entire scene
137153
/// with a [`Compose::SrcOver`].
138-
pub fn push_luminance_mask_layer(&mut self, alpha: f32, transform: Affine, clip: &impl Shape) {
154+
#[expect(
155+
single_use_lifetimes,
156+
reason = "False positive: https://github.com/rust-lang/rust/issues/129255"
157+
)]
158+
pub fn push_luminance_mask_layer<'a>(
159+
&mut self,
160+
clip_style: impl Into<StyleRef<'a>>,
161+
alpha: f32,
162+
transform: Affine,
163+
clip: &impl Shape,
164+
) {
139165
self.push_layer_inner(
140166
DrawBeginClip::luminance_mask(alpha.clamp(0.0, 1.0)),
167+
clip_style.into(),
141168
transform,
142169
clip,
143170
);
144171
}
145172

146173
/// Pushes a new layer clipped by the specified `clip` shape.
147174
///
175+
/// The `clip_style` controls how the `clip` shape is interpreted.
176+
///
177+
/// - Use [`Fill`] to clip to the interior of the shape, with the chosen fill rule.
178+
/// - Use [`Stroke`] (via `&Stroke`) to clip to the stroked outline of the shape.
179+
///
148180
/// The pushed layer is intended to not impact the "source" for blending; that is, any blends
149181
/// within this layer will still include content from before this method was called in the "source"
150182
/// of that blend operation.
@@ -157,21 +189,55 @@ impl Scene {
157189
///
158190
/// **However, the transforms are *not* saved or modified by the layer stack.**
159191
/// That is, the `transform` argument to this function only applies a transform to the `clip` shape.
160-
pub fn push_clip_layer(&mut self, transform: Affine, clip: &impl Shape) {
161-
self.push_layer_inner(DrawBeginClip::clip(), transform, clip);
192+
#[expect(
193+
single_use_lifetimes,
194+
reason = "False positive: https://github.com/rust-lang/rust/issues/129255"
195+
)]
196+
pub fn push_clip_layer<'a>(
197+
&mut self,
198+
clip_style: impl Into<StyleRef<'a>>,
199+
transform: Affine,
200+
clip: &impl Shape,
201+
) {
202+
self.push_layer_inner(DrawBeginClip::clip(), clip_style.into(), transform, clip);
162203
}
163204

164205
/// Helper for logic shared between [`Self::push_layer`] and [`Self::push_luminance_mask_layer`]
165-
fn push_layer_inner(
206+
fn push_layer_inner<'a>(
166207
&mut self,
167208
parameters: DrawBeginClip,
209+
clip_style: StyleRef<'a>,
168210
transform: Affine,
169211
clip: &impl Shape,
170212
) {
171213
let t = Transform::from_kurbo(&transform);
172214
self.encoding.encode_transform(t);
173-
self.encoding.encode_fill_style(Fill::NonZero);
174-
if !self.encoding.encode_shape(clip, true) {
215+
let (is_fill, stroke_for_estimate) = match clip_style {
216+
StyleRef::Fill(fill) => {
217+
self.encoding.encode_fill_style(fill);
218+
(true, None)
219+
}
220+
StyleRef::Stroke(stroke) => {
221+
let encoded_stroke = self.encoding.encode_stroke_style(stroke);
222+
(false, encoded_stroke.then_some(stroke))
223+
}
224+
};
225+
if stroke_for_estimate.is_none() && matches!(clip_style, StyleRef::Stroke(_)) {
226+
// If the stroke has zero width, encode a valid empty path. This suppresses
227+
// all drawing until the layer is popped.
228+
self.encoding.encode_fill_style(Fill::NonZero);
229+
self.encoding.encode_empty_shape();
230+
#[cfg(feature = "bump_estimate")]
231+
{
232+
use peniko::kurbo::PathEl;
233+
let path = [PathEl::MoveTo(Point::ZERO), PathEl::LineTo(Point::ZERO)];
234+
self.estimator.count_path(path.into_iter(), &t, None);
235+
}
236+
self.encoding.encode_begin_clip(parameters);
237+
return;
238+
}
239+
240+
if !self.encoding.encode_shape(clip, is_fill) {
175241
// If the layer shape is invalid, encode a valid empty path. This suppresses
176242
// all drawing until the layer is popped.
177243
self.encoding.encode_empty_shape();
@@ -183,7 +249,8 @@ impl Scene {
183249
}
184250
} else {
185251
#[cfg(feature = "bump_estimate")]
186-
self.estimator.count_path(clip.path_elements(0.1), &t, None);
252+
self.estimator
253+
.count_path(clip.path_elements(0.1), &t, stroke_for_estimate);
187254
}
188255
self.encoding.encode_begin_clip(parameters);
189256
}
@@ -894,7 +961,7 @@ impl ColorPainter for DrawColorGlyphs<'_> {
894961
};
895962
self.clip_depth += 1;
896963
self.scene
897-
.push_clip_layer(self.last_transform().to_kurbo(), &path.0);
964+
.push_clip_layer(Fill::NonZero, self.last_transform().to_kurbo(), &path.0);
898965
}
899966

900967
fn push_clip_box(&mut self, clip_box: skrifa::raw::types::BoundingBox<f32>) {
@@ -909,7 +976,7 @@ impl ColorPainter for DrawColorGlyphs<'_> {
909976
}
910977
self.clip_depth += 1;
911978
self.scene
912-
.push_clip_layer(self.last_transform().to_kurbo(), &clip_box);
979+
.push_clip_layer(Fill::NonZero, self.last_transform().to_kurbo(), &clip_box);
913980
}
914981

915982
fn pop_clip(&mut self) {
@@ -949,8 +1016,13 @@ impl ColorPainter for DrawColorGlyphs<'_> {
9491016
// TODO:
9501017
_ => Compose::SrcOver,
9511018
};
952-
self.scene
953-
.push_layer(blend, 1.0, self.last_transform().to_kurbo(), &self.clip_box);
1019+
self.scene.push_layer(
1020+
Fill::NonZero,
1021+
blend,
1022+
1.0,
1023+
self.last_transform().to_kurbo(),
1024+
&self.clip_box,
1025+
);
9541026
}
9551027

9561028
fn pop_layer(&mut self) {

0 commit comments

Comments
 (0)