Skip to main content

bevy_react/filters/
registry.rs

1//! The typed filter layer: the [`ReactFilter`] trait, resolved render passes
2//! ([`ResolvedFilterPass`]), and the [`FilterRegistry`] of known filters with
3//! their baked, `AssetServer`-free resolve/outset fn pointers.
4
5use std::any::TypeId;
6use std::collections::HashMap;
7use std::sync::Arc;
8
9use bevy::prelude::*;
10use bevy::shader::Shader;
11use serde::de::DeserializeOwned;
12use serde_json::Value;
13use ts_rs::TS;
14
15use super::params::{ParamSlot, check_param_cap};
16use crate::animations::ValueKind;
17use crate::registry::{NamedEntry, register_entry};
18use crate::ts_codegen::TsCollector;
19
20/// A typed, named filter: how its params deserialize (strict — built-ins use
21/// `#[serde(deny_unknown_fields)]`), pack into shader uniforms, and resolve
22/// into render passes.
23pub trait ReactFilter: Send + Sync + Sized + 'static {
24    /// The wire name in a [`FilterUse`](crate::filters::FilterUse)
25    /// (camelCase, e.g. `"hueRotate"`).
26    const NAME: &'static str;
27
28    /// Whether the effect is time-driven (must re-render every frame even
29    /// with static params). None of the built-ins are.
30    const USES_TIME: bool = false;
31
32    /// Whether this is a two-input **morph** filter (a `morphFilter` name).
33    /// The two families are separate: morph filters cannot appear in
34    /// `filter`/`backdropFilter` chains and regular filters cannot be
35    /// `morphFilter` names — enforced at resolve time per chain surface, and
36    /// mirrored in the generated TypeScript (`BevyFilters` vs
37    /// `BevyMorphFilters`). Set by `#[react_morph_filter]`; regular filters
38    /// keep the default.
39    const IS_MORPH: bool = false;
40
41    /// The params JSON of this filter's **identity** invocation (no visual
42    /// effect), if it has one — brightness/contrast/saturate `amount: 1`,
43    /// grayscale/sepia/invert `amount: 0` (their identity is `0`, NOT the CSS
44    /// shorthand default of `1`), blur `radius: 0`, hueRotate `angle: 0`.
45    ///
46    /// Consumed by the transition engine's filter channel: when a chain
47    /// gains/loses trailing entries, the shorter side is padded with identity
48    /// passes so the change *fades* instead of popping (see
49    /// [`plan_filter_ease`](crate::filters::plan_filter_ease)). The identity
50    /// value is resolved through the normal [`resolve`](Self::resolve) path,
51    /// so its packing, layout, and shader handles match the real filter's for
52    /// free. `None` (the default — `#[react_filter]` custom filters keep it)
53    /// opts out of extension: mismatched chains involving the filter swap
54    /// discretely.
55    fn identity_params() -> Option<Value> {
56        None
57    }
58
59    /// The pass shader. Lazy: called inside `resolve`, never at registration
60    /// time, so registering filters needs no `AssetServer`.
61    fn shader(assets: &AssetServer) -> Handle<Shader>;
62
63    /// Extra *logical* px the effect bleeds outside the node's rect (blur
64    /// reach). Identity for most filters. Fallible so params that cannot
65    /// yield a px value (blur's non-px radius) reject instead of silently
66    /// packing `0.0`.
67    fn outset(&self) -> Result<f32, String> {
68        Ok(0.0)
69    }
70
71    /// Pack the params into the uniform `Vec4` array plus the layout saying
72    /// where each named param landed. `Length` params pack their logical-px
73    /// value (see [`ParamSlot`]).
74    ///
75    /// **Contract:** this is the canonical *single-invocation* packing;
76    /// `resolve` overrides may repack (e.g. blur's per-direction params), but
77    /// must agree with the layout for named slots. Every slot obeys
78    /// [`ParamSlot`]'s no-straddle rule: `comp + len <= 4`, padding to the
79    /// next `Vec4` where needed. `#[react_filter]`-generated impls fill the
80    /// array contiguously in field declaration order; the built-ins keep
81    /// their hand-written canonical layouts.
82    fn pack(&self) -> (Vec<Vec4>, Arc<[ParamSlot]>);
83
84    /// Resolve into render passes. The default builds one pass straight from
85    /// [`pack`](Self::pack) (see [`resolve_single_pass`]); multi-pass effects
86    /// (blur: H then V) override it. Passes come back with `wire_index: 0` —
87    /// resolve fns don't know their chain position; the chain resolver
88    /// rewrites it (see [`ResolvedFilterPass::wire_index`]).
89    fn resolve(&self, assets: &AssetServer) -> Result<Vec<ResolvedFilterPass>, String> {
90        resolve_single_pass(self, assets)
91    }
92}
93
94/// Marker for two-input morph filters (`morphFilter` names). Implemented by
95/// `#[react_morph_filter]` (and the built-in morphs); the bound on
96/// `add_react_morph_filter` gives compile-time guidance — registration truth
97/// rides [`ReactFilter::IS_MORPH`].
98pub trait ReactMorphFilter: ReactFilter {}
99
100/// The default single-pass resolve body: [`ReactFilter::pack`] + the
101/// param-vec cap check + one pass tagged `wire_index: 0`.
102///
103/// `pub` so `#[react_filter]`-generated `resolve` overrides (which prepend
104/// `Length` px validation) can delegate to the canonical body from consumer
105/// crates instead of re-implementing the cap check.
106pub fn resolve_single_pass<T: ReactFilter>(
107    filter: &T,
108    assets: &AssetServer,
109) -> Result<Vec<ResolvedFilterPass>, String> {
110    let (params, layout) = filter.pack();
111    check_param_cap(T::NAME, params.len())?;
112    Ok(vec![ResolvedFilterPass {
113        shader: T::shader(assets),
114        params,
115        layout,
116        wire_index: 0,
117    }])
118}
119
120/// One resolved render pass of a filter chain.
121#[derive(Debug, Clone, PartialEq)]
122pub struct ResolvedFilterPass {
123    pub shader: Handle<Shader>,
124    /// The packed uniform array (at most
125    /// [`MAX_FILTER_PARAM_VECS`](crate::filters::MAX_FILTER_PARAM_VECS)
126    /// entries).
127    pub params: Vec<Vec4>,
128    /// Where each named param sits in `params` (animation + physical-px
129    /// rewrite metadata).
130    pub layout: Arc<[ParamSlot]>,
131    /// Index of the originating [`FilterUse`](crate::filters::FilterUse) in
132    /// the wire chain — blur's two expanded passes share one. Resolve fns
133    /// always emit `0`; the chain resolve system
134    /// ([`resolve_chains`](crate::filters::resolve_chains))
135    /// rewrites it per chain position.
136    ///
137    /// **Rewrite rule:** chains longer than `u8::MAX` saturate — the
138    /// rewriter must clamp to `u8::MAX`, never wrap. Wire input can't reach
139    /// saturation (decode caps chains at
140    /// [`MAX_CHAIN_LEN`](crate::filters::MAX_CHAIN_LEN) entries, so every
141    /// decoded index fits); the rule exists for programmatic chains.
142    pub wire_index: u8,
143}
144
145/// Physical-px rewrite: `Length` slots are packed logical (the [`ParamSlot`]
146/// contract) — scale them for upload. Bounds are defended so a custom
147/// filter's bad layout can't panic here. Shared by
148/// [`resolve_chains`](crate::filters::resolve_chains) and the
149/// transition padding
150/// ([`plan_filter_ease`](crate::filters::plan_filter_ease)) via
151/// [`stamp_and_push`].
152pub(super) fn rewrite_length_slots(pass: &mut ResolvedFilterPass, scale: f32) {
153    let layout = pass.layout.clone();
154    for slot in layout.iter().filter(|s| s.kind == ValueKind::Length) {
155        for comp in slot.comp..(slot.comp + slot.len).min(4) {
156            if let Some(vec) = pass.params.get_mut(slot.vec) {
157                vec[comp] *= scale;
158            }
159        }
160    }
161}
162
163/// Append one resolved chain entry's passes to a pass list: stamp the wire
164/// index — saturating at `u8::MAX`, never wrapping (see
165/// [`ResolvedFilterPass::wire_index`]; decode caps chains under the limit,
166/// but programmatic chains could exceed it) — and rewrite `Length` slots to
167/// physical px. The one post-processing path shared by the chain resolver
168/// and the transition engine's identity padding, so the two writers can never
169/// drift.
170pub(super) fn stamp_and_push(
171    passes: Vec<ResolvedFilterPass>,
172    wire_index: usize,
173    scale: f32,
174    out: &mut Vec<ResolvedFilterPass>,
175) {
176    let wire_index = wire_index.min(u8::MAX as usize) as u8;
177    for mut pass in passes {
178        pass.wire_index = wire_index;
179        rewrite_length_slots(&mut pass, scale);
180        out.push(pass);
181    }
182}
183
184/// One registered filter: everything the chain resolver needs, baked into
185/// `AssetServer`-free fn pointers at registration time (shader loading stays
186/// lazy — the pointers *receive* the `&AssetServer`).
187///
188/// `resolve` and `outset` each deserialize the params — intentionally, not an
189/// oversight: the duplication keeps `outset` `AssetServer`-free, so the future
190/// per-frame texture-sizing path never grows an `AssetServer` dependency. Do
191/// not "optimize" them into one fn.
192pub struct FilterRegistration {
193    type_id: TypeId,
194    /// Deserialize a raw [`FilterUse::params`](crate::filters::FilterUse)
195    /// value (strict) and resolve it into render passes. Errors are messages
196    /// for the devtools warning sink.
197    pub(crate) resolve: fn(&Value, &AssetServer) -> Result<Vec<ResolvedFilterPass>, String>,
198    /// Deserialize the raw params and report the filter's logical-px outset.
199    /// Separate from `resolve` so the chain resolver can size layer textures
200    /// without an `AssetServer` in hand.
201    pub(crate) outset: fn(&Value) -> Result<f32, String>,
202    /// Mirrors [`ReactFilter::USES_TIME`].
203    pub(crate) uses_time: bool,
204    /// Mirrors [`ReactFilter::IS_MORPH`] — which family the name belongs to
205    /// (regular chains vs `morphFilter`).
206    pub(crate) is_morph: bool,
207    /// Mirrors [`ReactFilter::identity_params`] — `None` means the filter has
208    /// no identity and cannot pad a chain extension (see
209    /// [`plan_filter_ease`](crate::filters::plan_filter_ease)).
210    pub(crate) identity: fn() -> Option<Value>,
211    /// The params type's TypeScript reference name (export-only; the wire
212    /// name is this entry's key in [`FilterRegistry::entries`], exactly like
213    /// the event registry).
214    pub(crate) ts_name: fn() -> String,
215    /// Collects the params type declaration (and its dependencies).
216    pub(crate) ts_collect: fn(&mut TsCollector),
217}
218
219impl NamedEntry for FilterRegistration {
220    fn type_id(&self) -> TypeId {
221        self.type_id
222    }
223}
224
225/// Known filters, keyed by wire name. Populated by
226/// [`register_builtin_filters`](crate::filters::register_builtin_filters);
227/// consumed by
228/// [`resolve_chains`](crate::filters::resolve_chains).
229#[derive(Resource, Default)]
230pub struct FilterRegistry {
231    pub(crate) entries: HashMap<&'static str, FilterRegistration>,
232}
233
234impl FilterRegistry {
235    /// Register filter type `T` under `T::NAME`. Idempotent per type; a
236    /// different type claiming an occupied name warns and replaces (see
237    /// [`register_entry`]).
238    pub fn register<T: ReactFilter + DeserializeOwned + TS>(&mut self) {
239        register_entry(
240            &mut self.entries,
241            T::NAME,
242            "filter",
243            FilterRegistration {
244                type_id: TypeId::of::<T>(),
245                resolve: |value, assets| {
246                    let passes = decode_params::<T>(value)?.resolve(assets)?;
247                    // Custom `resolve` overrides bypass the default's cap
248                    // check, so re-check every pass here.
249                    for pass in &passes {
250                        check_param_cap(T::NAME, pass.params.len())?;
251                    }
252                    Ok(passes)
253                },
254                outset: |value| decode_params::<T>(value)?.outset(),
255                uses_time: T::USES_TIME,
256                is_morph: T::IS_MORPH,
257                identity: T::identity_params,
258                // `T` is concrete here, so its TS shape is baked into these
259                // fns (the same split as `EventRegistration`).
260                ts_name: <T as TS>::name,
261                ts_collect: |c| c.add::<T>(),
262            },
263        );
264    }
265}
266
267fn decode_params<T: ReactFilter + DeserializeOwned>(value: &Value) -> Result<T, String> {
268    T::deserialize(value).map_err(|e| format!("filter {:?} params: {e}", T::NAME))
269}
270
271#[cfg(test)]
272mod tests {
273    use std::f32::consts::PI;
274
275    use serde::Deserialize;
276    use serde_json::json;
277
278    use super::super::test_util::asset_app;
279    use super::*;
280    use crate::filters::{
281        BlurParams, HueRotateParams, MAX_FILTER_PARAM_VECS, register_builtin_filters,
282    };
283
284    /// A filter packing more than `MAX_FILTER_PARAM_VECS` vec4s fails to
285    /// resolve.
286    #[test]
287    fn over_cap_param_vecs_are_rejected() {
288        struct NineVecs;
289        impl ReactFilter for NineVecs {
290            const NAME: &'static str = "nineVecs";
291            fn shader(_assets: &AssetServer) -> Handle<Shader> {
292                Handle::default()
293            }
294            fn pack(&self) -> (Vec<Vec4>, Arc<[ParamSlot]>) {
295                (
296                    vec![Vec4::ZERO; MAX_FILTER_PARAM_VECS + 1],
297                    Arc::from(Vec::new()),
298                )
299            }
300        }
301        let app = asset_app();
302        let assets = app.world().resource::<AssetServer>();
303        let err = NineVecs.resolve(assets).expect_err("over cap must reject");
304        assert!(err.contains("nineVecs"), "error names the filter: {err}");
305    }
306
307    /// The registry's baked `resolve` re-checks the cap on every pass, so a
308    /// custom `resolve` override can't smuggle an over-cap pass past the
309    /// default-resolve check.
310    #[test]
311    fn registry_recheck_rejects_over_cap_custom_resolve() {
312        #[derive(Deserialize, ts_rs::TS)]
313        #[serde(deny_unknown_fields)]
314        struct SneakyResolve {}
315        impl ReactFilter for SneakyResolve {
316            const NAME: &'static str = "sneakyResolve";
317            fn shader(_assets: &AssetServer) -> Handle<Shader> {
318                Handle::default()
319            }
320            fn pack(&self) -> (Vec<Vec4>, Arc<[ParamSlot]>) {
321                (Vec::new(), Arc::from(Vec::new()))
322            }
323            fn resolve(&self, assets: &AssetServer) -> Result<Vec<ResolvedFilterPass>, String> {
324                Ok(vec![ResolvedFilterPass {
325                    shader: Self::shader(assets),
326                    params: vec![Vec4::ZERO; MAX_FILTER_PARAM_VECS + 1],
327                    layout: Arc::from(Vec::new()),
328                    wire_index: 0,
329                }])
330            }
331        }
332        let app = asset_app();
333        let assets = app.world().resource::<AssetServer>();
334        let mut registry = FilterRegistry::default();
335        registry.register::<SneakyResolve>();
336        let err = (registry.entries["sneakyResolve"].resolve)(&json!({}), assets)
337            .expect_err("over-cap custom resolve must reject");
338        assert!(
339            err.contains("sneakyResolve"),
340            "error names the filter: {err}"
341        );
342    }
343
344    /// `register_builtin_filters` registers all sixteen names; running it
345    /// again (same types) is a no-op per `register_entry` semantics.
346    #[test]
347    fn builtin_filters_register_all_sixteen() {
348        let mut app = App::new();
349        register_builtin_filters(&mut app);
350        let registry = app.world().resource::<FilterRegistry>();
351        let mut names: Vec<_> = registry.entries.keys().copied().collect();
352        names.sort_unstable();
353        assert_eq!(
354            names,
355            [
356                "bloom",
357                "blur",
358                "brightness",
359                "chromaticAberration",
360                "contrast",
361                "crossfade",
362                "gradientMap",
363                "grayscale",
364                "hueRotate",
365                "invert",
366                "linearWipe",
367                "outline",
368                "pixelize",
369                "saturate",
370                "sepia",
371                "shadow",
372            ]
373        );
374        assert!(registry.entries.values().all(|r| !r.uses_time));
375        // The morph family is exactly the three morph built-ins.
376        let mut morphs: Vec<_> = registry
377            .entries
378            .iter()
379            .filter(|(_, r)| r.is_morph)
380            .map(|(name, _)| *name)
381            .collect();
382        morphs.sort_unstable();
383        assert_eq!(morphs, ["crossfade", "linearWipe", "pixelize"]);
384        register_builtin_filters(&mut app);
385        assert_eq!(app.world().resource::<FilterRegistry>().entries.len(), 16);
386    }
387
388    /// Every built-in entry carries working TS-export slots — `ts_name` names
389    /// the params type and `ts_collect` declares it into a collector — so the
390    /// TS exporter (`message.rs::render_typescript`) walks built-ins exactly
391    /// like custom filters.
392    #[test]
393    fn builtin_filters_have_working_ts_slots() {
394        let mut app = App::new();
395        register_builtin_filters(&mut app);
396        let registry = app.world().resource::<FilterRegistry>();
397        for (name, reg) in &registry.entries {
398            let ts = (reg.ts_name)();
399            let mut c = TsCollector::default();
400            (reg.ts_collect)(&mut c);
401            assert!(c.decls.contains_key(&ts), "{name}: no decl for {ts}");
402        }
403
404        let blur = &registry.entries["blur"];
405        assert_eq!((blur.ts_name)(), "BlurParams");
406        let mut c = TsCollector::default();
407        (blur.ts_collect)(&mut c);
408        let decl = &c.decls["BlurParams"];
409        // The `Length` field mirrors `#[react_filter]`'s wire-flexible
410        // `number | string` override.
411        assert!(decl.contains("radius"), "{decl}");
412        assert!(decl.contains("number | string"), "{decl}");
413
414        // The six amount ops share the macro-generated shape; `Angle` gets
415        // the same override as `Length`.
416        assert_eq!((registry.entries["grayscale"].ts_name)(), "GrayscaleParams");
417        assert_eq!((registry.entries["hueRotate"].ts_name)(), "HueRotateParams");
418        let mut c = TsCollector::default();
419        (registry.entries["hueRotate"].ts_collect)(&mut c);
420        assert!(
421            c.decls["HueRotateParams"].contains("angle: number | string"),
422            "{}",
423            c.decls["HueRotateParams"]
424        );
425    }
426
427    /// The registry's fn pointers carry the whole pipeline: deserialize →
428    /// pack → passes, and the separate outset accessor.
429    #[test]
430    fn registry_resolve_end_to_end() {
431        let app = asset_app();
432        let assets = app.world().resource::<AssetServer>();
433        let mut registry = FilterRegistry::default();
434        registry.register::<BlurParams>();
435        registry.register::<HueRotateParams>();
436
437        let blur = &registry.entries["blur"];
438        let passes = (blur.resolve)(&json!({ "radius": 8 }), assets).expect("blur resolves");
439        assert_eq!(passes.len(), 2);
440        assert_eq!(passes[0].params[0].x, 8.0);
441        assert_eq!(
442            (blur.outset)(&json!({ "radius": 8 })).expect("outset"),
443            24.0
444        );
445
446        let hue = &registry.entries["hueRotate"];
447        let passes = (hue.resolve)(&json!({ "angle": "0.5turn" }), assets).expect("hue resolves");
448        assert_eq!(passes.len(), 1);
449        assert!((passes[0].params[1].z - PI).abs() < 1e-4);
450        assert_eq!((hue.outset)(&json!({})).expect("outset"), 0.0);
451    }
452
453    /// Under the asset-capable harness every built-in resolves to real
454    /// embedded shader handles: the seven color ops share
455    /// `color_matrix.wgsl`, blur's two passes share `blur.wgsl`, the two
456    /// shaders are distinct, and bloom's four passes mix `bloom.wgsl`
457    /// (bright-pass/combine) with blur's handle (its middle passes).
458    #[test]
459    fn resolve_returns_embedded_shader_handles() {
460        let mut app = asset_app();
461        register_builtin_filters(&mut app);
462        let world = app.world();
463        let assets = world.resource::<AssetServer>();
464        let registry = world.resource::<FilterRegistry>();
465
466        let shader_of = |name: &str| {
467            let passes =
468                (registry.entries[name].resolve)(&json!({}), assets).expect("filter resolves");
469            let first = passes[0].shader.clone();
470            assert!(
471                passes.iter().all(|p| p.shader == first),
472                "all of {name}'s passes share one shader"
473            );
474            assert_ne!(first, Handle::default(), "{name} has a real shader");
475            first
476        };
477        let path_of = |handle: &Handle<Shader>| handle.path().expect("embedded path").to_string();
478
479        let color = shader_of("brightness");
480        for name in [
481            "contrast",
482            "saturate",
483            "grayscale",
484            "sepia",
485            "invert",
486            "hueRotate",
487        ] {
488            assert_eq!(shader_of(name), color, "{name} shares the color shader");
489        }
490        assert_eq!(
491            &path_of(&color),
492            "embedded://bevy_react/filters/builtin/color_matrix.wgsl"
493        );
494
495        let blur = shader_of("blur");
496        assert_ne!(blur, color);
497        assert_eq!(
498            &path_of(&blur),
499            "embedded://bevy_react/filters/builtin/blur.wgsl"
500        );
501
502        assert_eq!(
503            &path_of(&shader_of("chromaticAberration")),
504            "embedded://bevy_react/filters/builtin/chromatic_aberration.wgsl"
505        );
506
507        assert_eq!(
508            &path_of(&shader_of("gradientMap")),
509            "embedded://bevy_react/filters/builtin/gradient_map.wgsl"
510        );
511        assert_eq!(
512            &path_of(&shader_of("outline")),
513            "embedded://bevy_react/filters/builtin/outline.wgsl"
514        );
515
516        // Bloom deliberately mixes shaders across its passes, so it can't go
517        // through `shader_of`.
518        let passes =
519            (registry.entries["bloom"].resolve)(&json!({}), assets).expect("bloom resolves");
520        assert_eq!(passes.len(), 4);
521        assert_eq!(
522            &path_of(&passes[0].shader),
523            "embedded://bevy_react/filters/builtin/bloom.wgsl"
524        );
525        assert_eq!(passes[3].shader, passes[0].shader);
526        assert_eq!(passes[1].shader, blur, "middle passes reuse blur's shader");
527        assert_eq!(passes[2].shader, blur);
528
529        // Shadow mirrors bloom's structure: prep/combine on its own shader,
530        // blur's shader in the middle.
531        let passes =
532            (registry.entries["shadow"].resolve)(&json!({}), assets).expect("shadow resolves");
533        assert_eq!(passes.len(), 4);
534        assert_eq!(
535            &path_of(&passes[0].shader),
536            "embedded://bevy_react/filters/builtin/shadow.wgsl"
537        );
538        assert_eq!(passes[3].shader, passes[0].shader);
539        assert_eq!(passes[1].shader, blur, "middle passes reuse blur's shader");
540        assert_eq!(passes[2].shader, blur);
541    }
542}