Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421use bevy::prelude::*;use bevy::image::{Image, TextureFormatPixelInfo};use bevy_hanabi::prelude::*;use bevy_hanabi::Gradient as HanabiGradient;use jacquard::common::types::collection::Collection;
use crate::helix::HelixState;use crate::ingest::{EventSource, JetstreamEventMarker, NeedsParticle, TimeWindow};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum ZoomVariant { Outer, Inner,}
impl ZoomVariant { pub fn from_level(level: usize) -> Self { if level <= 1 { ZoomVariant::Outer } else { ZoomVariant::Inner } }}
#[derive(Clone)]pub struct ZoomEffects { pub live: Handle<EffectAsset>, pub hist: Handle<EffectAsset>,}
#[derive(Clone)]pub struct ZoomPair { pub outer: ZoomEffects, pub inner: ZoomEffects,}
impl ZoomPair { pub fn select(&self, zoom: ZoomVariant) -> &ZoomEffects { match zoom { ZoomVariant::Outer => &self.outer, ZoomVariant::Inner => &self.inner, } }}
/// NSID-keyed particle effects. Longest prefix match wins.#[derive(Resource)]pub struct ParticleEffects { pub effects: Vec<(&'static str, ZoomEffects, ZoomEffects)>, pub bsky_default: ZoomPair, pub shiny: ZoomPair,}
impl ParticleEffects { pub fn lookup(&self, collection_str: Option<&str>, zoom: ZoomVariant) -> &ZoomEffects { collection_str .and_then(|collection| { self.effects .iter() .find(|(nsid, _, _)| collection.starts_with(nsid)) .map(|(_, outer, inner)| match zoom { ZoomVariant::Outer => outer, ZoomVariant::Inner => inner, }) }) .unwrap_or_else(|| match collection_str { Some(c) if c.starts_with("app.bsky.") => self.bsky_default.select(zoom), _ => self.shiny.select(zoom), }) }}
#[derive(Clone, Copy)]pub struct BloomParams { pub speed: f32, pub lifetime: f32, pub size: f32, pub burst_count: f32,}
/// Soft radial gradient circle texture (gaussian-ish falloff).pub fn generate_soft_circle(images: &mut Assets<Image>) -> Handle<Image> { const SIZE: u32 = 32; let mut data = vec![0u8; (SIZE * SIZE * 4) as usize]; let center = SIZE as f32 / 2.0; for y in 0..SIZE { for x in 0..SIZE { let dx = x as f32 + 0.5 - center; let dy = y as f32 + 0.5 - center; let dist = (dx * dx + dy * dy).sqrt() / center; let alpha = (1.0 - dist * dist).max(0.0).powf(2.0); let i = ((y * SIZE + x) * 4) as usize; data[i] = 255; data[i + 1] = 255; data[i + 2] = 255; data[i + 3] = (alpha * 255.0) as u8; } } images.add(Image::new( bevy::render::render_resource::Extent3d { width: SIZE, height: SIZE, depth_or_array_layers: 1, }, bevy::render::render_resource::TextureDimension::D2, data, bevy::render::render_resource::TextureFormat::Rgba8UnormSrgb, default(), ))}
pub fn create_bloom_effect(gradient: HanabiGradient<Vec4>, params: BloomParams) -> EffectAsset { let writer = ExprWriter::new();
let init_pos = SetPositionSphereModifier { center: writer.lit(Vec3::ZERO).expr(), radius: writer.lit(0.005).expr(), dimension: ShapeDimension::Volume, };
let init_vel = SetVelocitySphereModifier { center: writer.lit(Vec3::ZERO).expr(), speed: writer.lit(params.speed).expr(), };
let init_age = SetAttributeModifier::new(Attribute::AGE, writer.lit(0.).expr()); let init_lifetime = SetAttributeModifier::new(Attribute::LIFETIME, writer.lit(params.lifetime).expr());
let spawner = SpawnerSettings::once(params.burst_count.into());
let mut size_gradient = HanabiGradient::new(); size_gradient.add_key(0.0, Vec3::splat(params.size)); size_gradient.add_key(0.8, Vec3::splat(params.size * 0.5)); size_gradient.add_key(1.0, Vec3::ZERO);
let slot_zero = writer.lit(0u32).expr(); let mut module = writer.finish(); module.add_texture_slot("particle");
EffectAsset::new(32, spawner, module) .init(init_pos) .init(init_vel) .init(init_age) .init(init_lifetime) .render(ParticleTextureModifier { texture_slot: slot_zero, sample_mapping: ImageSampleMapping::Modulate, }) .render(ColorOverLifetimeModifier { gradient: gradient.into(), blend: ColorBlendMode::Add, mask: ColorBlendMask::RGBA, }) .render(SizeOverLifetimeModifier { gradient: size_gradient.into(), screen_space_size: false, })}
fn fade_gradient(r: f32, g: f32, b: f32) -> HanabiGradient<Vec4> { let mut gradient = HanabiGradient::new(); gradient.add_key(0.0, Vec4::new(r, g, b, 1.0)); gradient.add_key(0.7, Vec4::new(r, g, b, 0.8)); gradient.add_key(1.0, Vec4::new(r, g, b, 0.0)); gradient}
fn make_zoom_effects( effects: &mut Assets<EffectAsset>, gradient: HanabiGradient<Vec4>, outward_speed: f32, lifetime: f32, size: f32, burst_count: f32,) -> ZoomEffects { let live_params = BloomParams { speed: outward_speed, lifetime, size, burst_count }; let hist_params = BloomParams { speed: -outward_speed, lifetime, size, burst_count }; ZoomEffects { live: effects.add(create_bloom_effect(gradient.clone(), live_params)), hist: effects.add(create_bloom_effect(gradient, hist_params)), }}
pub fn register_default_effects(effects: &mut Assets<EffectAsset>) -> ParticleEffects { let post_outer = make_zoom_effects(effects, fade_gradient(0.4, 0.8, 3.0), 0.0, 0.8, 0.008, 3.0); let post_inner = make_zoom_effects(effects, fade_gradient(0.4, 0.8, 3.0), 0.0, 1.2, 0.02, 4.0);
let like_outer = make_zoom_effects(effects, fade_gradient(3.0, 0.4, 1.5), 0.0, 0.8, 0.006, 3.0); let like_inner = make_zoom_effects(effects, fade_gradient(3.0, 0.4, 1.5), 0.0, 1.2, 0.015, 4.0);
let follow_outer = make_zoom_effects(effects, fade_gradient(0.3, 3.0, 1.0), 0.0, 0.8, 0.008, 4.0); let follow_inner = make_zoom_effects(effects, fade_gradient(0.3, 3.0, 1.0), 0.0, 1.2, 0.02, 5.0);
let bsky_default = ZoomPair { outer: make_zoom_effects(effects, fade_gradient(1.2, 1.2, 1.5), 0.0, 0.6, 0.005, 2.0), inner: make_zoom_effects(effects, fade_gradient(1.2, 1.2, 1.5), 0.0, 1.0, 0.012, 3.0), };
let mut gold_gradient = HanabiGradient::new(); gold_gradient.add_key(0.0, Vec4::new(2.0, 1.5, 0.2, 0.7)); gold_gradient.add_key(0.5, Vec4::new(1.5, 1.0, 0.1, 0.4)); gold_gradient.add_key(1.0, Vec4::new(1.0, 0.7, 0.05, 0.0));
let shiny = ZoomPair { outer: make_zoom_effects(effects, gold_gradient.clone(), 0.0, 1.0, 0.01, 4.0), inner: make_zoom_effects(effects, gold_gradient, 0.0, 1.5, 0.025, 5.0), };
let mut keyed: Vec<(&'static str, ZoomEffects, ZoomEffects)> = vec![ (jacquard::api::app_bsky::feed::post::PostRecord::NSID, post_outer, post_inner), (jacquard::api::app_bsky::feed::like::LikeRecord::NSID, like_outer, like_inner), (jacquard::api::app_bsky::graph::follow::FollowRecord::NSID, follow_outer, follow_inner), ]; keyed.sort_by_key(|item| std::cmp::Reverse(item.0.len()));
ParticleEffects { effects: keyed, bsky_default, shiny }}
#[derive(Resource)]pub struct ParticleTexture(pub Handle<Image>);
pub fn setup_particle_effects( mut commands: Commands, mut effects: ResMut<Assets<EffectAsset>>, mut images: ResMut<Assets<Image>>,) { let particle_effects = register_default_effects(&mut effects); let soft_circle = generate_soft_circle(&mut images); commands.insert_resource(ParticleTexture(soft_circle)); commands.insert_resource(particle_effects); commands.init_resource::<ZoomLevelState>(); commands.init_resource::<CleanupTimer>();}
pub fn spawn_event_particles( mut commands: Commands, query: Query<(Entity, &JetstreamEventMarker, &EventSource, &Transform), With<NeedsParticle>>, effects: Res<ParticleEffects>, state: Res<HelixState>, time: Res<Time>, particle_tex: Res<ParticleTexture>,) { let zoom = ZoomVariant::from_level(state.active_level); let now = time.elapsed_secs();
for (entity, event, source, _transform) in query.iter() { let collection_str = event.collection().map(|n| n.as_str()); let zoom_effects = effects.lookup(collection_str, zoom);
let effect_handle = match source { EventSource::Live => zoom_effects.live.clone(), EventSource::Historical => zoom_effects.hist.clone(), };
commands.entity(entity).with_child(( ParticleEffect::new(effect_handle), EffectMaterial { images: vec![particle_tex.0.clone()], }, Transform::default(), ));
commands.entity(entity).insert(SpawnedAt(now)); commands.entity(entity).remove::<NeedsParticle>(); }}
#[derive(Resource, Debug)]pub struct ZoomLevelState { pub current: ZoomVariant,}
impl Default for ZoomLevelState { fn default() -> Self { ZoomLevelState { current: ZoomVariant::Outer } }}
pub fn adjust_particle_zoom(state: Res<HelixState>, mut zoom_state: ResMut<ZoomLevelState>) { let current = ZoomVariant::from_level(state.active_level); if zoom_state.current != current { zoom_state.current = current; info!("zoom variant → {:?} (helix level {})", current, state.active_level); }}
#[derive(Resource, Default)]pub struct CleanupTimer { frame_count: u32,}
#[derive(Component)]pub struct SpawnedAt(pub f32);
const PARTICLE_GRACE_SECS: f32 = 2.0;const MAX_PARTICLE_LIFETIME_SECS: f32 = 1.5;
pub fn cleanup_expired_events( mut commands: Commands, event_query: Query<(Entity, &JetstreamEventMarker, Option<&SpawnedAt>), Without<NeedsParticle>>, time: Res<Time>, state: Res<HelixState>, window: Res<TimeWindow>, mut timer: ResMut<CleanupTimer>,) { const CLEANUP_MARGIN: f32 = 2.0;
timer.frame_count += 1; if !timer.frame_count.is_multiple_of(30) { return; }
if !window.is_initialized { return; }
let now = time.elapsed_secs();
for (entity, event_marker, spawned_at) in event_query.iter() { let t = window.time_to_t(event_marker.time_us()); let distance_from_focal = (t - state.focal_time).abs();
if distance_from_focal <= CLEANUP_MARGIN { continue; }
let should_despawn = match spawned_at { Some(sa) => (now - sa.0) >= MAX_PARTICLE_LIFETIME_SECS + PARTICLE_GRACE_SECS, None => true, };
if should_despawn { commands.entity(entity).despawn(); } }}
#[cfg(test)]mod tests { use super::*; use bevy::asset::Handle;
fn dummy_zoom_effects() -> ZoomEffects { ZoomEffects { live: Handle::default(), hist: Handle::default(), } }
fn dummy_zoom_pair() -> ZoomPair { ZoomPair { outer: dummy_zoom_effects(), inner: dummy_zoom_effects(), } }
fn test_particle_effects() -> ParticleEffects { use jacquard::api::app_bsky::feed::like::LikeRecord; use jacquard::api::app_bsky::feed::post::PostRecord; use jacquard::api::app_bsky::graph::follow::FollowRecord;
let mut keyed: Vec<(&'static str, ZoomEffects, ZoomEffects)> = vec![ (PostRecord::NSID, dummy_zoom_effects(), dummy_zoom_effects()), (LikeRecord::NSID, dummy_zoom_effects(), dummy_zoom_effects()), (FollowRecord::NSID, dummy_zoom_effects(), dummy_zoom_effects()), ]; keyed.sort_by_key(|item| std::cmp::Reverse(item.0.len()));
ParticleEffects { effects: keyed, bsky_default: dummy_zoom_pair(), shiny: dummy_zoom_pair(), } }
#[test] fn lookup_post_nsid_matches_post_entry() { use jacquard::api::app_bsky::feed::post::PostRecord; let effects = test_particle_effects(); let result = effects.lookup(Some(PostRecord::NSID), ZoomVariant::Outer); let result_ptr = result as *const ZoomEffects; let bsky_ptr = effects.bsky_default.select(ZoomVariant::Outer) as *const ZoomEffects; let shiny_ptr = effects.shiny.select(ZoomVariant::Outer) as *const ZoomEffects; assert_ne!(result_ptr, bsky_ptr, "post NSID matched bsky_default instead of post entry"); assert_ne!(result_ptr, shiny_ptr, "post NSID matched shiny instead of post entry"); }
#[test] fn lookup_unknown_bsky_collection_falls_to_bsky_default() { let effects = test_particle_effects(); let result = effects.lookup(Some("app.bsky.actor.something"), ZoomVariant::Outer); let result_ptr = result as *const ZoomEffects; let bsky_ptr = effects.bsky_default.select(ZoomVariant::Outer) as *const ZoomEffects; assert_eq!(result_ptr, bsky_ptr, "unknown app.bsky.* should fall to bsky_default"); }
#[test] fn lookup_non_bsky_collection_falls_to_shiny() { let effects = test_particle_effects(); let result = effects.lookup(Some("com.example.some.collection"), ZoomVariant::Outer); let result_ptr = result as *const ZoomEffects; let shiny_ptr = effects.shiny.select(ZoomVariant::Outer) as *const ZoomEffects; assert_eq!(result_ptr, shiny_ptr, "non-bsky NSID should fall to shiny"); }
#[test] fn lookup_none_collection_falls_to_shiny() { let effects = test_particle_effects(); let result = effects.lookup(None, ZoomVariant::Outer); let result_ptr = result as *const ZoomEffects; let shiny_ptr = effects.shiny.select(ZoomVariant::Outer) as *const ZoomEffects; assert_eq!(result_ptr, shiny_ptr, "None collection should fall to shiny"); }
#[test] fn lookup_inner_zoom_variant_selects_inner_effects() { let effects = test_particle_effects(); let result_outer = effects.lookup(None, ZoomVariant::Outer) as *const ZoomEffects; let result_inner = effects.lookup(None, ZoomVariant::Inner) as *const ZoomEffects; assert_ne!(result_outer, result_inner, "outer and inner should return different ZoomEffects"); }}