vello_hybrid: Partial (damage-region) rendering via RenderConfig#1737
vello_hybrid: Partial (damage-region) rendering via RenderConfig#1737AdrianEddy wants to merge 2 commits into
Conversation
4dc5649 to
dfae351
Compare
| /// fully cleared at the first root pass regardless of the scissor, which is | ||
| /// correct because scissored draws cannot write depth (or color) outside | ||
| /// the region, and depth never carries state across renders. | ||
| Region([u32; 4]), |
There was a problem hiding this comment.
I think this pattern lacks the type of flexibility a "damage region aware" consumer will need.
For example, a typical use case for damage regions is panning. But, a diagonal pan (which creates a damage region along two edges) would, via this API, require 2 separate render calls - 2 draw calls where one would do.
There was a problem hiding this comment.
Yes, this should be a &[RectU16] instead.
There was a problem hiding this comment.
Although the lifetime might be annoying. Let’s see.
| /// valid modes explicit and the "clear everything but only draw a region" | ||
| /// footgun unrepresentable. | ||
| #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] | ||
| pub enum RenderOptions { |
There was a problem hiding this comment.
On low tier mobile devices, it's much better to wgpu::LoadOp::DontCare when you know you'll be overwriting those pixels with some opaque background. This avoids a clear and an expensive wgpu::LoadOp::Load which pays expensive reads from the framebuffer to initialize memory.
There was a problem hiding this comment.
How do you plan to use this API?
One potential issue with this approach is that culling happens at the end of the pipeline. It's much better, at scene construction, for a consumer to push_clip_path the damage regions so they don't pay per-pixel costs for pixels that will eventually be thrown away at rasterization time.
For example, if I render an large image as the background. When I construct my scene, without clipping, we'll prepare all the strips for the entire viewport (re-sizing a scene is expensive, so we generally keep the same one around). Then, if I only want to render a small region of it, we'll use RenderOptions to provide culling after - nominally - a lot of the expensive work is over.
But, if you're rendering the same scene (without any scene mutations) between renders, then this approach could be worthwhile. Is that what you're doing?
I'm actually wondering whether a better approach here is to push the complexity to the consumer. We could expose the clear pipeline for ergonomics (that is, the consumer can renderer.clear_rect(...)) and then the consumer simply specifies at render time whether they want to LoadOp::[Clear(RGBA)|DontCare|Load].
My preference for Vello is for it to just focus on painting pixels as fast as it can and leave "compositor-like" smarts to the higher level.
There was a problem hiding this comment.
I.e. a lot of the complexity in Vello is reduced if a consumer instead:
scene.push_clip_path(/* Damage region rect */);
// build scene
scene.pop_clip_path();
renderer.clear_rect(...., /* Damage region rect */); /* May be unnecessary if // build scene pushes opaque bg
renderer.render(scene, ... , LoadOp::Load);Or alternatively,
scene.push_clip_path(/* Damage region rect */);
// Push opaque background
// build scene
scene.pop_clip_path();
renderer.render(/* texture target */, scene, ... , LoadOp::DontCare);
// Copy Damage region rect from texture to surface.There was a problem hiding this comment.
Mostly echoing what Taj said. The ability to define whether and what regions should be cleared is fine and important, but please remove the other culling stuff you added, you should just push clip paths in your damage regions if you want this.
Also, I noticed that you added a loot of comments (also in your other PRs), most of which seem superfluous to me. It would be great if you could cut away most of them and only keep the ones that really explains something non-obvious. 🙏 Documentation for struct fields is fine, but for example the Region one is also overly long. I think this one can for example be described in just 1-2 sentences, something like "Only clear regions in the root target that are contained within the rectangle" (or rectangles if we add multiple).
Thank you!
| /// fully cleared at the first root pass regardless of the scissor, which is | ||
| /// correct because scissored draws cannot write depth (or color) outside | ||
| /// the region, and depth never carries state across renders. | ||
| Region([u32; 4]), |
There was a problem hiding this comment.
Yes, this should be a &[RectU16] instead.
09c6da6 to
5e987c5
Compare
09f758c to
22ca3f2
Compare
|
I reshaped the PR to the consumer-driven decomposition suggested by @taj-p:
In my engine, the scene is rebuilt every frame - my retained scene graph culls at encode time, so on a partial frame the vello
I tried this first, and it initially failed to produce same results, because with any clip active, rects (including image and glyph quads) leave the GPU analytic rect path and go through CPU strip generation + mask intersection — and the two rasterizers quantize fractional-edge anti-aliasing differently. The analytic path quantizes the edge position to unorm8 and computes coverage in the shader; the strip path computes exact f32 coverage and quantizes after. At half-ties they round in opposite directions, so a full-viewport integer clip that removes nothing still changed rendered bytes by ±1 LSB at fractional edges. My damage pipeline treats "partial render == full render, byte-for-byte" as a hard invariant, so that ruled the clip recipe out as it stood. Rather than drop the idea, the second commit makes it byte-exact: clip paths that are disjoint, axis-aligned integer-rectangle sets are detected in ClipContext, and rect content clamps to the set analytically instead of demoting (an integer clip edge is exactly a 0/255 coverage step, so this is provably identical to the mask intersection). Nice side effect: this speeds up every rectangular clip viewport (scrolling containers), damage or not — on a clip-heavy stress scene in my engine it's ~2.3× on scene encode and ~2× on the frame. |
22ca3f2 to
9c29a78
Compare
Renderer::render_with_config takes RenderConfig { load, scissors }:
- TargetLoad::{Clear, Load, DontCare} rides the first root strip
pass's LoadOp (no standalone clear pass; DontCare skips the
tile-memory load on tiled GPUs, forwarding wgpu's opaque-coverage
contract to the caller).
- scissors is a set of pairwise-disjoint damage rects. Each root pass
executes its draw sequence once per rect under that rect's scissor
(scissor is dynamic pass state, so the target load is paid once),
and the scheduler culls strips and wide tiles that lie outside every
rect. Pixels outside the rects are preserved byte-exact; pixels
inside are byte-identical to a full render.
- clear_rects clears N disjoint rects in one scissored-quad pass, for
damage rects the scene will not repaint opaquely (LoadOp::Clear
ignores scissors per WebGPU semantics, so a clear pipeline is used).
- render() routes through the same path with the default config; its
whole-target clear now rides the first root pass's LoadOp (one pass
fewer per render, byte-identical output), with a standalone-clear
fallback when the scene schedules no root pass.
- partial_renders / culled_strips / culled_wide_tiles debug counters
let damage-tracking callers assert the partial machinery engaged.
Scheduler culling is winding-safe because it runs strictly after
winding resolution: strips carry resolved coverage plus fill_gap bits,
wide-tile command lists are self-contained, and each emitted quad is
culled independently. Content destined for intermediate targets
(filter layers, slot textures) is never culled.
…cull hint Two scene-construction optimizations that keep damage-region and clip-heavy scenes off slow paths without changing any pixel: - Integer-rectangle clips keep the GPU analytic rect path. ClipContext detects clip paths that are pairwise-disjoint, axis-aligned rectangle sets on integer device coordinates (path_as_integer_rect_set, IntRectSet; up to 16 rects; nested stacks compose by pairwise intersection; edge tolerance 1/512, which rounds to the same 0/255 coverage bytes as an exact integer edge). While every stacked clip is such a set, fill_rect, draw_texture_rects, and fill_blurred_rounded_rect clamp their rects to the set instead of demoting the content to CPU strip generation plus mask intersection. This is byte-identical by construction: an integer clip edge is a 0/255 coverage step, clamped edges land on full-coverage columns, and surviving fractional content edges rasterize on the same path as unclipped. A rect straddling several set rects splits into one quad per rect; disjointness means the pieces never double-blend. Paints sample by absolute position, so clamping needs no paint adjustment. Non-rectangle content under such clips still goes through the (lossless-in-the-interior) mask intersection. - Scene::set_cull_hint(Option<RectU16>) restricts flattening and strip generation to a bounding box, for renders that only present part of the target (damage rendering with a matching scissor). In-hint pixels are bit-identical to un-hinted generation. Flatten-level culling now also drops line segments (not only curves) that lie fully right of, above, or below the cull bbox - safe for the same reasons the curve culling is: winding accumulates left-to-right and strip rows are independent. The byte-exactness claims are pinned by GPU tests in vello_sparse_tests (clip_exact.rs): clipped vs unclipped scenes with fractional-edge rects, texture rects, paths, and strokes compared byte-for-byte inside/outside the clips, and hinted vs unhinted scenes compared inside the hint.
5306632 to
4152871
Compare
Adds partial (damage-region) rendering to
vello_hybridin a consumer-driven shape, plus two scene-construction fast paths that make clip- and damage-confined scenes cheap without changing any pixel.RenderConfig+clear_rects(vello_hybrid)TargetLoad::{Clear, Load, DontCare}rides the first root strip pass'sLoadOp— there is no standalone clear pass.DontCareforwards wgpu's opaque-coverage token for tiled GPUs (caller guarantees every pixel is covered opaquely before blending).scissors: &[RectU16]is a set of pairwise-disjoint damage rects. Each root pass executes its draw sequence once per rect under that rect's scissor (scissor is dynamic pass state, so the target load is paid once and the render stays one pass per scheduler step). Pixels outside the rects are preserved byte-exact; pixels inside are byte-identical to a full render. Multi-rect damage — a diagonal pan's two edge strips, two dirty widgets in opposite corners — is a single render call.clear_rectsclears N disjoint rects in one scissored-quad pass (LoadOp::Clearignores scissors per WebGPU semantics), for damage rects the scene won't repaint opaquely. Rects are clamped to the render size and empty ones dropped (some Metal drivers reject zero-extent scissors).render()routes through the same path with the default config, so every full render is now one pass cheaper (byte-identical output; standalone-clear fallback when a scene schedules no root pass).fill_gapbits; wide-tile command lists are self-contained); intermediate targets (filter layers, slot textures) are never culled.partial_renders/culled_strips/culled_wide_tilesdebug counters let callers assert the machinery engaged.Integer-rectangle clip fast path + cull hint (vello_common / vello_hybrid)
These make the "clip the scene to the damage at construction time" recipe byte-exact, and speed up rectangular clips generally:
ClipContextdetects clip paths that are pairwise-disjoint, axis-aligned rectangle sets on integer device coordinates. While every stacked clip is such a set,fill_rect,draw_texture_rects, andfill_blurred_rounded_rectclamp their rects to the set instead of demoting to CPU strips + mask intersection. Previously, any active clip pushed all rect content (including image and glyph quads) off the fast path onto the CPU strip rasterizer, whose fractional-edge anti-aliasing quantizes differently from the analytic rect shader by ±1 LSB — so clipping changed bytes even where the clip removed nothing. With the fast path, an integer clip edge is a 0/255 coverage step, clamped edges land on full-coverage columns, and surviving fractional content edges rasterize on the same path as unclipped: byte-identical, and much cheaper for the common scrolling-container / clipped-viewport case.Scene::set_cull_hint(Option<RectU16>)restricts flattening and strip generation to a bounding box, for renders that only present part of the target. In-hint pixels are bit-identical to un-hinted generation. Flatten-level culling now also drops line segments (previously only curves) fully right of, above, or below the cull bbox, under the same winding argument.Testing
vello_sparse_tests/tests/clip_exact.rs— GPU byte-exactness pins: clipped vs unclipped scenes (fractional-edge rects, translucent rects, texture rects, paths, strokes) compared byte-for-byte inside/outside single- and multi-rect integer clips, and hinted vs unhinted scenes compared inside the hint.sanitize_rects/RenderConfigsemantics.The WebGL backend is untouched for now — the scheduler plumbing is already shared (it passes an empty rect set), and GL's scissor-honoring clear means RenderConfig would map naturally there, but Load on a default drawing buffer needs preserveDrawingBuffer opt-in, so I'd rather do that as a follow-up with its own caveats documented.