Skip to content

vello_common: Generate rectangular clip strips lazily - #1989

Open
Thenewmanator15 wants to merge 5 commits into
linebender:mainfrom
Thenewmanator15:lazy-rect-clips
Open

Thenewmanator15 wants to merge 5 commits into
linebender:mainfrom
Thenewmanator15:lazy-rect-clips

Conversation

@Thenewmanator15

@Thenewmanator15 Thenewmanator15 commented Oct 7, 2026 •

Copy link
Copy Markdown

A rectangular clip currently generates its strips when it is pushed, for the full height of the clip. With many large clips around small content, that cost dominates rendering. This PR defers the strips for a rectangular clip until a path is drawn through it, and generates them only for the tile rows that path covers.

Motivation

I hit this with an Inkscape export of a CAD drawing. It has 7,434 <clipPath> elements. Each one is a rectangle the size of the whole drawing, wrapped around one short stroke, so none of them clips anything. Rendering the page through vello_cpu 0.3.0 with those clips sent through push_clip_rect took 37.5 ms at 150 ppi. With the clips removed by the caller it took 21.7 ms.

What changes

  • In a ClipContext made with the new ClipContext::new_lazy, push_clip_rect no longer generates strips when the clip sits directly on the viewport or on another rectangular clip. It records the clamped rectangle and a tile-aligned bounding box. ClipState uses lazy contexts, so the single-threaded dispatcher in vello_cpu and vello_gpu get this. ClipContext::new behaves as before.
  • PathDataRef gets a lazy_rect field. When it is set, strips and alphas are empty, and render_with_clip and intersect render the rectangle for the rows between the other path's first and last strip and intersect with that. Each tile row of a rectangle's strips is independent of the others, so those rows are the same as in the strips for the whole rectangle.
  • A rectangular clip on top of a path clip is generated when pushed, as before.
  • The multi-threaded dispatcher keeps using ClipContext::new, and asserts in debug builds that it never gets a lazy clip. It copies the clip's strips into every render task, so it needs a different change and I have left it alone.

PathDataRef::lazy_rect and ClipContext::new_lazy are additions to the public API of vello_common, and the new field breaks struct literals of PathDataRef. Code that uses ClipContext::new still gets strips, so nothing that reads them changes unless it opts in. I'm happy to restructure this if you would prefer a different shape, for example an enum for the clip data.

Results

Intel i7-12700K, Windows 11, single-threaded, release build.

N short strokes on a square canvas, each inside its own push_clip_rect covering the whole canvas, best of 7 runs:

Canvas Clips No clip Before After
500 px 7,434 4.5 ms 12.5 ms 5.7 ms
2000 px 7,434 15.1 ms 70.2 ms 16.3 ms
4000 px 7,434 25.9 ms 159.2 ms 28.1 ms

The CAD page, cold, median of 10 passes, measured with the patch applied to the 0.3.0 crates:

Before After
vello_cpu, 150 ppi 37.5 ms 20.0 ms
vello_cpu, 300 ppi 69.4 ms 32.0 ms
vello_gpu, 150 ppi 35.7 ms 18.5 ms

The timings on the larger canvases vary by 10 to 20% between runs on this machine.

Testing

  • cargo test -p vello_tests --release --tests gives the same result as main on my machine: 4,801 passed, 2 failed, 1,346 ignored. The two failures are filter_blur_with_image_gpu and filter_with_clip_rects_outside_of_viewport_gpu, which also fail for me on unmodified main (NVIDIA RTX 5070 Ti). The ignored tests are mostly AVX-512 variants, which this CPU can't run.
  • rectangular_clip_metadata_tracks_intersections_and_pops now uses a lazy context and asserts that the strips are deferred. eager_rectangular_clip_stores_its_strips keeps the old assertion for ClipContext::new.
  • New tests compare the strips and alphas produced through a lazy clip with those produced through an eager one: for 10 rectangles (containing the viewport, fractional edges, tile-aligned edges, thinner than a pixel, beside the path, empty, inverted) and 3 paths, one of which has no strips, both filled and stroked; with a path clip pushed on top of the lazy rectangle; in the append and replace-after modes; through ClipState with a shifted root viewport; and through intersect with either or both sides lazy. Where a path's rows miss an eager clip, intersecting still appends a lone sentinel strip and the lazy version appends nothing. The tests treat that as equal, since neither draws anything.
  • Outside the repository I compared pixel hashes of 1,200 random scenes (on the first version of this PR) (nested, fractional, off-canvas and rotated rectangle clips, with path clips mixed in) between vello_cpu 0.3.0 and the patched crate. All 1,200 match.

