frust_engine/filters/mod.rs
1//! Layer filters: a layer rendered in isolation, run through a sequence of
2//! filter passes, and composited from the page the last of them wrote.
3//!
4//! An opacity layer needs one page and one extra pass ([`schedule`]); a
5//! filtered layer needs a *second* page as well, because a filter pass reads a
6//! whole image and writes a whole image and a render pass cannot do both to one
7//! texture. The two pages the scheduler already ping-pongs between are exactly
8//! that pair: the layer's contents land in one, each pass writes the one it did
9//! not read, and the sequence is arranged so the result ends back in the page
10//! the parent's composite samples.
11//!
12//! [`schedule`]: crate::schedule
13//!
14//! ## What is served
15//!
16//! Two filters: [`LayerFilter::Blur`], the Gaussian blur that the theme
17//! layer's `GlassMaterial` backdrop needs, and [`LayerFilter::DropShadow`], a
18//! shadow-only drop shadow (`vello_common`'s `DropShadowOnly`, never
19//! compositing the layer's own unfiltered content back over the shadow — see
20//! [`drop_shadow`]'s own doc for why). Every other filter a recording can
21//! carry — a flood, a standalone offset, a drop shadow that composites the
22//! original back over itself, an edge mode other than [`SERVED_EDGE_MODE`], or
23//! a graph of more than one primitive — is refused by [`served_filter`] with a
24//! reason naming what was found, and the frame is skipped rather than rendered
25//! without its filter. That refusal is not a placeholder for a fallback: the
26//! engine tier carries no second renderer (see the [`schedule`] module header).
27//!
28//! The scene seam is deliberately absent. `frust_scene` has no filter command
29//! and gains none here — [`push_filter_layer`] is how the engine's own compiler
30//! will open a filtered layer once a later plan gives the display list
31//! something to lower from. This module proves the engine can render one.
32//!
33//! ## What is pure, and what is not
34//!
35//! Everything here is a decision over plain values, in the crate's usual split:
36//! which passes a blur costs and at what extents ([`blur::blur_passes`]), what
37//! the fragment stage reads out of the filter-data texture
38//! ([`blur::GpuGaussianBlur`]), and how one pass's instance is packed
39//! ([`blur::FilterInstanceData`]). No device is touched and no texture is
40//! allocated here.
41//!
42//! The three device-side halves live where every other engine pipeline's do:
43//! the WGSL program is assembled by [`crate::gpu::shader_src::FILTER`], the
44//! pipeline it is drawn through is
45//! [`crate::gpu::pipelines::EnginePipeline::Filter`], and the texture holding
46//! the frame's parameter blocks plus the bilinear sampler the kernels read
47//! through are [`crate::gpu::targets`]'. The renderer binds the pages the
48//! scheduler named and issues one instanced quad per pass
49//! ([`crate::renderer::FilterResources`]).
50
51pub mod blur;
52pub mod drop_shadow;
53
54use kurbo::Affine;
55use peniko::color::{AlphaColor, Srgb};
56use vello_common::filter::drop_shadow::DropShadow;
57use vello_common::filter::gaussian_blur::GaussianBlur;
58use vello_common::filter::{FilterData, PreparedFilter};
59use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
60use vello_common::geometry::SizeU16;
61use vello_common::record::{CommandRecorder, LayerProps, RecordedLayerKind};
62use vello_common::util::extract_scales;
63
64use crate::error::EngineError;
65
66/// What [`served_filter`] says this engine renders when a recording's
67/// primitive is neither of its two, so a refusal names the whole served set
68/// rather than one filter's half of it.
69const SERVED_FILTERS: &str = "the only filters it renders are a single Gaussian blur or a single, \
70 shadow-only drop shadow (no original content composited back over it)";
71
72/// The largest standard deviation a blur layer is served at, in the layer's
73/// own space and in device space alike.
74///
75/// A blur expands its layer by 3σ on every side, snapped up to the tile grid
76/// and carried as a `u16`; a σ past this bound would wrap that padding instead
77/// of expanding the layer. It sits an order of magnitude above the largest page
78/// the scheduler will ever size (the default ceiling is 4096 texels per axis),
79/// so nothing a frame could have rendered is refused by it.
80///
81/// Both the recorded σ and the device-space σ the layer's transform scales it
82/// into are held to it (see [`device_blur_sigma`]); the second is the one that
83/// reaches `vello_common`'s planner, and the planner does not terminate on an
84/// infinite one.
85pub const MAX_BLUR_SIGMA: f32 = 4096.0;
86
87/// The one edge mode this engine's filter kernels implement.
88///
89/// Every kernel bounds its taps against the region it is filtering and reads
90/// transparent black outside it (`sample_region_bilinear` in
91/// `shaders/filters_blur.wgsl`, `drop_shadow_load_checked` in
92/// `shaders/filters_drop_shadow.wgsl`), which is exactly
93/// [`EdgeMode::None`] — the mode
94/// [`LayerFilter::filter_data`] records and the only one
95/// [`served_filter`] serves.
96pub const SERVED_EDGE_MODE: EdgeMode = EdgeMode::None;
97
98/// A filter the engine can apply to an isolated layer.
99///
100/// Engine-internal on purpose: this is the vocabulary
101/// [`push_filter_layer`] records with, not a `frust_scene` command.
102#[derive(Debug, Clone, Copy, PartialEq)]
103pub enum LayerFilter {
104 /// A Gaussian blur of standard deviation `sigma`, in the layer's own user
105 /// space — the transform in force when the layer is opened scales it into
106 /// device space.
107 Blur {
108 /// The blur's standard deviation. Zero blurs nothing.
109 sigma: f32,
110 },
111 /// A shadow-only drop shadow: the layer's alpha, blurred by `sigma`,
112 /// shifted by `offset`, and recoloured to `color` — never the layer's own
113 /// unfiltered content composited back over it (see [`drop_shadow`]'s own
114 /// doc for why).
115 DropShadow {
116 /// Horizontal, vertical offset of the shadow, in the layer's own user
117 /// space — the transform in force when the layer is opened scales it
118 /// into device space, exactly as `sigma` is.
119 offset: (f32, f32),
120 /// The shadow's blur standard deviation. Zero blurs nothing, leaving a
121 /// hard-edged, offset silhouette.
122 sigma: f32,
123 /// The shadow's own colour. Its alpha scales the shadow's own opacity;
124 /// the layer's colour never reaches the shadow.
125 color: AlphaColor<Srgb>,
126 },
127}
128
129impl LayerFilter {
130 /// The recorder-side description of this filter under `transform`.
131 ///
132 /// This is what carries the filter's *expansion* into the recording: a
133 /// blurred layer's tile-aligned bounds grow by 3σ on every side, because
134 /// the blur paints outside the contents that produced it, and the page the
135 /// layer is rendered into is sized from those grown bounds.
136 ///
137 /// # Errors
138 ///
139 /// [`EngineError::InvalidGeometry`] for a σ that is not finite, is
140 /// negative, or is past [`MAX_BLUR_SIGMA`] — in the layer's own user space
141 /// *or* in the device space `transform` scales it into (a
142 /// [`Self::DropShadow`]'s own offset held to the same finiteness) — and
143 /// [`EngineError::InvalidTransform`] for a transform that is not finite —
144 /// the same terms the compiler already refuses a draw's own geometry and
145 /// transform on, checked here because every quantity below is derived from
146 /// both.
147 pub fn filter_data(self, transform: Affine) -> Result<FilterData, EngineError> {
148 if !transform.as_coeffs().iter().all(|coeff| coeff.is_finite()) {
149 return Err(EngineError::InvalidTransform);
150 }
151
152 match self {
153 Self::Blur { sigma } => {
154 // Both spaces, through the same predicate the served gate
155 // takes: what the planner is handed is σ scaled by the
156 // transform, and only the recorded half of that is visible
157 // here without asking.
158 if !served_sigmas(sigma, transform) {
159 return Err(EngineError::InvalidGeometry);
160 }
161
162 let filter = Filter::from_primitive(FilterPrimitive::GaussianBlur {
163 std_deviation: sigma,
164 edge_mode: SERVED_EDGE_MODE,
165 });
166
167 Ok(FilterData::new(filter, transform))
168 }
169 Self::DropShadow {
170 offset: (dx, dy),
171 sigma,
172 color,
173 } => {
174 if !served_sigmas(sigma, transform) || !dx.is_finite() || !dy.is_finite() {
175 return Err(EngineError::InvalidGeometry);
176 }
177
178 // `DropShadowOnly`, never `DropShadow`: the shadow-only shape
179 // is the whole of what this engine serves (see this module's
180 // own doc and `served_filter` below).
181 let filter = Filter::from_primitive(FilterPrimitive::DropShadowOnly {
182 dx,
183 dy,
184 std_deviation: sigma,
185 color,
186 edge_mode: SERVED_EDGE_MODE,
187 });
188
189 Ok(FilterData::new(filter, transform))
190 }
191 }
192 }
193}
194
195/// Opens a filtered layer on `recorder` — the engine-internal counterpart of
196/// [`CommandRecorder::push_layer`] for a layer that carries a filter.
197///
198/// Close it with [`CommandRecorder::pop_layer`], exactly like a regular layer.
199/// Popping is what computes the layer's bounds, and for a filtered layer those
200/// bounds are the *expanded* ones the filter paints into, which is what the
201/// scheduler sizes the layer's pages from.
202///
203/// # Errors
204///
205/// Whatever [`LayerFilter::filter_data`] refuses; nothing is recorded when it
206/// does, so a refused filter leaves the recording exactly as it was rather than
207/// opening a layer no `pop_layer` will balance.
208pub fn push_filter_layer<D>(
209 recorder: &mut CommandRecorder<D>,
210 props: LayerProps,
211 filter: LayerFilter,
212 transform: Affine,
213) -> Result<(), EngineError> {
214 let filter_data = filter.filter_data(transform)?;
215 recorder.push_layer(props, Some(filter_data));
216 Ok(())
217}
218
219/// The device-space standard deviation a blur of `sigma` runs at under
220/// `transform`.
221///
222/// The quantity `vello_common`'s own `transform_blur_params` computes and hands
223/// its planner — recomputed here, from the same `extract_scales` singular-value
224/// pair averaged the same way, because upstream keeps that function
225/// `pub(crate)` and because the whole point of the bound it feeds is to be
226/// taken *before* the value reaches the planner. Keep it in step with
227/// `vello_common::filter::gaussian_blur::transform_blur_params`.
228#[must_use]
229pub fn device_blur_sigma(sigma: f32, transform: Affine) -> f32 {
230 let (scale_x, scale_y) = extract_scales(&transform);
231 let uniform_scale = (scale_x + scale_y) / 2.0;
232 sigma * uniform_scale
233}
234
235/// Whether `sigma` is a standard deviation this engine blurs at both as
236/// recorded and as `transform` scales it into device space.
237///
238/// A range check rather than a chain of comparisons, which also settles the
239/// non-finite cases: neither a NaN nor an infinity is contained by it. Both
240/// ends matter — the recorded σ is what the layer's bounds are expanded by, and
241/// the device-space σ is what `vello_common`'s `plan_decimated_blur` halves
242/// down (`while remaining_variance > 4.0 { (v - 1.5) * 0.25 }`), a loop an
243/// infinite variance never leaves.
244#[must_use]
245fn served_sigmas(sigma: f32, transform: Affine) -> bool {
246 let served = 0.0..=MAX_BLUR_SIGMA;
247
248 served.contains(&sigma) && served.contains(&device_blur_sigma(sigma, transform))
249}
250
251/// One of the two filters this engine renders, read back off a recording.
252///
253/// The shared answer of [`served_filter`], so the three sites that have to know
254/// which filter a layer carries — [`crate::schedule`]'s `layer_role` and
255/// `filter_rounds`, and [`crate::renderer`]'s `filter_block` — dispatch on one
256/// decision rather than each re-deriving it from a try-blur-then-drop-shadow
257/// pair of their own.
258#[derive(Debug)]
259pub enum ServedFilter {
260 /// A Gaussian blur, prepared into device space.
261 Blur(GaussianBlur),
262 /// A shadow-only drop shadow, prepared into device space.
263 DropShadow(DropShadow),
264}
265
266/// The filter layer `id`'s recorded `kind` describes, or a reason naming what
267/// was found instead.
268///
269/// The reason is prose rather than an error variant because it reaches a log
270/// through [`EngineError::SchedulerEscalation`], where a caller reading it is
271/// trying to find out which recorded shape froze a surface. It names the
272/// primitive's *own* fault — an unserved edge mode, an unserved σ — rather than
273/// the fault of whichever filter was tried second, which is what a pair of
274/// independent gates tried in sequence can only report.
275///
276/// # Errors
277///
278/// A `kind` that is not a filter at all; a filter under a transform that is not
279/// finite; a filter graph of anything but one Gaussian blur or one shadow-only
280/// drop shadow (a `DropShadow` that composites its original content back over
281/// the shadow included — this engine serves only `DropShadowOnly`, see this
282/// module's own doc); an edge mode other than [`SERVED_EDGE_MODE`]; a σ outside
283/// [`MAX_BLUR_SIGMA`] as recorded or as the transform scales it; or a primitive
284/// whose prepared kernel is not the one its own tag named.
285pub fn served_filter(id: u32, kind: &RecordedLayerKind) -> Result<ServedFilter, String> {
286 let RecordedLayerKind::Filter { filter_data, .. } = kind else {
287 return Err(format!("layer {id} carries no filter"));
288 };
289
290 // Checked before preparing rather than after: every quantity the reference
291 // derives below is derived from the transform as well as from the
292 // primitive's own parameters, and a recording is not trusted to hold only
293 // the shapes the engine's own compiler records (E17).
294 let transform = filter_data.transform;
295 if !transform.as_coeffs().iter().all(|coeff| coeff.is_finite()) {
296 return Err(format!(
297 "layer {id} carries a filter under a non-finite transform ({:?}), which is what \
298 scales its σ into the device space the blur is planned in",
299 transform.as_coeffs()
300 ));
301 }
302
303 // Checked before preparing rather than after for the second reason too:
304 // `PreparedFilter::new` panics on a graph the reference has not
305 // implemented.
306 let primitives = filter_data.filter.graph.primitives.as_slice();
307 match primitives {
308 [
309 FilterPrimitive::GaussianBlur {
310 std_deviation,
311 edge_mode,
312 },
313 ] => {
314 served_edge_mode(id, "a Gaussian blur", *edge_mode)?;
315 served_sigma(id, "a Gaussian blur", *std_deviation, transform)?;
316
317 match PreparedFilter::new(&filter_data.filter, &transform) {
318 PreparedFilter::GaussianBlur(blur) => Ok(ServedFilter::Blur(blur)),
319 _ => Err(format!(
320 "layer {id} carries a Gaussian blur that prepared as another filter"
321 )),
322 }
323 }
324 [
325 FilterPrimitive::DropShadowOnly {
326 std_deviation,
327 edge_mode,
328 ..
329 },
330 ] => {
331 served_edge_mode(id, "a drop shadow", *edge_mode)?;
332 served_sigma(id, "a drop shadow", *std_deviation, transform)?;
333
334 match PreparedFilter::new(&filter_data.filter, &transform) {
335 PreparedFilter::DropShadow(shadow) => Ok(ServedFilter::DropShadow(shadow)),
336 _ => Err(format!(
337 "layer {id} carries a drop shadow that prepared as another filter"
338 )),
339 }
340 }
341 _ => Err(format!(
342 "layer {id} carries a filter graph of {} primitive(s) the engine serves as neither of \
343 its two filters; {SERVED_FILTERS}",
344 primitives.len()
345 )),
346 }
347}
348
349/// Refuses an edge mode this engine's kernels do not implement, by name.
350///
351/// Only [`SERVED_EDGE_MODE`] is implemented: `Wrap` and `Mirror` would have to
352/// address the region's opposite or reflected texels, and `Duplicate` would
353/// have to replicate its edge ones, while every kernel here reads transparent
354/// black outside the region unconditionally. Serving a recording that asks for
355/// one of the other three would render it as `None` and call it that filter,
356/// which is the silent-wrong-pixels answer this tier does not give.
357fn served_edge_mode(id: u32, filter: &str, edge_mode: EdgeMode) -> Result<(), String> {
358 if edge_mode == SERVED_EDGE_MODE {
359 return Ok(());
360 }
361
362 Err(format!(
363 "layer {id} carries {filter} with edge mode {edge_mode:?}, which this engine does not \
364 render; its filter kernels read transparent black outside the region they filter, which \
365 is {SERVED_EDGE_MODE:?}, and no other mode"
366 ))
367}
368
369/// Refuses a σ this engine does not blur at, by name, in both spaces.
370fn served_sigma(id: u32, filter: &str, sigma: f32, transform: Affine) -> Result<(), String> {
371 if served_sigmas(sigma, transform) {
372 return Ok(());
373 }
374
375 let device = device_blur_sigma(sigma, transform);
376 Err(format!(
377 "layer {id} carries {filter} of σ {sigma}, which its own transform scales to a \
378 device-space σ of {device}; this engine blurs at a standard deviation of 0 to \
379 {MAX_BLUR_SIGMA} in both spaces"
380 ))
381}
382
383/// The blur that layer `id`'s recorded filter describes, or a reason naming
384/// what was found instead.
385///
386/// [`served_filter`] narrowed to its blur arm, for a caller that has no answer
387/// for a drop shadow.
388///
389/// # Errors
390///
391/// Whatever [`served_filter`] refuses, and a layer that carries the
392/// shadow-only drop shadow instead.
393pub fn served_blur(id: u32, kind: &RecordedLayerKind) -> Result<GaussianBlur, String> {
394 match served_filter(id, kind)? {
395 ServedFilter::Blur(blur) => Ok(blur),
396 ServedFilter::DropShadow(_) => Err(format!(
397 "layer {id} carries a shadow-only drop shadow rather than a Gaussian blur"
398 )),
399 }
400}
401
402/// The drop shadow that layer `id`'s recorded filter describes, or a reason
403/// naming what was found instead.
404///
405/// [`served_filter`] narrowed to its drop-shadow arm, the counterpart of
406/// [`served_blur`].
407///
408/// # Errors
409///
410/// Whatever [`served_filter`] refuses, and a layer that carries the Gaussian
411/// blur instead.
412pub fn served_drop_shadow(id: u32, kind: &RecordedLayerKind) -> Result<DropShadow, String> {
413 match served_filter(id, kind)? {
414 ServedFilter::DropShadow(shadow) => Ok(shadow),
415 ServedFilter::Blur(_) => Err(format!(
416 "layer {id} carries a Gaussian blur rather than a shadow-only drop shadow"
417 )),
418 }
419}
420
421/// One pass of a filter's sequence.
422///
423/// The numbering is the wire format the fragment stage switches on; it matches
424/// the reference renderer's own — flood (1) and the drop-shadow composite that
425/// reads the layer's own unfiltered content back (7) are still reserved and
426/// still unserved (see [`drop_shadow`]'s own doc), and can be added later
427/// without renumbering.
428#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
429pub enum FilterPassKind {
430 /// Copy the source region through unchanged.
431 Copy,
432 /// Shift the source region by a drop shadow's own device-space offset.
433 Offset,
434 /// Halve both axes.
435 Downscale,
436 /// Convolve horizontally with the blur kernel.
437 BlurH,
438 /// Convolve vertically with the blur kernel.
439 BlurV,
440 /// Double both axes.
441 Upscale,
442 /// Recolour a blurred, offset alpha mask into a drop shadow's own
443 /// premultiplied colour.
444 Colorize,
445}
446
447impl FilterPassKind {
448 /// The value `filter_pass_kind` carries into the fragment stage.
449 #[must_use]
450 pub const fn code(self) -> u32 {
451 match self {
452 Self::Copy => 0,
453 Self::Offset => 2,
454 Self::Downscale => 3,
455 Self::BlurH => 4,
456 Self::BlurV => 5,
457 Self::Upscale => 6,
458 Self::Colorize => 8,
459 }
460 }
461}
462
463/// One pass of a filter's sequence, with the extents it reads and writes.
464///
465/// A pass reads the page the pass before it wrote and writes the other one, so
466/// `source` is the previous step's `dest`; the two differ only for the
467/// rescaling passes.
468#[derive(Debug, Clone, Copy, PartialEq, Eq)]
469pub struct FilterStep {
470 /// Which pass this is.
471 pub kind: FilterPassKind,
472 /// Extent of the region read, at the source page's origin.
473 pub source: SizeU16,
474 /// Extent of the region written, at the destination page's origin.
475 pub dest: SizeU16,
476}