this repo has no description
at main 420 lines 15 kB view raw
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}