I have not run the AVX-512 variants, macOS or Linux, or the no_std build.

Not included

  • Clips pushed through push_clip_path are unchanged, including paths that happen to be rectangles.

🤖 Generated with Claude Code

Thenewmanator15 and others added 2 commits October 7, 2026 19:02
A rectangular clip pushed onto the viewport or onto another rectangular
clip no longer generates its strips when it is pushed. They are generated
when a path is drawn through the clip, and only for the tile rows that
path covers. The cost of such a clip then depends on what is drawn
through it instead of on the height of the clip.

The multi-threaded dispatcher keeps the previous behaviour through
`ClipContext::new_eager`, because it copies the clip's strips into each
render task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@LaurenzV

LaurenzV commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

Sounds like whatever CAD program created those should be optimized. :D I can see it being useful to generate clip rects lazily (especially for GPU where we might not need them at all), but I need to carefully think about how this should be implemented.

In the meanwhile, could you not just detect such viewport-sized rects and don't emit them in the first place? Or are the clip paths really limited to the size of the artwork instead of the whole viewport?

@LaurenzV
LaurenzV self-requested a review October 7, 2026 18:12
@Thenewmanator15

Copy link
Copy Markdown
Author

Ha, yeah, it's an Inkscape export of a CAD drawing. 7,434 clips that clip nothing. :D

Turns out it's a known Inkscape bug. The source PDF has one clip for about 6,500 strokes, and Inkscape 1.4's PDF import made a copy of it for every stroke (inkscape/inkscape#5345 on GitLab, fixed for 1.5). So you're right that the exporter's at fault, but files from 1.4 and earlier will be around for a while.

They're the size of the artwork, not the viewport. The drawing's an image on a page, so the clips only cover its box. And most aren't exactly that size: 934 are the drawing's box, the other 6,500 are inset by 0.3 units. So checking against the viewport or the parent clip doesn't catch them.

Your suggestion works though. I skip them on my side now by checking each clip rect against the bounding box of what it clips. Page takes 21.7 ms that way, 20.0 ms with this PR and nothing skipped, so I'm not blocked. Only catch is you need the content's bounds up front. I get those from usvg, not every caller will have them.

Happy for you to take the idea and do it however suits Vello, especially if the GPU can skip the strips altogether. I can close this or leave it open as a reference, whichever's more useful.

@LaurenzV

LaurenzV commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

Cool, thanks! I definitely see some value in adding more smartness into Vello for this, but need to think about how to best achieve it.

Thenewmanator15 and others added 3 commits October 22, 2026 18:14
…`intersect`

`ClipContext::new` keeps the previous behaviour again, and the lazy one
is `ClipContext::new_lazy`, so code that reads a clip's strips doesn't
find them silently empty after an upgrade. `ClipState` asks for lazy
contexts, including the ones it takes from its pool. The multi-threaded
dispatcher is back on `ClipContext::new` and asserts that it never sees
a lazy clip.

`intersect` now handles a lazy clip on either side itself, by generating
the rectangle's strips for the rows it needs, with the same code
`render_with_clip` uses.

The bounding box of a lazy clip is rounded to whole tiles, using the
tile height for y, and stays inside the viewport.

New tests compare lazy and eager clips for stroked paths, a path clip on
top of a lazy rectangle, appending and replacing modes, paths without
strips, `ClipState` with a shifted root viewport, and `intersect` with
either or both sides lazy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…out std

`tile_bbox` rounds with `floor` and `ceil`, which f64 only has with std.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants