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