frust_engine/compile/clear.rs
1//! `ClearRect`: the platform-view hole punch, hoisted to the frame root and
2//! lowered to destination-out coverage.
3//!
4//! `frust_scene`'s `ClearRect` erases an axis-aligned rectangle to full
5//! transparency, colour and alpha both, so an OS view hosted behind a
6//! translucent surface shows through a slot the app's own opaque backdrop
7//! would otherwise seal. It is a real destination-clearing composite, not a
8//! skipped paint.
9//!
10//! ## Hoisting
11//!
12//! A punch confined to the group it was recorded in would only erase that
13//! group's own accumulated content — an app-root backdrop painted outside a
14//! scroll view's clip survives the group composite and seals the hole again.
15//! So the punch is hoisted past every enclosing clip, layer and snapshot
16//! bracket to the frame root, bounded by the intersection of those brackets'
17//! device-space bounds so a partially scrolled slot still clips to its
18//! viewport ([`punch_rect`]). At the root there is nothing left to hoist past,
19//! and the intersection is the punch itself.
20//!
21//! ## Destination-out, not a clear
22//!
23//! The lowered form is coverage drawn with `dst' = dst · (1 − src.a)`, which
24//! erases colour and alpha exactly where the source covers and leaves an
25//! uncovered pixel bit-identical. Deliberately not a clear op: a clear applies
26//! at whole-tile granularity, which bleeds the punch up to a tile past a
27//! tile-unaligned rectangle edge. Destination-out weights the erase by the
28//! source's own coverage instead, so a pixel-aligned edge lands pixel-exact
29//! however it falls inside a tile, and a fully covered pixel reads exactly
30//! `(0, 0, 0, 0)` on a target that carries alpha.
31//!
32//! ## The wiring contract
33//!
34//! This module produces the lowered form and stops there — a
35//! [`ClearPunch`](ClearPunch) per surviving punch, naming the strips that
36//! carry its coverage, the device bounds they cover, and the painter-order
37//! depth it was hoisted from. Issuing them is the renderer's, and is specified
38//! here so the pass that grows it has one place to read:
39//!
40//! 1. **One pass, at the punch's own painter-order position.** A punch is
41//! issued into the frame's own target once everything recorded *before* the
42//! clear has been recorded, and before anything recorded after it is. The
43//! renderer does that by cutting the surface round at the punch's depth —
44//! the ops before it end one pass, the punch pass follows, the ops after it
45//! resume in the next — so punches at one position are issued together and
46//! punches at different positions each get a pass. A punch past every op of
47//! the frame, which is what a `ClearRect` recorded last is, therefore lands
48//! after the last round exactly as an unconditionally-trailing pass would.
49//! They never touch an intermediate page: a punch inside an isolated layer
50//! was hoisted out of it.
51//! 2. **Fixed-function destination-out, over the strip program.** A punch is a
52//! strip run like any other, so it goes through the same strip program and
53//! instance layout; only the blend state differs — `src_factor: Zero`,
54//! `dst_factor: OneMinusSrcAlpha`, `operation: Add`, for the colour *and*
55//! the alpha component. That computes `dst · (1 − src.a)`, which is the
56//! `COMPOSE_DEST_OUT` arm of `shaders/blend.wgsl` evaluated for a
57//! premultiplied source, without a shader-side composite. It has to be
58//! fixed-function here: the blend module reads its backdrop with
59//! `textureLoad`, and the frame's own target is not readable inside the
60//! pass that writes it. `shaders/blend.wgsl` remains the route for a
61//! composite *between* intermediate textures.
62//! 3. **Ordered by the cut, and depth-tested against the one pass the cut
63//! cannot order it against.** Point 1's cut is what orders the punch against
64//! everything drawn into the surface *in a round*: recorded before the
65//! clear, it is erased; recorded after it, it lands on top of the erase.
66//! That holds whether or not the content wrote depth, so an anti-aliased
67//! fringe, a translucent paint's spans and a layer's composite all survive a
68//! punch they were recorded over.
69//!
70//! One pass escapes the cut, and it is why the punch still carries the depth
71//! it was hoisted from: the frame's opaque strips are recorded *once, ahead
72//! of every round* (see [`crate::renderer`]), so they hold post-clear
73//! coverage that no cut can put after the punch. The ordinary `LessEqual`
74//! test against the frame's depth attachment restores exactly that ordering
75//! — opaque coverage recorded after the clear wrote a nearer depth and the
76//! punch fails against it, opaque coverage recorded before it is erased —
77//! and it is the only ordering the test is asked for. Everything else in the
78//! frame writes no depth, and needs none: the cut already put it on the
79//! correct side.
80//!
81//! Without a depth attachment at all there is no separate opaque pass to
82//! order against: every instance travels through the surface rounds in
83//! painter order, so the cut alone is the whole contract and nothing is
84//! erased that was recorded after the clear.
85//!
86//! One shape the cut cannot express is a bracket that *straddles* the clear.
87//! An isolated layer reaches the surface as a single composite carrying the
88//! deepest index inside it, so a layer holding content recorded both before
89//! and after the clear composites after the punch as a whole — the half of
90//! it recorded before the clear survives where the reference renderer, which
91//! closes and reopens the bracket around the punch, erases it. That is the
92//! engine's one-composite-per-layer model, not this pass's ordering.
93//! 4. **Skipped whole on a target that disregards alpha.** Destination-out
94//! darkens colour as well as erasing alpha, so on an opaque presentation —
95//! where the erased alpha is disregarded — issuing the pass would leave a
96//! black rectangle where the display list says nothing should change. The
97//! punches are kept out of [`CompiledFrame::draws`](super::CompiledFrame)
98//! for exactly this reason: dropping the pass restores the frame exactly,
99//! which is the no-op the display list mandates there.
100
101use core::ops::Range;
102
103use kurbo::{Affine, Rect};
104use vello_common::geometry::RectU16;
105
106/// One hoisted clear, ready to be issued as destination-out coverage.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct ClearPunch {
109 /// Half-open range selecting this punch's coverage from the frame's shared
110 /// strip storage.
111 ///
112 /// The strips are generated after the frame's draws and referenced by no
113 /// draw, so a renderer that drops the punch pass reads the frame exactly
114 /// as if the clear had never been recorded.
115 pub strip_range: Range<usize>,
116 /// The device-space rectangle the punch covers, clamped to the grid the
117 /// strip pipeline addresses.
118 ///
119 /// The coverage in `strip_range` is authoritative for *which* pixels are
120 /// erased; this is the pass's own extent, for a scissor or a bounds check.
121 pub bounds: RectU16,
122 /// The painter-order depth the punch was hoisted from.
123 ///
124 /// A depth of its own rather than the depth of a neighbouring draw, so
125 /// every draw recorded after the clear sits strictly in front of it. It is
126 /// read twice: the renderer cuts the surface round at it (contract point 1)
127 /// and the depth test uses it against the frame's one depth-writing pass
128 /// (contract point 3).
129 pub depth: u32,
130}
131
132/// A punch the walk has hoisted, held until the frame's draws are done.
133///
134/// The rectangle is already in device space — the hoist happens where the
135/// clear was recorded, because that is the only place the brackets it is
136/// confined by are still open — while the coverage for it is generated at the
137/// end of the frame, where it belongs in the strip storage.
138#[derive(Debug, Clone, Copy, PartialEq)]
139pub struct StagedPunch {
140 /// The device-space rectangle to cover.
141 pub device: Rect,
142 /// The painter-order depth the punch was hoisted from.
143 pub depth: u32,
144}
145
146/// The device-space rectangle a clear punches, confined to every bracket open
147/// around it, or `None` when nothing of it survives.
148///
149/// `transform` is the clear's own composed transform and `groups` the
150/// device-space bounds of the open brackets, in any order. Intersecting rather
151/// than clipping is what makes the hoist safe: the punch leaves its brackets
152/// behind, so the only thing it can keep from them is the region they admitted
153/// it to.
154#[must_use]
155pub fn punch_rect(
156 rect: Rect,
157 transform: Affine,
158 groups: impl Iterator<Item = Rect>,
159) -> Option<Rect> {
160 let mut punch = transform.transform_rect_bbox(rect);
161 for bounds in groups {
162 punch = punch.intersect(bounds);
163 }
164
165 (punch.width() > 0.0 && punch.height() > 0.0).then_some(punch)
166}
167
168/// A device-space rectangle on the `u16` grid the strip pipeline addresses.
169///
170/// Clamped rather than refused, so a punch reaching far outside the viewport
171/// bounds the pass at the grid's edge instead of wrapping. Kept here rather
172/// than shared with the clip stack's own clamp: that one converts a rectangle
173/// already proven pixel-aligned, and this one converts an arbitrary punch.
174#[must_use]
175pub fn device_bounds(rect: Rect) -> RectU16 {
176 let coordinate = |value: f64| value.clamp(0.0, f64::from(u16::MAX)) as u16;
177 let x0 = coordinate(rect.x0);
178 let y0 = coordinate(rect.y0);
179 RectU16::new(
180 x0,
181 y0,
182 coordinate(rect.x1).max(x0),
183 coordinate(rect.y1).max(y0),
184 )
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 const OUTER: Rect = Rect::new(0.0, 0.0, 40.0, 40.0);
192 const INNER: Rect = Rect::new(10.0, 10.0, 30.0, 30.0);
193
194 #[test]
195 fn a_punch_at_the_root_is_its_own_transformed_rectangle() {
196 let punch = punch_rect(
197 Rect::new(2.0, 4.0, 6.0, 8.0),
198 Affine::translate((10.0, 20.0)),
199 core::iter::empty(),
200 );
201 assert_eq!(punch, Some(Rect::new(12.0, 24.0, 16.0, 28.0)));
202 }
203
204 #[test]
205 fn a_punch_is_confined_to_every_bracket_it_is_hoisted_past() {
206 let punch = punch_rect(
207 Rect::new(0.0, 0.0, 40.0, 40.0),
208 Affine::IDENTITY,
209 [OUTER, INNER].into_iter(),
210 );
211 assert_eq!(punch, Some(INNER));
212 }
213
214 #[test]
215 fn a_punch_no_open_bracket_admits_survives_nowhere() {
216 let punch = punch_rect(
217 Rect::new(34.0, 34.0, 40.0, 40.0),
218 Affine::IDENTITY,
219 [INNER].into_iter(),
220 );
221 assert_eq!(punch, None);
222 }
223
224 #[test]
225 fn a_degenerate_punch_survives_nowhere() {
226 let empty = punch_rect(
227 Rect::new(4.0, 4.0, 4.0, 12.0),
228 Affine::IDENTITY,
229 core::iter::empty(),
230 );
231 assert_eq!(empty, None);
232 }
233
234 #[test]
235 fn an_enormous_punch_clamps_rather_than_wrapping() {
236 assert_eq!(
237 device_bounds(Rect::new(-1e30, -1e30, 1e30, 1e30)),
238 RectU16::new(0, 0, u16::MAX, u16::MAX)
239 );
240 assert!(device_bounds(Rect::new(-1e30, -1e30, -1e29, -1e29)).is_empty());
241 }
242}