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