use 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, pub hist: Handle, } #[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) -> Handle { 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, 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 { 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, gradient: HanabiGradient, 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) -> 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); pub fn setup_particle_effects( mut commands: Commands, mut effects: ResMut>, mut images: ResMut>, ) { 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::(); commands.init_resource::(); } pub fn spawn_event_particles( mut commands: Commands, query: Query<(Entity, &JetstreamEventMarker, &EventSource, &Transform), With>, effects: Res, state: Res, time: Res