Skip to main content

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}