veloren_voxygen/scene/
particle.rs

1use super::{FigureMgr, SceneData, Terrain, terrain::BlocksOfInterest};
2use crate::{
3    ecs::comp::Interpolated,
4    mesh::{greedy::GreedyMesh, segment::generate_mesh_base_vol_particle},
5    render::{
6        Instances, Light, Model, ParticleDrawer, ParticleInstance, ParticleVertex, Renderer,
7        pipelines::particle::ParticleMode,
8    },
9    scene::terrain::FireplaceType,
10};
11use common::{
12    assets::{AssetExt, DotVoxAsset},
13    comp::{
14        self, Beam, Body, CharacterActivity, CharacterState, Fluid, Inventory, Ori, PhysicsState,
15        Pos, Scale, Shockwave, Vel, ability::Dodgeable, aura, beam, body, buff, item::Reagent,
16        object, shockwave,
17    },
18    figure::Segment,
19    outcome::Outcome,
20    resources::{DeltaTime, Time},
21    spiral::Spiral2d,
22    states::{self, utils::StageSection},
23    terrain::{Block, SpriteKind, TerrainChunk, TerrainGrid},
24    uid::IdMaps,
25    vol::{ReadVol, RectRasterableVol, SizedVol},
26};
27use common_base::prof_span;
28use hashbrown::HashMap;
29use rand::prelude::*;
30use specs::{Entity, Join, LendJoin, WorldExt};
31use std::{
32    f32::consts::{PI, TAU},
33    time::Duration,
34};
35use vek::*;
36
37pub struct ParticleMgr {
38    /// keep track of lifespans
39    particles: Vec<Particle>,
40
41    /// keep track of timings
42    scheduler: HeartbeatScheduler,
43
44    /// GPU Instance Buffer
45    instances: Instances<ParticleInstance>,
46
47    /// GPU Vertex Buffers
48    model_cache: HashMap<&'static str, Model<ParticleVertex>>,
49}
50
51impl ParticleMgr {
52    pub fn new(renderer: &mut Renderer) -> Self {
53        Self {
54            particles: Vec::new(),
55            scheduler: HeartbeatScheduler::new(),
56            instances: default_instances(renderer),
57            model_cache: default_cache(renderer),
58        }
59    }
60
61    pub fn handle_outcome(
62        &mut self,
63        outcome: &Outcome,
64        scene_data: &SceneData,
65        figure_mgr: &FigureMgr,
66    ) {
67        prof_span!("ParticleMgr::handle_outcome");
68        let time = scene_data.state.get_time();
69        let mut rng = thread_rng();
70
71        match outcome {
72            Outcome::Lightning { pos } => {
73                self.particles.resize_with(self.particles.len() + 800, || {
74                    Particle::new_directed(
75                        Duration::from_secs_f32(rng.gen_range(0.5..1.0)),
76                        time,
77                        ParticleMode::Lightning,
78                        *pos + Vec3::new(0.0, 0.0, rng.gen_range(0.0..600.0)),
79                        *pos,
80                    )
81                });
82            },
83            Outcome::SpriteDelete { pos, sprite } => match sprite {
84                SpriteKind::SeaUrchin => {
85                    let pos = pos.map(|e| e as f32 + 0.5);
86                    self.particles.resize_with(self.particles.len() + 10, || {
87                        Particle::new_directed(
88                            Duration::from_secs_f32(rng.gen_range(0.1..0.5)),
89                            time,
90                            ParticleMode::Steam,
91                            pos + Vec3::new(0.0, 0.0, rng.gen_range(0.0..1.5)),
92                            pos,
93                        )
94                    });
95                },
96                SpriteKind::EnsnaringVines => {},
97                _ => {},
98            },
99            Outcome::Explosion {
100                pos,
101                power,
102                radius,
103                is_attack,
104                reagent,
105            } => {
106                if *is_attack {
107                    match reagent {
108                        Some(Reagent::Green) => {
109                            self.particles.resize_with(
110                                self.particles.len() + (60.0 * power.abs()) as usize,
111                                || {
112                                    Particle::new_directed(
113                                        Duration::from_secs_f32(rng.gen_range(0.2..3.0)),
114                                        time,
115                                        ParticleMode::EnergyNature,
116                                        *pos,
117                                        *pos + Vec3::<f32>::zero()
118                                            .map(|_| rng.gen_range(-1.0..1.0))
119                                            .normalized()
120                                            * rng.gen_range(1.0..*radius),
121                                    )
122                                },
123                            );
124                        },
125                        Some(Reagent::Red) => {
126                            self.particles.resize_with(
127                                self.particles.len() + (75.0 * power.abs()) as usize,
128                                || {
129                                    Particle::new_directed(
130                                        Duration::from_millis(500),
131                                        time,
132                                        ParticleMode::Explosion,
133                                        *pos,
134                                        *pos + Vec3::<f32>::zero()
135                                            .map(|_| rng.gen_range(-1.0..1.0))
136                                            .normalized()
137                                            * *radius,
138                                    )
139                                },
140                            );
141                        },
142                        Some(Reagent::White) => {
143                            self.particles.resize_with(
144                                self.particles.len() + (75.0 * power.abs()) as usize,
145                                || {
146                                    Particle::new_directed(
147                                        Duration::from_millis(500),
148                                        time,
149                                        ParticleMode::Ice,
150                                        *pos,
151                                        *pos + Vec3::<f32>::zero()
152                                            .map(|_| rng.gen_range(-1.0..1.0))
153                                            .normalized()
154                                            * *radius,
155                                    )
156                                },
157                            );
158                        },
159                        Some(Reagent::Purple) => {
160                            self.particles.resize_with(
161                                self.particles.len() + (75.0 * power.abs()) as usize,
162                                || {
163                                    Particle::new_directed(
164                                        Duration::from_millis(500),
165                                        time,
166                                        ParticleMode::CultistFlame,
167                                        *pos,
168                                        *pos + Vec3::<f32>::zero()
169                                            .map(|_| rng.gen_range(-1.0..1.0))
170                                            .normalized()
171                                            * *radius,
172                                    )
173                                },
174                            );
175                        },
176                        Some(Reagent::Phoenix) => {
177                            self.particles.resize_with(
178                                self.particles.len() + (5.0 * power.abs()) as usize,
179                                || {
180                                    Particle::new_directed(
181                                        Duration::from_millis(300),
182                                        time,
183                                        ParticleMode::Explosion,
184                                        *pos,
185                                        *pos + Vec3::<f32>::zero()
186                                            .map(|_| rng.gen_range(-1.0..1.0))
187                                            .normalized()
188                                            * *radius,
189                                    )
190                                },
191                            );
192                        },
193                        _ => {},
194                    }
195                } else {
196                    self.particles.resize_with(
197                        self.particles.len() + if reagent.is_some() { 300 } else { 150 },
198                        || {
199                            Particle::new(
200                                Duration::from_millis(if reagent.is_some() { 1000 } else { 250 }),
201                                time,
202                                match reagent {
203                                    Some(Reagent::Blue) => ParticleMode::FireworkBlue,
204                                    Some(Reagent::Green) => ParticleMode::FireworkGreen,
205                                    Some(Reagent::Purple) => ParticleMode::FireworkPurple,
206                                    Some(Reagent::Red) => ParticleMode::FireworkRed,
207                                    Some(Reagent::White) => ParticleMode::FireworkWhite,
208                                    Some(Reagent::Yellow) => ParticleMode::FireworkYellow,
209                                    Some(Reagent::Phoenix) => ParticleMode::FireworkYellow,
210                                    None => ParticleMode::Shrapnel,
211                                },
212                                *pos,
213                            )
214                        },
215                    );
216
217                    self.particles.resize_with(
218                        self.particles.len() + if reagent.is_some() { 100 } else { 200 },
219                        || {
220                            Particle::new(
221                                Duration::from_secs(4),
222                                time,
223                                ParticleMode::CampfireSmoke,
224                                *pos + Vec3::<f32>::zero()
225                                    .map(|_| rng.gen_range(-1.0..1.0))
226                                    .normalized()
227                                    * *radius,
228                            )
229                        },
230                    );
231                }
232            },
233            Outcome::BreakBlock { pos, .. } => {
234                // TODO: Use color field when particle colors are a thing
235                self.particles.resize_with(self.particles.len() + 30, || {
236                    Particle::new(
237                        Duration::from_millis(200),
238                        time,
239                        ParticleMode::Shrapnel,
240                        pos.map(|e| e as f32 + 0.5),
241                    )
242                });
243            },
244            Outcome::DamagedBlock {
245                pos, stage_changed, ..
246            } => {
247                self.particles.resize_with(
248                    self.particles.len() + if *stage_changed { 30 } else { 10 },
249                    || {
250                        Particle::new(
251                            Duration::from_millis(if *stage_changed { 200 } else { 100 }),
252                            time,
253                            ParticleMode::Shrapnel,
254                            pos.map(|e| e as f32 + 0.5),
255                        )
256                    },
257                );
258            },
259            Outcome::SpriteUnlocked { .. } => {},
260            Outcome::FailedSpriteUnlock { pos } => {
261                // TODO: Use color field when particle colors are a thing
262                self.particles.resize_with(self.particles.len() + 10, || {
263                    Particle::new(
264                        Duration::from_millis(50),
265                        time,
266                        ParticleMode::Shrapnel,
267                        pos.map(|e| e as f32 + 0.5),
268                    )
269                });
270            },
271            Outcome::SummonedCreature { pos, body } => match body {
272                Body::BipedSmall(b) if matches!(b.species, body::biped_small::Species::Husk) => {
273                    self.particles.resize_with(
274                        self.particles.len()
275                            + 2 * usize::from(self.scheduler.heartbeats(Duration::from_millis(1))),
276                        || {
277                            let start_pos = pos + Vec3::unit_z() * body.height() / 2.0;
278                            let end_pos = pos
279                                + Vec3::new(
280                                    2.0 * rng.gen::<f32>() - 1.0,
281                                    2.0 * rng.gen::<f32>() - 1.0,
282                                    0.0,
283                                )
284                                .normalized()
285                                    * (body.max_radius() + 4.0)
286                                + Vec3::unit_z() * (body.height() + 2.0) * rng.gen::<f32>();
287
288                            Particle::new_directed(
289                                Duration::from_secs_f32(0.5),
290                                time,
291                                ParticleMode::CultistFlame,
292                                start_pos,
293                                end_pos,
294                            )
295                        },
296                    );
297                },
298                Body::BipedSmall(b) if matches!(b.species, body::biped_small::Species::Boreal) => {
299                    self.particles.resize_with(
300                        self.particles.len()
301                            + 2 * usize::from(self.scheduler.heartbeats(Duration::from_millis(1))),
302                        || {
303                            let start_pos = pos + Vec3::unit_z() * body.height() / 2.0;
304                            let end_pos = pos
305                                + Vec3::new(
306                                    2.0 * rng.gen::<f32>() - 1.0,
307                                    2.0 * rng.gen::<f32>() - 1.0,
308                                    0.0,
309                                )
310                                .normalized()
311                                    * (body.max_radius() + 4.0)
312                                + Vec3::unit_z() * (body.height() + 20.0) * rng.gen::<f32>();
313
314                            Particle::new_directed(
315                                Duration::from_secs_f32(0.5),
316                                time,
317                                ParticleMode::GigaSnow,
318                                start_pos,
319                                end_pos,
320                            )
321                        },
322                    );
323                },
324                _ => {},
325            },
326            Outcome::ProjectileHit { pos, target, .. } => {
327                if target.is_some() {
328                    let ecs = scene_data.state.ecs();
329                    if target
330                        .and_then(|target| ecs.read_resource::<IdMaps>().uid_entity(target))
331                        .and_then(|entity| {
332                            ecs.read_storage::<Body>()
333                                .get(entity)
334                                .map(|body| body.bleeds())
335                        })
336                        .unwrap_or(false)
337                    {
338                        self.particles.resize_with(self.particles.len() + 30, || {
339                            Particle::new(
340                                Duration::from_millis(250),
341                                time,
342                                ParticleMode::Blood,
343                                *pos,
344                            )
345                        })
346                    };
347                };
348            },
349            Outcome::Block { pos, parry, .. } => {
350                if *parry {
351                    self.particles.resize_with(self.particles.len() + 10, || {
352                        Particle::new(
353                            Duration::from_millis(200),
354                            time,
355                            ParticleMode::GunPowderSpark,
356                            *pos + Vec3::unit_z(),
357                        )
358                    });
359                }
360            },
361            Outcome::GroundSlam { pos, .. } => {
362                self.particles.resize_with(self.particles.len() + 100, || {
363                    Particle::new(
364                        Duration::from_millis(1000),
365                        time,
366                        ParticleMode::BigShrapnel,
367                        *pos,
368                    )
369                });
370            },
371            Outcome::SurpriseEgg { pos, .. } => {
372                self.particles.resize_with(self.particles.len() + 50, || {
373                    Particle::new(
374                        Duration::from_millis(1000),
375                        time,
376                        ParticleMode::SurpriseEgg,
377                        *pos,
378                    )
379                });
380            },
381            Outcome::FlashFreeze { pos, .. } => {
382                self.particles.resize_with(
383                    self.particles.len()
384                        + 2 * usize::from(self.scheduler.heartbeats(Duration::from_millis(1))),
385                    || {
386                        let start_pos = pos + Vec3::unit_z() - 1.0;
387                        let end_pos = pos
388                            + Vec3::new(
389                                4.0 * rng.gen::<f32>() - 1.0,
390                                4.0 * rng.gen::<f32>() - 1.0,
391                                0.0,
392                            )
393                            .normalized()
394                                * 1.5
395                            + Vec3::unit_z()
396                            + 5.0 * rng.gen::<f32>();
397
398                        Particle::new_directed(
399                            Duration::from_secs_f32(0.5),
400                            time,
401                            ParticleMode::GigaSnow,
402                            start_pos,
403                            end_pos,
404                        )
405                    },
406                );
407            },
408            Outcome::CyclopsCharge { pos } => {
409                self.particles.push(Particle::new_directed(
410                    Duration::from_secs_f32(rng.gen_range(0.1..0.2)),
411                    time,
412                    ParticleMode::CyclopsCharge,
413                    *pos + Vec3::new(0.0, 0.0, 5.3),
414                    *pos + Vec3::new(0.0, 0.0, 5.6 + 0.5 * rng.gen_range(0.0..0.2)),
415                ));
416            },
417            Outcome::FlamethrowerCharge { pos } | Outcome::FuseCharge { pos } => {
418                self.particles.push(Particle::new_directed(
419                    Duration::from_secs_f32(rng.gen_range(0.1..0.2)),
420                    time,
421                    ParticleMode::CampfireFire,
422                    *pos + Vec3::new(0.0, 0.0, 1.2),
423                    *pos + Vec3::new(0.0, 0.0, 1.5 + 0.5 * rng.gen_range(0.0..0.2)),
424                ));
425            },
426            Outcome::TerracottaStatueCharge { pos } => {
427                self.particles.push(Particle::new_directed(
428                    Duration::from_secs_f32(rng.gen_range(0.1..0.2)),
429                    time,
430                    ParticleMode::FireworkYellow,
431                    *pos + Vec3::new(0.0, 0.0, 4.0),
432                    *pos + Vec3::new(0.0, 0.0, 5.0 + 0.5 * rng.gen_range(0.3..0.8)),
433                ));
434            },
435            Outcome::Death { pos, .. } => {
436                self.particles.resize_with(self.particles.len() + 40, || {
437                    Particle::new(
438                        Duration::from_millis(400 + rng.gen_range(0..100)),
439                        time,
440                        ParticleMode::Death,
441                        *pos + Vec3::unit_z()
442                            + Vec3::<f32>::zero()
443                                .map(|_| rng.gen_range(-0.1..0.1))
444                                .normalized(),
445                    )
446                });
447            },
448            Outcome::GroundDig { pos, .. } => {
449                self.particles.resize_with(self.particles.len() + 12, || {
450                    Particle::new(
451                        Duration::from_millis(200),
452                        time,
453                        ParticleMode::BigShrapnel,
454                        *pos,
455                    )
456                });
457            },
458            Outcome::TeleportedByPortal { pos, .. } => {
459                self.particles.resize_with(self.particles.len() + 80, || {
460                    Particle::new_directed(
461                        Duration::from_millis(500),
462                        time,
463                        ParticleMode::CultistFlame,
464                        *pos,
465                        pos + Vec3::unit_z()
466                            + Vec3::zero()
467                                .map(|_: f32| rng.gen_range(-0.1..0.1))
468                                .normalized()
469                                * 2.0,
470                    )
471                });
472            },
473            Outcome::ClayGolemDash { pos, .. } => {
474                self.particles.resize_with(self.particles.len() + 100, || {
475                    Particle::new(
476                        Duration::from_millis(1000),
477                        time,
478                        ParticleMode::ClayShrapnel,
479                        *pos,
480                    )
481                });
482            },
483            Outcome::HeadLost { uid, head } => {
484                if let Some(entity) = scene_data
485                    .state
486                    .ecs()
487                    .read_resource::<IdMaps>()
488                    .uid_entity(*uid)
489                {
490                    if let Some(pos) = scene_data.state.read_component_copied::<Pos>(entity) {
491                        let heads = figure_mgr.get_heads(scene_data, entity);
492                        let head_pos = pos.0 + heads.get(*head).copied().unwrap_or_default();
493
494                        self.particles.resize_with(self.particles.len() + 40, || {
495                            Particle::new(
496                                Duration::from_millis(1000),
497                                time,
498                                ParticleMode::Death,
499                                head_pos
500                                    + Vec3::<f32>::zero()
501                                        .map(|_| rng.gen_range(-0.1..0.1))
502                                        .normalized(),
503                            )
504                        });
505                    }
506                };
507            },
508            Outcome::Splash {
509                vel,
510                pos,
511                mass,
512                kind,
513            } => {
514                let mode = match kind {
515                    comp::fluid_dynamics::LiquidKind::Water => ParticleMode::WaterFoam,
516                    comp::fluid_dynamics::LiquidKind::Lava => ParticleMode::CampfireFire,
517                };
518                let magnitude = (-vel.z).max(0.0);
519                let energy = mass * magnitude;
520                if energy > 0.0 {
521                    let count = ((0.6 * energy.sqrt()).ceil() as usize).min(500);
522                    let mut i = 0;
523                    let r = 0.5 / count as f32;
524                    self.particles
525                        .resize_with(self.particles.len() + count, || {
526                            let t = i as f32 / count as f32 + rng.gen_range(-r..=r);
527                            i += 1;
528                            let angle = t * TAU;
529                            let s = angle.sin();
530                            let c = angle.cos();
531                            let energy = energy
532                                * f32::abs(rng.gen_range(0.0..1.0) + rng.gen_range(0.0..1.0) - 0.5);
533
534                            let axis = -Vec3::unit_z();
535                            let plane = Vec3::new(c, s, 0.0);
536
537                            let pos = *pos + plane * rng.gen_range(0.0..0.5);
538
539                            let energy = energy.sqrt() * 0.5;
540
541                            let dir = plane * (1.0 + energy) - axis * energy;
542
543                            Particle::new_directed(
544                                Duration::from_millis(4000),
545                                time,
546                                mode,
547                                pos,
548                                pos + dir,
549                            )
550                        });
551                }
552            },
553            Outcome::Transformation { pos } => {
554                self.particles.resize_with(self.particles.len() + 100, || {
555                    Particle::new(
556                        Duration::from_millis(1400),
557                        time,
558                        ParticleMode::Transformation,
559                        *pos,
560                    )
561                });
562            },
563            Outcome::ProjectileShot { .. }
564            | Outcome::Beam { .. }
565            | Outcome::ExpChange { .. }
566            | Outcome::SkillPointGain { .. }
567            | Outcome::ComboChange { .. }
568            | Outcome::HealthChange { .. }
569            | Outcome::PoiseChange { .. }
570            | Outcome::Utterance { .. }
571            | Outcome::IceSpikes { .. }
572            | Outcome::IceCrack { .. }
573            | Outcome::Glider { .. }
574            | Outcome::Whoosh { .. }
575            | Outcome::Swoosh { .. }
576            | Outcome::Slash { .. }
577            | Outcome::Bleep { .. }
578            | Outcome::Charge { .. }
579            | Outcome::Steam { .. }
580            | Outcome::FireShockwave { .. }
581            | Outcome::PortalActivated { .. }
582            | Outcome::FromTheAshes { .. }
583            | Outcome::LaserBeam { .. } => {},
584        }
585    }
586
587    pub fn maintain(
588        &mut self,
589        renderer: &mut Renderer,
590        scene_data: &SceneData,
591        terrain: &Terrain<TerrainChunk>,
592        figure_mgr: &FigureMgr,
593        lights: &mut Vec<Light>,
594    ) {
595        prof_span!("ParticleMgr::maintain");
596        if scene_data.particles_enabled {
597            // update timings
598            self.scheduler.maintain(scene_data.state.get_time());
599
600            // remove dead Particle
601            self.particles
602                .retain(|p| p.alive_until > scene_data.state.get_time());
603
604            // add new Particle
605            self.maintain_body_particles(scene_data);
606            self.maintain_char_state_particles(scene_data, figure_mgr);
607            self.maintain_beam_particles(scene_data, lights);
608            self.maintain_block_particles(scene_data, terrain, figure_mgr);
609            self.maintain_shockwave_particles(scene_data);
610            self.maintain_aura_particles(scene_data);
611            self.maintain_buff_particles(scene_data);
612
613            self.upload_particles(renderer);
614        } else {
615            // remove all particle lifespans
616            if !self.particles.is_empty() {
617                self.particles.clear();
618                self.upload_particles(renderer);
619            }
620
621            // remove all timings
622            self.scheduler.clear();
623        }
624    }
625
626    fn maintain_body_particles(&mut self, scene_data: &SceneData) {
627        prof_span!("ParticleMgr::maintain_body_particles");
628        let ecs = scene_data.state.ecs();
629        for (body, interpolated, vel) in (
630            &ecs.read_storage::<Body>(),
631            &ecs.read_storage::<Interpolated>(),
632            ecs.read_storage::<Vel>().maybe(),
633        )
634            .join()
635        {
636            match body {
637                Body::Object(object::Body::CampfireLit) => {
638                    self.maintain_campfirelit_particles(scene_data, interpolated.pos, vel)
639                },
640                Body::Object(object::Body::BarrelOrgan) => {
641                    self.maintain_barrel_organ_particles(scene_data, interpolated.pos, vel)
642                },
643                Body::Object(object::Body::BoltFire) => {
644                    self.maintain_boltfire_particles(scene_data, interpolated.pos, vel)
645                },
646                Body::Object(object::Body::BoltFireBig) => {
647                    self.maintain_boltfirebig_particles(scene_data, interpolated.pos, vel)
648                },
649                Body::Object(object::Body::FireRainDrop) => {
650                    self.maintain_fireraindrop_particles(scene_data, interpolated.pos, vel)
651                },
652                Body::Object(object::Body::BoltNature) => {
653                    self.maintain_boltnature_particles(scene_data, interpolated.pos, vel)
654                },
655                Body::Object(object::Body::Tornado) => {
656                    self.maintain_tornado_particles(scene_data, interpolated.pos)
657                },
658                Body::Object(object::Body::FieryTornado) => {
659                    self.maintain_fiery_tornado_particles(scene_data, interpolated.pos)
660                },
661                Body::Object(object::Body::Mine) => {
662                    self.maintain_mine_particles(scene_data, interpolated.pos)
663                },
664                Body::Object(
665                    object::Body::Bomb
666                    | object::Body::FireworkBlue
667                    | object::Body::FireworkGreen
668                    | object::Body::FireworkPurple
669                    | object::Body::FireworkRed
670                    | object::Body::FireworkWhite
671                    | object::Body::FireworkYellow
672                    | object::Body::IronPikeBomb,
673                ) => self.maintain_bomb_particles(scene_data, interpolated.pos, vel),
674                Body::Object(object::Body::PortalActive) => {
675                    self.maintain_active_portal_particles(scene_data, interpolated.pos)
676                },
677                Body::Object(object::Body::Portal) => {
678                    self.maintain_portal_particles(scene_data, interpolated.pos)
679                },
680                _ => {},
681            }
682        }
683    }
684
685    fn maintain_hydra_tail_swipe_particles(
686        &mut self,
687        scene_data: &SceneData,
688        figure_mgr: &FigureMgr,
689        entity: Entity,
690        pos: Vec3<f32>,
691        state: &CharacterState,
692        inventory: Option<&Inventory>,
693    ) {
694        let Some(ability_id) = state
695            .ability_info()
696            .and_then(|info| info.ability.map(|a| a.ability_id(Some(state), inventory)))
697        else {
698            return;
699        };
700
701        if ability_id != Some("common.abilities.custom.hydra.tail_swipe") {
702            return;
703        }
704
705        let Some(stage_section) = state.stage_section() else {
706            return;
707        };
708
709        let particle_count = match stage_section {
710            StageSection::Charge => 1,
711            StageSection::Action => 10,
712            _ => return,
713        };
714
715        let Some((start, end)) = figure_mgr.get_tail(scene_data, entity) else {
716            return;
717        };
718
719        let start = pos + start;
720        let end = pos + end;
721
722        let time = scene_data.state.get_time();
723        let mut rng = thread_rng();
724
725        self.particles.resize_with(
726            self.particles.len()
727                + particle_count * self.scheduler.heartbeats(Duration::from_millis(33)) as usize,
728            || {
729                let t = rng.gen_range(0.0..1.0);
730                let p = start * t + end * (1.0 - t) - Vec3::new(0.0, 0.0, 0.5);
731
732                Particle::new(
733                    Duration::from_millis(500),
734                    time,
735                    ParticleMode::GroundShockwave,
736                    p,
737                )
738            },
739        );
740    }
741
742    fn maintain_campfirelit_particles(
743        &mut self,
744        scene_data: &SceneData,
745        pos: Vec3<f32>,
746        vel: Option<&Vel>,
747    ) {
748        prof_span!("ParticleMgr::maintain_campfirelit_particles");
749        let time = scene_data.state.get_time();
750        let dt = scene_data.state.get_delta_time();
751        let mut rng = thread_rng();
752
753        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(50)) {
754            self.particles.push(Particle::new(
755                Duration::from_millis(250),
756                time,
757                ParticleMode::CampfireFire,
758                pos,
759            ));
760
761            self.particles.push(Particle::new(
762                Duration::from_secs(10),
763                time,
764                ParticleMode::CampfireSmoke,
765                pos.map(|e| e + thread_rng().gen_range(-0.25..0.25))
766                    + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.gen::<f32>()),
767            ));
768        }
769    }
770
771    fn maintain_barrel_organ_particles(
772        &mut self,
773        scene_data: &SceneData,
774        pos: Vec3<f32>,
775        vel: Option<&Vel>,
776    ) {
777        prof_span!("ParticleMgr::maintain_barrel_organ_particles");
778        let time = scene_data.state.get_time();
779        let dt = scene_data.state.get_delta_time();
780        let mut rng = thread_rng();
781
782        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(20)) {
783            self.particles.push(Particle::new(
784                Duration::from_millis(250),
785                time,
786                ParticleMode::BarrelOrgan,
787                pos,
788            ));
789
790            self.particles.push(Particle::new(
791                Duration::from_secs(10),
792                time,
793                ParticleMode::BarrelOrgan,
794                pos.map(|e| e + thread_rng().gen_range(-0.25..0.25))
795                    + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.gen::<f32>()),
796            ));
797        }
798    }
799
800    fn maintain_boltfire_particles(
801        &mut self,
802        scene_data: &SceneData,
803        pos: Vec3<f32>,
804        vel: Option<&Vel>,
805    ) {
806        prof_span!("ParticleMgr::maintain_boltfire_particles");
807        let time = scene_data.state.get_time();
808        let dt = scene_data.state.get_delta_time();
809        let mut rng = thread_rng();
810
811        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(4)) {
812            self.particles.push(Particle::new(
813                Duration::from_millis(500),
814                time,
815                ParticleMode::CampfireFire,
816                pos,
817            ));
818            self.particles.push(Particle::new(
819                Duration::from_secs(1),
820                time,
821                ParticleMode::CampfireSmoke,
822                pos.map(|e| e + rng.gen_range(-0.25..0.25))
823                    + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.gen::<f32>()),
824            ));
825        }
826    }
827
828    fn maintain_boltfirebig_particles(
829        &mut self,
830        scene_data: &SceneData,
831        pos: Vec3<f32>,
832        vel: Option<&Vel>,
833    ) {
834        prof_span!("ParticleMgr::maintain_boltfirebig_particles");
835        let time = scene_data.state.get_time();
836        let dt = scene_data.state.get_delta_time();
837        let mut rng = thread_rng();
838
839        // fire
840        self.particles.resize_with(
841            self.particles.len() + usize::from(self.scheduler.heartbeats(Duration::from_millis(2))),
842            || {
843                Particle::new(
844                    Duration::from_millis(500),
845                    time,
846                    ParticleMode::CampfireFire,
847                    pos.map(|e| e + rng.gen_range(-0.25..0.25))
848                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.gen::<f32>()),
849                )
850            },
851        );
852
853        // smoke
854        self.particles.resize_with(
855            self.particles.len() + usize::from(self.scheduler.heartbeats(Duration::from_millis(5))),
856            || {
857                Particle::new(
858                    Duration::from_secs(2),
859                    time,
860                    ParticleMode::CampfireSmoke,
861                    pos.map(|e| e + rng.gen_range(-0.25..0.25))
862                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt),
863                )
864            },
865        );
866    }
867
868    fn maintain_fireraindrop_particles(
869        &mut self,
870        scene_data: &SceneData,
871        pos: Vec3<f32>,
872        vel: Option<&Vel>,
873    ) {
874        prof_span!("ParticleMgr::maintain_fireraindrop_particles");
875        let time = scene_data.state.get_time();
876        let dt = scene_data.state.get_delta_time();
877        let mut rng = thread_rng();
878
879        // trace
880        self.particles.resize_with(
881            self.particles.len()
882                + usize::from(self.scheduler.heartbeats(Duration::from_millis(100))),
883            || {
884                Particle::new(
885                    Duration::from_millis(300),
886                    time,
887                    ParticleMode::FieryDropletTrace,
888                    pos.map(|e| e + rng.gen_range(-0.25..0.25))
889                        + Vec3::new(0.0, 0.0, 0.5)
890                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.gen::<f32>()),
891                )
892            },
893        );
894    }
895
896    fn maintain_boltnature_particles(
897        &mut self,
898        scene_data: &SceneData,
899        pos: Vec3<f32>,
900        vel: Option<&Vel>,
901    ) {
902        let time = scene_data.state.get_time();
903        let dt = scene_data.state.get_delta_time();
904        let mut rng = thread_rng();
905
906        // nature
907        self.particles.resize_with(
908            self.particles.len() + usize::from(self.scheduler.heartbeats(Duration::from_millis(2))),
909            || {
910                Particle::new(
911                    Duration::from_millis(500),
912                    time,
913                    ParticleMode::CampfireSmoke,
914                    pos.map(|e| e + rng.gen_range(-0.25..0.25))
915                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.gen::<f32>()),
916                )
917            },
918        );
919    }
920
921    fn maintain_tornado_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
922        let time = scene_data.state.get_time();
923        let mut rng = thread_rng();
924
925        // air particles
926        self.particles.resize_with(
927            self.particles.len() + usize::from(self.scheduler.heartbeats(Duration::from_millis(5))),
928            || {
929                Particle::new(
930                    Duration::from_millis(1000),
931                    time,
932                    ParticleMode::Tornado,
933                    pos.map(|e| e + rng.gen_range(-0.25..0.25)),
934                )
935            },
936        );
937    }
938
939    fn maintain_fiery_tornado_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
940        let time = scene_data.state.get_time();
941        let mut rng = thread_rng();
942
943        // air particles
944        self.particles.resize_with(
945            self.particles.len() + usize::from(self.scheduler.heartbeats(Duration::from_millis(5))),
946            || {
947                Particle::new(
948                    Duration::from_millis(1000),
949                    time,
950                    ParticleMode::FieryTornado,
951                    pos.map(|e| e + rng.gen_range(-0.25..0.25)),
952                )
953            },
954        );
955    }
956
957    fn maintain_bomb_particles(
958        &mut self,
959        scene_data: &SceneData,
960        pos: Vec3<f32>,
961        vel: Option<&Vel>,
962    ) {
963        prof_span!("ParticleMgr::maintain_bomb_particles");
964        let time = scene_data.state.get_time();
965        let dt = scene_data.state.get_delta_time();
966        let mut rng = thread_rng();
967
968        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(10)) {
969            // sparks
970            self.particles.push(Particle::new(
971                Duration::from_millis(1500),
972                time,
973                ParticleMode::GunPowderSpark,
974                pos,
975            ));
976
977            // smoke
978            self.particles.push(Particle::new(
979                Duration::from_secs(2),
980                time,
981                ParticleMode::CampfireSmoke,
982                pos + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.gen::<f32>()),
983            ));
984        }
985    }
986
987    fn maintain_active_portal_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
988        prof_span!("ParticleMgr::maintain_active_portal_particles");
989
990        let time = scene_data.state.get_time();
991        let mut rng = thread_rng();
992
993        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(5)) {
994            let outer_pos =
995                pos + (Vec2::unit_x().rotated_z(rng.gen_range((0.)..PI * 2.)) * 2.7).with_z(0.);
996
997            self.particles.push(Particle::new_directed(
998                Duration::from_secs_f32(rng.gen_range(0.4..0.8)),
999                time,
1000                ParticleMode::CultistFlame,
1001                outer_pos,
1002                outer_pos + Vec3::unit_z() * rng.gen_range(5.0..7.0),
1003            ));
1004        }
1005    }
1006
1007    fn maintain_portal_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1008        prof_span!("ParticleMgr::maintain_portal_particles");
1009
1010        let time = scene_data.state.get_time();
1011        let mut rng = thread_rng();
1012
1013        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(150)) {
1014            let outer_pos = pos
1015                + (Vec2::unit_x().rotated_z(rng.gen_range((0.)..PI * 2.))
1016                    * rng.gen_range(1.0..2.9))
1017                .with_z(0.);
1018
1019            self.particles.push(Particle::new_directed(
1020                Duration::from_secs_f32(rng.gen_range(0.5..3.0)),
1021                time,
1022                ParticleMode::CultistFlame,
1023                outer_pos,
1024                outer_pos + Vec3::unit_z() * rng.gen_range(3.0..4.0),
1025            ));
1026        }
1027    }
1028
1029    fn maintain_mine_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1030        prof_span!("ParticleMgr::maintain_mine_particles");
1031        let time = scene_data.state.get_time();
1032
1033        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(1)) {
1034            // sparks
1035            self.particles.push(Particle::new(
1036                Duration::from_millis(25),
1037                time,
1038                ParticleMode::GunPowderSpark,
1039                pos,
1040            ));
1041        }
1042    }
1043
1044    fn maintain_char_state_particles(&mut self, scene_data: &SceneData, figure_mgr: &FigureMgr) {
1045        prof_span!("ParticleMgr::maintain_char_state_particles");
1046        let state = scene_data.state;
1047        let ecs = state.ecs();
1048        let time = state.get_time();
1049        let dt = scene_data.state.get_delta_time();
1050        let mut rng = thread_rng();
1051
1052        for (
1053            entity,
1054            interpolated,
1055            vel,
1056            character_state,
1057            body,
1058            ori,
1059            character_activity,
1060            physics,
1061            inventory,
1062        ) in (
1063            &ecs.entities(),
1064            &ecs.read_storage::<Interpolated>(),
1065            ecs.read_storage::<Vel>().maybe(),
1066            &ecs.read_storage::<CharacterState>(),
1067            &ecs.read_storage::<Body>(),
1068            &ecs.read_storage::<Ori>(),
1069            &ecs.read_storage::<CharacterActivity>(),
1070            &ecs.read_storage::<PhysicsState>(),
1071            ecs.read_storage::<Inventory>().maybe(),
1072        )
1073            .join()
1074        {
1075            match character_state {
1076                CharacterState::Boost(_) => {
1077                    self.particles.resize_with(
1078                        self.particles.len()
1079                            + usize::from(self.scheduler.heartbeats(Duration::from_millis(10))),
1080                        || {
1081                            Particle::new(
1082                                Duration::from_millis(250),
1083                                time,
1084                                ParticleMode::PortalFizz,
1085                                // Output particles from broom, not from character ass
1086                                interpolated.pos
1087                                    - ori.to_horizontal().look_dir().to_vec()
1088                                    - vel.map_or(Vec3::zero(), |v| v.0 * dt * rng.gen::<f32>()),
1089                            )
1090                        },
1091                    );
1092                },
1093                CharacterState::BasicMelee(c) => {
1094                    if let Some(specifier) = c.static_data.frontend_specifier {
1095                        match specifier {
1096                            states::basic_melee::FrontendSpecifier::FlameTornado => {
1097                                if matches!(c.stage_section, StageSection::Action) {
1098                                    let time = scene_data.state.get_time();
1099                                    let mut rng = thread_rng();
1100                                    self.particles.resize_with(
1101                                        self.particles.len()
1102                                            + 10
1103                                            + usize::from(
1104                                                self.scheduler.heartbeats(Duration::from_millis(5)),
1105                                            ),
1106                                        || {
1107                                            Particle::new(
1108                                                Duration::from_millis(1000),
1109                                                time,
1110                                                ParticleMode::FlameTornado,
1111                                                interpolated
1112                                                    .pos
1113                                                    .map(|e| e + rng.gen_range(-0.25..0.25)),
1114                                            )
1115                                        },
1116                                    );
1117                                }
1118                            },
1119                        }
1120                    }
1121                },
1122                CharacterState::RapidMelee(c) => {
1123                    if let Some(specifier) = c.static_data.frontend_specifier {
1124                        match specifier {
1125                            states::rapid_melee::FrontendSpecifier::CultistVortex => {
1126                                if matches!(c.stage_section, StageSection::Action) {
1127                                    let range = c.static_data.melee_constructor.range;
1128                                    // Particles for vortex
1129                                    let heartbeats =
1130                                        self.scheduler.heartbeats(Duration::from_millis(3));
1131                                    self.particles.resize_with(
1132                                        self.particles.len()
1133                                            + range.powi(2) as usize * usize::from(heartbeats)
1134                                                / 150,
1135                                        || {
1136                                            let rand_dist =
1137                                                range * (1.0 - rng.gen::<f32>().powi(10));
1138                                            let init_pos = Vec3::new(
1139                                                2.0 * rng.gen::<f32>() - 1.0,
1140                                                2.0 * rng.gen::<f32>() - 1.0,
1141                                                0.0,
1142                                            )
1143                                            .normalized()
1144                                                * rand_dist
1145                                                + interpolated.pos
1146                                                + Vec3::unit_z() * 0.05;
1147                                            Particle::new_directed(
1148                                                Duration::from_millis(900),
1149                                                time,
1150                                                ParticleMode::CultistFlame,
1151                                                init_pos,
1152                                                interpolated.pos,
1153                                            )
1154                                        },
1155                                    );
1156                                    // Particles for lifesteal effect
1157                                    for (_entity_b, interpolated_b, body_b, _health_b) in (
1158                                        &ecs.entities(),
1159                                        &ecs.read_storage::<Interpolated>(),
1160                                        &ecs.read_storage::<Body>(),
1161                                        &ecs.read_storage::<comp::Health>(),
1162                                    )
1163                                        .join()
1164                                        .filter(|(e, _, _, h)| !h.is_dead && entity != *e)
1165                                    {
1166                                        if interpolated.pos.distance_squared(interpolated_b.pos)
1167                                            < range.powi(2)
1168                                        {
1169                                            let heartbeats = self
1170                                                .scheduler
1171                                                .heartbeats(Duration::from_millis(20));
1172                                            self.particles.resize_with(
1173                                                self.particles.len()
1174                                                    + range.powi(2) as usize
1175                                                        * usize::from(heartbeats)
1176                                                        / 150,
1177                                                || {
1178                                                    let start_pos = interpolated_b.pos
1179                                                        + Vec3::unit_z() * body_b.height() * 0.5
1180                                                        + Vec3::<f32>::zero()
1181                                                            .map(|_| rng.gen_range(-1.0..1.0))
1182                                                            .normalized()
1183                                                            * 1.0;
1184                                                    Particle::new_directed(
1185                                                        Duration::from_millis(900),
1186                                                        time,
1187                                                        ParticleMode::CultistFlame,
1188                                                        start_pos,
1189                                                        interpolated.pos
1190                                                            + Vec3::unit_z() * body.height() * 0.5,
1191                                                    )
1192                                                },
1193                                            );
1194                                        }
1195                                    }
1196                                }
1197                            },
1198                            states::rapid_melee::FrontendSpecifier::Whirlwind => {
1199                                if matches!(c.stage_section, StageSection::Action) {
1200                                    let time = scene_data.state.get_time();
1201                                    let mut rng = thread_rng();
1202                                    self.particles.resize_with(
1203                                        self.particles.len()
1204                                            + 3
1205                                            + usize::from(
1206                                                self.scheduler.heartbeats(Duration::from_millis(5)),
1207                                            ),
1208                                        || {
1209                                            Particle::new(
1210                                                Duration::from_millis(1000),
1211                                                time,
1212                                                ParticleMode::Whirlwind,
1213                                                interpolated
1214                                                    .pos
1215                                                    .map(|e| e + rng.gen_range(-0.25..0.25)),
1216                                            )
1217                                        },
1218                                    );
1219                                }
1220                            },
1221                        }
1222                    }
1223                },
1224                CharacterState::RepeaterRanged(repeater) => {
1225                    if let Some(specifier) = repeater.static_data.specifier {
1226                        match specifier {
1227                            states::repeater_ranged::FrontendSpecifier::FireRain => {
1228                                // base, dark clouds
1229                                self.particles.resize_with(
1230                                    self.particles.len()
1231                                        + 2 * usize::from(
1232                                            self.scheduler.heartbeats(Duration::from_millis(25)),
1233                                        ),
1234                                    || {
1235                                        let rand_pos = {
1236                                            let theta = rng.gen::<f32>() * TAU;
1237                                            let radius = repeater
1238                                                .static_data
1239                                                .properties_of_aoe
1240                                                .map(|aoe| aoe.radius)
1241                                                .unwrap_or_default()
1242                                                * rng.gen::<f32>().sqrt();
1243                                            let x = radius * theta.sin();
1244                                            let y = radius * theta.cos();
1245                                            Vec2::new(x, y) + interpolated.pos.xy()
1246                                        };
1247                                        let pos1 = rand_pos.with_z(
1248                                            repeater
1249                                                .static_data
1250                                                .properties_of_aoe
1251                                                .map(|aoe| aoe.height)
1252                                                .unwrap_or_default()
1253                                                + interpolated.pos.z
1254                                                + 2.0 * rng.gen::<f32>(),
1255                                        );
1256                                        Particle::new_directed(
1257                                            Duration::from_secs_f32(3.0),
1258                                            time,
1259                                            ParticleMode::PhoenixCloud,
1260                                            pos1,
1261                                            pos1 + Vec3::new(7.09, 4.09, 18.09),
1262                                        )
1263                                    },
1264                                );
1265                                self.particles.resize_with(
1266                                    self.particles.len()
1267                                        + 2 * usize::from(
1268                                            self.scheduler.heartbeats(Duration::from_millis(25)),
1269                                        ),
1270                                    || {
1271                                        let rand_pos = {
1272                                            let theta = rng.gen::<f32>() * TAU;
1273                                            let radius = repeater
1274                                                .static_data
1275                                                .properties_of_aoe
1276                                                .map(|aoe| aoe.radius)
1277                                                .unwrap_or_default()
1278                                                * rng.gen::<f32>().sqrt();
1279                                            let x = radius * theta.sin();
1280                                            let y = radius * theta.cos();
1281                                            Vec2::new(x, y) + interpolated.pos.xy()
1282                                        };
1283                                        let pos1 = rand_pos.with_z(
1284                                            repeater
1285                                                .static_data
1286                                                .properties_of_aoe
1287                                                .map(|aoe| aoe.height)
1288                                                .unwrap_or_default()
1289                                                + interpolated.pos.z
1290                                                + 1.5 * rng.gen::<f32>(),
1291                                        );
1292                                        Particle::new_directed(
1293                                            Duration::from_secs_f32(2.5),
1294                                            time,
1295                                            ParticleMode::PhoenixCloud,
1296                                            pos1,
1297                                            pos1 + Vec3::new(10.025, 4.025, 17.025),
1298                                        )
1299                                    },
1300                                );
1301                            },
1302                        }
1303                    }
1304                },
1305                CharacterState::Blink(c) => {
1306                    if let Some(specifier) = c.static_data.frontend_specifier {
1307                        match specifier {
1308                            states::blink::FrontendSpecifier::CultistFlame => {
1309                                self.particles.resize_with(
1310                                    self.particles.len()
1311                                        + usize::from(
1312                                            self.scheduler.heartbeats(Duration::from_millis(10)),
1313                                        ),
1314                                    || {
1315                                        let center_pos =
1316                                            interpolated.pos + Vec3::unit_z() * body.height() / 2.0;
1317                                        let outer_pos = interpolated.pos
1318                                            + Vec3::new(
1319                                                2.0 * rng.gen::<f32>() - 1.0,
1320                                                2.0 * rng.gen::<f32>() - 1.0,
1321                                                0.0,
1322                                            )
1323                                            .normalized()
1324                                                * (body.max_radius() + 2.0)
1325                                            + Vec3::unit_z() * body.height() * rng.gen::<f32>();
1326
1327                                        let (start_pos, end_pos) =
1328                                            if matches!(c.stage_section, StageSection::Buildup) {
1329                                                (outer_pos, center_pos)
1330                                            } else {
1331                                                (center_pos, outer_pos)
1332                                            };
1333
1334                                        Particle::new_directed(
1335                                            Duration::from_secs_f32(0.5),
1336                                            time,
1337                                            ParticleMode::CultistFlame,
1338                                            start_pos,
1339                                            end_pos,
1340                                        )
1341                                    },
1342                                );
1343                            },
1344                            states::blink::FrontendSpecifier::FlameThrower => {
1345                                self.particles.resize_with(
1346                                    self.particles.len()
1347                                        + usize::from(
1348                                            self.scheduler.heartbeats(Duration::from_millis(10)),
1349                                        ),
1350                                    || {
1351                                        let center_pos =
1352                                            interpolated.pos + Vec3::unit_z() * body.height() / 2.0;
1353                                        let outer_pos = interpolated.pos
1354                                            + Vec3::new(
1355                                                2.0 * rng.gen::<f32>() - 1.0,
1356                                                2.0 * rng.gen::<f32>() - 1.0,
1357                                                0.0,
1358                                            )
1359                                            .normalized()
1360                                                * (body.max_radius() + 2.0)
1361                                            + Vec3::unit_z() * body.height() * rng.gen::<f32>();
1362
1363                                        let (start_pos, end_pos) =
1364                                            if matches!(c.stage_section, StageSection::Buildup) {
1365                                                (outer_pos, center_pos)
1366                                            } else {
1367                                                (center_pos, outer_pos)
1368                                            };
1369
1370                                        Particle::new_directed(
1371                                            Duration::from_secs_f32(0.5),
1372                                            time,
1373                                            ParticleMode::FlameThrower,
1374                                            start_pos,
1375                                            end_pos,
1376                                        )
1377                                    },
1378                                );
1379                            },
1380                        }
1381                    }
1382                },
1383                CharacterState::SelfBuff(c) => {
1384                    if let Some(specifier) = c.static_data.specifier {
1385                        match specifier {
1386                            states::self_buff::FrontendSpecifier::FromTheAshes => {
1387                                if matches!(c.stage_section, StageSection::Action) {
1388                                    let pos = interpolated.pos;
1389                                    self.particles.resize_with(
1390                                        self.particles.len()
1391                                            + 2 * usize::from(
1392                                                self.scheduler.heartbeats(Duration::from_millis(1)),
1393                                            ),
1394                                        || {
1395                                            let start_pos = pos + Vec3::unit_z() - 1.0;
1396                                            let end_pos = pos
1397                                                + Vec3::new(
1398                                                    4.0 * rng.gen::<f32>() - 1.0,
1399                                                    4.0 * rng.gen::<f32>() - 1.0,
1400                                                    0.0,
1401                                                )
1402                                                .normalized()
1403                                                    * 1.5
1404                                                + Vec3::unit_z()
1405                                                + 5.0 * rng.gen::<f32>();
1406
1407                                            Particle::new_directed(
1408                                                Duration::from_secs_f32(0.5),
1409                                                time,
1410                                                ParticleMode::FieryBurst,
1411                                                start_pos,
1412                                                end_pos,
1413                                            )
1414                                        },
1415                                    );
1416                                    self.particles.resize_with(
1417                                        self.particles.len()
1418                                            + usize::from(
1419                                                self.scheduler
1420                                                    .heartbeats(Duration::from_millis(10)),
1421                                            ),
1422                                        || {
1423                                            Particle::new(
1424                                                Duration::from_millis(650),
1425                                                time,
1426                                                ParticleMode::FieryBurstVortex,
1427                                                pos.map(|e| e + rng.gen_range(-0.25..0.25))
1428                                                    + Vec3::new(0.0, 0.0, 1.0),
1429                                            )
1430                                        },
1431                                    );
1432                                    self.particles.resize_with(
1433                                        self.particles.len()
1434                                            + usize::from(
1435                                                self.scheduler
1436                                                    .heartbeats(Duration::from_millis(40)),
1437                                            ),
1438                                        || {
1439                                            Particle::new(
1440                                                Duration::from_millis(1000),
1441                                                time,
1442                                                ParticleMode::FieryBurstSparks,
1443                                                pos.map(|e| e + rng.gen_range(-0.25..0.25)),
1444                                            )
1445                                        },
1446                                    );
1447                                    self.particles.resize_with(
1448                                        self.particles.len()
1449                                            + usize::from(
1450                                                self.scheduler
1451                                                    .heartbeats(Duration::from_millis(14)),
1452                                            ),
1453                                        || {
1454                                            let pos1 = pos.map(|e| e + rng.gen_range(-0.25..0.25));
1455                                            Particle::new_directed(
1456                                                Duration::from_millis(1000),
1457                                                time,
1458                                                ParticleMode::FieryBurstAsh,
1459                                                pos1,
1460                                                Vec3::new(
1461                                                    4.5,    // radius of rand spawn
1462                                                    20.4,   // integer part - radius of the curve part, fractional part - relative time of setting particle on fire
1463                                                    8.58)   // height of the flight
1464                                                    + pos1,
1465                                            )
1466                                        },
1467                                    );
1468                                }
1469                            },
1470                        }
1471                    }
1472                    use buff::BuffKind;
1473                    if let BuffKind::Frenzied = c.static_data.buff_kind {
1474                        if matches!(c.stage_section, StageSection::Action) {
1475                            self.particles.resize_with(
1476                                self.particles.len()
1477                                    + usize::from(
1478                                        self.scheduler.heartbeats(Duration::from_millis(5)),
1479                                    ),
1480                                || {
1481                                    let start_pos = interpolated.pos
1482                                        + Vec3::new(
1483                                            body.max_radius(),
1484                                            body.max_radius(),
1485                                            body.height() / 2.0,
1486                                        )
1487                                        .map(|d| d * rng.gen_range(-1.0..1.0));
1488                                    let end_pos =
1489                                        interpolated.pos + (start_pos - interpolated.pos) * 6.0;
1490                                    Particle::new_directed(
1491                                        Duration::from_secs(1),
1492                                        time,
1493                                        ParticleMode::Enraged,
1494                                        start_pos,
1495                                        end_pos,
1496                                    )
1497                                },
1498                            );
1499                        }
1500                    }
1501                },
1502                CharacterState::BasicBeam(beam) => {
1503                    let ori = *ori;
1504                    let _look_dir = *character_activity.look_dir.unwrap_or(ori.look_dir());
1505                    let dir = ori.look_dir(); //.with_z(look_dir.z);
1506                    let specifier = beam.static_data.specifier;
1507                    if specifier == beam::FrontendSpecifier::PhoenixLaser
1508                        && matches!(beam.stage_section, StageSection::Buildup)
1509                    {
1510                        self.particles.resize_with(
1511                            self.particles.len()
1512                                + 2 * usize::from(
1513                                    self.scheduler.heartbeats(Duration::from_millis(2)),
1514                                ),
1515                            || {
1516                                let mut left_right_alignment =
1517                                    dir.cross(Vec3::new(0.0, 0.0, 1.0)).normalized();
1518                                if rng.gen_bool(0.5) {
1519                                    left_right_alignment *= -1.0;
1520                                }
1521                                let start = interpolated.pos
1522                                    + left_right_alignment * 4.0
1523                                    + dir.normalized() * 6.0;
1524                                let lifespan = Duration::from_secs_f32(0.5);
1525                                Particle::new_directed(
1526                                    lifespan,
1527                                    time,
1528                                    ParticleMode::PhoenixBuildUpAim,
1529                                    start,
1530                                    interpolated.pos
1531                                        + dir.normalized() * 3.0
1532                                        + left_right_alignment * 0.4
1533                                        + vel
1534                                            .map_or(Vec3::zero(), |v| v.0 * lifespan.as_secs_f32()),
1535                                )
1536                            },
1537                        );
1538                    }
1539                },
1540                CharacterState::Glide(glide) => {
1541                    if let Some(Fluid::Air {
1542                        vel: air_vel,
1543                        elevation: _,
1544                    }) = physics.in_fluid
1545                    {
1546                        // Empirical observation is that air_vel is somewhere
1547                        // between 0.0 and 13.0, but we are extending to be sure
1548                        const MAX_AIR_VEL: f32 = 15.0;
1549                        const MIN_AIR_VEL: f32 = -2.0;
1550
1551                        let minmax_norm = |val, min, max| (val - min) / (max - min);
1552
1553                        let wind_speed = air_vel.0.magnitude();
1554
1555                        // Less means more frequent particles
1556                        let heartbeat = 200
1557                            - Lerp::lerp(
1558                                50u64,
1559                                150,
1560                                minmax_norm(wind_speed, MIN_AIR_VEL, MAX_AIR_VEL),
1561                            );
1562
1563                        let new_count = self.particles.len()
1564                            + usize::from(
1565                                self.scheduler.heartbeats(Duration::from_millis(heartbeat)),
1566                            );
1567
1568                        // More number, longer particles
1569                        let duration = Lerp::lerp(
1570                            0u64,
1571                            1000,
1572                            minmax_norm(wind_speed, MIN_AIR_VEL, MAX_AIR_VEL),
1573                        );
1574                        let duration = Duration::from_millis(duration);
1575
1576                        self.particles.resize_with(new_count, || {
1577                            let start_pos = interpolated.pos
1578                                + Vec3::new(
1579                                    body.max_radius(),
1580                                    body.max_radius(),
1581                                    body.height() / 2.0,
1582                                )
1583                                .map(|d| d * rng.gen_range(-10.0..10.0));
1584
1585                            Particle::new_directed(
1586                                duration,
1587                                time,
1588                                ParticleMode::Airflow,
1589                                start_pos,
1590                                start_pos + air_vel.0,
1591                            )
1592                        });
1593
1594                        // When using the glide boost, emit particles
1595                        if let Some(states::glide::Boost::Forward(_)) = &glide.booster
1596                            && let Some(figure_state) =
1597                                figure_mgr.states.character_states.get(&entity)
1598                            && let Some(tp0) = figure_state.main_abs_trail_points
1599                            && let Some(tp1) = figure_state.off_abs_trail_points
1600                        {
1601                            for _ in 0..self.scheduler.heartbeats(Duration::from_millis(5)) {
1602                                self.particles.push(Particle::new(
1603                                    Duration::from_secs(2),
1604                                    time,
1605                                    ParticleMode::EngineJet,
1606                                    ((tp0.0 + tp1.1) * 0.5)
1607                                        // TODO: This offset is used to position the particles at the engine outlet. Ideally, we'd have a way to configure this per-glider
1608                                        + Vec3::unit_z() * 0.5
1609                                        + Vec3::<f32>::zero().map(|_| rng.gen_range(-0.25..0.25))
1610                                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.gen::<f32>()),
1611                                ));
1612                            }
1613                        }
1614                    }
1615                },
1616                CharacterState::Transform(data) => {
1617                    if matches!(data.stage_section, StageSection::Buildup)
1618                        && let Some(specifier) = data.static_data.specifier
1619                    {
1620                        match specifier {
1621                            states::transform::FrontendSpecifier::Evolve => {
1622                                self.particles.resize_with(
1623                                    self.particles.len()
1624                                        + usize::from(
1625                                            self.scheduler.heartbeats(Duration::from_millis(10)),
1626                                        ),
1627                                    || {
1628                                        let start_pos = interpolated.pos
1629                                            + (Vec2::unit_y()
1630                                                * rng.gen::<f32>()
1631                                                * body.max_radius())
1632                                            .rotated_z(rng.gen_range(0.0..(PI * 2.0)))
1633                                            .with_z(body.height() * rng.gen::<f32>());
1634
1635                                        Particle::new_directed(
1636                                            Duration::from_millis(100),
1637                                            time,
1638                                            ParticleMode::BarrelOrgan,
1639                                            start_pos,
1640                                            start_pos + Vec3::unit_z() * 2.0,
1641                                        )
1642                                    },
1643                                )
1644                            },
1645                            states::transform::FrontendSpecifier::Cursekeeper => {
1646                                self.particles.resize_with(
1647                                    self.particles.len()
1648                                        + usize::from(
1649                                            self.scheduler.heartbeats(Duration::from_millis(10)),
1650                                        ),
1651                                    || {
1652                                        let start_pos = interpolated.pos
1653                                            + (Vec2::unit_y()
1654                                                * rng.gen::<f32>()
1655                                                * body.max_radius())
1656                                            .rotated_z(rng.gen_range(0.0..(PI * 2.0)))
1657                                            .with_z(body.height() * rng.gen::<f32>());
1658
1659                                        Particle::new_directed(
1660                                            Duration::from_millis(100),
1661                                            time,
1662                                            ParticleMode::FireworkPurple,
1663                                            start_pos,
1664                                            start_pos + Vec3::unit_z() * 2.0,
1665                                        )
1666                                    },
1667                                )
1668                            },
1669                        }
1670                    }
1671                },
1672                CharacterState::ChargedMelee(_melee) => {
1673                    self.maintain_hydra_tail_swipe_particles(
1674                        scene_data,
1675                        figure_mgr,
1676                        entity,
1677                        interpolated.pos,
1678                        character_state,
1679                        inventory,
1680                    );
1681                },
1682                _ => {},
1683            }
1684        }
1685    }
1686
1687    fn maintain_beam_particles(&mut self, scene_data: &SceneData, lights: &mut Vec<Light>) {
1688        let state = scene_data.state;
1689        let ecs = state.ecs();
1690        let time = state.get_time();
1691        let terrain = state.terrain();
1692        // Limit to 100 per tick, so at less than 10 FPS particle generation
1693        // work doesn't increase frame cost further.
1694        let tick_elapse = u32::from(self.scheduler.heartbeats(Duration::from_millis(1)).min(100));
1695        let mut rng = thread_rng();
1696
1697        for (beam, ori) in (&ecs.read_storage::<Beam>(), &ecs.read_storage::<Ori>()).join() {
1698            let beam_tick_count = tick_elapse as f32 * beam.specifier.particles_per_sec();
1699            let beam_tick_count = if rng.gen_bool(f64::from(beam_tick_count.fract())) {
1700                beam_tick_count.ceil() as u32
1701            } else {
1702                beam_tick_count.floor() as u32
1703            };
1704
1705            if beam_tick_count == 0 {
1706                continue;
1707            }
1708
1709            let distributed_time = tick_elapse as f64 / (beam_tick_count * 1000) as f64;
1710            let angle = (beam.end_radius / beam.range).atan();
1711            let beam_dir = (beam.bezier.ctrl - beam.bezier.start)
1712                .try_normalized()
1713                .unwrap_or(*ori.look_dir());
1714            let raycast_distance = |from, to| terrain.ray(from, to).until(Block::is_solid).cast().0;
1715
1716            self.particles.reserve(beam_tick_count as usize);
1717            match beam.specifier {
1718                beam::FrontendSpecifier::Flamethrower => {
1719                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
1720                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
1721                    // Emit a light when using flames
1722                    if scene_data.flashing_lights_enabled {
1723                        lights.push(Light::new(
1724                            beam.bezier.start,
1725                            Rgb::new(1.0, 0.25, 0.05).map(|e| e * rng.gen_range(0.8..1.2)),
1726                            2.0,
1727                        ));
1728                    }
1729
1730                    for i in 0..beam_tick_count {
1731                        let phi: f32 = rng.gen_range(0.0..angle);
1732                        let theta: f32 = rng.gen_range(0.0..2.0 * PI);
1733                        let offset_z =
1734                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
1735                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
1736                        self.particles.push(Particle::new_directed_with_collision(
1737                            Duration::from_secs_f64(beam.duration.0),
1738                            time + distributed_time * i as f64,
1739                            ParticleMode::FlameThrower,
1740                            beam.bezier.start,
1741                            beam.bezier.start + random_ori * beam.range,
1742                            raycast_distance,
1743                        ));
1744                    }
1745                },
1746                beam::FrontendSpecifier::Cultist => {
1747                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
1748                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
1749                    // Emit a light when using flames
1750                    if scene_data.flashing_lights_enabled {
1751                        lights.push(Light::new(
1752                            beam.bezier.start,
1753                            Rgb::new(1.0, 0.0, 1.0).map(|e| e * rng.gen_range(0.5..1.0)),
1754                            2.0,
1755                        ));
1756                    }
1757                    for i in 0..beam_tick_count {
1758                        let phi: f32 = rng.gen_range(0.0..angle);
1759                        let theta: f32 = rng.gen_range(0.0..2.0 * PI);
1760                        let offset_z =
1761                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
1762                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
1763                        self.particles.push(Particle::new_directed_with_collision(
1764                            Duration::from_secs_f64(beam.duration.0),
1765                            time + distributed_time * i as f64,
1766                            ParticleMode::CultistFlame,
1767                            beam.bezier.start,
1768                            beam.bezier.start + random_ori * beam.range,
1769                            raycast_distance,
1770                        ));
1771                    }
1772                },
1773                beam::FrontendSpecifier::LifestealBeam => {
1774                    // Emit a light when using lifesteal beam
1775                    if scene_data.flashing_lights_enabled {
1776                        lights.push(Light::new(beam.bezier.start, Rgb::new(0.8, 1.0, 0.5), 1.0));
1777                    }
1778
1779                    // If the beam is one straight line, we can run raycast one time.
1780                    let bezier_end = beam.bezier.start + beam_dir * beam.range;
1781                    let distance = raycast_distance(beam.bezier.start, bezier_end);
1782                    for i in 0..beam_tick_count {
1783                        self.particles.push(Particle::new_directed_with_collision(
1784                            Duration::from_secs_f64(beam.duration.0),
1785                            time + distributed_time * i as f64,
1786                            ParticleMode::LifestealBeam,
1787                            beam.bezier.start,
1788                            bezier_end,
1789                            |_from, _to| distance,
1790                        ));
1791                    }
1792                },
1793                beam::FrontendSpecifier::Gravewarden => {
1794                    for i in 0..beam_tick_count {
1795                        let mut offset = 0.5;
1796                        let side = Vec2::new(-beam_dir.y, beam_dir.x);
1797                        self.particles.resize_with(self.particles.len() + 2, || {
1798                            offset = -offset;
1799                            Particle::new_directed_with_collision(
1800                                Duration::from_secs_f64(beam.duration.0),
1801                                time + distributed_time * i as f64,
1802                                ParticleMode::Laser,
1803                                beam.bezier.start + beam_dir * 1.5 + side * offset,
1804                                beam.bezier.start + beam_dir * beam.range + side * offset,
1805                                raycast_distance,
1806                            )
1807                        });
1808                    }
1809                },
1810                beam::FrontendSpecifier::WebStrand => {
1811                    let bezier_end = beam.bezier.start + beam_dir * beam.range;
1812                    let distance = raycast_distance(beam.bezier.start, bezier_end);
1813                    for i in 0..beam_tick_count {
1814                        self.particles.push(Particle::new_directed_with_collision(
1815                            Duration::from_secs_f64(beam.duration.0),
1816                            time + distributed_time * i as f64,
1817                            ParticleMode::WebStrand,
1818                            beam.bezier.start,
1819                            bezier_end,
1820                            |_from, _to| distance,
1821                        ));
1822                    }
1823                },
1824                beam::FrontendSpecifier::Bubbles => {
1825                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
1826                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
1827                    for i in 0..beam_tick_count {
1828                        let phi: f32 = rng.gen_range(0.0..angle);
1829                        let theta: f32 = rng.gen_range(0.0..2.0 * PI);
1830                        let offset_z =
1831                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
1832                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
1833                        self.particles.push(Particle::new_directed_with_collision(
1834                            Duration::from_secs_f64(beam.duration.0),
1835                            time + distributed_time * i as f64,
1836                            ParticleMode::Bubbles,
1837                            beam.bezier.start,
1838                            beam.bezier.start + random_ori * beam.range,
1839                            raycast_distance,
1840                        ));
1841                    }
1842                },
1843                beam::FrontendSpecifier::Poison => {
1844                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
1845                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
1846                    for i in 0..beam_tick_count {
1847                        let phi: f32 = rng.gen_range(0.0..angle);
1848                        let theta: f32 = rng.gen_range(0.0..2.0 * PI);
1849                        let offset_z =
1850                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
1851                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
1852                        self.particles.push(Particle::new_directed_with_collision(
1853                            Duration::from_secs_f64(beam.duration.0),
1854                            time + distributed_time * i as f64,
1855                            ParticleMode::Poison,
1856                            beam.bezier.start,
1857                            beam.bezier.start + random_ori * beam.range,
1858                            raycast_distance,
1859                        ));
1860                    }
1861                },
1862                beam::FrontendSpecifier::Ink => {
1863                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
1864                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
1865                    for i in 0..beam_tick_count {
1866                        let phi: f32 = rng.gen_range(0.0..angle);
1867                        let theta: f32 = rng.gen_range(0.0..2.0 * PI);
1868                        let offset_z =
1869                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
1870                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
1871                        self.particles.push(Particle::new_directed_with_collision(
1872                            Duration::from_secs_f64(beam.duration.0),
1873                            time + distributed_time * i as f64,
1874                            ParticleMode::Bubbles,
1875                            beam.bezier.start,
1876                            beam.bezier.start + random_ori * beam.range,
1877                            raycast_distance,
1878                        ));
1879                    }
1880                },
1881                beam::FrontendSpecifier::Steam => {
1882                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
1883                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
1884                    for i in 0..beam_tick_count {
1885                        let phi: f32 = rng.gen_range(0.0..angle);
1886                        let theta: f32 = rng.gen_range(0.0..2.0 * PI);
1887                        let offset_z =
1888                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
1889                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
1890                        self.particles.push(Particle::new_directed_with_collision(
1891                            Duration::from_secs_f64(beam.duration.0),
1892                            time + distributed_time * i as f64,
1893                            ParticleMode::Steam,
1894                            beam.bezier.start,
1895                            beam.bezier.start + random_ori * beam.range,
1896                            raycast_distance,
1897                        ));
1898                    }
1899                },
1900                beam::FrontendSpecifier::Lightning => {
1901                    let bezier_end = beam.bezier.start + beam_dir * beam.range;
1902                    let distance = raycast_distance(beam.bezier.start, bezier_end);
1903                    for i in 0..beam_tick_count {
1904                        self.particles.push(Particle::new_directed_with_collision(
1905                            Duration::from_secs_f64(beam.duration.0),
1906                            time + distributed_time * i as f64,
1907                            ParticleMode::Lightning,
1908                            beam.bezier.start,
1909                            bezier_end,
1910                            |_from, _to| distance,
1911                        ));
1912                    }
1913                },
1914                beam::FrontendSpecifier::Frost => {
1915                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
1916                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
1917                    for i in 0..beam_tick_count {
1918                        let phi: f32 = rng.gen_range(0.0..angle);
1919                        let theta: f32 = rng.gen_range(0.0..2.0 * PI);
1920                        let offset_z =
1921                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
1922                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
1923                        self.particles.push(Particle::new_directed_with_collision(
1924                            Duration::from_secs_f64(beam.duration.0),
1925                            time + distributed_time * i as f64,
1926                            ParticleMode::Ice,
1927                            beam.bezier.start,
1928                            beam.bezier.start + random_ori * beam.range,
1929                            raycast_distance,
1930                        ));
1931                    }
1932                },
1933                beam::FrontendSpecifier::PhoenixLaser => {
1934                    let bezier_end = beam.bezier.start + beam_dir * beam.range;
1935                    let distance = raycast_distance(beam.bezier.start, bezier_end);
1936                    for i in 0..beam_tick_count {
1937                        self.particles.push(Particle::new_directed_with_collision(
1938                            Duration::from_secs_f64(beam.duration.0),
1939                            time + distributed_time * i as f64,
1940                            ParticleMode::PhoenixBeam,
1941                            beam.bezier.start,
1942                            bezier_end,
1943                            |_from, _to| distance,
1944                        ));
1945                    }
1946                },
1947            }
1948        }
1949    }
1950
1951    fn maintain_aura_particles(&mut self, scene_data: &SceneData) {
1952        let state = scene_data.state;
1953        let ecs = state.ecs();
1954        let time = state.get_time();
1955        let mut rng = thread_rng();
1956        let dt = scene_data.state.get_delta_time();
1957
1958        for (interp, pos, auras, body_maybe) in (
1959            ecs.read_storage::<Interpolated>().maybe(),
1960            &ecs.read_storage::<Pos>(),
1961            &ecs.read_storage::<comp::Auras>(),
1962            ecs.read_storage::<comp::Body>().maybe(),
1963        )
1964            .join()
1965        {
1966            let pos = interp.map_or(pos.0, |i| i.pos);
1967
1968            for (_, aura) in auras.auras.iter() {
1969                match aura.aura_kind {
1970                    aura::AuraKind::Buff {
1971                        kind: buff::BuffKind::ProtectingWard,
1972                        ..
1973                    } => {
1974                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
1975                        self.particles.resize_with(
1976                            self.particles.len()
1977                                + aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
1978                            || {
1979                                let rand_dist = aura.radius * (1.0 - rng.gen::<f32>().powi(100));
1980                                let init_pos = Vec3::new(rand_dist, 0_f32, 0_f32);
1981                                let duration = Duration::from_secs_f64(
1982                                    aura.end_time
1983                                        .map_or(1.0, |end| end.0 - time)
1984                                        .clamp(0.0, 1.0),
1985                                );
1986                                Particle::new_directed(
1987                                    duration,
1988                                    time,
1989                                    ParticleMode::EnergyNature,
1990                                    pos,
1991                                    pos + init_pos,
1992                                )
1993                            },
1994                        );
1995                    },
1996                    aura::AuraKind::Buff {
1997                        kind: buff::BuffKind::Regeneration,
1998                        ..
1999                    } => {
2000                        if auras.auras.iter().any(|(_, aura)| {
2001                            matches!(aura.aura_kind, aura::AuraKind::Buff {
2002                                kind: buff::BuffKind::ProtectingWard,
2003                                ..
2004                            })
2005                        }) {
2006                            // If same entity has both protecting ward and regeneration auras, skip
2007                            // particles for regeneration
2008                            continue;
2009                        }
2010                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2011                        self.particles.resize_with(
2012                            self.particles.len()
2013                                + aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
2014                            || {
2015                                let rand_dist = aura.radius * (1.0 - rng.gen::<f32>().powi(100));
2016                                let init_pos = Vec3::new(rand_dist, 0_f32, 0_f32);
2017                                let duration = Duration::from_secs_f64(
2018                                    aura.end_time
2019                                        .map_or(1.0, |end| end.0 - time)
2020                                        .clamp(0.0, 1.0),
2021                                );
2022                                Particle::new_directed(
2023                                    duration,
2024                                    time,
2025                                    ParticleMode::EnergyHealing,
2026                                    pos,
2027                                    pos + init_pos,
2028                                )
2029                            },
2030                        );
2031                    },
2032                    aura::AuraKind::Buff {
2033                        kind: buff::BuffKind::Burning,
2034                        ..
2035                    } => {
2036                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2037                        self.particles.resize_with(
2038                            self.particles.len()
2039                                + aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
2040                            || {
2041                                let rand_pos = {
2042                                    let theta = rng.gen::<f32>() * TAU;
2043                                    let radius = aura.radius * rng.gen::<f32>().sqrt();
2044                                    let x = radius * theta.sin();
2045                                    let y = radius * theta.cos();
2046                                    Vec2::new(x, y) + pos.xy()
2047                                };
2048                                let duration = Duration::from_secs_f64(
2049                                    aura.end_time
2050                                        .map_or(1.0, |end| end.0 - time)
2051                                        .clamp(0.0, 1.0),
2052                                );
2053                                Particle::new_directed(
2054                                    duration,
2055                                    time,
2056                                    ParticleMode::FlameThrower,
2057                                    rand_pos.with_z(pos.z),
2058                                    rand_pos.with_z(pos.z + 1.0),
2059                                )
2060                            },
2061                        );
2062                    },
2063                    aura::AuraKind::Buff {
2064                        kind: buff::BuffKind::Hastened,
2065                        ..
2066                    } => {
2067                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2068                        self.particles.resize_with(
2069                            self.particles.len()
2070                                + aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
2071                            || {
2072                                let rand_dist = aura.radius * (1.0 - rng.gen::<f32>().powi(100));
2073                                let init_pos = Vec3::new(rand_dist, 0_f32, 0_f32);
2074                                let duration = Duration::from_secs_f64(
2075                                    aura.end_time
2076                                        .map_or(1.0, |end| end.0 - time)
2077                                        .clamp(0.0, 1.0),
2078                                );
2079                                Particle::new_directed(
2080                                    duration,
2081                                    time,
2082                                    ParticleMode::EnergyBuffing,
2083                                    pos,
2084                                    pos + init_pos,
2085                                )
2086                            },
2087                        );
2088                    },
2089                    aura::AuraKind::Buff {
2090                        kind: buff::BuffKind::Frozen,
2091                        ..
2092                    } => {
2093                        let is_new_aura = aura.data.duration.is_none_or(|max_dur| {
2094                            let rem_dur = aura.end_time.map_or(time, |e| e.0) - time;
2095                            rem_dur > max_dur.0 * 0.9
2096                        });
2097                        if is_new_aura {
2098                            let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2099                            self.particles.resize_with(
2100                                self.particles.len()
2101                                    + aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
2102                                || {
2103                                    let rand_angle = rng.gen_range(0.0..TAU);
2104                                    let offset =
2105                                        Vec2::new(rand_angle.cos(), rand_angle.sin()) * aura.radius;
2106                                    let z_start = body_maybe
2107                                        .map_or(0.0, |b| rng.gen_range(0.5..0.75) * b.height());
2108                                    let z_end = body_maybe
2109                                        .map_or(0.0, |b| rng.gen_range(0.0..3.0) * b.height());
2110                                    Particle::new_directed(
2111                                        Duration::from_secs(3),
2112                                        time,
2113                                        ParticleMode::Ice,
2114                                        pos + Vec3::unit_z() * z_start,
2115                                        pos + offset.with_z(z_end),
2116                                    )
2117                                },
2118                            );
2119                        }
2120                    },
2121                    aura::AuraKind::Buff {
2122                        kind: buff::BuffKind::Heatstroke,
2123                        ..
2124                    } => {
2125                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2126                        self.particles.resize_with(
2127                            self.particles.len()
2128                                + aura.radius.powi(2) as usize * usize::from(heartbeats) / 900,
2129                            || {
2130                                let rand_dist = aura.radius * (1.0 - rng.gen::<f32>().powi(100));
2131                                let init_pos = Vec3::new(rand_dist, 0_f32, 0_f32);
2132                                let duration = Duration::from_secs_f64(
2133                                    aura.end_time
2134                                        .map_or(1.0, |end| end.0 - time)
2135                                        .clamp(0.0, 1.0),
2136                                );
2137                                Particle::new_directed(
2138                                    duration,
2139                                    time,
2140                                    ParticleMode::EnergyPhoenix,
2141                                    pos,
2142                                    pos + init_pos,
2143                                )
2144                            },
2145                        );
2146
2147                        let num_particles = aura.radius.powi(2) * dt / 50.0;
2148                        let num_particles = num_particles.floor() as usize
2149                            + usize::from(rng.gen_bool(f64::from(num_particles % 1.0)));
2150                        self.particles
2151                            .resize_with(self.particles.len() + num_particles, || {
2152                                let rand_pos = {
2153                                    let theta = rng.gen::<f32>() * TAU;
2154                                    let radius = aura.radius * rng.gen::<f32>().sqrt();
2155                                    let x = radius * theta.sin();
2156                                    let y = radius * theta.cos();
2157                                    Vec2::new(x, y) + pos.xy()
2158                                };
2159                                let duration = Duration::from_secs_f64(
2160                                    aura.end_time
2161                                        .map_or(1.0, |end| end.0 - time)
2162                                        .clamp(0.0, 1.0),
2163                                );
2164                                Particle::new_directed(
2165                                    duration,
2166                                    time,
2167                                    ParticleMode::FieryBurstAsh,
2168                                    pos,
2169                                    Vec3::new(
2170                                                    0.0,    // radius of rand spawn
2171                                                    20.0,   // integer part - radius of the curve part, fractional part - relative time of setting particle on fire
2172                                                    5.5)    // height of the flight
2173                                                    + rand_pos.with_z(pos.z),
2174                                )
2175                            });
2176                    },
2177                    _ => {},
2178                }
2179            }
2180        }
2181    }
2182
2183    fn maintain_buff_particles(&mut self, scene_data: &SceneData) {
2184        let state = scene_data.state;
2185        let ecs = state.ecs();
2186        let time = state.get_time();
2187        let mut rng = thread_rng();
2188
2189        for (interp, pos, buffs, body, ori, scale) in (
2190            ecs.read_storage::<Interpolated>().maybe(),
2191            &ecs.read_storage::<Pos>(),
2192            &ecs.read_storage::<comp::Buffs>(),
2193            &ecs.read_storage::<Body>(),
2194            &ecs.read_storage::<Ori>(),
2195            ecs.read_storage::<Scale>().maybe(),
2196        )
2197            .join()
2198        {
2199            let pos = interp.map_or(pos.0, |i| i.pos);
2200
2201            for (buff_kind, buff_keys) in buffs
2202                .kinds
2203                .iter()
2204                .filter_map(|(kind, keys)| keys.as_ref().map(|keys| (kind, keys)))
2205            {
2206                use buff::BuffKind;
2207                match buff_kind {
2208                    BuffKind::Cursed | BuffKind::Burning => {
2209                        self.particles.resize_with(
2210                            self.particles.len()
2211                                + usize::from(self.scheduler.heartbeats(Duration::from_millis(15))),
2212                            || {
2213                                let start_pos = pos
2214                                    + Vec3::unit_z() * body.height() * 0.25
2215                                    + Vec3::<f32>::zero()
2216                                        .map(|_| rng.gen_range(-1.0..1.0))
2217                                        .normalized()
2218                                        * 0.25;
2219                                let end_pos = start_pos
2220                                    + Vec3::unit_z() * body.height()
2221                                    + Vec3::<f32>::zero()
2222                                        .map(|_| rng.gen_range(-1.0..1.0))
2223                                        .normalized();
2224                                Particle::new_directed(
2225                                    Duration::from_secs(1),
2226                                    time,
2227                                    if matches!(buff_kind, BuffKind::Cursed) {
2228                                        ParticleMode::CultistFlame
2229                                    } else {
2230                                        ParticleMode::FlameThrower
2231                                    },
2232                                    start_pos,
2233                                    end_pos,
2234                                )
2235                            },
2236                        );
2237                    },
2238                    BuffKind::PotionSickness => {
2239                        let mut multiplicity = 0;
2240                        // Only show particles for potion sickness at the beginning, after the
2241                        // drinking animation finishes
2242                        if buff_keys.0
2243                            .iter()
2244                            .filter_map(|key| buffs.buffs.get(*key))
2245                            .any(|buff| {
2246                                matches!(buff.elapsed(Time(time)), dur if (1.0..=1.5).contains(&dur.0))
2247                            })
2248                        {
2249                            multiplicity = 1;
2250                        }
2251                        self.particles.resize_with(
2252                            self.particles.len()
2253                                + multiplicity
2254                                    * usize::from(
2255                                        self.scheduler.heartbeats(Duration::from_millis(25)),
2256                                    ),
2257                            || {
2258                                let start_pos = pos
2259                                    + Vec3::unit_z() * body.eye_height(scale.map_or(1.0, |s| s.0));
2260                                let (radius, theta) =
2261                                    (rng.gen_range(0.0f32..1.0).sqrt(), rng.gen_range(0.0..TAU));
2262                                let end_pos = pos
2263                                    + *ori.look_dir()
2264                                    + Vec3::<f32>::new(
2265                                        radius * theta.cos(),
2266                                        radius * theta.sin(),
2267                                        0.0,
2268                                    ) * 0.25;
2269                                Particle::new_directed(
2270                                    Duration::from_secs(1),
2271                                    time,
2272                                    ParticleMode::PotionSickness,
2273                                    start_pos,
2274                                    end_pos,
2275                                )
2276                            },
2277                        );
2278                    },
2279                    BuffKind::Frenzied => {
2280                        self.particles.resize_with(
2281                            self.particles.len()
2282                                + usize::from(self.scheduler.heartbeats(Duration::from_millis(15))),
2283                            || {
2284                                let start_pos = pos
2285                                    + Vec3::new(
2286                                        body.max_radius(),
2287                                        body.max_radius(),
2288                                        body.height() / 2.0,
2289                                    )
2290                                    .map(|d| d * rng.gen_range(-1.0..1.0));
2291                                let end_pos = start_pos
2292                                    + Vec3::unit_z() * body.height()
2293                                    + Vec3::<f32>::zero()
2294                                        .map(|_| rng.gen_range(-1.0..1.0))
2295                                        .normalized();
2296                                Particle::new_directed(
2297                                    Duration::from_secs(1),
2298                                    time,
2299                                    ParticleMode::Enraged,
2300                                    start_pos,
2301                                    end_pos,
2302                                )
2303                            },
2304                        );
2305                    },
2306                    BuffKind::Polymorphed => {
2307                        let mut multiplicity = 0;
2308                        // Only show particles for polymorph at the beginning, after the
2309                        // drinking animation finishes
2310                        if buff_keys.0
2311                            .iter()
2312                            .filter_map(|key| buffs.buffs.get(*key))
2313                            .any(|buff| {
2314                                matches!(buff.elapsed(Time(time)), dur if (0.1..=0.3).contains(&dur.0))
2315                            })
2316                        {
2317                            multiplicity = 1;
2318                        }
2319                        self.particles.resize_with(
2320                            self.particles.len()
2321                                + multiplicity
2322                                    * self.scheduler.heartbeats(Duration::from_millis(3)) as usize,
2323                            || {
2324                                let start_pos = pos
2325                                    + Vec3::unit_z() * body.eye_height(scale.map_or(1.0, |s| s.0))
2326                                        / 2.0;
2327                                let end_pos = start_pos
2328                                    + Vec3::<f32>::zero()
2329                                        .map(|_| rng.gen_range(-1.0..1.0))
2330                                        .normalized()
2331                                        * 5.0;
2332
2333                                Particle::new_directed(
2334                                    Duration::from_secs(2),
2335                                    time,
2336                                    ParticleMode::Explosion,
2337                                    start_pos,
2338                                    end_pos,
2339                                )
2340                            },
2341                        )
2342                    },
2343                    _ => {},
2344                }
2345            }
2346        }
2347    }
2348
2349    fn maintain_block_particles(
2350        &mut self,
2351        scene_data: &SceneData,
2352        terrain: &Terrain<TerrainChunk>,
2353        figure_mgr: &FigureMgr,
2354    ) {
2355        prof_span!("ParticleMgr::maintain_block_particles");
2356        let dt = scene_data.state.ecs().fetch::<DeltaTime>().0;
2357        let time = scene_data.state.get_time();
2358        let player_pos = scene_data
2359            .state
2360            .read_component_copied::<Interpolated>(scene_data.viewpoint_entity)
2361            .map(|i| i.pos)
2362            .unwrap_or_default();
2363        let player_chunk = player_pos.xy().map2(TerrainChunk::RECT_SIZE, |e, sz| {
2364            (e.floor() as i32).div_euclid(sz as i32)
2365        });
2366
2367        struct BlockParticles<'a> {
2368            // The function to select the blocks of interest that we should emit from
2369            blocks: fn(&'a BlocksOfInterest) -> BlockParticleSlice<'a>,
2370            // The range, in chunks, that the particles should be generated in from the player
2371            range: usize,
2372            // The emission rate, per block per second, of the generated particles
2373            rate: f32,
2374            // The number of seconds that each particle should live for
2375            lifetime: f32,
2376            // The visual mode of the generated particle
2377            mode: ParticleMode,
2378            // Condition that must be true
2379            cond: fn(&SceneData) -> bool,
2380        }
2381
2382        enum BlockParticleSlice<'a> {
2383            Positions(&'a [Vec3<i32>]),
2384            PositionsAndDirs(&'a [(Vec3<i32>, Vec3<f32>)]),
2385        }
2386
2387        impl BlockParticleSlice<'_> {
2388            fn len(&self) -> usize {
2389                match self {
2390                    Self::Positions(blocks) => blocks.len(),
2391                    Self::PositionsAndDirs(blocks) => blocks.len(),
2392                }
2393            }
2394        }
2395
2396        let particles: &[BlockParticles] = &[
2397            BlockParticles {
2398                blocks: |boi| BlockParticleSlice::Positions(&boi.leaves),
2399                range: 4,
2400                rate: 0.0125,
2401                lifetime: 30.0,
2402                mode: ParticleMode::Leaf,
2403                cond: |_| true,
2404            },
2405            BlockParticles {
2406                blocks: |boi| BlockParticleSlice::Positions(&boi.drip),
2407                range: 4,
2408                rate: 0.004,
2409                lifetime: 20.0,
2410                mode: ParticleMode::Drip,
2411                cond: |_| true,
2412            },
2413            BlockParticles {
2414                blocks: |boi| BlockParticleSlice::Positions(&boi.fires),
2415                range: 2,
2416                rate: 20.0,
2417                lifetime: 0.25,
2418                mode: ParticleMode::CampfireFire,
2419                cond: |_| true,
2420            },
2421            BlockParticles {
2422                blocks: |boi| BlockParticleSlice::Positions(&boi.fire_bowls),
2423                range: 2,
2424                rate: 20.0,
2425                lifetime: 0.25,
2426                mode: ParticleMode::FireBowl,
2427                cond: |_| true,
2428            },
2429            BlockParticles {
2430                blocks: |boi| BlockParticleSlice::Positions(&boi.fireflies),
2431                range: 6,
2432                rate: 0.004,
2433                lifetime: 40.0,
2434                mode: ParticleMode::Firefly,
2435                cond: |sd| sd.state.get_day_period().is_dark(),
2436            },
2437            BlockParticles {
2438                blocks: |boi| BlockParticleSlice::Positions(&boi.flowers),
2439                range: 5,
2440                rate: 0.002,
2441                lifetime: 40.0,
2442                mode: ParticleMode::Firefly,
2443                cond: |sd| sd.state.get_day_period().is_dark(),
2444            },
2445            BlockParticles {
2446                blocks: |boi| BlockParticleSlice::Positions(&boi.beehives),
2447                range: 3,
2448                rate: 0.5,
2449                lifetime: 30.0,
2450                mode: ParticleMode::Bee,
2451                cond: |sd| sd.state.get_day_period().is_light(),
2452            },
2453            BlockParticles {
2454                blocks: |boi| BlockParticleSlice::Positions(&boi.snow),
2455                range: 4,
2456                rate: 0.025,
2457                lifetime: 15.0,
2458                mode: ParticleMode::Snow,
2459                cond: |_| true,
2460            },
2461            BlockParticles {
2462                blocks: |boi| BlockParticleSlice::PositionsAndDirs(&boi.one_way_walls),
2463                range: 2,
2464                rate: 12.0,
2465                lifetime: 1.5,
2466                mode: ParticleMode::PortalFizz,
2467                cond: |_| true,
2468            },
2469            BlockParticles {
2470                blocks: |boi| BlockParticleSlice::Positions(&boi.spores),
2471                range: 4,
2472                rate: 0.055,
2473                lifetime: 20.0,
2474                mode: ParticleMode::Spore,
2475                cond: |_| true,
2476            },
2477            BlockParticles {
2478                blocks: |boi| BlockParticleSlice::PositionsAndDirs(&boi.waterfall),
2479                range: 2,
2480                rate: 4.0,
2481                lifetime: 5.0,
2482                mode: ParticleMode::WaterFoam,
2483                cond: |_| true,
2484            },
2485        ];
2486
2487        let ecs = scene_data.state.ecs();
2488        let mut rng = thread_rng();
2489        // Hard cap for performance reasons; Assuming that 25% of a chunk is covered in
2490        // lava or 32*32*0.25 = 256 TODO: Make this a setting?
2491        let cap = 512;
2492        for particles in particles.iter() {
2493            if !(particles.cond)(scene_data) {
2494                continue;
2495            }
2496
2497            for offset in Spiral2d::new().take((particles.range * 2 + 1).pow(2)) {
2498                let chunk_pos = player_chunk + offset;
2499
2500                terrain.get(chunk_pos).map(|chunk_data| {
2501                    let blocks = (particles.blocks)(&chunk_data.blocks_of_interest);
2502
2503                    let avg_particles = dt * (blocks.len() as f32 * particles.rate).min(cap as f32);
2504                    let particle_count = avg_particles.trunc() as usize
2505                        + (rng.gen::<f32>() < avg_particles.fract()) as usize;
2506
2507                    self.particles
2508                        .resize_with(self.particles.len() + particle_count, || {
2509                            match blocks {
2510                                BlockParticleSlice::Positions(blocks) => {
2511                                    // Can't fail, resize only occurs if blocks > 0
2512                                    let block_pos = Vec3::from(
2513                                        chunk_pos * TerrainChunk::RECT_SIZE.map(|e| e as i32),
2514                                    ) + blocks.choose(&mut rng).copied().unwrap();
2515                                    Particle::new(
2516                                        Duration::from_secs_f32(particles.lifetime),
2517                                        time,
2518                                        particles.mode,
2519                                        block_pos.map(|e: i32| e as f32 + rng.gen::<f32>()),
2520                                    )
2521                                },
2522                                BlockParticleSlice::PositionsAndDirs(blocks) => {
2523                                    // Can't fail, resize only occurs if blocks > 0
2524                                    let (block_offset, particle_dir) =
2525                                        blocks.choose(&mut rng).copied().unwrap();
2526                                    let block_pos = Vec3::from(
2527                                        chunk_pos * TerrainChunk::RECT_SIZE.map(|e| e as i32),
2528                                    ) + block_offset;
2529                                    let particle_pos =
2530                                        block_pos.map(|e: i32| e as f32 + rng.gen::<f32>());
2531                                    Particle::new_directed(
2532                                        Duration::from_secs_f32(particles.lifetime),
2533                                        time,
2534                                        particles.mode,
2535                                        particle_pos,
2536                                        particle_pos + particle_dir,
2537                                    )
2538                                },
2539                            }
2540                        })
2541                });
2542            }
2543
2544            for (entity, body, interpolated, collider) in (
2545                &ecs.entities(),
2546                &ecs.read_storage::<comp::Body>(),
2547                &ecs.read_storage::<crate::ecs::comp::Interpolated>(),
2548                ecs.read_storage::<comp::Collider>().maybe(),
2549            )
2550                .join()
2551            {
2552                if let Some((blocks_of_interest, offset)) =
2553                    figure_mgr.get_blocks_of_interest(entity, body, collider)
2554                {
2555                    let mat = Mat4::from(interpolated.ori.to_quat())
2556                        .translated_3d(interpolated.pos)
2557                        * Mat4::translation_3d(offset);
2558
2559                    let blocks = (particles.blocks)(blocks_of_interest);
2560
2561                    let avg_particles = dt * blocks.len() as f32 * particles.rate;
2562                    let particle_count = avg_particles.trunc() as usize
2563                        + (rng.gen::<f32>() < avg_particles.fract()) as usize;
2564
2565                    self.particles
2566                        .resize_with(self.particles.len() + particle_count, || {
2567                            match blocks {
2568                                BlockParticleSlice::Positions(blocks) => {
2569                                    let rel_pos = blocks
2570                                        .choose(&mut rng)
2571                                        .copied()
2572                                        // Can't fail, resize only occurs if blocks > 0
2573                                        .unwrap()
2574                                        .map(|e: i32| e as f32 + rng.gen::<f32>());
2575                                    let wpos = mat.mul_point(rel_pos);
2576
2577                                    Particle::new(
2578                                        Duration::from_secs_f32(particles.lifetime),
2579                                        time,
2580                                        particles.mode,
2581                                        wpos,
2582                                    )
2583                                },
2584                                BlockParticleSlice::PositionsAndDirs(blocks) => {
2585                                    // Can't fail, resize only occurs if blocks > 0
2586                                    let (block_offset, particle_dir) =
2587                                        blocks.choose(&mut rng).copied().unwrap();
2588                                    let particle_pos =
2589                                        block_offset.map(|e: i32| e as f32 + rng.gen::<f32>());
2590                                    let wpos = mat.mul_point(particle_pos);
2591                                    Particle::new_directed(
2592                                        Duration::from_secs_f32(particles.lifetime),
2593                                        time,
2594                                        particles.mode,
2595                                        wpos,
2596                                        wpos + mat.mul_direction(particle_dir),
2597                                    )
2598                                },
2599                            }
2600                        })
2601                }
2602            }
2603        }
2604        // smoke is more complex as it comes with varying rate and color
2605        {
2606            struct SmokeProperties {
2607                position: Vec3<i32>,
2608                strength: f32,
2609                dry_chance: f32,
2610            }
2611
2612            let range = 8_usize;
2613            let rate = 3.0 / 128.0;
2614            let lifetime = 40.0;
2615            let time_of_day = scene_data
2616                .state
2617                .get_time_of_day()
2618                .rem_euclid(24.0 * 60.0 * 60.0) as f32;
2619
2620            for offset in Spiral2d::new().take((range * 2 + 1).pow(2)) {
2621                let chunk_pos = player_chunk + offset;
2622
2623                terrain.get(chunk_pos).map(|chunk_data| {
2624                    let blocks = &chunk_data.blocks_of_interest.smokers;
2625                    let mut smoke_properties: Vec<SmokeProperties> = Vec::new();
2626                    let block_pos =
2627                        Vec3::from(chunk_pos * TerrainChunk::RECT_SIZE.map(|e| e as i32));
2628                    let mut sum = 0.0_f32;
2629                    for smoker in blocks.iter() {
2630                        let position = block_pos + smoker.position;
2631                        let (strength, dry_chance) = {
2632                            match smoker.kind {
2633                                FireplaceType::House => {
2634                                    let prop = crate::scene::smoke_cycle::smoke_at_time(
2635                                        position,
2636                                        chunk_data.blocks_of_interest.temperature,
2637                                        time_of_day,
2638                                    );
2639                                    (
2640                                        prop.0,
2641                                        if prop.1 {
2642                                            // fire started, dark smoke
2643                                            0.8 - chunk_data.blocks_of_interest.humidity
2644                                        } else {
2645                                            // fire continues, light smoke
2646                                            1.2 - chunk_data.blocks_of_interest.humidity
2647                                        },
2648                                    )
2649                                },
2650                                FireplaceType::Workshop => (128.0, 1.0),
2651                            }
2652                        };
2653                        sum += strength;
2654                        smoke_properties.push(SmokeProperties {
2655                            position,
2656                            strength,
2657                            dry_chance,
2658                        });
2659                    }
2660                    let avg_particles = dt * sum * rate;
2661
2662                    let particle_count = avg_particles.trunc() as usize
2663                        + (rng.gen::<f32>() < avg_particles.fract()) as usize;
2664                    let chosen = smoke_properties.choose_multiple_weighted(
2665                        &mut rng,
2666                        particle_count,
2667                        |smoker| smoker.strength,
2668                    );
2669                    if let Ok(chosen) = chosen {
2670                        self.particles.extend(chosen.map(|smoker| {
2671                            Particle::new(
2672                                Duration::from_secs_f32(lifetime),
2673                                time,
2674                                if rng.gen::<f32>() > smoker.dry_chance {
2675                                    ParticleMode::BlackSmoke
2676                                } else {
2677                                    ParticleMode::CampfireSmoke
2678                                },
2679                                smoker.position.map(|e: i32| e as f32 + rng.gen::<f32>()),
2680                            )
2681                        }));
2682                    }
2683                });
2684            }
2685        }
2686    }
2687
2688    fn maintain_shockwave_particles(&mut self, scene_data: &SceneData) {
2689        let state = scene_data.state;
2690        let ecs = state.ecs();
2691        let time = state.get_time();
2692        let dt = scene_data.state.ecs().fetch::<DeltaTime>().0;
2693        let terrain = scene_data.state.ecs().fetch::<TerrainGrid>();
2694
2695        for (_entity, interp, pos, ori, shockwave) in (
2696            &ecs.entities(),
2697            ecs.read_storage::<Interpolated>().maybe(),
2698            &ecs.read_storage::<Pos>(),
2699            &ecs.read_storage::<Ori>(),
2700            &ecs.read_storage::<Shockwave>(),
2701        )
2702            .join()
2703        {
2704            let pos = interp.map_or(pos.0, |i| i.pos);
2705            let ori = interp.map_or(*ori, |i| i.ori);
2706
2707            let elapsed = time - shockwave.creation.unwrap_or(time);
2708            let speed = shockwave.properties.speed;
2709
2710            let percent = elapsed as f32 / shockwave.properties.duration.as_secs_f32();
2711
2712            let distance = speed * elapsed as f32;
2713
2714            let radians = shockwave.properties.angle.to_radians();
2715
2716            let ori_vec = ori.look_vec();
2717            let theta = ori_vec.y.atan2(ori_vec.x) - radians / 2.0;
2718            let dtheta = radians / distance;
2719
2720            // Number of particles derived from arc length (for new particles at least, old
2721            // can be converted later)
2722            let arc_length = distance * radians;
2723
2724            use shockwave::FrontendSpecifier;
2725            match shockwave.properties.specifier {
2726                FrontendSpecifier::Ground => {
2727                    let heartbeats = self.scheduler.heartbeats(Duration::from_millis(2));
2728                    for heartbeat in 0..heartbeats {
2729                        // 1 / 3 the size of terrain voxel
2730                        let scale = 1.0 / 3.0;
2731
2732                        let scaled_speed = speed * scale;
2733
2734                        let sub_tick_interpolation = scaled_speed * 1000.0 * heartbeat as f32;
2735
2736                        let distance = speed * (elapsed as f32 - sub_tick_interpolation);
2737
2738                        let particle_count_factor = radians / (3.0 * scale);
2739                        let new_particle_count = distance * particle_count_factor;
2740                        self.particles.reserve(new_particle_count as usize);
2741
2742                        for d in 0..(new_particle_count as i32) {
2743                            let arc_position = theta + dtheta * d as f32 / particle_count_factor;
2744
2745                            let position = pos
2746                                + distance * Vec3::new(arc_position.cos(), arc_position.sin(), 0.0);
2747
2748                            // Arbitrary number chosen that is large enough to be able to accurately
2749                            // place particles most of the time, but also not too big as to make ray
2750                            // be too large (for performance reasons)
2751                            let half_ray_length = 10.0;
2752                            let mut last_air = false;
2753                            // TODO: Optimize ray to only be cast at most once per block per tick if
2754                            // it becomes an issue.
2755                            // From imbris:
2756                            //      each ray is ~2 us
2757                            //      at 30 FPS, it peaked at 113 rays in a tick
2758                            //      total time was 240 us (although potentially half that is
2759                            //          overhead from the profiling of each ray)
2760                            let _ = terrain
2761                                .ray(
2762                                    position + Vec3::unit_z() * half_ray_length,
2763                                    position - Vec3::unit_z() * half_ray_length,
2764                                )
2765                                .for_each(|block: &Block, pos: Vec3<i32>| {
2766                                    if block.is_solid() && block.get_sprite().is_none() {
2767                                        if last_air {
2768                                            let position = position.xy().with_z(pos.z as f32 + 1.0);
2769
2770                                            let position_snapped =
2771                                                ((position / scale).floor() + 0.5) * scale;
2772
2773                                            self.particles.push(Particle::new(
2774                                                Duration::from_millis(250),
2775                                                time,
2776                                                ParticleMode::GroundShockwave,
2777                                                position_snapped,
2778                                            ));
2779                                            last_air = false;
2780                                        }
2781                                    } else {
2782                                        last_air = true;
2783                                    }
2784                                })
2785                                .cast();
2786                        }
2787                    }
2788                },
2789                FrontendSpecifier::Fire => {
2790                    let heartbeats = self.scheduler.heartbeats(Duration::from_millis(2));
2791                    for _ in 0..heartbeats {
2792                        for d in 0..3 * distance as i32 {
2793                            let arc_position = theta + dtheta * d as f32 / 3.0;
2794
2795                            let position = pos
2796                                + distance * Vec3::new(arc_position.cos(), arc_position.sin(), 0.0);
2797
2798                            self.particles.push(Particle::new(
2799                                Duration::from_secs_f32((distance + 10.0) / 50.0),
2800                                time,
2801                                ParticleMode::FireShockwave,
2802                                position,
2803                            ));
2804                        }
2805                    }
2806                },
2807                FrontendSpecifier::Water => {
2808                    // 1 particle per unit length of arc
2809                    let particles_per_length = arc_length as usize;
2810                    let dtheta = radians / particles_per_length as f32;
2811                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
2812                    // lower duration = more particles
2813                    let heartbeats = self
2814                        .scheduler
2815                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
2816
2817                    // Reserves capacity for new particles
2818                    let new_particle_count = particles_per_length * heartbeats as usize;
2819                    self.particles.reserve(new_particle_count);
2820
2821                    for i in 0..particles_per_length {
2822                        let angle = dtheta * i as f32;
2823                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
2824                        for j in 0..heartbeats {
2825                            // Sub tick dt
2826                            let dt = (j as f32 / heartbeats as f32) * dt;
2827                            let distance = distance + speed * dt;
2828                            let pos1 = pos + distance * direction - Vec3::unit_z();
2829                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
2830                            let time = time + dt as f64;
2831
2832                            self.particles.push(Particle::new_directed(
2833                                Duration::from_secs_f32(0.5),
2834                                time,
2835                                ParticleMode::Water,
2836                                pos1,
2837                                pos2,
2838                            ));
2839                        }
2840                    }
2841                },
2842                FrontendSpecifier::Lightning => {
2843                    // 1 particle per unit length of arc
2844                    let particles_per_length = arc_length as usize;
2845                    let dtheta = radians / particles_per_length as f32;
2846                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
2847                    // lower duration = more particles
2848                    let heartbeats = self
2849                        .scheduler
2850                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
2851
2852                    // Reserves capacity for new particles
2853                    let new_particle_count = particles_per_length * heartbeats as usize;
2854                    self.particles.reserve(new_particle_count);
2855
2856                    for i in 0..particles_per_length {
2857                        let angle = dtheta * i as f32;
2858                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
2859                        for j in 0..heartbeats {
2860                            // Sub tick dt
2861                            let dt = (j as f32 / heartbeats as f32) * dt;
2862                            let distance = distance + speed * dt;
2863                            let pos1 = pos + distance * direction - Vec3::unit_z();
2864                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
2865                            let time = time + dt as f64;
2866
2867                            self.particles.push(Particle::new_directed(
2868                                Duration::from_secs_f32(0.5),
2869                                time,
2870                                ParticleMode::Lightning,
2871                                pos1,
2872                                pos2,
2873                            ));
2874                        }
2875                    }
2876                },
2877                FrontendSpecifier::Steam => {
2878                    // 1 particle per unit length of arc
2879                    let particles_per_length = arc_length as usize;
2880                    let dtheta = radians / particles_per_length as f32;
2881                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
2882                    // lower duration = more particles
2883                    let heartbeats = self
2884                        .scheduler
2885                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
2886
2887                    // Reserves capacity for new particles
2888                    let new_particle_count = particles_per_length * heartbeats as usize;
2889                    self.particles.reserve(new_particle_count);
2890
2891                    for i in 0..particles_per_length {
2892                        let angle = dtheta * i as f32;
2893                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
2894                        for j in 0..heartbeats {
2895                            // Sub tick dt
2896                            let dt = (j as f32 / heartbeats as f32) * dt;
2897                            let distance = distance + speed * dt;
2898                            let pos1 = pos + distance * direction - Vec3::unit_z();
2899                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
2900                            let time = time + dt as f64;
2901
2902                            self.particles.push(Particle::new_directed(
2903                                Duration::from_secs_f32(0.5),
2904                                time,
2905                                ParticleMode::Steam,
2906                                pos1,
2907                                pos2,
2908                            ));
2909                        }
2910                    }
2911                },
2912                FrontendSpecifier::Poison => {
2913                    // 1 particle per unit length of arc
2914                    let particles_per_length = arc_length as usize;
2915                    let dtheta = radians / particles_per_length as f32;
2916                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
2917                    // lower duration = more particles
2918                    let heartbeats = self
2919                        .scheduler
2920                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
2921
2922                    // Reserves capacity for new particles
2923                    let new_particle_count = particles_per_length * heartbeats as usize;
2924                    self.particles.reserve(new_particle_count);
2925
2926                    for i in 0..particles_per_length {
2927                        let angle = theta + dtheta * i as f32;
2928                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
2929                        for j in 0..heartbeats {
2930                            // Sub tick dt
2931                            let dt = (j as f32 / heartbeats as f32) * dt;
2932                            let distance = distance + speed * dt;
2933                            let pos1 = pos + distance * direction - Vec3::unit_z();
2934                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
2935                            let time = time + dt as f64;
2936
2937                            self.particles.push(Particle::new_directed(
2938                                Duration::from_secs_f32(0.5),
2939                                time,
2940                                ParticleMode::Poison,
2941                                pos1,
2942                                pos2,
2943                            ));
2944                        }
2945                    }
2946                },
2947                FrontendSpecifier::AcidCloud => {
2948                    let particles_per_height = 5;
2949                    // 1 particle per unit length of arc
2950                    let particles_per_length = arc_length as usize;
2951                    let dtheta = radians / particles_per_length as f32;
2952                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
2953                    // lower duration = more particles
2954                    let heartbeats = self
2955                        .scheduler
2956                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
2957
2958                    // Reserves capacity for new particles
2959                    let new_particle_count =
2960                        particles_per_length * heartbeats as usize * particles_per_height;
2961                    self.particles.reserve(new_particle_count);
2962
2963                    for i in 0..particles_per_height {
2964                        let height = (i as f32 / (particles_per_height as f32 - 1.0)) * 4.0;
2965                        for j in 0..particles_per_length {
2966                            let angle = theta + dtheta * j as f32;
2967                            let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
2968                            for k in 0..heartbeats {
2969                                // Sub tick dt
2970                                let dt = (k as f32 / heartbeats as f32) * dt;
2971                                let distance = distance + speed * dt;
2972                                let pos1 = pos + distance * direction - Vec3::unit_z()
2973                                    + Vec3::unit_z() * height;
2974                                let pos2 = pos1 + direction;
2975                                let time = time + dt as f64;
2976
2977                                self.particles.push(Particle::new_directed(
2978                                    Duration::from_secs_f32(0.5),
2979                                    time,
2980                                    ParticleMode::Poison,
2981                                    pos1,
2982                                    pos2,
2983                                ));
2984                            }
2985                        }
2986                    }
2987                },
2988                FrontendSpecifier::Ink => {
2989                    // 1 particle per unit length of arc
2990                    let particles_per_length = arc_length as usize;
2991                    let dtheta = radians / particles_per_length as f32;
2992                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
2993                    // lower duration = more particles
2994                    let heartbeats = self
2995                        .scheduler
2996                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
2997
2998                    // Reserves capacity for new particles
2999                    let new_particle_count = particles_per_length * heartbeats as usize;
3000                    self.particles.reserve(new_particle_count);
3001
3002                    for i in 0..particles_per_length {
3003                        let angle = theta + dtheta * i as f32;
3004                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
3005                        for j in 0..heartbeats {
3006                            // Sub tick dt
3007                            let dt = (j as f32 / heartbeats as f32) * dt;
3008                            let distance = distance + speed * dt;
3009                            let pos1 = pos + distance * direction - Vec3::unit_z();
3010                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
3011                            let time = time + dt as f64;
3012
3013                            self.particles.push(Particle::new_directed(
3014                                Duration::from_secs_f32(0.5),
3015                                time,
3016                                ParticleMode::Ink,
3017                                pos1,
3018                                pos2,
3019                            ));
3020                        }
3021                    }
3022                },
3023                FrontendSpecifier::IceSpikes | FrontendSpecifier::Ice => {
3024                    // 1 / 3 the size of terrain voxel
3025                    let scale = 1.0 / 3.0;
3026                    let scaled_distance = distance / scale;
3027                    let scaled_speed = speed / scale;
3028
3029                    // 1 particle per scaled unit length of arc
3030                    let particles_per_length = (0.25 * arc_length / scale) as usize;
3031                    let dtheta = radians / particles_per_length as f32;
3032                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
3033                    // lower duration = more particles
3034                    let heartbeats = self
3035                        .scheduler
3036                        .heartbeats(Duration::from_secs_f32(3.0 / scaled_speed));
3037
3038                    // Reserves capacity for new particles
3039                    let new_particle_count = particles_per_length * heartbeats as usize;
3040                    self.particles.reserve(new_particle_count);
3041                    // higher wave when wave doesn't require ground
3042                    let wave = if matches!(shockwave.properties.dodgeable, Dodgeable::Jump) {
3043                        0.5
3044                    } else {
3045                        8.0
3046                    };
3047                    // Used to make taller the further out spikes are
3048                    let height_scale = wave + 1.5 * percent;
3049                    for i in 0..particles_per_length {
3050                        let angle = theta + dtheta * i as f32;
3051                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
3052                        for j in 0..heartbeats {
3053                            // Sub tick dt
3054                            let dt = (j as f32 / heartbeats as f32) * dt;
3055                            let scaled_distance = scaled_distance + scaled_speed * dt;
3056                            let mut pos1 = pos + (scaled_distance * direction).floor() * scale;
3057                            let time = time + dt as f64;
3058
3059                            // Arbitrary number chosen that is large enough to be able to accurately
3060                            // place particles most of the time, but also not too big as to make ray
3061                            // be too large (for performance reasons)
3062                            let half_ray_length = 10.0;
3063                            let mut last_air = false;
3064                            // TODO: Optimize ray to only be cast at most once per block per tick if
3065                            // it becomes an issue.
3066                            // From imbris:
3067                            //      each ray is ~2 us
3068                            //      at 30 FPS, it peaked at 113 rays in a tick
3069                            //      total time was 240 us (although potentially half that is
3070                            //          overhead from the profiling of each ray)
3071                            let _ = terrain
3072                                .ray(
3073                                    pos1 + Vec3::unit_z() * half_ray_length,
3074                                    pos1 - Vec3::unit_z() * half_ray_length,
3075                                )
3076                                .for_each(|block: &Block, pos: Vec3<i32>| {
3077                                    if block.is_solid() && block.get_sprite().is_none() {
3078                                        if last_air {
3079                                            pos1 = pos1.xy().with_z(pos.z as f32 + 1.0);
3080                                            last_air = false;
3081                                        }
3082                                    } else {
3083                                        last_air = true;
3084                                    }
3085                                })
3086                                .cast();
3087
3088                            let get_positions = |a| {
3089                                let pos1 = match a {
3090                                    2 => pos1 + Vec3::unit_x() * scale,
3091                                    3 => pos1 - Vec3::unit_x() * scale,
3092                                    4 => pos1 + Vec3::unit_y() * scale,
3093                                    5 => pos1 - Vec3::unit_y() * scale,
3094                                    _ => pos1,
3095                                };
3096                                let pos2 = if a == 1 {
3097                                    pos1 + Vec3::unit_z() * 5.0 * height_scale
3098                                } else {
3099                                    pos1 + Vec3::unit_z() * 1.0 * height_scale
3100                                };
3101                                (pos1, pos2)
3102                            };
3103
3104                            for a in 1..=5 {
3105                                let (pos1, pos2) = get_positions(a);
3106                                self.particles.push(Particle::new_directed(
3107                                    Duration::from_secs_f32(0.5),
3108                                    time,
3109                                    ParticleMode::IceSpikes,
3110                                    pos1,
3111                                    pos2,
3112                                ));
3113                            }
3114                        }
3115                    }
3116                },
3117            }
3118        }
3119    }
3120
3121    fn upload_particles(&mut self, renderer: &mut Renderer) {
3122        prof_span!("ParticleMgr::upload_particles");
3123        let all_cpu_instances = self
3124            .particles
3125            .iter()
3126            .map(|p| p.instance)
3127            .collect::<Vec<ParticleInstance>>();
3128
3129        // TODO: optimise buffer writes
3130        let gpu_instances = renderer.create_instances(&all_cpu_instances);
3131
3132        self.instances = gpu_instances;
3133    }
3134
3135    pub fn render<'a>(&'a self, drawer: &mut ParticleDrawer<'_, 'a>, scene_data: &SceneData) {
3136        prof_span!("ParticleMgr::render");
3137        if scene_data.particles_enabled {
3138            let model = &self
3139                .model_cache
3140                .get(DEFAULT_MODEL_KEY)
3141                .expect("Expected particle model in cache");
3142
3143            drawer.draw(model, &self.instances);
3144        }
3145    }
3146
3147    pub fn particle_count(&self) -> usize { self.instances.count() }
3148
3149    pub fn particle_count_visible(&self) -> usize { self.instances.count() }
3150}
3151
3152fn default_instances(renderer: &mut Renderer) -> Instances<ParticleInstance> {
3153    let empty_vec = Vec::new();
3154
3155    renderer.create_instances(&empty_vec)
3156}
3157
3158const DEFAULT_MODEL_KEY: &str = "voxygen.voxel.particle";
3159
3160fn default_cache(renderer: &mut Renderer) -> HashMap<&'static str, Model<ParticleVertex>> {
3161    let mut model_cache = HashMap::new();
3162
3163    model_cache.entry(DEFAULT_MODEL_KEY).or_insert_with(|| {
3164        let vox = DotVoxAsset::load_expect(DEFAULT_MODEL_KEY);
3165
3166        // NOTE: If we add texturing we may eventually try to share it among all
3167        // particles in a single atlas.
3168        let max_texture_size = renderer.max_texture_size();
3169        let max_size = Vec2::from(u16::try_from(max_texture_size).unwrap_or(u16::MAX));
3170        let mut greedy = GreedyMesh::new(max_size, crate::mesh::greedy::general_config());
3171
3172        let segment = Segment::from_vox_model_index(&vox.read().0, 0);
3173        let segment_size = segment.size();
3174        let mut mesh = generate_mesh_base_vol_particle(segment, &mut greedy).0;
3175        // Center particle vertices around origin
3176        for vert in mesh.vertices_mut() {
3177            vert.pos[0] -= segment_size.x as f32 / 2.0;
3178            vert.pos[1] -= segment_size.y as f32 / 2.0;
3179            vert.pos[2] -= segment_size.z as f32 / 2.0;
3180        }
3181
3182        // NOTE: Ignoring coloring / lighting for now.
3183        drop(greedy);
3184
3185        renderer
3186            .create_model(&mesh)
3187            .expect("Failed to create particle model")
3188    });
3189
3190    model_cache
3191}
3192
3193/// Accumulates heartbeats to be consumed on the next tick.
3194struct HeartbeatScheduler {
3195    /// Duration = Heartbeat Frequency/Intervals
3196    /// f64 = Last update time
3197    /// u8 = number of heartbeats since last update
3198    /// - if it's more frequent then tick rate, it could be 1 or more.
3199    /// - if it's less frequent then tick rate, it could be 1 or 0.
3200    /// - if it's equal to the tick rate, it could be between 2 and 0, due to
3201    ///   delta time variance etc.
3202    timers: HashMap<Duration, (f64, u8)>,
3203
3204    last_known_time: f64,
3205}
3206
3207impl HeartbeatScheduler {
3208    pub fn new() -> Self {
3209        HeartbeatScheduler {
3210            timers: HashMap::new(),
3211            last_known_time: 0.0,
3212        }
3213    }
3214
3215    /// updates the last elapsed times and elapsed counts
3216    /// this should be called once, and only once per tick.
3217    pub fn maintain(&mut self, now: f64) {
3218        prof_span!("HeartbeatScheduler::maintain");
3219        self.last_known_time = now;
3220
3221        for (frequency, (last_update, heartbeats)) in self.timers.iter_mut() {
3222            // the number of frequency cycles that have occurred.
3223            let total_heartbeats = (now - *last_update) / frequency.as_secs_f64();
3224
3225            // exclude partial frequency cycles
3226            let full_heartbeats = total_heartbeats.floor();
3227
3228            *heartbeats = full_heartbeats as u8;
3229
3230            // the remaining partial frequency cycle, as a decimal.
3231            let partial_heartbeat = total_heartbeats - full_heartbeats;
3232
3233            // the remaining partial frequency cycle, as a unit of time(f64).
3234            let partial_heartbeat_as_time = frequency.mul_f64(partial_heartbeat).as_secs_f64();
3235
3236            // now minus the left over heart beat count precision as seconds,
3237            // Note: we want to preserve incomplete heartbeats, and roll them
3238            // over into the next update.
3239            *last_update = now - partial_heartbeat_as_time;
3240        }
3241    }
3242
3243    /// returns the number of times this duration has elapsed since the last
3244    /// tick:
3245    ///   - if it's more frequent then tick rate, it could be 1 or more.
3246    ///   - if it's less frequent then tick rate, it could be 1 or 0.
3247    ///   - if it's equal to the tick rate, it could be between 2 and 0, due to
3248    ///     delta time variance.
3249    pub fn heartbeats(&mut self, frequency: Duration) -> u8 {
3250        prof_span!("HeartbeatScheduler::heartbeats");
3251        let last_known_time = self.last_known_time;
3252
3253        self.timers
3254            .entry(frequency)
3255            .or_insert_with(|| (last_known_time, 0))
3256            .1
3257    }
3258
3259    pub fn clear(&mut self) { self.timers.clear() }
3260}
3261
3262#[derive(Clone, Copy)]
3263struct Particle {
3264    alive_until: f64, // created_at + lifespan
3265    instance: ParticleInstance,
3266}
3267
3268impl Particle {
3269    fn new(lifespan: Duration, time: f64, mode: ParticleMode, pos: Vec3<f32>) -> Self {
3270        Particle {
3271            alive_until: time + lifespan.as_secs_f64(),
3272            instance: ParticleInstance::new(time, lifespan.as_secs_f32(), mode, pos),
3273        }
3274    }
3275
3276    fn new_directed(
3277        lifespan: Duration,
3278        time: f64,
3279        mode: ParticleMode,
3280        pos1: Vec3<f32>,
3281        pos2: Vec3<f32>,
3282    ) -> Self {
3283        Particle {
3284            alive_until: time + lifespan.as_secs_f64(),
3285            instance: ParticleInstance::new_directed(
3286                time,
3287                lifespan.as_secs_f32(),
3288                mode,
3289                pos1,
3290                pos2,
3291            ),
3292        }
3293    }
3294
3295    fn new_directed_with_collision(
3296        lifespan: Duration,
3297        time: f64,
3298        mode: ParticleMode,
3299        pos1: Vec3<f32>,
3300        pos2: Vec3<f32>,
3301        distance: impl Fn(Vec3<f32>, Vec3<f32>) -> f32,
3302    ) -> Self {
3303        let dir = pos2 - pos1;
3304        let end_distance = pos1.distance(pos2);
3305        let (end_pos, lifespawn) = if end_distance > 0.1 {
3306            let ratio = distance(pos1, pos2) / end_distance;
3307            (pos1 + ratio * dir, lifespan.mul_f32(ratio))
3308        } else {
3309            (pos2, lifespan)
3310        };
3311
3312        Self::new_directed(lifespawn, time, mode, pos1, end_pos)
3313    }
3314}