-
Notifications
You must be signed in to change notification settings - Fork 203
vello: make Scene clip/layers honor fill rule (even-odd clips) #1332
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
vello: make Scene clip/layers honor fill rule (even-odd clips) #1332
Conversation
44741b4 to
cc22788
Compare
ec8a2c0 to
a82b356
Compare
|
This was done with LLM assistance as it apparently understands some of the Vello internals better than me. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks right to me, but I've not looked much at Vello Classic's shaders before, so it probably makes sense to have at least one more approval.
I was first confused at how this was able to work at all with the following include_tile logic, but have since convinced myself that that was a case of vestigial documentation of old logic (which I propose a fix for in #1333). The actual checks there and what the even-odd-filled clipping does seem to match.
vello/vello_shaders/shader/coarse.wgsl
Lines 335 to 344 in c15340d
| // If this draw object represents an even-odd fill and we know that no line segment | |
| // crosses this tile and then this draw object should not contribute to the tile if its | |
| // backdrop (i.e. the winding number of its top-left corner) is even. | |
| let backdrop_clear = select(tile.backdrop, abs(tile.backdrop) & 1, even_odd) == 0; | |
| let include_tile = n_segs != 0u || (backdrop_clear == is_clip) || is_blend; | |
| if include_tile { | |
| let el_slice = el_ix / 32u; | |
| let el_mask = 1u << (el_ix & 31u); | |
| atomicOr(&sh_bitmaps[el_slice][y * N_TILE_X + x], el_mask); | |
| } |
DJMcNab
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm fairly comfortable with this change, especially given the testing that's done.
As I say, I'd also like a screenshot test, which would correct our missing test for clip_test.
Obviously, I'm not a massive fan of the extra API complexity for this fairly niche use case. But I don't see how to avoid that in an ergonomic way, unless we introduce helpers which ignore this, which is also pretty nasty.
This is a light approval; in combination with Tom's review I think this gets it over the line. I would also quite like one of Raph or Chad to give it a once over; I'm not really a domain expert on this code. But I don't think we can usefully make that a blocker.
I'm somewhat less worried about API complexity here, it's an important feature for correctness/completeness, but there's a good general question here about function parameter growth in these kinds of APIs versus, say, the One very nitty detail (which doesn't really solve the issue in your comment) is that it may be nice to name the parameter |
ab7fdb2 to
0b07ab0
Compare
I've renamed it. I did go with this order to match |
0b07ab0 to
677e790
Compare
tomcur
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good!
The logic LGTM. If we're adding a new parameter, I wonder if we should go all the way and take a |
677e790 to
a3f37cd
Compare
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.
a3f37cd to
b6b7ec5
Compare
At first, I didn't really understand how this would work ... but with some LLM assistance, I think I have it working. This deserves a bit of review, especially the bit about handling an empty stroke. |
tomcur
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks correct to me.
I do have a follow-up that enables dashed stroked clipping (like Scene::stroke) and rejigs some of the invalid/empty path handling, but I think it makes sense to do that as a separate PR: the current PR works fine as-is, and this would be a bit of a larger diff to Scene::push_layer_inner (plus questions about potentially factoring out some shared logic with Scene::stroke).
For completeness:
--- a/vello/src/scene.rs
+++ b/vello/src/scene.rs
@@ -212,34 +212,57 @@ impl Scene {
) {
let t = Transform::from_kurbo(&transform);
self.encoding.encode_transform(t);
- let (is_fill, stroke_for_estimate) = match clip_style {
+
+ // The logic for encoding the clip shape differs between fill and stroke style clips, but
+ // the logic is otherwise similar.
+ //
+ // `encoded_result` will be `true` if and only if a valid path has been encoded. If it is
+ // `false`, we will need to explicitly encode a valid empty path.
+ let encoded_result = match clip_style {
StyleRef::Fill(fill) => {
self.encoding.encode_fill_style(fill);
- (true, None)
+ #[cfg(feature = "bump_estimate")]
+ self.estimator.count_path(clip.path_elements(0.1), &t, None);
+ self.encoding.encode_shape(clip, true)
}
StyleRef::Stroke(stroke) => {
let encoded_stroke = self.encoding.encode_stroke_style(stroke);
- (false, encoded_stroke.then_some(stroke))
+ if !encoded_stroke {
+ // If the stroke has zero width, encode a fill and indicate no path was
+ // encoded.
+ self.encoding.encode_fill_style(Fill::NonZero);
+ false
+ } else {
+ if stroke.dash_pattern.is_empty() {
+ #[cfg(feature = "bump_estimate")]
+ self.estimator
+ .count_path(clip.path_elements(0.1), &t, Some(stroke));
+ self.encoding.encode_shape(clip, false)
+ } else {
+ // TODO: We currently collect the output of the dash iterator because
+ // `encode_path_elements` wants to consume the iterator. We want to avoid calling
+ // `dash` twice when `bump_estimate` is enabled because it internally allocates.
+ // Bump estimation will move to resolve time rather than scene construction time,
+ // so we can revert this back to not collecting when that happens.
+ let dashed = peniko::kurbo::dash(
+ clip.path_elements(0.1),
+ stroke.dash_offset,
+ &stroke.dash_pattern,
+ )
+ .collect::<Vec<_>>();
+ #[cfg(feature = "bump_estimate")]
+ self.estimator
+ .count_path(dashed.iter().copied(), &t, Some(stroke));
+ self.encoding
+ .encode_path_elements(dashed.into_iter(), false)
+ }
+ }
}
};
- if stroke_for_estimate.is_none() && matches!(clip_style, StyleRef::Stroke(_)) {
- // If the stroke has zero width, encode a valid empty path. This suppresses
- // all drawing until the layer is popped.
- self.encoding.encode_fill_style(Fill::NonZero);
- self.encoding.encode_empty_shape();
- #[cfg(feature = "bump_estimate")]
- {
- use peniko::kurbo::PathEl;
- let path = [PathEl::MoveTo(Point::ZERO), PathEl::LineTo(Point::ZERO)];
- self.estimator.count_path(path.into_iter(), &t, None);
- }
- self.encoding.encode_begin_clip(parameters);
- return;
- }
- if !self.encoding.encode_shape(clip, is_fill) {
- // If the layer shape is invalid, encode a valid empty path. This suppresses
- // all drawing until the layer is popped.
+ if !encoded_result {
+ // If the layer shape is invalid or a zero-width stroke, encode a valid empty path.
+ // This suppresses all drawing until the layer is popped.
self.encoding.encode_empty_shape();
#[cfg(feature = "bump_estimate")]
{
@@ -247,10 +270,6 @@ impl Scene {
let path = [PathEl::MoveTo(Point::ZERO), PathEl::LineTo(Point::ZERO)];
self.estimator.count_path(path.into_iter(), &t, None);
}
- } else {
- #[cfg(feature = "bump_estimate")]
- self.estimator
- .count_path(clip.path_elements(0.1), &t, stroke_for_estimate);
}| - Breaking change: Make `Scene` clip / layers honor fill rule (even-odd clips). ([#1332][] by [@waywardmonkeys][]) | ||
| When pushing a layer, you should use `Fill::NonZero` as the clip fill rule to achieve the same behavior as previous versions. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should now be updated, something like the following.
| - Breaking change: Make `Scene` clip / layers honor fill rule (even-odd clips). ([#1332][] by [@waywardmonkeys][]) | |
| When pushing a layer, you should use `Fill::NonZero` as the clip fill rule to achieve the same behavior as previous versions. | |
| - Breaking change: Allow setting `Scene` layer clipping style, adding even-odd filled path clipping and stroked path clipping. ([#1332][] by [@waywardmonkeys][]) | |
| When pushing a layer, you should use `StyleRef::Fill(Fill::NonZero)` as the clip fill rule to achieve the same behavior as previous versions. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It takes Into<StyleRef> so Fill::NonZero works and is more concise.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point, didn't realize we had the From, but that makes sense.
Scene layer APIs now take a
peniko::StyleReffor the clip shape (push_layer,push_clip_layer,push_luminance_mask_layer), fixing the long-standing hardcodedFill::NonZeroclip 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
StyleRefalso 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:
BEGIN_CLIPa 1-word info slot so clip draw flags can be stored.draw_flagsforBEGIN_CLIPindraw_leaf.clip_leaf, propagateinfo_offsetfromBEGIN_CLIPto the correspondingEND_CLIP.BEGIN_CLIPdraw flags through toEND_CLIP, and by updating the “clip-zero” fast path to respect even-odd backdrop parity (GPU + CPU paths).Existing callsites can continue passing
Fill::NonZeroto preserve prior behavior;Fill::EvenOddselects even-odd clips.