frust_engine/filters/drop_shadow.rs
1//! The drop-shadow filter: its GPU parameter block, and the pass sequence a
2//! shadowed layer renders as.
3//!
4//! Ported from the sparse-strip reference renderer's `vello_hybrid` 0.2.0
5//! `filter.rs` (the `GpuDropShadow` half) and `shaders/filters/drop_shadow.wesl`
6//! — narrowed the same way [`crate::filters::blur`] narrows the reference's
7//! Gaussian blur: this engine serves only the *shadow-only* shape
8//! (`vello_common`'s `DropShadow::new_shadow_only`, `composite_original` always
9//! `false`). The reference's other shape composites the layer's own unfiltered
10//! content back over the shadow, which needs a third live texture beyond the
11//! two the scheduler's ping-pong ever hands a layer (see
12//! [`crate::schedule`]'s *Filter rounds* section) — a shape nothing in frust's
13//! widget set asks for yet, so it is left unserved rather than half-built.
14//!
15//! ## What a shadow costs
16//!
17//! A shadow is the same blur pass sequence [`crate::filters::blur::blur_passes`]
18//! already plans, plus one pass before it and one after: a shift by the
19//! shadow's own device-space offset ([`FilterPassKind::Offset`]), and a
20//! recolour of the blurred, offset alpha mask into the shadow's own
21//! premultiplied colour ([`FilterPassKind::Colorize`]). Both cost one pass each
22//! and neither changes extent, so wrapping the blur's own even-length sequence
23//! in one more pass on each side keeps the whole thing even — the same
24//! even-sequence invariant [`crate::filters::blur::blur_passes`] documents,
25//! checked here rather than assumed.
26//!
27//! ## The kernel these passes read
28//!
29//! The taps and weights [`GpuDropShadow`] packs are read by [`filter.wgsl`]'s
30//! existing `PASS_BLUR_H`/`PASS_BLUR_V` cases unmodified: [`GpuDropShadow`]
31//! places its header, centre weight, linear weights and linear offsets at the
32//! identical texel offsets [`crate::filters::blur::GpuGaussianBlur`] does, so a
33//! drop shadow's blur passes are indistinguishable, to the fragment stage, from
34//! a plain blur's. Only the offset and the colour — carried in the block's
35//! third texel, which a blur's own leaves as padding — are read by code this
36//! module's own [`shaders/filters_drop_shadow.wgsl`] prelude adds.
37//!
38//! ## Two shadow paths, and which one to reach for
39//!
40//! Frust now has two ways to paint a drop shadow, and they are not
41//! interchangeable — each is the right tool for a different shape of caller.
42//!
43//! [`frust_scene::Command::BlurredRoundedRect`] is a *scene command*: a widget
44//! records one, the engine compiler (`compile::blur_rrect`) encodes it as a
45//! `vello_common` blurred-rounded-rect paint the strip shader evaluates per
46//! pixel (the CPU oracle mirrors it through vello_cpu's
47//! `fill_blurred_rounded_rect`), and the shadow of a rectangle with a single
48//! corner radius (per-corner radii collapse to
49//! [`frust_scene::CornerRadii::largest`] at encode time — see that command's
50//! own doc) is painted analytically, in one draw, with no intermediate
51//! texture and no extra render pass. That is the whole of what it can shadow:
52//! one rectangle, gaussian-blurred by an error-function approximation
53//! (`erf7`) baked into the strip shader's own kernel, never arbitrary content.
54//!
55//! A drop-shadow [filter](crate::filters) layer is the opposite trade. It
56//! shadows *whatever a layer's contents turn out to be* — text, an image, a
57//! stack of widgets, anything the layer's own draws paint — because it works
58//! from the layer's rasterized alpha rather than from one rectangle's
59//! geometry. That generality costs a page for the layer's own contents, a
60//! second page for the pass sequence to ping-pong into, and `2 +
61//! 2·n_decimations` render passes ([`drop_shadow_passes`]) instead of the one
62//! draw a `BlurredRoundedRect` costs — and, on this tier, it is reachable only
63//! through [`push_filter_layer`](crate::filters::push_filter_layer), since
64//! `frust_scene` carries no filter command yet (see [`crate::filters`]'s own
65//! doc for why).
66//!
67//! **The recommendation this module exists to let a docs card cite:** a
68//! widget shadowing its own rectangle — a card, a button, a sheet — keeps
69//! using `BlurredRoundedRect`; nothing here should replace it, and nothing
70//! about this filter makes that command obsolete. A drop-shadow filter layer
71//! is for the case `BlurredRoundedRect` cannot serve at all: shadowing
72//! content whose shape is not a single rectangle, or is not known until the
73//! layer's own contents are rasterized.
74//!
75//! [`filter.wgsl`]: ../../../shaders/filter.wgsl
76//! [`shaders/filters_drop_shadow.wgsl`]: ../../../shaders/filters_drop_shadow.wgsl
77
78use bytemuck::{Pod, Zeroable};
79use vello_common::filter::drop_shadow::DropShadow;
80use vello_common::filter::gaussian_blur::GaussianBlur;
81use vello_common::geometry::SizeU16;
82
83use crate::filters::blur::{
84 self, FILTER_SIZE_BYTES, GpuFilterData, LinearKernel, MAX_TAPS_PER_SIDE, edge_mode_code,
85 filter_type,
86};
87use crate::filters::{FilterPassKind, FilterStep};
88
89const _: () = assert!(
90 size_of::<GpuDropShadow>() == FILTER_SIZE_BYTES,
91 "every filter's parameter block is one uniform size, which is what makes the type-erased \
92 block addressable by a plain texel multiple"
93);
94
95/// A drop shadow's parameter block, as the fragment stage reads it.
96///
97/// `header`, `center_weight`, `linear_weights` and `linear_offsets` sit at the
98/// same offsets [`crate::filters::blur::GpuGaussianBlur`] gives them, so the
99/// blur passes of a shadow's own sequence read this block through the exact
100/// accessors a plain blur's does; `dx`, `dy` and `color` are what a blur's own
101/// block leaves as padding, and only the offset and colourize passes this
102/// module adds ever read them.
103#[repr(C, align(16))]
104#[derive(Debug, Clone, Copy, PartialEq, Zeroable, Pod)]
105pub struct GpuDropShadow {
106 /// Packed filter kind, edge mode, decimation count and tap count — the
107 /// composite-original bit is never set, since this engine serves only the
108 /// shadow-only shape.
109 pub header: u32,
110 /// Weight of the kernel's centre tap.
111 pub center_weight: f32,
112 /// Merged weight of each bilinear tap pair.
113 pub linear_weights: [f32; MAX_TAPS_PER_SIDE],
114 /// Fractional offset of each bilinear tap pair.
115 pub linear_offsets: [f32; MAX_TAPS_PER_SIDE],
116 /// Horizontal device-space offset of the shadow.
117 pub dx: f32,
118 /// Vertical device-space offset of the shadow.
119 pub dy: f32,
120 /// The shadow's own premultiplied colour, packed as RGBA8.
121 pub color: u32,
122 /// Unused; present so every filter kind is one stride wide.
123 pub _padding: [u32; 1],
124}
125
126impl From<&DropShadow> for GpuDropShadow {
127 fn from(shadow: &DropShadow) -> Self {
128 let kernel = LinearKernel::new(&shadow.kernel, shadow.kernel_size);
129
130 Self {
131 header: pack_drop_shadow_header(
132 edge_mode_code(shadow.edge_mode),
133 u32::try_from(shadow.n_decimations).unwrap_or(u32::MAX),
134 u32::from(kernel.n_taps),
135 ),
136 center_weight: kernel.center_weight,
137 linear_weights: kernel.weights,
138 linear_offsets: kernel.offsets,
139 dx: shadow.dx,
140 dy: shadow.dy,
141 color: shadow.color.premultiply().to_rgba8().to_u32(),
142 _padding: [0; 1],
143 }
144 }
145}
146
147impl From<GpuDropShadow> for GpuFilterData {
148 fn from(shadow: GpuDropShadow) -> Self {
149 bytemuck::cast(shadow)
150 }
151}
152
153/// The packed header of a drop shadow's parameter block.
154///
155/// See the bit layout documented in `shaders/filter.wgsl`; bit 13
156/// (`composite_original`) is always left `0` here — this engine never composes
157/// the reference's original-compositing shape (see this module's own doc).
158const fn pack_drop_shadow_header(edge_mode: u32, n_decimations: u32, n_linear_taps: u32) -> u32 {
159 (filter_type::DROP_SHADOW & 0x1F)
160 | ((edge_mode & 0x3) << 5)
161 | ((n_decimations & 0xF) << 7)
162 | ((n_linear_taps & 0x3) << 11)
163}
164
165/// The passes a drop shadow of `shadow` over a layer of `size` renders as, in
166/// execution order.
167///
168/// The shape is the reference's own drop-shadow plan narrowed to the
169/// shadow-only case: an [`FilterPassKind::Offset`] shifting the layer by the
170/// shadow's own device-space `(dx, dy)`, then exactly the pass sequence
171/// [`blur::blur_passes`] plans for the shadow's blur, then a
172/// [`FilterPassKind::Colorize`] recolouring the result into the shadow's own
173/// premultiplied colour.
174///
175/// Neither the offset nor the colourize pass rescales, so both read and write
176/// `size` — the layer's own, undecimated extent, exactly as the blur
177/// sequence's own first and last steps do. Wrapping an always-even sequence in
178/// one more pass on each side keeps the whole thing even, so the result lands
179/// back in the page the layer's own contents were rendered into; the check
180/// below is what keeps that a checked property rather than an assumed one, the
181/// same way [`blur::blur_passes`] checks its own.
182#[must_use]
183pub fn drop_shadow_passes(shadow: &DropShadow, size: SizeU16) -> Vec<FilterStep> {
184 let blur = GaussianBlur {
185 std_deviation: shadow.std_deviation,
186 n_decimations: shadow.n_decimations,
187 kernel: shadow.kernel,
188 kernel_size: shadow.kernel_size,
189 edge_mode: shadow.edge_mode,
190 };
191
192 let mut steps: Vec<FilterStep> = Vec::new();
193 steps.push(FilterStep {
194 kind: FilterPassKind::Offset,
195 source: size,
196 dest: size,
197 });
198 steps.extend(blur::blur_passes(&blur, size));
199 steps.push(FilterStep {
200 kind: FilterPassKind::Colorize,
201 source: size,
202 dest: size,
203 });
204
205 // The reference's `ensure_result_in_original`, carried over from
206 // `blur::blur_passes` for the same reason: a pass writes the page it did
207 // not read, so an odd-length sequence would strand the result in the
208 // scratch page. Offset and colourize add exactly two passes to the blur's
209 // own always-even sequence, so this never fires today; it is what keeps
210 // that a checked fact rather than an assumption a future change to either
211 // wrapper pass could quietly break.
212 if !steps.len().is_multiple_of(2) {
213 let size = steps.last().map_or(size, |step| step.dest);
214 steps.push(FilterStep {
215 kind: FilterPassKind::Copy,
216 source: size,
217 dest: size,
218 });
219 }
220
221 steps
222}