bevy_react/filters/builtin/
bloom.rs1use 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
17const MODE_BRIGHT: f32 = 0.0;
19const 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#[derive(Debug, Clone, Copy, PartialEq, Deserialize, ts_rs::TS)]
51#[serde(deny_unknown_fields)]
52pub struct BloomParams {
53 #[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 Some(serde_json::json!({ "intensity": 0.0 }))
111 }
112
113 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 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 #[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 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 #[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 #[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 #[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 = ®istry.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 assert!((bloom.resolve)(&json!({ "radius": 4 }), assets).is_ok());
236 assert_eq!((bloom.outset)(&json!({ "radius": "4px" })), Ok(12.0));
237 }
238
239 #[test]
242 fn unknown_bloom_param_rejects() {
243 assert!(serde_json::from_value::<BloomParams>(json!({ "radiu": 4 })).is_err());
244 }
245}