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