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}