Skip to main content

bevy_react/filters/builtin/
bloom.rs

1//! The `bloom` built-in: bright areas bleed light over their surroundings.
2
3use std::sync::Arc;
4
5use bevy::asset::load_embedded_asset;
6use bevy::prelude::*;
7use bevy::shader::Shader;
8use serde::Deserialize;
9use serde_json::Value;
10
11use crate::animations::ValueKind;
12use crate::filters::builtin::BlurParams;
13use crate::filters::params::{ParamSlot, length_logical_px, static_layout};
14use crate::filters::registry::{ReactFilter, ResolvedFilterPass};
15use crate::protocol::units::Length;
16
17/// Bright-pass mode marker in `params[0].w` (see `bloom.wgsl`).
18const MODE_BRIGHT: f32 = 0.0;
19/// Combine mode marker in `params[0].w`.
20const MODE_COMBINE: f32 = 1.0;
21
22fn default_radius() -> Length {
23    Length::Px(12.0)
24}
25
26fn default_threshold() -> f32 {
27    0.7
28}
29
30fn default_intensity() -> f32 {
31    1.0
32}
33
34/// `bloom`: glow where bright areas bleed light — a bright-pass thresholded
35/// at `threshold`, blurred by `radius`, and added back onto the original
36/// scaled by `intensity`. `{ name: "bloom" }` with no params is a visible
37/// glow (shorthand-default convention); the true identity is `intensity: 0`.
38///
39/// `threshold` is a cut on **perceptual luminance** (`0..=1`, gamma-encoded —
40/// how bright the color *looks*, not its linear energy); `1` blooms nothing,
41/// `0` blooms everything.
42///
43/// Resolves to **four** passes: bright-pass (`bloom.wgsl`), blur H + V
44/// (literally `blur.wgsl`, sharing the plain blur's pipeline), and a combine
45/// (`bloom.wgsl`) that reads the original capture through the prelude's
46/// always-bound `capture_texture`. All three named params ride every pass in
47/// the same slots, so length rewriting and per-param animation bindings
48/// (which write all passes of a wire entry) stay uniform; the mode switch is
49/// pass-internal in `params[0].w`, like blur's direction.
50#[derive(Debug, Clone, Copy, PartialEq, Deserialize, ts_rs::TS)]
51#[serde(deny_unknown_fields)]
52pub struct BloomParams {
53    // Mirrors `#[react_filter]`'s override for a `Length` field.
54    #[serde(default = "default_radius")]
55    #[ts(type = "number | string")]
56    pub radius: Length,
57    #[serde(default = "default_threshold")]
58    pub threshold: f32,
59    #[serde(default = "default_intensity")]
60    pub intensity: f32,
61}
62
63impl Default for BloomParams {
64    fn default() -> Self {
65        Self {
66            radius: default_radius(),
67            threshold: default_threshold(),
68            intensity: default_intensity(),
69        }
70    }
71}
72
73fn bloom_layout() -> Arc<[ParamSlot]> {
74    static_layout![
75        ParamSlot {
76            name: "radius",
77            kind: ValueKind::Length,
78            vec: 0,
79            comp: 0,
80            len: 1,
81        },
82        ParamSlot {
83            name: "threshold",
84            kind: ValueKind::Scalar,
85            vec: 1,
86            comp: 0,
87            len: 1,
88        },
89        ParamSlot {
90            name: "intensity",
91            kind: ValueKind::Scalar,
92            vec: 1,
93            comp: 1,
94            len: 1,
95        },
96    ]
97}
98
99impl ReactFilter for BloomParams {
100    const NAME: &'static str = "bloom";
101
102    fn shader(assets: &AssetServer) -> Handle<Shader> {
103        load_embedded_asset!(assets, "bloom.wgsl")
104    }
105
106    fn identity_params() -> Option<Value> {
107        // Deserializes through the struct, so radius/threshold take their
108        // defaults; intensity 0 makes the combine pass return exactly the
109        // original regardless of them.
110        Some(serde_json::json!({ "intensity": 0.0 }))
111    }
112
113    /// Glow reach = blur reach: 3σ-style, like `blur`.
114    fn outset(&self) -> Result<f32, String> {
115        Ok(3.0 * length_logical_px(Self::NAME, "radius", self.radius)?)
116    }
117
118    fn pack(&self) -> (Vec<Vec4>, Arc<[ParamSlot]>) {
119        // The bright-pass's packing; `resolve` builds all four passes. `pack`
120        // is infallible, so a non-px radius falls back to 0 here — but it can
121        // never reach the shader: `resolve`/`outset` reject it first.
122        let radius = length_logical_px(Self::NAME, "radius", self.radius).unwrap_or(0.0);
123        (
124            vec![
125                Vec4::new(radius, 0.0, 0.0, MODE_BRIGHT),
126                Vec4::new(self.threshold, self.intensity, 0.0, 0.0),
127            ],
128            bloom_layout(),
129        )
130    }
131
132    fn resolve(&self, assets: &AssetServer) -> Result<Vec<ResolvedFilterPass>, String> {
133        let radius = length_logical_px(Self::NAME, "radius", self.radius)?;
134        let bloom_shader = Self::shader(assets);
135        let blur_shader = BlurParams::shader(assets);
136        let named = Vec4::new(self.threshold, self.intensity, 0.0, 0.0);
137        let pass = |shader: &Handle<Shader>, internal: (f32, f32, f32)| ResolvedFilterPass {
138            shader: shader.clone(),
139            params: vec![Vec4::new(radius, internal.0, internal.1, internal.2), named],
140            layout: bloom_layout(),
141            wire_index: 0,
142        };
143        Ok(vec![
144            pass(&bloom_shader, (0.0, 0.0, MODE_BRIGHT)),
145            pass(&blur_shader, (1.0, 0.0, 0.0)),
146            pass(&blur_shader, (0.0, 1.0, 0.0)),
147            pass(&bloom_shader, (0.0, 0.0, MODE_COMBINE)),
148        ])
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use serde_json::json;
155
156    use super::*;
157    use crate::filters::registry::FilterRegistry;
158    use crate::filters::test_util::{asset_app, params};
159
160    /// Bloom expands into bright-pass → blur H → blur V → combine, all
161    /// `wire_index: 0`, with the named params (radius/threshold/intensity) in
162    /// the same slots of every pass and the mode/direction components
163    /// pass-internal.
164    #[test]
165    fn bloom_resolves_to_four_passes() {
166        let app = asset_app();
167        let assets = app.world().resource::<AssetServer>();
168        let passes = params::<BloomParams>(json!({
169            "radius": 4, "threshold": 0.5, "intensity": 2
170        }))
171        .resolve(assets)
172        .expect("bloom resolves");
173        assert_eq!(passes.len(), 4);
174
175        let named = Vec4::new(0.5, 2.0, 0.0, 0.0);
176        assert_eq!(passes[0].params, vec![Vec4::new(4.0, 0.0, 0.0, 0.0), named]);
177        assert_eq!(passes[1].params, vec![Vec4::new(4.0, 1.0, 0.0, 0.0), named]);
178        assert_eq!(passes[2].params, vec![Vec4::new(4.0, 0.0, 1.0, 0.0), named]);
179        assert_eq!(passes[3].params, vec![Vec4::new(4.0, 0.0, 0.0, 1.0), named]);
180        assert!(passes.iter().all(|p| p.wire_index == 0));
181        assert_eq!(passes[0].layout[0].kind, ValueKind::Length);
182
183        // Bright-pass/combine share bloom's shader; the middle passes are
184        // literally the blur shader (so they share its compiled pipeline).
185        assert_eq!(passes[0].shader, passes[3].shader);
186        assert_eq!(passes[1].shader, passes[2].shader);
187        assert_eq!(passes[1].shader, BlurParams::shader(assets));
188        assert_ne!(passes[0].shader, passes[1].shader);
189    }
190
191    /// Bloom bleeds 3x its blur radius, like `blur`; the default radius is
192    /// 12px, so the no-params outset is 36 logical px.
193    #[test]
194    fn bloom_outset_is_three_radii() {
195        assert_eq!(
196            params::<BloomParams>(json!({ "radius": 4 })).outset(),
197            Ok(12.0)
198        );
199        assert_eq!(params::<BloomParams>(json!({})).outset(), Ok(36.0));
200    }
201
202    /// Empty params take the shorthand defaults: a visible glow.
203    #[test]
204    fn bloom_empty_params_default_to_a_visible_glow() {
205        let p = params::<BloomParams>(json!({}));
206        assert_eq!(p, BloomParams::default());
207        assert_eq!(p.radius, Length::Px(12.0));
208        assert_eq!(p.threshold, 0.7);
209        assert_eq!(p.intensity, 1.0);
210    }
211
212    /// A non-px bloom radius rejects from both baked registry fns, naming the
213    /// offending unit — same contract as blur.
214    #[test]
215    fn non_px_bloom_radius_rejects_from_registry() {
216        let app = asset_app();
217        let assets = app.world().resource::<AssetServer>();
218        let mut registry = FilterRegistry::default();
219        registry.register::<BloomParams>();
220        let bloom = &registry.entries["bloom"];
221
222        let value = json!({ "radius": "50%" });
223        let err = (bloom.resolve)(&value, assets).expect_err("percent radius must reject resolve");
224        assert!(
225            err.contains("px") && err.contains("%"),
226            "names the unit: {err}"
227        );
228        let err = (bloom.outset)(&value).expect_err("percent radius must reject outset");
229        assert!(
230            err.contains("px") && err.contains("%"),
231            "names the unit: {err}"
232        );
233
234        // Bare numbers and explicit px stay accepted.
235        assert!((bloom.resolve)(&json!({ "radius": 4 }), assets).is_ok());
236        assert_eq!((bloom.outset)(&json!({ "radius": "4px" })), Ok(12.0));
237    }
238
239    /// `deny_unknown_fields`: a typoed param rejects instead of silently
240    /// falling back to the defaults.
241    #[test]
242    fn unknown_bloom_param_rejects() {
243        assert!(serde_json::from_value::<BloomParams>(json!({ "radiu": 4 })).is_err());
244    }
245}