Skip to main content

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}