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 ®istry.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 = ®istry.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 = ®istry.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 = ®istry.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}