this repo has no description
1use bevy::prelude::*;
2use bevy::image::{Image, TextureFormatPixelInfo};
3use bevy_hanabi::prelude::*;
4use bevy_hanabi::Gradient as HanabiGradient;
5use jacquard::common::types::collection::Collection;
6
7use crate::helix::HelixState;
8use crate::ingest::{EventSource, JetstreamEventMarker, NeedsParticle, TimeWindow};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ZoomVariant {
12 Outer,
13 Inner,
14}
15
16impl ZoomVariant {
17 pub fn from_level(level: usize) -> Self {
18 if level <= 1 { ZoomVariant::Outer } else { ZoomVariant::Inner }
19 }
20}
21
22#[derive(Clone)]
23pub struct ZoomEffects {
24 pub live: Handle<EffectAsset>,
25 pub hist: Handle<EffectAsset>,
26}
27
28#[derive(Clone)]
29pub struct ZoomPair {
30 pub outer: ZoomEffects,
31 pub inner: ZoomEffects,
32}
33
34impl ZoomPair {
35 pub fn select(&self, zoom: ZoomVariant) -> &ZoomEffects {
36 match zoom {
37 ZoomVariant::Outer => &self.outer,
38 ZoomVariant::Inner => &self.inner,
39 }
40 }
41}
42
43/// NSID-keyed particle effects. Longest prefix match wins.
44#[derive(Resource)]
45pub struct ParticleEffects {
46 pub effects: Vec<(&'static str, ZoomEffects, ZoomEffects)>,
47 pub bsky_default: ZoomPair,
48 pub shiny: ZoomPair,
49}
50
51impl ParticleEffects {
52 pub fn lookup(&self, collection_str: Option<&str>, zoom: ZoomVariant) -> &ZoomEffects {
53 collection_str
54 .and_then(|collection| {
55 self.effects
56 .iter()
57 .find(|(nsid, _, _)| collection.starts_with(nsid))
58 .map(|(_, outer, inner)| match zoom {
59 ZoomVariant::Outer => outer,
60 ZoomVariant::Inner => inner,
61 })
62 })
63 .unwrap_or_else(|| match collection_str {
64 Some(c) if c.starts_with("app.bsky.") => self.bsky_default.select(zoom),
65 _ => self.shiny.select(zoom),
66 })
67 }
68}
69
70#[derive(Clone, Copy)]
71pub struct BloomParams {
72 pub speed: f32,
73 pub lifetime: f32,
74 pub size: f32,
75 pub burst_count: f32,
76}
77
78/// Soft radial gradient circle texture (gaussian-ish falloff).
79pub fn generate_soft_circle(images: &mut Assets<Image>) -> Handle<Image> {
80 const SIZE: u32 = 32;
81 let mut data = vec![0u8; (SIZE * SIZE * 4) as usize];
82 let center = SIZE as f32 / 2.0;
83 for y in 0..SIZE {
84 for x in 0..SIZE {
85 let dx = x as f32 + 0.5 - center;
86 let dy = y as f32 + 0.5 - center;
87 let dist = (dx * dx + dy * dy).sqrt() / center;
88 let alpha = (1.0 - dist * dist).max(0.0).powf(2.0);
89 let i = ((y * SIZE + x) * 4) as usize;
90 data[i] = 255;
91 data[i + 1] = 255;
92 data[i + 2] = 255;
93 data[i + 3] = (alpha * 255.0) as u8;
94 }
95 }
96 images.add(Image::new(
97 bevy::render::render_resource::Extent3d {
98 width: SIZE,
99 height: SIZE,
100 depth_or_array_layers: 1,
101 },
102 bevy::render::render_resource::TextureDimension::D2,
103 data,
104 bevy::render::render_resource::TextureFormat::Rgba8UnormSrgb,
105 default(),
106 ))
107}
108
109pub fn create_bloom_effect(gradient: HanabiGradient<Vec4>, params: BloomParams) -> EffectAsset {
110 let writer = ExprWriter::new();
111
112 let init_pos = SetPositionSphereModifier {
113 center: writer.lit(Vec3::ZERO).expr(),
114 radius: writer.lit(0.005).expr(),
115 dimension: ShapeDimension::Volume,
116 };
117
118 let init_vel = SetVelocitySphereModifier {
119 center: writer.lit(Vec3::ZERO).expr(),
120 speed: writer.lit(params.speed).expr(),
121 };
122
123 let init_age = SetAttributeModifier::new(Attribute::AGE, writer.lit(0.).expr());
124 let init_lifetime =
125 SetAttributeModifier::new(Attribute::LIFETIME, writer.lit(params.lifetime).expr());
126
127 let spawner = SpawnerSettings::once(params.burst_count.into());
128
129 let mut size_gradient = HanabiGradient::new();
130 size_gradient.add_key(0.0, Vec3::splat(params.size));
131 size_gradient.add_key(0.8, Vec3::splat(params.size * 0.5));
132 size_gradient.add_key(1.0, Vec3::ZERO);
133
134 let slot_zero = writer.lit(0u32).expr();
135 let mut module = writer.finish();
136 module.add_texture_slot("particle");
137
138 EffectAsset::new(32, spawner, module)
139 .init(init_pos)
140 .init(init_vel)
141 .init(init_age)
142 .init(init_lifetime)
143 .render(ParticleTextureModifier {
144 texture_slot: slot_zero,
145 sample_mapping: ImageSampleMapping::Modulate,
146 })
147 .render(ColorOverLifetimeModifier {
148 gradient: gradient.into(),
149 blend: ColorBlendMode::Add,
150 mask: ColorBlendMask::RGBA,
151 })
152 .render(SizeOverLifetimeModifier {
153 gradient: size_gradient.into(),
154 screen_space_size: false,
155 })
156}
157
158fn fade_gradient(r: f32, g: f32, b: f32) -> HanabiGradient<Vec4> {
159 let mut gradient = HanabiGradient::new();
160 gradient.add_key(0.0, Vec4::new(r, g, b, 1.0));
161 gradient.add_key(0.7, Vec4::new(r, g, b, 0.8));
162 gradient.add_key(1.0, Vec4::new(r, g, b, 0.0));
163 gradient
164}
165
166fn make_zoom_effects(
167 effects: &mut Assets<EffectAsset>,
168 gradient: HanabiGradient<Vec4>,
169 outward_speed: f32,
170 lifetime: f32,
171 size: f32,
172 burst_count: f32,
173) -> ZoomEffects {
174 let live_params = BloomParams { speed: outward_speed, lifetime, size, burst_count };
175 let hist_params = BloomParams { speed: -outward_speed, lifetime, size, burst_count };
176 ZoomEffects {
177 live: effects.add(create_bloom_effect(gradient.clone(), live_params)),
178 hist: effects.add(create_bloom_effect(gradient, hist_params)),
179 }
180}
181
182pub fn register_default_effects(effects: &mut Assets<EffectAsset>) -> ParticleEffects {
183 let post_outer = make_zoom_effects(effects, fade_gradient(0.4, 0.8, 3.0), 0.0, 0.8, 0.008, 3.0);
184 let post_inner = make_zoom_effects(effects, fade_gradient(0.4, 0.8, 3.0), 0.0, 1.2, 0.02, 4.0);
185
186 let like_outer = make_zoom_effects(effects, fade_gradient(3.0, 0.4, 1.5), 0.0, 0.8, 0.006, 3.0);
187 let like_inner = make_zoom_effects(effects, fade_gradient(3.0, 0.4, 1.5), 0.0, 1.2, 0.015, 4.0);
188
189 let follow_outer = make_zoom_effects(effects, fade_gradient(0.3, 3.0, 1.0), 0.0, 0.8, 0.008, 4.0);
190 let follow_inner = make_zoom_effects(effects, fade_gradient(0.3, 3.0, 1.0), 0.0, 1.2, 0.02, 5.0);
191
192 let bsky_default = ZoomPair {
193 outer: make_zoom_effects(effects, fade_gradient(1.2, 1.2, 1.5), 0.0, 0.6, 0.005, 2.0),
194 inner: make_zoom_effects(effects, fade_gradient(1.2, 1.2, 1.5), 0.0, 1.0, 0.012, 3.0),
195 };
196
197 let mut gold_gradient = HanabiGradient::new();
198 gold_gradient.add_key(0.0, Vec4::new(2.0, 1.5, 0.2, 0.7));
199 gold_gradient.add_key(0.5, Vec4::new(1.5, 1.0, 0.1, 0.4));
200 gold_gradient.add_key(1.0, Vec4::new(1.0, 0.7, 0.05, 0.0));
201
202 let shiny = ZoomPair {
203 outer: make_zoom_effects(effects, gold_gradient.clone(), 0.0, 1.0, 0.01, 4.0),
204 inner: make_zoom_effects(effects, gold_gradient, 0.0, 1.5, 0.025, 5.0),
205 };
206
207 let mut keyed: Vec<(&'static str, ZoomEffects, ZoomEffects)> = vec![
208 (jacquard::api::app_bsky::feed::post::PostRecord::NSID, post_outer, post_inner),
209 (jacquard::api::app_bsky::feed::like::LikeRecord::NSID, like_outer, like_inner),
210 (jacquard::api::app_bsky::graph::follow::FollowRecord::NSID, follow_outer, follow_inner),
211 ];
212 keyed.sort_by_key(|item| std::cmp::Reverse(item.0.len()));
213
214 ParticleEffects { effects: keyed, bsky_default, shiny }
215}
216
217#[derive(Resource)]
218pub struct ParticleTexture(pub Handle<Image>);
219
220pub fn setup_particle_effects(
221 mut commands: Commands,
222 mut effects: ResMut<Assets<EffectAsset>>,
223 mut images: ResMut<Assets<Image>>,
224) {
225 let particle_effects = register_default_effects(&mut effects);
226 let soft_circle = generate_soft_circle(&mut images);
227 commands.insert_resource(ParticleTexture(soft_circle));
228 commands.insert_resource(particle_effects);
229 commands.init_resource::<ZoomLevelState>();
230 commands.init_resource::<CleanupTimer>();
231}
232
233pub fn spawn_event_particles(
234 mut commands: Commands,
235 query: Query<(Entity, &JetstreamEventMarker, &EventSource, &Transform), With<NeedsParticle>>,
236 effects: Res<ParticleEffects>,
237 state: Res<HelixState>,
238 time: Res<Time>,
239 particle_tex: Res<ParticleTexture>,
240) {
241 let zoom = ZoomVariant::from_level(state.active_level);
242 let now = time.elapsed_secs();
243
244 for (entity, event, source, _transform) in query.iter() {
245 let collection_str = event.collection().map(|n| n.as_str());
246 let zoom_effects = effects.lookup(collection_str, zoom);
247
248 let effect_handle = match source {
249 EventSource::Live => zoom_effects.live.clone(),
250 EventSource::Historical => zoom_effects.hist.clone(),
251 };
252
253 commands.entity(entity).with_child((
254 ParticleEffect::new(effect_handle),
255 EffectMaterial {
256 images: vec![particle_tex.0.clone()],
257 },
258 Transform::default(),
259 ));
260
261 commands.entity(entity).insert(SpawnedAt(now));
262 commands.entity(entity).remove::<NeedsParticle>();
263 }
264}
265
266#[derive(Resource, Debug)]
267pub struct ZoomLevelState {
268 pub current: ZoomVariant,
269}
270
271impl Default for ZoomLevelState {
272 fn default() -> Self {
273 ZoomLevelState { current: ZoomVariant::Outer }
274 }
275}
276
277pub fn adjust_particle_zoom(state: Res<HelixState>, mut zoom_state: ResMut<ZoomLevelState>) {
278 let current = ZoomVariant::from_level(state.active_level);
279 if zoom_state.current != current {
280 zoom_state.current = current;
281 info!("zoom variant → {:?} (helix level {})", current, state.active_level);
282 }
283}
284
285#[derive(Resource, Default)]
286pub struct CleanupTimer {
287 frame_count: u32,
288}
289
290#[derive(Component)]
291pub struct SpawnedAt(pub f32);
292
293const PARTICLE_GRACE_SECS: f32 = 2.0;
294const MAX_PARTICLE_LIFETIME_SECS: f32 = 1.5;
295
296pub fn cleanup_expired_events(
297 mut commands: Commands,
298 event_query: Query<(Entity, &JetstreamEventMarker, Option<&SpawnedAt>), Without<NeedsParticle>>,
299 time: Res<Time>,
300 state: Res<HelixState>,
301 window: Res<TimeWindow>,
302 mut timer: ResMut<CleanupTimer>,
303) {
304 const CLEANUP_MARGIN: f32 = 2.0;
305
306 timer.frame_count += 1;
307 if !timer.frame_count.is_multiple_of(30) {
308 return;
309 }
310
311 if !window.is_initialized {
312 return;
313 }
314
315 let now = time.elapsed_secs();
316
317 for (entity, event_marker, spawned_at) in event_query.iter() {
318 let t = window.time_to_t(event_marker.time_us());
319 let distance_from_focal = (t - state.focal_time).abs();
320
321 if distance_from_focal <= CLEANUP_MARGIN {
322 continue;
323 }
324
325 let should_despawn = match spawned_at {
326 Some(sa) => (now - sa.0) >= MAX_PARTICLE_LIFETIME_SECS + PARTICLE_GRACE_SECS,
327 None => true,
328 };
329
330 if should_despawn {
331 commands.entity(entity).despawn();
332 }
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use bevy::asset::Handle;
340
341 fn dummy_zoom_effects() -> ZoomEffects {
342 ZoomEffects {
343 live: Handle::default(),
344 hist: Handle::default(),
345 }
346 }
347
348 fn dummy_zoom_pair() -> ZoomPair {
349 ZoomPair {
350 outer: dummy_zoom_effects(),
351 inner: dummy_zoom_effects(),
352 }
353 }
354
355 fn test_particle_effects() -> ParticleEffects {
356 use jacquard::api::app_bsky::feed::like::LikeRecord;
357 use jacquard::api::app_bsky::feed::post::PostRecord;
358 use jacquard::api::app_bsky::graph::follow::FollowRecord;
359
360 let mut keyed: Vec<(&'static str, ZoomEffects, ZoomEffects)> = vec![
361 (PostRecord::NSID, dummy_zoom_effects(), dummy_zoom_effects()),
362 (LikeRecord::NSID, dummy_zoom_effects(), dummy_zoom_effects()),
363 (FollowRecord::NSID, dummy_zoom_effects(), dummy_zoom_effects()),
364 ];
365 keyed.sort_by_key(|item| std::cmp::Reverse(item.0.len()));
366
367 ParticleEffects {
368 effects: keyed,
369 bsky_default: dummy_zoom_pair(),
370 shiny: dummy_zoom_pair(),
371 }
372 }
373
374 #[test]
375 fn lookup_post_nsid_matches_post_entry() {
376 use jacquard::api::app_bsky::feed::post::PostRecord;
377 let effects = test_particle_effects();
378 let result = effects.lookup(Some(PostRecord::NSID), ZoomVariant::Outer);
379 let result_ptr = result as *const ZoomEffects;
380 let bsky_ptr = effects.bsky_default.select(ZoomVariant::Outer) as *const ZoomEffects;
381 let shiny_ptr = effects.shiny.select(ZoomVariant::Outer) as *const ZoomEffects;
382 assert_ne!(result_ptr, bsky_ptr, "post NSID matched bsky_default instead of post entry");
383 assert_ne!(result_ptr, shiny_ptr, "post NSID matched shiny instead of post entry");
384 }
385
386 #[test]
387 fn lookup_unknown_bsky_collection_falls_to_bsky_default() {
388 let effects = test_particle_effects();
389 let result = effects.lookup(Some("app.bsky.actor.something"), ZoomVariant::Outer);
390 let result_ptr = result as *const ZoomEffects;
391 let bsky_ptr = effects.bsky_default.select(ZoomVariant::Outer) as *const ZoomEffects;
392 assert_eq!(result_ptr, bsky_ptr, "unknown app.bsky.* should fall to bsky_default");
393 }
394
395 #[test]
396 fn lookup_non_bsky_collection_falls_to_shiny() {
397 let effects = test_particle_effects();
398 let result = effects.lookup(Some("com.example.some.collection"), ZoomVariant::Outer);
399 let result_ptr = result as *const ZoomEffects;
400 let shiny_ptr = effects.shiny.select(ZoomVariant::Outer) as *const ZoomEffects;
401 assert_eq!(result_ptr, shiny_ptr, "non-bsky NSID should fall to shiny");
402 }
403
404 #[test]
405 fn lookup_none_collection_falls_to_shiny() {
406 let effects = test_particle_effects();
407 let result = effects.lookup(None, ZoomVariant::Outer);
408 let result_ptr = result as *const ZoomEffects;
409 let shiny_ptr = effects.shiny.select(ZoomVariant::Outer) as *const ZoomEffects;
410 assert_eq!(result_ptr, shiny_ptr, "None collection should fall to shiny");
411 }
412
413 #[test]
414 fn lookup_inner_zoom_variant_selects_inner_effects() {
415 let effects = test_particle_effects();
416 let result_outer = effects.lookup(None, ZoomVariant::Outer) as *const ZoomEffects;
417 let result_inner = effects.lookup(None, ZoomVariant::Inner) as *const ZoomEffects;
418 assert_ne!(result_outer, result_inner, "outer and inner should return different ZoomEffects");
419 }
420}