Skip to main content

frust_engine/compile/
blur_rrect.rs

1//! Blurred rounded rectangle encoding: `Command::BlurredRoundedRect` to a
2//! `vello_common` [`EncodedPaint::BlurredRoundedRect`] entry, plus the padded
3//! bounding rectangle a strip generator rasterizes it through.
4//!
5//! A gaussian-blurred shadow has no hard edge, so it is compiled in two
6//! halves that meet only at the draw the compiler records: the *coverage*
7//! this file names — a plain axis-aligned rectangle, inflated past `rect` by
8//! the kernel's own falloff distance ([`inflated_bounds`]) — decides which
9//! pixels the draw can paint at all, while the *shape* (the rounding, the
10//! standard deviation, the falloff curve itself) is baked into the encoded
11//! paint ([`encode_blurred_rounded_rect`]) and evaluated per pixel by the
12//! fragment shader (`calculate_blurred_rounded_rect` in `helpers.wgsl`,
13//! dispatched by the `PaintType::BlurredRoundedRect` branch in `strip.wgsl` —
14//! both already carry this primitive's GPU half, ported ahead of this file
15//! from `helpers/blurred_rounded_rect.wesl`; there is nothing of theirs left
16//! to add here). Generating strip coverage for a plain rectangle rather than
17//! for the rounded shape itself is deliberate, not a shortcut: the corners
18//! the shader rounds are *inside* that rectangle, and the blur reaches
19//! outside the rectangle the un-blurred shadow would occupy, so a strip
20//! generator that rasterized the rounded shape instead would clip exactly
21//! the pixels the blur exists to reach.
22//!
23//! ## Radius arity
24//!
25//! `vello_common` 0.2.0's [`BlurredRoundedRectangle`] takes a single `f32`
26//! `radius` — the same arity vello_cpu's `fill_blurred_rounded_rect` carries,
27//! which is why the CPU oracle collapses a per-corner shadow through
28//! [`CornerRadii::largest`] (see `frust-testing::oracle_cpu`; recorded as the
29//! accepted limitation `render-blurred-shadow-corner-collapse`). No vello 0.2 type in
30//! this dependency line expresses a per-corner blurred rectangle, so this
31//! encoder collapses the same way, through the same method, for the same
32//! reason `frust_scene::CornerRadii::largest`'s own doc gives: a shadow
33//! rounded *more* than its caster only lightens a square corner, while one
34//! rounded *less* pushes a hard wedge out through a rounded corner's notch —
35//! collapsing to the largest radius is the direction that costs the least
36//! fidelity. There is no fidelity win to report here: this engine tier is
37//! bounded by the identical single-radius vocabulary those tiers
38//! already are, not by a choice made in this file.
39//!
40//! ## Invert
41//!
42//! [`BlurredRoundedRectangle::invert`] lets `vello_hybrid` paint the inverse
43//! of the blur coverage for an inset shadow (`vello_hybrid` 0.2.0
44//! `scene.rs:639-650`), but [`frust_scene::Command::BlurredRoundedRect`]
45//! carries no `invert` field — `frust-scene`'s display list has no
46//! inset-shadow command yet — so every shadow this compiler lowers is
47//! encoded with `invert: false`. Adding an inset variant is a `frust-scene`
48//! change, out of this file's scope.
49
50use peniko::Color;
51
52use vello_common::blurred_rounded_rect::BlurredRoundedRectangle;
53use vello_common::encode::{EncodeExt, EncodedPaint};
54use vello_common::kurbo::{Affine, Rect};
55use vello_common::paint::Paint;
56
57use frust_scene::CornerRadii;
58
59/// How many standard deviations of blur kernel the coverage rectangle in
60/// [`inflated_bounds`] is padded by, on every side.
61///
62/// `vello_hybrid` 0.2.0's own `Scene::fill_blurred_rounded_rect`
63/// (`scene.rs:670`) inflates by this same `2.5 * std_dev` before
64/// rasterizing, and this compiler matches it exactly rather than deriving a
65/// tolerance of its own: past this distance the gaussian falloff
66/// `calculate_blurred_rounded_rect` evaluates is close enough to zero that a
67/// visible difference from a wider pad is not expected, and a narrower one
68/// risks clipping a visible tail — the two render tiers and this one should
69/// agree on where a shadow ends.
70const BLUR_KERNEL_STD_DEVS: f64 = 2.5;
71
72/// The widest pad [`inflated_bounds`] will apply, in the same pre-transform
73/// units as the rectangle it inflates.
74///
75/// The compiler accepts any finite `std_dev`, and an extreme one inflates
76/// the coverage rectangle to coordinates that degrade the strip generator's
77/// tile arithmetic into malformed strips outside the tile-snapped viewport.
78/// The cap bounds the *pad* — never the standard deviation the shader
79/// evaluates — at a distance no addressable surface approaches
80/// (`max_texture_size` tops out at 16384), so it cannot change a rendered
81/// pixel: coverage past the surface is invisible, and every realistic blur
82/// keeps its full falloff.
83const MAX_KERNEL_PAD: f64 = 1.0e6;
84
85/// The axis-aligned rectangle a blurred rounded rectangle's strip coverage is
86/// generated over, in the same (pre-transform) coordinate space as `rect`.
87///
88/// Padding by [`BLUR_KERNEL_STD_DEVS`] standard deviations on every side is
89/// what keeps the strip generator from clipping the blur's own falloff tail
90/// — see the module doc for why this is a plain rectangle pad rather than a
91/// rounded one. The pad is capped at [`MAX_KERNEL_PAD`], a coverage-only
92/// bound that no visible blur reaches. A non-finite or negative `std_dev`
93/// is not guarded here: it cannot reach this function at all, because
94/// [`super::check_geometry`] refuses a non-finite `std_dev` before any
95/// command is compiled, and `frust_scene::SceneBuilder`'s blurred-rect
96/// constructors take a caller-supplied standard deviation on the same terms
97/// every other geometry parameter is — a negative one is nonsensical but
98/// not this compiler's to reject.
99#[must_use]
100pub fn inflated_bounds(rect: Rect, std_dev: f64) -> Rect {
101    let kernel = (BLUR_KERNEL_STD_DEVS * std_dev).min(MAX_KERNEL_PAD);
102    rect.inflate(kernel, kernel)
103}
104
105/// Encode a blurred rounded rectangle into `encoded_paints`, returning the
106/// paint the draw that rasterizes [`inflated_bounds`] references.
107///
108/// `radii` collapses through [`CornerRadii::largest`] — see the module doc's
109/// "Radius arity" section for why. `transform` is the paint transform in
110/// effect for the draw, the same composed transform the coverage rectangle
111/// is rasterized under; `BlurredRoundedRectangle::encode_into` folds its
112/// inverse into the encoded entry, which is the direction the shader samples
113/// in.
114///
115/// Unlike an image, this can never fail: every field a
116/// [`Command::BlurredRoundedRect`](frust_scene::Command::BlurredRoundedRect)
117/// carries reaches here already checked finite by
118/// [`super::check_geometry`], and `vello_common`'s own encoding clamps a
119/// degenerate radius or a vanishing standard deviation internally rather
120/// than refusing them — so, unlike [`crate::compile::paint::encode_brush`]'s
121/// image arm, there is no skip case for a caller here to route around.
122#[must_use]
123pub fn encode_blurred_rounded_rect(
124    rect: Rect,
125    radii: CornerRadii,
126    std_dev: f64,
127    color: Color,
128    transform: Affine,
129    encoded_paints: &mut Vec<EncodedPaint>,
130) -> Paint {
131    let shadow = BlurredRoundedRectangle {
132        rect,
133        color,
134        radius: radii.largest() as f32,
135        std_dev: std_dev as f32,
136        // See the module doc's "Invert" section: the display list carries no
137        // inset-shadow flag to plumb through.
138        invert: false,
139    };
140
141    shadow.encode_into(encoded_paints, transform, None)
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use peniko::color::palette::css::RED;
148
149    #[test]
150    fn inflated_bounds_pads_every_side_by_the_kernel_distance() {
151        let rect = Rect::new(10.0, 10.0, 50.0, 40.0);
152        let inflated = inflated_bounds(rect, 4.0);
153
154        let kernel = BLUR_KERNEL_STD_DEVS * 4.0;
155        assert_eq!(inflated.x0, rect.x0 - kernel);
156        assert_eq!(inflated.y0, rect.y0 - kernel);
157        assert_eq!(inflated.x1, rect.x1 + kernel);
158        assert_eq!(inflated.y1, rect.y1 + kernel);
159    }
160
161    #[test]
162    fn inflated_bounds_is_the_identity_at_zero_std_dev() {
163        let rect = Rect::new(0.0, 0.0, 20.0, 20.0);
164        assert_eq!(inflated_bounds(rect, 0.0), rect);
165    }
166
167    #[test]
168    fn encoding_appends_exactly_one_entry_and_indexes_it() {
169        let mut encoded_paints = Vec::new();
170        let rect = Rect::new(0.0, 0.0, 40.0, 30.0);
171
172        let paint = encode_blurred_rounded_rect(
173            rect,
174            CornerRadii::uniform(6.0),
175            3.0,
176            RED,
177            Affine::IDENTITY,
178            &mut encoded_paints,
179        );
180
181        assert_eq!(encoded_paints.len(), 1);
182        match paint {
183            Paint::Indexed(indexed) => assert_eq!(indexed.index(), 0),
184            Paint::Solid(_) => panic!("a blurred rounded rectangle always encodes indexed"),
185        }
186        match &encoded_paints[0] {
187            EncodedPaint::BlurredRoundedRect(entry) => {
188                assert_eq!(
189                    entry.color,
190                    vello_common::paint::PremulColor::from_alpha_color(RED)
191                );
192                assert!(
193                    !entry.invert,
194                    "the display list carries no inset-shadow flag"
195                );
196            }
197            other => panic!("expected an encoded blurred rounded rectangle, got {other:?}"),
198        }
199    }
200
201    #[test]
202    fn per_corner_radii_collapse_to_the_largest_corner() {
203        let mut a = Vec::new();
204        let mut b = Vec::new();
205        let rect = Rect::new(0.0, 0.0, 40.0, 40.0);
206
207        let _ = encode_blurred_rounded_rect(
208            rect,
209            CornerRadii::new(12.0, 0.0, 0.0, 0.0),
210            2.0,
211            RED,
212            Affine::IDENTITY,
213            &mut a,
214        );
215        let _ = encode_blurred_rounded_rect(
216            rect,
217            CornerRadii::uniform(12.0),
218            2.0,
219            RED,
220            Affine::IDENTITY,
221            &mut b,
222        );
223
224        let radius = |paints: &[EncodedPaint]| match &paints[0] {
225            EncodedPaint::BlurredRoundedRect(entry) => entry.r1,
226            other => panic!("expected an encoded blurred rounded rectangle, got {other:?}"),
227        };
228        assert_eq!(
229            radius(&a),
230            radius(&b),
231            "a shadow rounded on one corner only must encode the same outer radius as one \
232             rounded on every corner to the same value, since only the largest corner survives"
233        );
234    }
235
236    #[test]
237    fn appending_a_second_entry_does_not_disturb_the_first() {
238        let mut encoded_paints = Vec::new();
239        let rect = Rect::new(0.0, 0.0, 20.0, 20.0);
240
241        let first = encode_blurred_rounded_rect(
242            rect,
243            CornerRadii::uniform(4.0),
244            1.0,
245            RED,
246            Affine::IDENTITY,
247            &mut encoded_paints,
248        );
249        let second = encode_blurred_rounded_rect(
250            rect,
251            CornerRadii::uniform(8.0),
252            2.0,
253            RED,
254            Affine::IDENTITY,
255            &mut encoded_paints,
256        );
257
258        assert_eq!(encoded_paints.len(), 2);
259        match (first, second) {
260            (Paint::Indexed(a), Paint::Indexed(b)) => {
261                assert_eq!(a.index(), 0);
262                assert_eq!(b.index(), 1);
263            }
264            _ => panic!("both entries must encode indexed"),
265        }
266    }
267}