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