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(50)) {
1026            self.particles.push(Particle::new(
1027                Duration::from_millis(250),
1028                time,
1029                ParticleMode::CampfireFire,
1030                pos,
1031                scene_data,
1032            ));
1033
1034            self.particles.push(Particle::new(
1035                Duration::from_secs(10),
1036                time,
1037                ParticleMode::CampfireSmoke,
1038                pos.map(|e| e + rand::rng().random_range(-0.25..0.25))
1039                    + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1040                scene_data,
1041            ));
1042        }
1043    }
1044
1045    fn maintain_barrel_organ_particles(
1046        &mut self,
1047        scene_data: &SceneData,
1048        pos: Vec3<f32>,
1049        vel: Option<&Vel>,
1050    ) {
1051        prof_span!("ParticleMgr::maintain_barrel_organ_particles");
1052        let time = scene_data.state.get_time();
1053        let dt = scene_data.state.get_delta_time();
1054        let mut rng = rand::rng();
1055
1056        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(20)) {
1057            self.particles.push(Particle::new(
1058                Duration::from_millis(250),
1059                time,
1060                ParticleMode::BarrelOrgan,
1061                pos,
1062                scene_data,
1063            ));
1064
1065            self.particles.push(Particle::new(
1066                Duration::from_secs(10),
1067                time,
1068                ParticleMode::BarrelOrgan,
1069                pos.map(|e| e + rand::rng().random_range(-0.25..0.25))
1070                    + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1071                scene_data,
1072            ));
1073        }
1074    }
1075
1076    fn maintain_boltfire_particles(
1077        &mut self,
1078        scene_data: &SceneData,
1079        pos: Vec3<f32>,
1080        vel: Option<&Vel>,
1081    ) {
1082        prof_span!("ParticleMgr::maintain_boltfire_particles");
1083        let time = scene_data.state.get_time();
1084        let dt = scene_data.state.get_delta_time();
1085        let mut rng = rand::rng();
1086
1087        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(4)) {
1088            self.particles.push(Particle::new(
1089                Duration::from_millis(500),
1090                time,
1091                ParticleMode::CampfireFire,
1092                pos,
1093                scene_data,
1094            ));
1095            self.particles.push(Particle::new(
1096                Duration::from_secs(1),
1097                time,
1098                ParticleMode::CampfireSmoke,
1099                pos.map(|e| e + rng.random_range(-0.25..0.25))
1100                    + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1101                scene_data,
1102            ));
1103        }
1104    }
1105
1106    fn maintain_boltfirebig_particles(
1107        &mut self,
1108        scene_data: &SceneData,
1109        pos: Vec3<f32>,
1110        vel: Option<&Vel>,
1111    ) {
1112        prof_span!("ParticleMgr::maintain_boltfirebig_particles");
1113        let time = scene_data.state.get_time();
1114        let dt = scene_data.state.get_delta_time();
1115        let mut rng = rand::rng();
1116
1117        // fire
1118        self.particles.resize_with(
1119            self.particles.len() + usize::from(self.scheduler.heartbeats(Duration::from_millis(2))),
1120            || {
1121                Particle::new(
1122                    Duration::from_millis(500),
1123                    time,
1124                    ParticleMode::CampfireFire,
1125                    pos.map(|e| e + rng.random_range(-0.25..0.25))
1126                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1127                    scene_data,
1128                )
1129            },
1130        );
1131
1132        // smoke
1133        self.particles.resize_with(
1134            self.particles.len() + usize::from(self.scheduler.heartbeats(Duration::from_millis(5))),
1135            || {
1136                Particle::new(
1137                    Duration::from_secs(2),
1138                    time,
1139                    ParticleMode::CampfireSmoke,
1140                    pos.map(|e| e + rng.random_range(-0.25..0.25))
1141                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt),
1142                    scene_data,
1143                )
1144            },
1145        );
1146    }
1147
1148    fn maintain_fireraindrop_particles(
1149        &mut self,
1150        scene_data: &SceneData,
1151        pos: Vec3<f32>,
1152        vel: Option<&Vel>,
1153    ) {
1154        prof_span!("ParticleMgr::maintain_fireraindrop_particles");
1155        let time = scene_data.state.get_time();
1156        let dt = scene_data.state.get_delta_time();
1157        let mut rng = rand::rng();
1158
1159        // trace
1160        self.particles.resize_with(
1161            self.particles.len()
1162                + usize::from(self.scheduler.heartbeats(Duration::from_millis(100))),
1163            || {
1164                Particle::new(
1165                    Duration::from_millis(300),
1166                    time,
1167                    ParticleMode::FieryDropletTrace,
1168                    pos.map(|e| e + rng.random_range(-0.25..0.25))
1169                        + Vec3::new(0.0, 0.0, 0.5)
1170                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1171                    scene_data,
1172                )
1173            },
1174        );
1175    }
1176
1177    fn maintain_boltnature_particles(
1178        &mut self,
1179        scene_data: &SceneData,
1180        pos: Vec3<f32>,
1181        vel: Option<&Vel>,
1182    ) {
1183        let time = scene_data.state.get_time();
1184        let dt = scene_data.state.get_delta_time();
1185        let mut rng = rand::rng();
1186
1187        // nature
1188        self.particles.resize_with(
1189            self.particles.len() + usize::from(self.scheduler.heartbeats(Duration::from_millis(2))),
1190            || {
1191                Particle::new(
1192                    Duration::from_millis(500),
1193                    time,
1194                    ParticleMode::CampfireSmoke,
1195                    pos.map(|e| e + rng.random_range(-0.25..0.25))
1196                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1197                    scene_data,
1198                )
1199            },
1200        );
1201    }
1202
1203    fn maintain_tornado_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1204        let time = scene_data.state.get_time();
1205        let mut rng = rand::rng();
1206
1207        // air particles
1208        self.particles.resize_with(
1209            self.particles.len() + usize::from(self.scheduler.heartbeats(Duration::from_millis(5))),
1210            || {
1211                Particle::new(
1212                    Duration::from_millis(1000),
1213                    time,
1214                    ParticleMode::Tornado,
1215                    pos.map(|e| e + rng.random_range(-0.25..0.25)),
1216                    scene_data,
1217                )
1218            },
1219        );
1220    }
1221
1222    fn maintain_fiery_tornado_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1223        let time = scene_data.state.get_time();
1224        let mut rng = rand::rng();
1225
1226        // air particles
1227        self.particles.resize_with(
1228            self.particles.len() + usize::from(self.scheduler.heartbeats(Duration::from_millis(5))),
1229            || {
1230                Particle::new(
1231                    Duration::from_millis(1000),
1232                    time,
1233                    ParticleMode::FieryTornado,
1234                    pos.map(|e| e + rng.random_range(-0.25..0.25)),
1235                    scene_data,
1236                )
1237            },
1238        );
1239    }
1240
1241    fn maintain_bomb_particles(
1242        &mut self,
1243        scene_data: &SceneData,
1244        pos: Vec3<f32>,
1245        vel: Option<&Vel>,
1246    ) {
1247        prof_span!("ParticleMgr::maintain_bomb_particles");
1248        let time = scene_data.state.get_time();
1249        let dt = scene_data.state.get_delta_time();
1250        let mut rng = rand::rng();
1251
1252        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(10)) {
1253            // sparks
1254            self.particles.push(Particle::new(
1255                Duration::from_millis(1500),
1256                time,
1257                ParticleMode::GunPowderSpark,
1258                pos,
1259                scene_data,
1260            ));
1261
1262            // smoke
1263            self.particles.push(Particle::new(
1264                Duration::from_secs(2),
1265                time,
1266                ParticleMode::CampfireSmoke,
1267                pos + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1268                scene_data,
1269            ));
1270        }
1271    }
1272
1273    fn maintain_active_portal_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1274        prof_span!("ParticleMgr::maintain_active_portal_particles");
1275
1276        let time = scene_data.state.get_time();
1277        let mut rng = rand::rng();
1278
1279        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(5)) {
1280            let outer_pos =
1281                pos + (Vec2::unit_x().rotated_z(rng.random_range((0.)..PI * 2.)) * 2.7).with_z(0.);
1282
1283            self.particles.push(Particle::new_directed(
1284                Duration::from_secs_f32(rng.random_range(0.4..0.8)),
1285                time,
1286                ParticleMode::CultistFlame,
1287                outer_pos,
1288                outer_pos + Vec3::unit_z() * rng.random_range(5.0..7.0),
1289                scene_data,
1290            ));
1291        }
1292    }
1293
1294    fn maintain_portal_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1295        prof_span!("ParticleMgr::maintain_portal_particles");
1296
1297        let time = scene_data.state.get_time();
1298        let mut rng = rand::rng();
1299
1300        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(150)) {
1301            let outer_pos = pos
1302                + (Vec2::unit_x().rotated_z(rng.random_range((0.)..PI * 2.))
1303                    * rng.random_range(1.0..2.9))
1304                .with_z(0.);
1305
1306            self.particles.push(Particle::new_directed(
1307                Duration::from_secs_f32(rng.random_range(0.5..3.0)),
1308                time,
1309                ParticleMode::CultistFlame,
1310                outer_pos,
1311                outer_pos + Vec3::unit_z() * rng.random_range(3.0..4.0),
1312                scene_data,
1313            ));
1314        }
1315    }
1316
1317    fn maintain_mine_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1318        prof_span!("ParticleMgr::maintain_mine_particles");
1319        let time = scene_data.state.get_time();
1320
1321        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(1)) {
1322            // sparks
1323            self.particles.push(Particle::new(
1324                Duration::from_millis(25),
1325                time,
1326                ParticleMode::GunPowderSpark,
1327                pos,
1328                scene_data,
1329            ));
1330        }
1331    }
1332
1333    fn maintain_char_state_particles(&mut self, scene_data: &SceneData, figure_mgr: &FigureMgr) {
1334        prof_span!("ParticleMgr::maintain_char_state_particles");
1335        let state = scene_data.state;
1336        let ecs = state.ecs();
1337        let time = state.get_time();
1338        let dt = scene_data.state.get_delta_time();
1339        let mut rng = rand::rng();
1340
1341        for (
1342            entity,
1343            interpolated,
1344            vel,
1345            character_state,
1346            body,
1347            ori,
1348            character_activity,
1349            physics,
1350            inventory,
1351        ) in (
1352            &ecs.entities(),
1353            &ecs.read_storage::<Interpolated>(),
1354            ecs.read_storage::<Vel>().maybe(),
1355            &ecs.read_storage::<CharacterState>(),
1356            &ecs.read_storage::<Body>(),
1357            &ecs.read_storage::<Ori>(),
1358            &ecs.read_storage::<CharacterActivity>(),
1359            &ecs.read_storage::<PhysicsState>(),
1360            ecs.read_storage::<Inventory>().maybe(),
1361        )
1362            .join()
1363        {
1364            match character_state {
1365                CharacterState::Boost(_) => {
1366                    self.particles.resize_with(
1367                        self.particles.len()
1368                            + usize::from(self.scheduler.heartbeats(Duration::from_millis(10))),
1369                        || {
1370                            Particle::new(
1371                                Duration::from_millis(250),
1372                                time,
1373                                ParticleMode::PortalFizz,
1374                                // Output particles from broom, not from character ass
1375                                interpolated.pos
1376                                    - ori.to_horizontal().look_dir().to_vec()
1377                                    - vel.map_or(Vec3::zero(), |v| v.0 * dt * rng.random::<f32>()),
1378                                scene_data,
1379                            )
1380                        },
1381                    );
1382                },
1383                CharacterState::BasicMelee(c) => {
1384                    if let Some(specifier) = c.static_data.frontend_specifier {
1385                        match specifier {
1386                            states::basic_melee::FrontendSpecifier::FlameTornado => {
1387                                if matches!(c.stage_section, StageSection::Action) {
1388                                    let time = scene_data.state.get_time();
1389                                    let mut rng = rand::rng();
1390                                    self.particles.resize_with(
1391                                        self.particles.len()
1392                                            + 10
1393                                            + usize::from(
1394                                                self.scheduler.heartbeats(Duration::from_millis(5)),
1395                                            ),
1396                                        || {
1397                                            Particle::new(
1398                                                Duration::from_millis(1000),
1399                                                time,
1400                                                ParticleMode::FlameTornado,
1401                                                interpolated
1402                                                    .pos
1403                                                    .map(|e| e + rng.random_range(-0.25..0.25)),
1404                                                scene_data,
1405                                            )
1406                                        },
1407                                    );
1408                                }
1409                            },
1410                            states::basic_melee::FrontendSpecifier::FireGigasWhirlwind => {
1411                                if matches!(c.stage_section, StageSection::Action) {
1412                                    let time = scene_data.state.get_time();
1413                                    let mut rng = rand::rng();
1414                                    self.particles.resize_with(
1415                                        self.particles.len()
1416                                            + 3
1417                                            + usize::from(
1418                                                self.scheduler.heartbeats(Duration::from_millis(5)),
1419                                            ),
1420                                        || {
1421                                            Particle::new(
1422                                                Duration::from_millis(600),
1423                                                time,
1424                                                ParticleMode::FireGigasWhirlwind,
1425                                                interpolated
1426                                                    .pos
1427                                                    .map(|e| e + rng.random_range(-0.25..0.25))
1428                                                    + 3.0 * Vec3::<f32>::unit_z(),
1429                                                scene_data,
1430                                            )
1431                                        },
1432                                    );
1433                                }
1434                            },
1435                        }
1436                    }
1437                },
1438                CharacterState::RapidMelee(c) => {
1439                    if let Some(specifier) = c.static_data.frontend_specifier {
1440                        match specifier {
1441                            states::rapid_melee::FrontendSpecifier::CultistVortex => {
1442                                if matches!(c.stage_section, StageSection::Action) {
1443                                    let range = c.static_data.melee_constructor.range;
1444                                    // Particles for vortex
1445                                    let heartbeats =
1446                                        self.scheduler.heartbeats(Duration::from_millis(3));
1447                                    self.particles.resize_with(
1448                                        self.particles.len()
1449                                            + range.powi(2) as usize * usize::from(heartbeats)
1450                                                / 150,
1451                                        || {
1452                                            let rand_dist =
1453                                                range * (1.0 - rng.random::<f32>().powi(10));
1454                                            let init_pos = Vec3::new(
1455                                                2.0 * rng.random::<f32>() - 1.0,
1456                                                2.0 * rng.random::<f32>() - 1.0,
1457                                                0.0,
1458                                            )
1459                                            .normalized()
1460                                                * rand_dist
1461                                                + interpolated.pos
1462                                                + Vec3::unit_z() * 0.05;
1463                                            Particle::new_directed(
1464                                                Duration::from_millis(900),
1465                                                time,
1466                                                ParticleMode::CultistFlame,
1467                                                init_pos,
1468                                                interpolated.pos,
1469                                                scene_data,
1470                                            )
1471                                        },
1472                                    );
1473                                    // Particles for lifesteal effect
1474                                    for (_entity_b, interpolated_b, body_b, _health_b) in (
1475                                        &ecs.entities(),
1476                                        &ecs.read_storage::<Interpolated>(),
1477                                        &ecs.read_storage::<Body>(),
1478                                        &ecs.read_storage::<comp::Health>(),
1479                                    )
1480                                        .join()
1481                                        .filter(|(e, _, _, h)| !h.is_dead && entity != *e)
1482                                    {
1483                                        if interpolated.pos.distance_squared(interpolated_b.pos)
1484                                            < range.powi(2)
1485                                        {
1486                                            let heartbeats = self
1487                                                .scheduler
1488                                                .heartbeats(Duration::from_millis(20));
1489                                            self.particles.resize_with(
1490                                                self.particles.len()
1491                                                    + range.powi(2) as usize
1492                                                        * usize::from(heartbeats)
1493                                                        / 150,
1494                                                || {
1495                                                    let start_pos = interpolated_b.pos
1496                                                        + Vec3::unit_z() * body_b.height() * 0.5
1497                                                        + Vec3::<f32>::zero()
1498                                                            .map(|_| rng.random_range(-1.0..1.0))
1499                                                            .normalized()
1500                                                            * 1.0;
1501                                                    Particle::new_directed(
1502                                                        Duration::from_millis(900),
1503                                                        time,
1504                                                        ParticleMode::CultistFlame,
1505                                                        start_pos,
1506                                                        interpolated.pos
1507                                                            + Vec3::unit_z() * body.height() * 0.5,
1508                                                        scene_data,
1509                                                    )
1510                                                },
1511                                            );
1512                                        }
1513                                    }
1514                                }
1515                            },
1516                            states::rapid_melee::FrontendSpecifier::IceWhirlwind => {
1517                                if matches!(c.stage_section, StageSection::Action) {
1518                                    let time = scene_data.state.get_time();
1519                                    let mut rng = rand::rng();
1520                                    self.particles.resize_with(
1521                                        self.particles.len()
1522                                            + 3
1523                                            + usize::from(
1524                                                self.scheduler.heartbeats(Duration::from_millis(5)),
1525                                            ),
1526                                        || {
1527                                            Particle::new(
1528                                                Duration::from_millis(1000),
1529                                                time,
1530                                                ParticleMode::IceWhirlwind,
1531                                                interpolated
1532                                                    .pos
1533                                                    .map(|e| e + rng.random_range(-0.25..0.25)),
1534                                                scene_data,
1535                                            )
1536                                        },
1537                                    );
1538                                }
1539                            },
1540                        }
1541                    }
1542                },
1543                CharacterState::RepeaterRanged(repeater) => {
1544                    if let Some(specifier) = repeater.static_data.specifier {
1545                        match specifier {
1546                            states::repeater_ranged::FrontendSpecifier::FireRainPhoenix => {
1547                                // base, dark clouds
1548                                self.particles.resize_with(
1549                                    self.particles.len()
1550                                        + 2 * usize::from(
1551                                            self.scheduler.heartbeats(Duration::from_millis(25)),
1552                                        ),
1553                                    || {
1554                                        let rand_pos = {
1555                                            let theta = rng.random::<f32>() * TAU;
1556                                            let radius = repeater
1557                                                .static_data
1558                                                .properties_of_aoe
1559                                                .map(|aoe| aoe.radius)
1560                                                .unwrap_or_default()
1561                                                * rng.random::<f32>().sqrt();
1562                                            let x = radius * theta.sin();
1563                                            let y = radius * theta.cos();
1564                                            Vec2::new(x, y) + interpolated.pos.xy()
1565                                        };
1566                                        let pos1 = rand_pos.with_z(
1567                                            repeater
1568                                                .static_data
1569                                                .properties_of_aoe
1570                                                .map(|aoe| aoe.height)
1571                                                .unwrap_or_default()
1572                                                + interpolated.pos.z
1573                                                + 2.0 * rng.random::<f32>(),
1574                                        );
1575                                        Particle::new_directed(
1576                                            Duration::from_secs_f32(3.0),
1577                                            time,
1578                                            ParticleMode::PhoenixCloud,
1579                                            pos1,
1580                                            pos1 + Vec3::new(7.09, 4.09, 18.09),
1581                                            scene_data,
1582                                        )
1583                                    },
1584                                );
1585                                self.particles.resize_with(
1586                                    self.particles.len()
1587                                        + 2 * usize::from(
1588                                            self.scheduler.heartbeats(Duration::from_millis(25)),
1589                                        ),
1590                                    || {
1591                                        let rand_pos = {
1592                                            let theta = rng.random::<f32>() * TAU;
1593                                            let radius = repeater
1594                                                .static_data
1595                                                .properties_of_aoe
1596                                                .map(|aoe| aoe.radius)
1597                                                .unwrap_or_default()
1598                                                * rng.random::<f32>().sqrt();
1599                                            let x = radius * theta.sin();
1600                                            let y = radius * theta.cos();
1601                                            Vec2::new(x, y) + interpolated.pos.xy()
1602                                        };
1603                                        let pos1 = rand_pos.with_z(
1604                                            repeater
1605                                                .static_data
1606                                                .properties_of_aoe
1607                                                .map(|aoe| aoe.height)
1608                                                .unwrap_or_default()
1609                                                + interpolated.pos.z
1610                                                + 1.5 * rng.random::<f32>(),
1611                                        );
1612                                        Particle::new_directed(
1613                                            Duration::from_secs_f32(2.5),
1614                                            time,
1615                                            ParticleMode::PhoenixCloud,
1616                                            pos1,
1617                                            pos1 + Vec3::new(10.025, 4.025, 17.025),
1618                                            scene_data,
1619                                        )
1620                                    },
1621                                );
1622                            },
1623                        }
1624                    }
1625                },
1626                CharacterState::Blink(c) => {
1627                    if let Some(specifier) = c.static_data.frontend_specifier {
1628                        match specifier {
1629                            states::blink::FrontendSpecifier::CultistFlame => {
1630                                self.particles.resize_with(
1631                                    self.particles.len()
1632                                        + usize::from(
1633                                            self.scheduler.heartbeats(Duration::from_millis(10)),
1634                                        ),
1635                                    || {
1636                                        let center_pos =
1637                                            interpolated.pos + Vec3::unit_z() * body.height() / 2.0;
1638                                        let outer_pos = interpolated.pos
1639                                            + Vec3::new(
1640                                                2.0 * rng.random::<f32>() - 1.0,
1641                                                2.0 * rng.random::<f32>() - 1.0,
1642                                                0.0,
1643                                            )
1644                                            .normalized()
1645                                                * (body.max_radius() + 2.0)
1646                                            + Vec3::unit_z() * body.height() * rng.random::<f32>();
1647
1648                                        let (start_pos, end_pos) =
1649                                            if matches!(c.stage_section, StageSection::Buildup) {
1650                                                (outer_pos, center_pos)
1651                                            } else {
1652                                                (center_pos, outer_pos)
1653                                            };
1654
1655                                        Particle::new_directed(
1656                                            Duration::from_secs_f32(0.5),
1657                                            time,
1658                                            ParticleMode::CultistFlame,
1659                                            start_pos,
1660                                            end_pos,
1661                                            scene_data,
1662                                        )
1663                                    },
1664                                );
1665                            },
1666                            states::blink::FrontendSpecifier::FlameThrower => {
1667                                self.particles.resize_with(
1668                                    self.particles.len()
1669                                        + usize::from(
1670                                            self.scheduler.heartbeats(Duration::from_millis(10)),
1671                                        ),
1672                                    || {
1673                                        let center_pos =
1674                                            interpolated.pos + Vec3::unit_z() * body.height() / 2.0;
1675                                        let outer_pos = interpolated.pos
1676                                            + Vec3::new(
1677                                                2.0 * rng.random::<f32>() - 1.0,
1678                                                2.0 * rng.random::<f32>() - 1.0,
1679                                                0.0,
1680                                            )
1681                                            .normalized()
1682                                                * (body.max_radius() + 2.0)
1683                                            + Vec3::unit_z() * body.height() * rng.random::<f32>();
1684
1685                                        let (start_pos, end_pos) =
1686                                            if matches!(c.stage_section, StageSection::Buildup) {
1687                                                (outer_pos, center_pos)
1688                                            } else {
1689                                                (center_pos, outer_pos)
1690                                            };
1691
1692                                        Particle::new_directed(
1693                                            Duration::from_secs_f32(0.5),
1694                                            time,
1695                                            ParticleMode::FlameThrower,
1696                                            start_pos,
1697                                            end_pos,
1698                                            scene_data,
1699                                        )
1700                                    },
1701                                );
1702                            },
1703                        }
1704                    }
1705                },
1706                CharacterState::SelfBuff(c) => {
1707                    if let Some(specifier) = c.static_data.specifier {
1708                        match specifier {
1709                            states::self_buff::FrontendSpecifier::FromTheAshes => {
1710                                if matches!(c.stage_section, StageSection::Action) {
1711                                    let pos = interpolated.pos;
1712                                    self.particles.resize_with(
1713                                        self.particles.len()
1714                                            + 2 * usize::from(
1715                                                self.scheduler.heartbeats(Duration::from_millis(1)),
1716                                            ),
1717                                        || {
1718                                            let start_pos = pos + Vec3::unit_z() - 1.0;
1719                                            let end_pos = pos
1720                                                + Vec3::new(
1721                                                    4.0 * rng.random::<f32>() - 1.0,
1722                                                    4.0 * rng.random::<f32>() - 1.0,
1723                                                    0.0,
1724                                                )
1725                                                .normalized()
1726                                                    * 1.5
1727                                                + Vec3::unit_z()
1728                                                + 5.0 * rng.random::<f32>();
1729
1730                                            Particle::new_directed(
1731                                                Duration::from_secs_f32(0.5),
1732                                                time,
1733                                                ParticleMode::FieryBurst,
1734                                                start_pos,
1735                                                end_pos,
1736                                                scene_data,
1737                                            )
1738                                        },
1739                                    );
1740                                    self.particles.resize_with(
1741                                        self.particles.len()
1742                                            + usize::from(
1743                                                self.scheduler
1744                                                    .heartbeats(Duration::from_millis(10)),
1745                                            ),
1746                                        || {
1747                                            Particle::new(
1748                                                Duration::from_millis(650),
1749                                                time,
1750                                                ParticleMode::FieryBurstVortex,
1751                                                pos.map(|e| e + rng.random_range(-0.25..0.25))
1752                                                    + Vec3::new(0.0, 0.0, 1.0),
1753                                                scene_data,
1754                                            )
1755                                        },
1756                                    );
1757                                    self.particles.resize_with(
1758                                        self.particles.len()
1759                                            + usize::from(
1760                                                self.scheduler
1761                                                    .heartbeats(Duration::from_millis(40)),
1762                                            ),
1763                                        || {
1764                                            Particle::new(
1765                                                Duration::from_millis(1000),
1766                                                time,
1767                                                ParticleMode::FieryBurstSparks,
1768                                                pos.map(|e| e + rng.random_range(-0.25..0.25)),
1769                                                scene_data,
1770                                            )
1771                                        },
1772                                    );
1773                                    self.particles.resize_with(
1774                                        self.particles.len()
1775                                            + usize::from(
1776                                                self.scheduler
1777                                                    .heartbeats(Duration::from_millis(14)),
1778                                            ),
1779                                        || {
1780                                            let pos1 =
1781                                                pos.map(|e| e + rng.random_range(-0.25..0.25));
1782                                            Particle::new_directed(
1783                                                Duration::from_millis(1000),
1784                                                time,
1785                                                ParticleMode::FieryBurstAsh,
1786                                                pos1,
1787                                                Vec3::new(
1788                                                    4.5,    // radius of rand spawn
1789                                                    20.4,   // integer part - radius of the curve part, fractional part - relative time of setting particle on fire
1790                                                    8.58)   // height of the flight
1791                                                    + pos1,
1792                                                scene_data,
1793                                            )
1794                                        },
1795                                    );
1796                                }
1797                            },
1798                        }
1799                    }
1800                    use buff::BuffKind;
1801                    if c.static_data
1802                        .buffs
1803                        .iter()
1804                        .any(|buff_desc| matches!(buff_desc.kind, BuffKind::Frenzied))
1805                        && matches!(c.stage_section, StageSection::Action)
1806                    {
1807                        self.particles.resize_with(
1808                            self.particles.len()
1809                                + usize::from(self.scheduler.heartbeats(Duration::from_millis(5))),
1810                            || {
1811                                let start_pos = interpolated.pos
1812                                    + Vec3::new(
1813                                        body.max_radius(),
1814                                        body.max_radius(),
1815                                        body.height() / 2.0,
1816                                    )
1817                                    .map(|d| d * rng.random_range(-1.0..1.0));
1818                                let end_pos =
1819                                    interpolated.pos + (start_pos - interpolated.pos) * 6.0;
1820                                Particle::new_directed(
1821                                    Duration::from_secs(1),
1822                                    time,
1823                                    ParticleMode::Enraged,
1824                                    start_pos,
1825                                    end_pos,
1826                                    scene_data,
1827                                )
1828                            },
1829                        );
1830                    }
1831                },
1832                CharacterState::BasicBeam(beam) => {
1833                    let ori = *ori;
1834                    let _look_dir = *character_activity.look_dir.unwrap_or(ori.look_dir());
1835                    let dir = ori.look_dir(); //.with_z(look_dir.z);
1836                    let specifier = beam.static_data.specifier;
1837                    if specifier == beam::FrontendSpecifier::PhoenixLaser
1838                        && matches!(beam.stage_section, StageSection::Buildup)
1839                    {
1840                        self.particles.resize_with(
1841                            self.particles.len()
1842                                + 2 * usize::from(
1843                                    self.scheduler.heartbeats(Duration::from_millis(2)),
1844                                ),
1845                            || {
1846                                let mut left_right_alignment =
1847                                    dir.cross(Vec3::new(0.0, 0.0, 1.0)).normalized();
1848                                if rng.random_bool(0.5) {
1849                                    left_right_alignment *= -1.0;
1850                                }
1851                                let start = interpolated.pos
1852                                    + left_right_alignment * 4.0
1853                                    + dir.normalized() * 6.0;
1854                                let lifespan = Duration::from_secs_f32(0.5);
1855                                Particle::new_directed(
1856                                    lifespan,
1857                                    time,
1858                                    ParticleMode::PhoenixBuildUpAim,
1859                                    start,
1860                                    interpolated.pos
1861                                        + dir.normalized() * 3.0
1862                                        + left_right_alignment * 0.4
1863                                        + vel
1864                                            .map_or(Vec3::zero(), |v| v.0 * lifespan.as_secs_f32()),
1865                                    scene_data,
1866                                )
1867                            },
1868                        );
1869                    }
1870                },
1871                CharacterState::Glide(glide) => {
1872                    if let Some(Fluid::Air {
1873                        vel: air_vel,
1874                        elevation: _,
1875                    }) = physics.in_fluid
1876                    {
1877                        // Empirical observation is that air_vel is somewhere
1878                        // between 0.0 and 13.0, but we are extending to be sure
1879                        const MAX_AIR_VEL: f32 = 15.0;
1880                        const MIN_AIR_VEL: f32 = -2.0;
1881
1882                        let minmax_norm = |val, min, max| (val - min) / (max - min);
1883
1884                        let wind_speed = air_vel.0.magnitude();
1885
1886                        // Less means more frequent particles
1887                        let heartbeat = 200
1888                            - Lerp::lerp(
1889                                50u64,
1890                                150,
1891                                minmax_norm(wind_speed, MIN_AIR_VEL, MAX_AIR_VEL),
1892                            );
1893
1894                        let new_count = self.particles.len()
1895                            + usize::from(
1896                                self.scheduler.heartbeats(Duration::from_millis(heartbeat)),
1897                            );
1898
1899                        // More number, longer particles
1900                        let duration = Lerp::lerp(
1901                            0u64,
1902                            1000,
1903                            minmax_norm(wind_speed, MIN_AIR_VEL, MAX_AIR_VEL),
1904                        );
1905                        let duration = Duration::from_millis(duration);
1906
1907                        self.particles.resize_with(new_count, || {
1908                            let start_pos = interpolated.pos
1909                                + Vec3::new(
1910                                    body.max_radius(),
1911                                    body.max_radius(),
1912                                    body.height() / 2.0,
1913                                )
1914                                .map(|d| d * rng.random_range(-10.0..10.0));
1915
1916                            Particle::new_directed(
1917                                duration,
1918                                time,
1919                                ParticleMode::Airflow,
1920                                start_pos,
1921                                start_pos + air_vel.0,
1922                                scene_data,
1923                            )
1924                        });
1925
1926                        // When using the glide boost, emit particles
1927                        if let Some(states::glide::Boost::Forward(_)) = &glide.booster
1928                            && let Some(figure_state) =
1929                                figure_mgr.states.character_states.get(&entity)
1930                            && let Some(tp0) = figure_state.primary_abs_trail_points
1931                            && let Some(tp1) = figure_state.secondary_abs_trail_points
1932                        {
1933                            for _ in 0..self.scheduler.heartbeats(Duration::from_millis(5)) {
1934                                self.particles.push(Particle::new(
1935                                    Duration::from_secs(2),
1936                                    time,
1937                                    ParticleMode::EngineJet,
1938                                    ((tp0.0 + tp1.1) * 0.5)
1939                                        // TODO: This offset is used to position the particles at the engine outlet. Ideally, we'd have a way to configure this per-glider
1940                                        + Vec3::unit_z() * 0.5
1941                                        + Vec3::<f32>::zero().map(|_| rng.random_range(-0.25..0.25))
1942                                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1943                                scene_data,
1944                                ));
1945                            }
1946                        }
1947                    }
1948                },
1949                CharacterState::Transform(data) => {
1950                    if matches!(data.stage_section, StageSection::Buildup)
1951                        && let Some(specifier) = data.static_data.specifier
1952                    {
1953                        match specifier {
1954                            states::transform::FrontendSpecifier::Evolve => {
1955                                self.particles.resize_with(
1956                                    self.particles.len()
1957                                        + usize::from(
1958                                            self.scheduler.heartbeats(Duration::from_millis(10)),
1959                                        ),
1960                                    || {
1961                                        let start_pos = interpolated.pos
1962                                            + (Vec2::unit_y()
1963                                                * rng.random::<f32>()
1964                                                * body.max_radius())
1965                                            .rotated_z(rng.random_range(0.0..(PI * 2.0)))
1966                                            .with_z(body.height() * rng.random::<f32>());
1967
1968                                        Particle::new_directed(
1969                                            Duration::from_millis(100),
1970                                            time,
1971                                            ParticleMode::BarrelOrgan,
1972                                            start_pos,
1973                                            start_pos + Vec3::unit_z() * 2.0,
1974                                            scene_data,
1975                                        )
1976                                    },
1977                                )
1978                            },
1979                            states::transform::FrontendSpecifier::Cursekeeper => {
1980                                self.particles.resize_with(
1981                                    self.particles.len()
1982                                        + usize::from(
1983                                            self.scheduler.heartbeats(Duration::from_millis(10)),
1984                                        ),
1985                                    || {
1986                                        let start_pos = interpolated.pos
1987                                            + (Vec2::unit_y()
1988                                                * rng.random::<f32>()
1989                                                * body.max_radius())
1990                                            .rotated_z(rng.random_range(0.0..(PI * 2.0)))
1991                                            .with_z(body.height() * rng.random::<f32>());
1992
1993                                        Particle::new_directed(
1994                                            Duration::from_millis(100),
1995                                            time,
1996                                            ParticleMode::FireworkPurple,
1997                                            start_pos,
1998                                            start_pos + Vec3::unit_z() * 2.0,
1999                                            scene_data,
2000                                        )
2001                                    },
2002                                )
2003                            },
2004                        }
2005                    }
2006                },
2007                CharacterState::ChargedMelee(_melee) => {
2008                    self.maintain_hydra_tail_swipe_particles(
2009                        scene_data,
2010                        figure_mgr,
2011                        entity,
2012                        interpolated.pos,
2013                        body,
2014                        character_state,
2015                        inventory,
2016                    );
2017                },
2018                _ => {},
2019            }
2020        }
2021    }
2022
2023    fn maintain_beam_particles(&mut self, scene_data: &SceneData, lights: &mut Vec<Light>) {
2024        let state = scene_data.state;
2025        let ecs = state.ecs();
2026        let time = state.get_time();
2027        let terrain = state.terrain();
2028        // Limit to 100 per tick, so at less than 10 FPS particle generation
2029        // work doesn't increase frame cost further.
2030        let tick_elapse = u32::from(self.scheduler.heartbeats(Duration::from_millis(1)).min(100));
2031        let mut rng = rand::rng();
2032
2033        for (beam, ori) in (&ecs.read_storage::<Beam>(), &ecs.read_storage::<Ori>()).join() {
2034            let particles_per_sec = (match beam.specifier {
2035                beam::FrontendSpecifier::Flamethrower
2036                | beam::FrontendSpecifier::Bubbles
2037                | beam::FrontendSpecifier::Steam
2038                | beam::FrontendSpecifier::Frost
2039                | beam::FrontendSpecifier::Poison
2040                | beam::FrontendSpecifier::Ink
2041                | beam::FrontendSpecifier::PhoenixLaser
2042                | beam::FrontendSpecifier::Gravewarden => 300.0,
2043                beam::FrontendSpecifier::FirePillar | beam::FrontendSpecifier::FlameWallPillar => {
2044                    40.0 * beam.end_radius.powi(2)
2045                },
2046                beam::FrontendSpecifier::LifestealBeam => 420.0,
2047                beam::FrontendSpecifier::Cultist => 960.0,
2048                beam::FrontendSpecifier::WebStrand => 180.0,
2049                beam::FrontendSpecifier::Lightning => 120.0,
2050                beam::FrontendSpecifier::FireGigasOverheat => 1600.0,
2051            }) / 1000.0;
2052
2053            let beam_tick_count = tick_elapse as f32 * particles_per_sec;
2054            let beam_tick_count = if rng.random_bool(f64::from(beam_tick_count.fract())) {
2055                beam_tick_count.ceil() as u32
2056            } else {
2057                beam_tick_count.floor() as u32
2058            };
2059
2060            if beam_tick_count == 0 {
2061                continue;
2062            }
2063
2064            let distributed_time = tick_elapse as f64 / (beam_tick_count * 1000) as f64;
2065            let angle = (beam.end_radius / beam.range).atan();
2066            let beam_dir = (beam.bezier.ctrl - beam.bezier.start)
2067                .try_normalized()
2068                .unwrap_or(*ori.look_dir());
2069            let raycast_distance = |from, to| terrain.ray(from, to).until(Block::is_solid).cast().0;
2070
2071            self.particles.reserve(beam_tick_count as usize);
2072            match beam.specifier {
2073                beam::FrontendSpecifier::Flamethrower => {
2074                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2075                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2076                    // Emit a light when using flames
2077                    if scene_data.flashing_lights_enabled {
2078                        lights.push(Light::new(
2079                            beam.bezier.start,
2080                            Rgb::new(1.0, 0.25, 0.05).map(|e| e * rng.random_range(0.8..1.2)),
2081                            2.0,
2082                        ));
2083                    }
2084
2085                    for i in 0..beam_tick_count {
2086                        let phi: f32 = rng.random_range(0.0..angle);
2087                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2088                        let offset_z =
2089                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2090                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2091                        self.particles.push(Particle::new_directed_with_collision(
2092                            Duration::from_secs_f64(beam.duration.0),
2093                            time + distributed_time * i as f64,
2094                            ParticleMode::FlameThrower,
2095                            beam.bezier.start,
2096                            beam.bezier.start + random_ori * beam.range,
2097                            scene_data,
2098                            raycast_distance,
2099                        ));
2100                    }
2101                },
2102                beam::FrontendSpecifier::FireGigasOverheat => {
2103                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2104                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2105                    // Emit a light when using flames
2106                    if scene_data.flashing_lights_enabled {
2107                        lights.push(Light::new(
2108                            beam.bezier.start,
2109                            Rgb::new(1.0, 0.25, 0.05).map(|e| e * rng.random_range(0.8..1.2)),
2110                            2.0,
2111                        ));
2112                    }
2113
2114                    for i in 0..beam_tick_count {
2115                        let phi: f32 = rng.random_range(0.0..angle);
2116                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2117                        let offset_z =
2118                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2119                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2120                        self.particles.push(Particle::new_directed_with_collision(
2121                            Duration::from_secs_f64(beam.duration.0),
2122                            time + distributed_time * i as f64,
2123                            ParticleMode::FireGigasOverheat,
2124                            beam.bezier.start,
2125                            beam.bezier.start + random_ori * beam.range,
2126                            scene_data,
2127                            raycast_distance,
2128                        ));
2129                    }
2130                },
2131                beam::FrontendSpecifier::FirePillar | beam::FrontendSpecifier::FlameWallPillar => {
2132                    // Emit a light when using flames
2133                    if scene_data.flashing_lights_enabled {
2134                        lights.push(Light::new(
2135                            beam.bezier.start,
2136                            Rgb::new(1.0, 0.25, 0.05).map(|e| e * rng.random_range(0.8..1.2)),
2137                            2.0,
2138                        ));
2139                    }
2140
2141                    for i in 0..beam_tick_count {
2142                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2143                        let radius = beam.start_radius * (1.0 - rng.random::<f32>().powi(8));
2144                        let offset = Vec3::new(radius * theta.cos(), radius * theta.sin(), 0.0);
2145                        self.particles.push(Particle::new_directed_with_collision(
2146                            Duration::from_secs_f64(beam.duration.0),
2147                            time + distributed_time * i as f64,
2148                            ParticleMode::FirePillar,
2149                            beam.bezier.start + offset,
2150                            beam.bezier.start + offset + beam.range * Vec3::unit_z(),
2151                            scene_data,
2152                            raycast_distance,
2153                        ));
2154                    }
2155                },
2156                beam::FrontendSpecifier::Cultist => {
2157                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2158                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2159                    // Emit a light when using flames
2160                    if scene_data.flashing_lights_enabled {
2161                        lights.push(Light::new(
2162                            beam.bezier.start,
2163                            Rgb::new(1.0, 0.0, 1.0).map(|e| e * rng.random_range(0.5..1.0)),
2164                            2.0,
2165                        ));
2166                    }
2167                    for i in 0..beam_tick_count {
2168                        let phi: f32 = rng.random_range(0.0..angle);
2169                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2170                        let offset_z =
2171                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2172                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2173                        self.particles.push(Particle::new_directed_with_collision(
2174                            Duration::from_secs_f64(beam.duration.0),
2175                            time + distributed_time * i as f64,
2176                            ParticleMode::CultistFlame,
2177                            beam.bezier.start,
2178                            beam.bezier.start + random_ori * beam.range,
2179                            scene_data,
2180                            raycast_distance,
2181                        ));
2182                    }
2183                },
2184                beam::FrontendSpecifier::LifestealBeam => {
2185                    // Emit a light when using lifesteal beam
2186                    if scene_data.flashing_lights_enabled {
2187                        lights.push(Light::new(beam.bezier.start, Rgb::new(0.8, 1.0, 0.5), 1.0));
2188                    }
2189
2190                    // If the beam is one straight line, we can run raycast one time.
2191                    let bezier_end = beam.bezier.start + beam_dir * beam.range;
2192                    let distance = raycast_distance(beam.bezier.start, bezier_end);
2193                    for i in 0..beam_tick_count {
2194                        self.particles.push(Particle::new_directed_with_collision(
2195                            Duration::from_secs_f64(beam.duration.0),
2196                            time + distributed_time * i as f64,
2197                            ParticleMode::LifestealBeam,
2198                            beam.bezier.start,
2199                            bezier_end,
2200                            scene_data,
2201                            |_from, _to| distance,
2202                        ));
2203                    }
2204                },
2205                beam::FrontendSpecifier::Gravewarden => {
2206                    for i in 0..beam_tick_count {
2207                        let mut offset = 0.5;
2208                        let side = Vec2::new(-beam_dir.y, beam_dir.x);
2209                        self.particles.resize_with(self.particles.len() + 2, || {
2210                            offset = -offset;
2211                            Particle::new_directed_with_collision(
2212                                Duration::from_secs_f64(beam.duration.0),
2213                                time + distributed_time * i as f64,
2214                                ParticleMode::Laser,
2215                                beam.bezier.start + beam_dir * 1.5 + side * offset,
2216                                beam.bezier.start + beam_dir * beam.range + side * offset,
2217                                scene_data,
2218                                raycast_distance,
2219                            )
2220                        });
2221                    }
2222                },
2223                beam::FrontendSpecifier::WebStrand => {
2224                    let bezier_end = beam.bezier.start + beam_dir * beam.range;
2225                    let distance = raycast_distance(beam.bezier.start, bezier_end);
2226                    for i in 0..beam_tick_count {
2227                        self.particles.push(Particle::new_directed_with_collision(
2228                            Duration::from_secs_f64(beam.duration.0),
2229                            time + distributed_time * i as f64,
2230                            ParticleMode::WebStrand,
2231                            beam.bezier.start,
2232                            bezier_end,
2233                            scene_data,
2234                            |_from, _to| distance,
2235                        ));
2236                    }
2237                },
2238                beam::FrontendSpecifier::Bubbles => {
2239                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2240                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2241                    for i in 0..beam_tick_count {
2242                        let phi: f32 = rng.random_range(0.0..angle);
2243                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2244                        let offset_z =
2245                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2246                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2247                        self.particles.push(Particle::new_directed_with_collision(
2248                            Duration::from_secs_f64(beam.duration.0),
2249                            time + distributed_time * i as f64,
2250                            ParticleMode::Bubbles,
2251                            beam.bezier.start,
2252                            beam.bezier.start + random_ori * beam.range,
2253                            scene_data,
2254                            raycast_distance,
2255                        ));
2256                    }
2257                },
2258                beam::FrontendSpecifier::Poison => {
2259                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2260                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2261                    for i in 0..beam_tick_count {
2262                        let phi: f32 = rng.random_range(0.0..angle);
2263                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2264                        let offset_z =
2265                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2266                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2267                        self.particles.push(Particle::new_directed_with_collision(
2268                            Duration::from_secs_f64(beam.duration.0),
2269                            time + distributed_time * i as f64,
2270                            ParticleMode::Poison,
2271                            beam.bezier.start,
2272                            beam.bezier.start + random_ori * beam.range,
2273                            scene_data,
2274                            raycast_distance,
2275                        ));
2276                    }
2277                },
2278                beam::FrontendSpecifier::Ink => {
2279                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2280                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2281                    for i in 0..beam_tick_count {
2282                        let phi: f32 = rng.random_range(0.0..angle);
2283                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2284                        let offset_z =
2285                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2286                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2287                        self.particles.push(Particle::new_directed_with_collision(
2288                            Duration::from_secs_f64(beam.duration.0),
2289                            time + distributed_time * i as f64,
2290                            ParticleMode::Bubbles,
2291                            beam.bezier.start,
2292                            beam.bezier.start + random_ori * beam.range,
2293                            scene_data,
2294                            raycast_distance,
2295                        ));
2296                    }
2297                },
2298                beam::FrontendSpecifier::Steam => {
2299                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2300                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2301                    for i in 0..beam_tick_count {
2302                        let phi: f32 = rng.random_range(0.0..angle);
2303                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2304                        let offset_z =
2305                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2306                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2307                        self.particles.push(Particle::new_directed_with_collision(
2308                            Duration::from_secs_f64(beam.duration.0),
2309                            time + distributed_time * i as f64,
2310                            ParticleMode::Steam,
2311                            beam.bezier.start,
2312                            beam.bezier.start + random_ori * beam.range,
2313                            scene_data,
2314                            raycast_distance,
2315                        ));
2316                    }
2317                },
2318                beam::FrontendSpecifier::Lightning => {
2319                    let bezier_end = beam.bezier.start + beam_dir * beam.range;
2320                    let distance = raycast_distance(beam.bezier.start, bezier_end);
2321                    for i in 0..beam_tick_count {
2322                        self.particles.push(Particle::new_directed_with_collision(
2323                            Duration::from_secs_f64(beam.duration.0),
2324                            time + distributed_time * i as f64,
2325                            ParticleMode::Lightning,
2326                            beam.bezier.start,
2327                            bezier_end,
2328                            scene_data,
2329                            |_from, _to| distance,
2330                        ));
2331                    }
2332                },
2333                beam::FrontendSpecifier::Frost => {
2334                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2335                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2336                    for i in 0..beam_tick_count {
2337                        let phi: f32 = rng.random_range(0.0..angle);
2338                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2339                        let offset_z =
2340                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2341                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2342                        self.particles.push(Particle::new_directed_with_collision(
2343                            Duration::from_secs_f64(beam.duration.0),
2344                            time + distributed_time * i as f64,
2345                            ParticleMode::Ice,
2346                            beam.bezier.start,
2347                            beam.bezier.start + random_ori * beam.range,
2348                            scene_data,
2349                            raycast_distance,
2350                        ));
2351                    }
2352                },
2353                beam::FrontendSpecifier::PhoenixLaser => {
2354                    let bezier_end = beam.bezier.start + beam_dir * beam.range;
2355                    let distance = raycast_distance(beam.bezier.start, bezier_end);
2356                    for i in 0..beam_tick_count {
2357                        self.particles.push(Particle::new_directed_with_collision(
2358                            Duration::from_secs_f64(beam.duration.0),
2359                            time + distributed_time * i as f64,
2360                            ParticleMode::PhoenixBeam,
2361                            beam.bezier.start,
2362                            bezier_end,
2363                            scene_data,
2364                            |_from, _to| distance,
2365                        ));
2366                    }
2367                },
2368            }
2369        }
2370    }
2371
2372    fn maintain_aura_particles(&mut self, scene_data: &SceneData) {
2373        let state = scene_data.state;
2374        let ecs = state.ecs();
2375        let time = state.get_time();
2376        let mut rng = rand::rng();
2377        let dt = scene_data.state.get_delta_time();
2378
2379        for (interp, pos, auras, body_maybe) in (
2380            ecs.read_storage::<Interpolated>().maybe(),
2381            &ecs.read_storage::<Pos>(),
2382            &ecs.read_storage::<comp::Auras>(),
2383            ecs.read_storage::<comp::Body>().maybe(),
2384        )
2385            .join()
2386        {
2387            let pos = interp.map_or(pos.0, |i| i.pos);
2388
2389            for (_, aura) in auras.auras.iter() {
2390                match aura.aura_kind {
2391                    aura::AuraKind::Buff {
2392                        kind: buff::BuffKind::ProtectingWard,
2393                        ..
2394                    } => {
2395                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2396                        self.particles.resize_with(
2397                            self.particles.len()
2398                                + aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
2399                            || {
2400                                let rand_dist = aura.radius * (1.0 - rng.random::<f32>().powi(100));
2401                                let init_pos = Vec3::new(rand_dist, 0_f32, 0_f32);
2402                                let duration = Duration::from_secs_f64(
2403                                    aura.end_time
2404                                        .map_or(1.0, |end| end.0 - time)
2405                                        .clamp(0.0, 1.0),
2406                                );
2407                                Particle::new_directed(
2408                                    duration,
2409                                    time,
2410                                    ParticleMode::EnergyNature,
2411                                    pos,
2412                                    pos + init_pos,
2413                                    scene_data,
2414                                )
2415                            },
2416                        );
2417                    },
2418                    aura::AuraKind::Buff {
2419                        kind: buff::BuffKind::Regeneration,
2420                        ..
2421                    } => {
2422                        if auras.auras.iter().any(|(_, aura)| {
2423                            matches!(aura.aura_kind, aura::AuraKind::Buff {
2424                                kind: buff::BuffKind::ProtectingWard,
2425                                ..
2426                            })
2427                        }) {
2428                            // If same entity has both protecting ward and regeneration auras, skip
2429                            // particles for regeneration
2430                            continue;
2431                        }
2432                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2433                        self.particles.resize_with(
2434                            self.particles.len()
2435                                + aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
2436                            || {
2437                                let rand_dist = aura.radius * (1.0 - rng.random::<f32>().powi(100));
2438                                let init_pos = Vec3::new(rand_dist, 0_f32, 0_f32);
2439                                let duration = Duration::from_secs_f64(
2440                                    aura.end_time
2441                                        .map_or(1.0, |end| end.0 - time)
2442                                        .clamp(0.0, 1.0),
2443                                );
2444                                Particle::new_directed(
2445                                    duration,
2446                                    time,
2447                                    ParticleMode::EnergyHealing,
2448                                    pos,
2449                                    pos + init_pos,
2450                                    scene_data,
2451                                )
2452                            },
2453                        );
2454                    },
2455                    aura::AuraKind::Buff {
2456                        kind: buff::BuffKind::Burning,
2457                        ..
2458                    } => {
2459                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2460                        self.particles.resize_with(
2461                            self.particles.len()
2462                                + aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
2463                            || {
2464                                let rand_pos = {
2465                                    let theta = rng.random::<f32>() * TAU;
2466                                    let radius = aura.radius * rng.random::<f32>().sqrt();
2467                                    let x = radius * theta.sin();
2468                                    let y = radius * theta.cos();
2469                                    Vec2::new(x, y) + pos.xy()
2470                                };
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::FlameThrower,
2480                                    rand_pos.with_z(pos.z),
2481                                    rand_pos.with_z(pos.z + 1.0),
2482                                    scene_data,
2483                                )
2484                            },
2485                        );
2486                    },
2487                    aura::AuraKind::Buff {
2488                        kind: buff::BuffKind::Hastened,
2489                        ..
2490                    } => {
2491                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2492                        self.particles.resize_with(
2493                            self.particles.len()
2494                                + aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
2495                            || {
2496                                let rand_dist = aura.radius * (1.0 - rng.random::<f32>().powi(100));
2497                                let init_pos = Vec3::new(rand_dist, 0_f32, 0_f32);
2498                                let duration = Duration::from_secs_f64(
2499                                    aura.end_time
2500                                        .map_or(1.0, |end| end.0 - time)
2501                                        .clamp(0.0, 1.0),
2502                                );
2503                                Particle::new_directed(
2504                                    duration,
2505                                    time,
2506                                    ParticleMode::EnergyBuffing,
2507                                    pos,
2508                                    pos + init_pos,
2509                                    scene_data,
2510                                )
2511                            },
2512                        );
2513                    },
2514                    aura::AuraKind::Buff {
2515                        kind: buff::BuffKind::Frozen,
2516                        ..
2517                    } => {
2518                        let is_new_aura = aura.data.duration.is_none_or(|max_dur| {
2519                            let rem_dur = aura.end_time.map_or(time, |e| e.0) - time;
2520                            rem_dur > max_dur.0 * 0.9
2521                        });
2522                        if is_new_aura {
2523                            let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2524                            self.particles.resize_with(
2525                                self.particles.len()
2526                                    + aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
2527                                || {
2528                                    let rand_angle = rng.random_range(0.0..TAU);
2529                                    let offset =
2530                                        Vec2::new(rand_angle.cos(), rand_angle.sin()) * aura.radius;
2531                                    let z_start = body_maybe
2532                                        .map_or(0.0, |b| rng.random_range(0.5..0.75) * b.height());
2533                                    let z_end = body_maybe
2534                                        .map_or(0.0, |b| rng.random_range(0.0..3.0) * b.height());
2535                                    Particle::new_directed(
2536                                        Duration::from_secs(3),
2537                                        time,
2538                                        ParticleMode::Ice,
2539                                        pos + Vec3::unit_z() * z_start,
2540                                        pos + offset.with_z(z_end),
2541                                        scene_data,
2542                                    )
2543                                },
2544                            );
2545                        }
2546                    },
2547                    aura::AuraKind::Buff {
2548                        kind: buff::BuffKind::Heatstroke,
2549                        ..
2550                    } => {
2551                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2552                        self.particles.resize_with(
2553                            self.particles.len()
2554                                + aura.radius.powi(2) as usize * usize::from(heartbeats) / 900,
2555                            || {
2556                                let rand_dist = aura.radius * (1.0 - rng.random::<f32>().powi(100));
2557                                let init_pos = Vec3::new(rand_dist, 0_f32, 0_f32);
2558                                let duration = Duration::from_secs_f64(
2559                                    aura.end_time
2560                                        .map_or(1.0, |end| end.0 - time)
2561                                        .clamp(0.0, 1.0),
2562                                );
2563                                Particle::new_directed(
2564                                    duration,
2565                                    time,
2566                                    ParticleMode::EnergyPhoenix,
2567                                    pos,
2568                                    pos + init_pos,
2569                                    scene_data,
2570                                )
2571                            },
2572                        );
2573
2574                        let num_particles = aura.radius.powi(2) * dt / 50.0;
2575                        let num_particles = num_particles.floor() as usize
2576                            + usize::from(rng.random_bool(f64::from(num_particles % 1.0)));
2577                        self.particles
2578                            .resize_with(self.particles.len() + num_particles, || {
2579                                let rand_pos = {
2580                                    let theta = rng.random::<f32>() * TAU;
2581                                    let radius = aura.radius * rng.random::<f32>().sqrt();
2582                                    let x = radius * theta.sin();
2583                                    let y = radius * theta.cos();
2584                                    Vec2::new(x, y) + pos.xy()
2585                                };
2586                                let duration = Duration::from_secs_f64(
2587                                    aura.end_time
2588                                        .map_or(1.0, |end| end.0 - time)
2589                                        .clamp(0.0, 1.0),
2590                                );
2591                                Particle::new_directed(
2592                                    duration,
2593                                    time,
2594                                    ParticleMode::FieryBurstAsh,
2595                                    pos,
2596                                    Vec3::new(
2597                                                    0.0,    // radius of rand spawn
2598                                                    20.0,   // integer part - radius of the curve part, fractional part - relative time of setting particle on fire
2599                                                    5.5)    // height of the flight
2600                                                    + rand_pos.with_z(pos.z),
2601                                    scene_data,
2602                                )
2603                            });
2604                    },
2605                    _ => {},
2606                }
2607            }
2608        }
2609    }
2610
2611    fn maintain_buff_particles(&mut self, scene_data: &SceneData) {
2612        let state = scene_data.state;
2613        let ecs = state.ecs();
2614        let time = state.get_time();
2615        let mut rng = rand::rng();
2616
2617        for (interp, pos, buffs, body, ori, scale) in (
2618            ecs.read_storage::<Interpolated>().maybe(),
2619            &ecs.read_storage::<Pos>(),
2620            &ecs.read_storage::<comp::Buffs>(),
2621            &ecs.read_storage::<Body>(),
2622            &ecs.read_storage::<Ori>(),
2623            ecs.read_storage::<Scale>().maybe(),
2624        )
2625            .join()
2626        {
2627            let pos = interp.map_or(pos.0, |i| i.pos);
2628
2629            for (buff_kind, buff_keys) in buffs
2630                .kinds
2631                .iter()
2632                .filter_map(|(kind, keys)| keys.as_ref().map(|keys| (kind, keys)))
2633            {
2634                use buff::BuffKind;
2635                match buff_kind {
2636                    BuffKind::Cursed | BuffKind::Burning => {
2637                        self.particles.resize_with(
2638                            self.particles.len()
2639                                + usize::from(self.scheduler.heartbeats(Duration::from_millis(15))),
2640                            || {
2641                                let start_pos = pos
2642                                    + Vec3::unit_z() * body.height() * 0.25
2643                                    + Vec3::<f32>::zero()
2644                                        .map(|_| rng.random_range(-1.0..1.0))
2645                                        .normalized()
2646                                        * 0.25;
2647                                let end_pos = start_pos
2648                                    + Vec3::unit_z() * body.height()
2649                                    + Vec3::<f32>::zero()
2650                                        .map(|_| rng.random_range(-1.0..1.0))
2651                                        .normalized();
2652                                Particle::new_directed(
2653                                    Duration::from_secs(1),
2654                                    time,
2655                                    if matches!(buff_kind, BuffKind::Cursed) {
2656                                        ParticleMode::CultistFlame
2657                                    } else {
2658                                        ParticleMode::FlameThrower
2659                                    },
2660                                    start_pos,
2661                                    end_pos,
2662                                    scene_data,
2663                                )
2664                            },
2665                        );
2666                    },
2667                    BuffKind::PotionSickness => {
2668                        let mut multiplicity = 0;
2669                        // Only show particles for potion sickness at the beginning, after the
2670                        // drinking animation finishes
2671                        if buff_keys.0
2672                            .iter()
2673                            .filter_map(|key| buffs.buffs.get(*key))
2674                            .any(|buff| {
2675                                matches!(buff.elapsed(Time(time)), dur if (1.0..=1.5).contains(&dur.0))
2676                            })
2677                        {
2678                            multiplicity = 1;
2679                        }
2680                        self.particles.resize_with(
2681                            self.particles.len()
2682                                + multiplicity
2683                                    * usize::from(
2684                                        self.scheduler.heartbeats(Duration::from_millis(25)),
2685                                    ),
2686                            || {
2687                                let start_pos = pos
2688                                    + Vec3::unit_z() * body.eye_height(scale.map_or(1.0, |s| s.0));
2689                                let (radius, theta) = (
2690                                    rng.random_range(0.0f32..1.0).sqrt(),
2691                                    rng.random_range(0.0..TAU),
2692                                );
2693                                let end_pos = pos
2694                                    + *ori.look_dir()
2695                                    + Vec3::<f32>::new(
2696                                        radius * theta.cos(),
2697                                        radius * theta.sin(),
2698                                        0.0,
2699                                    ) * 0.25;
2700                                Particle::new_directed(
2701                                    Duration::from_secs(1),
2702                                    time,
2703                                    ParticleMode::PotionSickness,
2704                                    start_pos,
2705                                    end_pos,
2706                                    scene_data,
2707                                )
2708                            },
2709                        );
2710                    },
2711                    BuffKind::Frenzied => {
2712                        self.particles.resize_with(
2713                            self.particles.len()
2714                                + usize::from(self.scheduler.heartbeats(Duration::from_millis(15))),
2715                            || {
2716                                let start_pos = pos
2717                                    + Vec3::new(
2718                                        body.max_radius(),
2719                                        body.max_radius(),
2720                                        body.height() / 2.0,
2721                                    )
2722                                    .map(|d| d * rng.random_range(-1.0..1.0));
2723                                let end_pos = start_pos
2724                                    + Vec3::unit_z() * body.height()
2725                                    + Vec3::<f32>::zero()
2726                                        .map(|_| rng.random_range(-1.0..1.0))
2727                                        .normalized();
2728                                Particle::new_directed(
2729                                    Duration::from_secs(1),
2730                                    time,
2731                                    ParticleMode::Enraged,
2732                                    start_pos,
2733                                    end_pos,
2734                                    scene_data,
2735                                )
2736                            },
2737                        );
2738                    },
2739                    BuffKind::Polymorphed => {
2740                        let mut multiplicity = 0;
2741                        // Only show particles for polymorph at the beginning, after the
2742                        // drinking animation finishes
2743                        if buff_keys.0
2744                            .iter()
2745                            .filter_map(|key| buffs.buffs.get(*key))
2746                            .any(|buff| {
2747                                matches!(buff.elapsed(Time(time)), dur if (0.1..=0.3).contains(&dur.0))
2748                            })
2749                        {
2750                            multiplicity = 1;
2751                        }
2752                        self.particles.resize_with(
2753                            self.particles.len()
2754                                + multiplicity
2755                                    * self.scheduler.heartbeats(Duration::from_millis(3)) as usize,
2756                            || {
2757                                let start_pos = pos
2758                                    + Vec3::unit_z() * body.eye_height(scale.map_or(1.0, |s| s.0))
2759                                        / 2.0;
2760                                let end_pos = start_pos
2761                                    + Vec3::<f32>::zero()
2762                                        .map(|_| rng.random_range(-1.0..1.0))
2763                                        .normalized()
2764                                        * 5.0;
2765
2766                                Particle::new_directed(
2767                                    Duration::from_secs(2),
2768                                    time,
2769                                    ParticleMode::Explosion,
2770                                    start_pos,
2771                                    end_pos,
2772                                    scene_data,
2773                                )
2774                            },
2775                        )
2776                    },
2777                    _ => {},
2778                }
2779            }
2780        }
2781    }
2782
2783    fn maintain_block_particles(
2784        &mut self,
2785        scene_data: &SceneData,
2786        terrain: &Terrain<TerrainChunk>,
2787        figure_mgr: &FigureMgr,
2788    ) {
2789        prof_span!("ParticleMgr::maintain_block_particles");
2790        let dt = scene_data.state.ecs().fetch::<DeltaTime>().0;
2791        let time = scene_data.state.get_time();
2792        let player_pos = scene_data
2793            .state
2794            .read_component_copied::<Interpolated>(scene_data.viewpoint_entity)
2795            .map(|i| i.pos)
2796            .unwrap_or_default();
2797        let player_chunk = player_pos.xy().map2(TerrainChunk::RECT_SIZE, |e, sz| {
2798            (e.floor() as i32).div_euclid(sz as i32)
2799        });
2800
2801        struct BlockParticles<'a> {
2802            // The function to select the blocks of interest that we should emit from
2803            blocks: fn(&'a BlocksOfInterest) -> BlockParticleSlice<'a>,
2804            // The range, in chunks, that the particles should be generated in from the player
2805            range: usize,
2806            // The emission rate, per block per second, of the generated particles
2807            rate: f32,
2808            // The number of seconds that each particle should live for
2809            lifetime: f32,
2810            // The visual mode of the generated particle
2811            mode: ParticleMode,
2812            // Condition that must be true
2813            cond: fn(&SceneData) -> bool,
2814        }
2815
2816        enum BlockParticleSlice<'a> {
2817            Positions(&'a [Vec3<i32>]),
2818            PositionsAndDirs(&'a [(Vec3<i32>, Vec3<f32>)]),
2819        }
2820
2821        impl BlockParticleSlice<'_> {
2822            fn len(&self) -> usize {
2823                match self {
2824                    Self::Positions(blocks) => blocks.len(),
2825                    Self::PositionsAndDirs(blocks) => blocks.len(),
2826                }
2827            }
2828        }
2829
2830        let particles: &[BlockParticles] = &[
2831            BlockParticles {
2832                blocks: |boi| BlockParticleSlice::Positions(&boi.leaves),
2833                range: 4,
2834                rate: 0.0125,
2835                lifetime: 30.0,
2836                mode: ParticleMode::Leaf,
2837                cond: |_| true,
2838            },
2839            BlockParticles {
2840                blocks: |boi| BlockParticleSlice::Positions(&boi.drip),
2841                range: 4,
2842                rate: 0.004,
2843                lifetime: 20.0,
2844                mode: ParticleMode::Drip,
2845                cond: |_| true,
2846            },
2847            BlockParticles {
2848                blocks: |boi| BlockParticleSlice::Positions(&boi.fires),
2849                range: 2,
2850                rate: 20.0,
2851                lifetime: 0.25,
2852                mode: ParticleMode::CampfireFire,
2853                cond: |_| true,
2854            },
2855            BlockParticles {
2856                blocks: |boi| BlockParticleSlice::Positions(&boi.fire_bowls),
2857                range: 2,
2858                rate: 20.0,
2859                lifetime: 0.25,
2860                mode: ParticleMode::FireBowl,
2861                cond: |_| true,
2862            },
2863            BlockParticles {
2864                blocks: |boi| BlockParticleSlice::Positions(&boi.fireflies),
2865                range: 6,
2866                rate: 0.004,
2867                lifetime: 40.0,
2868                mode: ParticleMode::Firefly,
2869                cond: |sd| sd.state.get_day_period().is_dark(),
2870            },
2871            BlockParticles {
2872                blocks: |boi| BlockParticleSlice::Positions(&boi.flowers),
2873                range: 5,
2874                rate: 0.002,
2875                lifetime: 40.0,
2876                mode: ParticleMode::Firefly,
2877                cond: |sd| sd.state.get_day_period().is_dark(),
2878            },
2879            BlockParticles {
2880                blocks: |boi| BlockParticleSlice::Positions(&boi.beehives),
2881                range: 3,
2882                rate: 0.5,
2883                lifetime: 30.0,
2884                mode: ParticleMode::Bee,
2885                cond: |sd| sd.state.get_day_period().is_light(),
2886            },
2887            BlockParticles {
2888                blocks: |boi| BlockParticleSlice::Positions(&boi.snow),
2889                range: 4,
2890                rate: 0.025,
2891                lifetime: 15.0,
2892                mode: ParticleMode::Snow,
2893                cond: |_| true,
2894            },
2895            BlockParticles {
2896                blocks: |boi| BlockParticleSlice::PositionsAndDirs(&boi.one_way_walls),
2897                range: 2,
2898                rate: 12.0,
2899                lifetime: 1.5,
2900                mode: ParticleMode::PortalFizz,
2901                cond: |_| true,
2902            },
2903            BlockParticles {
2904                blocks: |boi| BlockParticleSlice::Positions(&boi.spores),
2905                range: 4,
2906                rate: 0.055,
2907                lifetime: 20.0,
2908                mode: ParticleMode::Spore,
2909                cond: |_| true,
2910            },
2911            BlockParticles {
2912                blocks: |boi| BlockParticleSlice::PositionsAndDirs(&boi.waterfall),
2913                range: 2,
2914                rate: 4.0,
2915                lifetime: 5.0,
2916                mode: ParticleMode::WaterFoam,
2917                cond: |_| true,
2918            },
2919            BlockParticles {
2920                blocks: |boi| BlockParticleSlice::Positions(&boi.train_smokes),
2921                range: 2,
2922                rate: 50.0,
2923                lifetime: 8.0,
2924                mode: ParticleMode::TrainSmoke,
2925                cond: |_| true,
2926            },
2927        ];
2928
2929        let ecs = scene_data.state.ecs();
2930        let mut rng = rand::rng();
2931        // Hard cap for performance reasons; Assuming that 25% of a chunk is covered in
2932        // lava or 32*32*0.25 = 256 TODO: Make this a setting?
2933        let cap = 512;
2934        for particles in particles.iter() {
2935            if !(particles.cond)(scene_data) {
2936                continue;
2937            }
2938
2939            for offset in Spiral2d::new().take((particles.range * 2 + 1).pow(2)) {
2940                let chunk_pos = player_chunk + offset;
2941
2942                terrain.get(chunk_pos).map(|chunk_data| {
2943                    let blocks = (particles.blocks)(&chunk_data.blocks_of_interest);
2944
2945                    let avg_particles = dt * (blocks.len() as f32 * particles.rate).min(cap as f32);
2946                    let particle_count = avg_particles.trunc() as usize
2947                        + (rng.random::<f32>() < avg_particles.fract()) as usize;
2948
2949                    self.particles
2950                        .resize_with(self.particles.len() + particle_count, || {
2951                            match blocks {
2952                                BlockParticleSlice::Positions(blocks) => {
2953                                    // Can't fail, resize only occurs if blocks > 0
2954                                    let block_pos = Vec3::from(
2955                                        chunk_pos * TerrainChunk::RECT_SIZE.map(|e| e as i32),
2956                                    ) + blocks.choose(&mut rng).copied().unwrap();
2957                                    Particle::new(
2958                                        Duration::from_secs_f32(particles.lifetime),
2959                                        time,
2960                                        particles.mode,
2961                                        block_pos.map(|e: i32| e as f32 + rng.random::<f32>()),
2962                                        scene_data,
2963                                    )
2964                                },
2965                                BlockParticleSlice::PositionsAndDirs(blocks) => {
2966                                    // Can't fail, resize only occurs if blocks > 0
2967                                    let (block_offset, particle_dir) =
2968                                        blocks.choose(&mut rng).copied().unwrap();
2969                                    let block_pos = Vec3::from(
2970                                        chunk_pos * TerrainChunk::RECT_SIZE.map(|e| e as i32),
2971                                    ) + block_offset;
2972                                    let particle_pos =
2973                                        block_pos.map(|e: i32| e as f32 + rng.random::<f32>());
2974                                    Particle::new_directed(
2975                                        Duration::from_secs_f32(particles.lifetime),
2976                                        time,
2977                                        particles.mode,
2978                                        particle_pos,
2979                                        particle_pos + particle_dir,
2980                                        scene_data,
2981                                    )
2982                                },
2983                            }
2984                        })
2985                });
2986            }
2987
2988            for (entity, body, interpolated, collider) in (
2989                &ecs.entities(),
2990                &ecs.read_storage::<comp::Body>(),
2991                &ecs.read_storage::<crate::ecs::comp::Interpolated>(),
2992                ecs.read_storage::<comp::Collider>().maybe(),
2993            )
2994                .join()
2995            {
2996                if let Some((blocks_of_interest, offset)) =
2997                    figure_mgr.get_blocks_of_interest(entity, body, collider)
2998                {
2999                    let mat = Mat4::from(interpolated.ori.to_quat())
3000                        .translated_3d(interpolated.pos)
3001                        * Mat4::translation_3d(offset);
3002
3003                    let blocks = (particles.blocks)(blocks_of_interest);
3004
3005                    let avg_particles = dt * blocks.len() as f32 * particles.rate;
3006                    let particle_count = avg_particles.trunc() as usize
3007                        + (rng.random::<f32>() < avg_particles.fract()) as usize;
3008
3009                    self.particles
3010                        .resize_with(self.particles.len() + particle_count, || {
3011                            match blocks {
3012                                BlockParticleSlice::Positions(blocks) => {
3013                                    let rel_pos = blocks
3014                                        .choose(&mut rng)
3015                                        .copied()
3016                                        // Can't fail, resize only occurs if blocks > 0
3017                                        .unwrap()
3018                                        .map(|e: i32| e as f32 + rng.random::<f32>());
3019                                    let wpos = mat.mul_point(rel_pos);
3020
3021                                    Particle::new(
3022                                        Duration::from_secs_f32(particles.lifetime),
3023                                        time,
3024                                        particles.mode,
3025                                        wpos,
3026                                        scene_data,
3027                                    )
3028                                },
3029                                BlockParticleSlice::PositionsAndDirs(blocks) => {
3030                                    // Can't fail, resize only occurs if blocks > 0
3031                                    let (block_offset, particle_dir) =
3032                                        blocks.choose(&mut rng).copied().unwrap();
3033                                    let particle_pos =
3034                                        block_offset.map(|e: i32| e as f32 + rng.random::<f32>());
3035                                    let wpos = mat.mul_point(particle_pos);
3036                                    Particle::new_directed(
3037                                        Duration::from_secs_f32(particles.lifetime),
3038                                        time,
3039                                        particles.mode,
3040                                        wpos,
3041                                        wpos + mat.mul_direction(particle_dir),
3042                                        scene_data,
3043                                    )
3044                                },
3045                            }
3046                        })
3047                }
3048            }
3049        }
3050        // smoke is more complex as it comes with varying rate and color
3051        {
3052            struct SmokeProperties {
3053                position: Vec3<i32>,
3054                strength: f32,
3055                dry_chance: f32,
3056            }
3057
3058            let range = 8_usize;
3059            let rate = 3.0 / 128.0;
3060            let lifetime = 40.0;
3061            let time_of_day = scene_data
3062                .state
3063                .get_time_of_day()
3064                .rem_euclid(24.0 * 60.0 * 60.0) as f32;
3065
3066            let smokers = Spiral2d::new()
3067                .take((range * 2 + 1).pow(2))
3068                .flat_map(|offset| {
3069                    let chunk_pos = player_chunk + offset;
3070                    let block_pos =
3071                        Vec3::<i32>::from(chunk_pos * TerrainChunk::RECT_SIZE.map(|e| e as i32));
3072                    terrain.get(chunk_pos).into_iter().flat_map(move |chunk| {
3073                        chunk.blocks_of_interest.smokers.iter().map(move |smoker| {
3074                            (
3075                                block_pos.as_::<f32>() + smoker.position.as_(),
3076                                smoker.kind,
3077                                chunk.blocks_of_interest.temperature,
3078                                chunk.blocks_of_interest.humidity,
3079                            )
3080                        })
3081                    })
3082                })
3083                .chain(
3084                    (
3085                        &ecs.entities(),
3086                        &ecs.read_storage::<comp::Body>(),
3087                        &ecs.read_storage::<crate::ecs::comp::Interpolated>(),
3088                        ecs.read_storage::<comp::Collider>().maybe(),
3089                    )
3090                        .join()
3091                        .flat_map(|(entity, body, interpolated, collider)| {
3092                            figure_mgr
3093                                .get_blocks_of_interest(entity, body, collider)
3094                                .into_iter()
3095                                .flat_map(|(boi, offset)| {
3096                                    let mat = Mat4::from(interpolated.ori.to_quat())
3097                                        .translated_3d(interpolated.pos)
3098                                        * Mat4::translation_3d(offset);
3099                                    boi.smokers.iter().map(move |smoker| {
3100                                        (
3101                                            mat.mul_point(smoker.position.as_::<f32>() + 0.5),
3102                                            smoker.kind,
3103                                            0.0, // TODO: Actual temperature & humidity
3104                                            0.5,
3105                                        )
3106                                    })
3107                                })
3108                        })
3109                        .collect::<Vec<_>>(),
3110                );
3111
3112            let mut smoke_properties: Vec<SmokeProperties> = Vec::new();
3113            let mut sum = 0.0_f32;
3114            for (pos, kind, temperature, humidity) in smokers {
3115                let (strength, dry_chance) = {
3116                    match kind {
3117                        FireplaceType::House => {
3118                            let prop = crate::scene::smoke_cycle::smoke_at_time(
3119                                pos.round().as_(),
3120                                temperature,
3121                                time_of_day,
3122                            );
3123                            (
3124                                prop.0,
3125                                if prop.1 {
3126                                    // fire started, dark smoke
3127                                    0.8 - humidity
3128                                } else {
3129                                    // fire continues, light smoke
3130                                    1.2 - humidity
3131                                },
3132                            )
3133                        },
3134                        FireplaceType::Workshop => (128.0, 1.0),
3135                    }
3136                };
3137                sum += strength;
3138                smoke_properties.push(SmokeProperties {
3139                    position: pos.round().as_(),
3140                    strength,
3141                    dry_chance,
3142                });
3143            }
3144            let avg_particles = dt * sum * rate;
3145
3146            let particle_count = avg_particles.trunc() as usize
3147                + (rng.random::<f32>() < avg_particles.fract()) as usize;
3148            let chosen =
3149                smoke_properties
3150                    .choose_multiple_weighted(&mut rng, particle_count, |smoker| smoker.strength);
3151            if let Ok(chosen) = chosen {
3152                self.particles.extend(chosen.map(|smoker| {
3153                    Particle::new(
3154                        Duration::from_secs_f32(lifetime),
3155                        time,
3156                        if rng.random::<f32>() > smoker.dry_chance {
3157                            ParticleMode::BlackSmoke
3158                        } else {
3159                            ParticleMode::CampfireSmoke
3160                        },
3161                        smoker.position.map(|e: i32| e as f32 + rng.random::<f32>()),
3162                        scene_data,
3163                    )
3164                }));
3165            }
3166        }
3167    }
3168
3169    fn maintain_shockwave_particles(&mut self, scene_data: &SceneData) {
3170        let state = scene_data.state;
3171        let ecs = state.ecs();
3172        let time = state.get_time();
3173        let dt = scene_data.state.ecs().fetch::<DeltaTime>().0;
3174        let terrain = scene_data.state.ecs().fetch::<TerrainGrid>();
3175
3176        for (_entity, interp, pos, ori, shockwave) in (
3177            &ecs.entities(),
3178            ecs.read_storage::<Interpolated>().maybe(),
3179            &ecs.read_storage::<Pos>(),
3180            &ecs.read_storage::<Ori>(),
3181            &ecs.read_storage::<Shockwave>(),
3182        )
3183            .join()
3184        {
3185            let pos = interp.map_or(pos.0, |i| i.pos);
3186            let ori = interp.map_or(*ori, |i| i.ori);
3187
3188            let elapsed = time - shockwave.creation.unwrap_or(time);
3189            let speed = shockwave.properties.speed;
3190
3191            let percent = elapsed as f32 / shockwave.properties.duration.as_secs_f32();
3192
3193            let distance = speed * elapsed as f32;
3194
3195            let radians = shockwave.properties.angle.to_radians();
3196
3197            let ori_vec = ori.look_vec();
3198            let theta = ori_vec.y.atan2(ori_vec.x) - radians / 2.0;
3199            let dtheta = radians / distance;
3200
3201            // Number of particles derived from arc length (for new particles at least, old
3202            // can be converted later)
3203            let arc_length = distance * radians;
3204
3205            use shockwave::FrontendSpecifier;
3206            match shockwave.properties.specifier {
3207                FrontendSpecifier::Ground => {
3208                    let heartbeats = self.scheduler.heartbeats(Duration::from_millis(2));
3209                    for heartbeat in 0..heartbeats {
3210                        // 1 / 3 the size of terrain voxel
3211                        let scale = 1.0 / 3.0;
3212
3213                        let scaled_speed = speed * scale;
3214
3215                        let sub_tick_interpolation = scaled_speed * 1000.0 * heartbeat as f32;
3216
3217                        let distance = speed * (elapsed as f32 - sub_tick_interpolation);
3218
3219                        let particle_count_factor = radians / (3.0 * scale);
3220                        let new_particle_count = distance * particle_count_factor;
3221                        self.particles.reserve(new_particle_count as usize);
3222
3223                        for d in 0..(new_particle_count as i32) {
3224                            let arc_position = theta + dtheta * d as f32 / particle_count_factor;
3225
3226                            let position = pos
3227                                + distance * Vec3::new(arc_position.cos(), arc_position.sin(), 0.0);
3228
3229                            // Arbitrary number chosen that is large enough to be able to accurately
3230                            // place particles most of the time, but also not too big as to make ray
3231                            // be too large (for performance reasons)
3232                            let half_ray_length = 10.0;
3233                            let mut last_air = false;
3234                            // TODO: Optimize ray to only be cast at most once per block per tick if
3235                            // it becomes an issue.
3236                            // From imbris:
3237                            //      each ray is ~2 us
3238                            //      at 30 FPS, it peaked at 113 rays in a tick
3239                            //      total time was 240 us (although potentially half that is
3240                            //          overhead from the profiling of each ray)
3241                            let _ = terrain
3242                                .ray(
3243                                    position + Vec3::unit_z() * half_ray_length,
3244                                    position - Vec3::unit_z() * half_ray_length,
3245                                )
3246                                .for_each(|block: &Block, pos: Vec3<i32>| {
3247                                    if block.is_solid() && block.get_sprite().is_none() {
3248                                        if last_air {
3249                                            let position = position.xy().with_z(pos.z as f32 + 1.0);
3250
3251                                            let position_snapped =
3252                                                ((position / scale).floor() + 0.5) * scale;
3253
3254                                            self.particles.push(Particle::new(
3255                                                Duration::from_millis(250),
3256                                                time,
3257                                                ParticleMode::GroundShockwave,
3258                                                position_snapped,
3259                                                scene_data,
3260                                            ));
3261                                            last_air = false;
3262                                        }
3263                                    } else {
3264                                        last_air = true;
3265                                    }
3266                                })
3267                                .cast();
3268                        }
3269                    }
3270                },
3271                FrontendSpecifier::Fire => {
3272                    let heartbeats = self.scheduler.heartbeats(Duration::from_millis(2));
3273                    for _ in 0..heartbeats {
3274                        for d in 0..3 * distance as i32 {
3275                            let arc_position = theta + dtheta * d as f32 / 3.0;
3276
3277                            let position = pos
3278                                + distance * Vec3::new(arc_position.cos(), arc_position.sin(), 0.0);
3279
3280                            self.particles.push(Particle::new(
3281                                Duration::from_secs_f32((distance + 10.0) / 50.0),
3282                                time,
3283                                ParticleMode::FireShockwave,
3284                                position,
3285                                scene_data,
3286                            ));
3287                        }
3288                    }
3289                },
3290                FrontendSpecifier::FireLow => {
3291                    let heartbeats = self.scheduler.heartbeats(Duration::from_millis(2));
3292                    for heartbeat in 0..heartbeats {
3293                        // 1 / 3 the size of terrain voxel
3294                        let scale = 1.0 / 3.0;
3295
3296                        let scaled_speed = speed * scale;
3297
3298                        let sub_tick_interpolation = scaled_speed * 1000.0 * heartbeat as f32;
3299
3300                        let distance = speed * (elapsed as f32 - sub_tick_interpolation);
3301
3302                        let particle_count_factor = radians / (3.0 * scale);
3303                        let new_particle_count = distance * particle_count_factor;
3304                        self.particles.reserve(new_particle_count as usize);
3305
3306                        for d in 0..(new_particle_count as i32) {
3307                            let arc_position = theta + dtheta * d as f32 / particle_count_factor;
3308
3309                            let position = pos
3310                                + distance * Vec3::new(arc_position.cos(), arc_position.sin(), 0.0);
3311
3312                            // Arbitrary number chosen that is large enough to be able to accurately
3313                            // place particles most of the time, but also not too big as to make ray
3314                            // be too large (for performance reasons)
3315                            let half_ray_length = 10.0;
3316                            let mut last_air = false;
3317                            // TODO: Optimize ray to only be cast at most once per block per tick if
3318                            // it becomes an issue.
3319                            // From imbris:
3320                            //      each ray is ~2 us
3321                            //      at 30 FPS, it peaked at 113 rays in a tick
3322                            //      total time was 240 us (although potentially half that is
3323                            //          overhead from the profiling of each ray)
3324                            let _ = terrain
3325                                .ray(
3326                                    position + Vec3::unit_z() * half_ray_length,
3327                                    position - Vec3::unit_z() * half_ray_length,
3328                                )
3329                                .for_each(|block: &Block, pos: Vec3<i32>| {
3330                                    if block.is_solid() && block.get_sprite().is_none() {
3331                                        if last_air {
3332                                            let position = position.xy().with_z(pos.z as f32 + 1.0);
3333
3334                                            let position_snapped =
3335                                                ((position / scale).floor() + 0.5) * scale;
3336
3337                                            self.particles.push(Particle::new(
3338                                                Duration::from_millis(250),
3339                                                time,
3340                                                ParticleMode::FireLowShockwave,
3341                                                position_snapped,
3342                                                scene_data,
3343                                            ));
3344                                            last_air = false;
3345                                        }
3346                                    } else {
3347                                        last_air = true;
3348                                    }
3349                                })
3350                                .cast();
3351                        }
3352                    }
3353                },
3354                FrontendSpecifier::Water => {
3355                    // 1 particle per unit length of arc
3356                    let particles_per_length = arc_length as usize;
3357                    let dtheta = radians / particles_per_length as f32;
3358                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
3359                    // lower duration = more particles
3360                    let heartbeats = self
3361                        .scheduler
3362                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
3363
3364                    // Reserves capacity for new particles
3365                    let new_particle_count = particles_per_length * heartbeats as usize;
3366                    self.particles.reserve(new_particle_count);
3367
3368                    for i in 0..particles_per_length {
3369                        let angle = dtheta * i as f32;
3370                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
3371                        for j in 0..heartbeats {
3372                            // Sub tick dt
3373                            let dt = (j as f32 / heartbeats as f32) * dt;
3374                            let distance = distance + speed * dt;
3375                            let pos1 = pos + distance * direction - Vec3::unit_z();
3376                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
3377                            let time = time + dt as f64;
3378
3379                            self.particles.push(Particle::new_directed(
3380                                Duration::from_secs_f32(0.5),
3381                                time,
3382                                ParticleMode::Water,
3383                                pos1,
3384                                pos2,
3385                                scene_data,
3386                            ));
3387                        }
3388                    }
3389                },
3390                FrontendSpecifier::Lightning => {
3391                    // 1 particle per unit length of arc
3392                    let particles_per_length = arc_length as usize;
3393                    let dtheta = radians / particles_per_length as f32;
3394                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
3395                    // lower duration = more particles
3396                    let heartbeats = self
3397                        .scheduler
3398                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
3399
3400                    // Reserves capacity for new particles
3401                    let new_particle_count = particles_per_length * heartbeats as usize;
3402                    self.particles.reserve(new_particle_count);
3403
3404                    for i in 0..particles_per_length {
3405                        let angle = dtheta * i as f32;
3406                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
3407                        for j in 0..heartbeats {
3408                            // Sub tick dt
3409                            let dt = (j as f32 / heartbeats as f32) * dt;
3410                            let distance = distance + speed * dt;
3411                            let pos1 = pos + distance * direction - Vec3::unit_z();
3412                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
3413                            let time = time + dt as f64;
3414
3415                            self.particles.push(Particle::new_directed(
3416                                Duration::from_secs_f32(0.5),
3417                                time,
3418                                ParticleMode::Lightning,
3419                                pos1,
3420                                pos2,
3421                                scene_data,
3422                            ));
3423                        }
3424                    }
3425                },
3426                FrontendSpecifier::Steam => {
3427                    // 1 particle per unit length of arc
3428                    let particles_per_length = arc_length as usize;
3429                    let dtheta = radians / particles_per_length as f32;
3430                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
3431                    // lower duration = more particles
3432                    let heartbeats = self
3433                        .scheduler
3434                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
3435
3436                    // Reserves capacity for new particles
3437                    let new_particle_count = particles_per_length * heartbeats as usize;
3438                    self.particles.reserve(new_particle_count);
3439
3440                    for i in 0..particles_per_length {
3441                        let angle = dtheta * i as f32;
3442                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
3443                        for j in 0..heartbeats {
3444                            // Sub tick dt
3445                            let dt = (j as f32 / heartbeats as f32) * dt;
3446                            let distance = distance + speed * dt;
3447                            let pos1 = pos + distance * direction - Vec3::unit_z();
3448                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
3449                            let time = time + dt as f64;
3450
3451                            self.particles.push(Particle::new_directed(
3452                                Duration::from_secs_f32(0.5),
3453                                time,
3454                                ParticleMode::Steam,
3455                                pos1,
3456                                pos2,
3457                                scene_data,
3458                            ));
3459                        }
3460                    }
3461                },
3462                FrontendSpecifier::Poison => {
3463                    // 1 particle per unit length of arc
3464                    let particles_per_length = arc_length as usize;
3465                    let dtheta = radians / particles_per_length as f32;
3466                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
3467                    // lower duration = more particles
3468                    let heartbeats = self
3469                        .scheduler
3470                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
3471
3472                    // Reserves capacity for new particles
3473                    let new_particle_count = particles_per_length * heartbeats as usize;
3474                    self.particles.reserve(new_particle_count);
3475
3476                    for i in 0..particles_per_length {
3477                        let angle = theta + dtheta * i as f32;
3478                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
3479                        for j in 0..heartbeats {
3480                            // Sub tick dt
3481                            let dt = (j as f32 / heartbeats as f32) * dt;
3482                            let distance = distance + speed * dt;
3483                            let pos1 = pos + distance * direction - Vec3::unit_z();
3484                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
3485                            let time = time + dt as f64;
3486
3487                            self.particles.push(Particle::new_directed(
3488                                Duration::from_secs_f32(0.5),
3489                                time,
3490                                ParticleMode::Poison,
3491                                pos1,
3492                                pos2,
3493                                scene_data,
3494                            ));
3495                        }
3496                    }
3497                },
3498                FrontendSpecifier::AcidCloud => {
3499                    let particles_per_height = 5;
3500                    // 1 particle per unit length of arc
3501                    let particles_per_length = arc_length as usize;
3502                    let dtheta = radians / particles_per_length as f32;
3503                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
3504                    // lower duration = more particles
3505                    let heartbeats = self
3506                        .scheduler
3507                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
3508
3509                    // Reserves capacity for new particles
3510                    let new_particle_count =
3511                        particles_per_length * heartbeats as usize * particles_per_height;
3512                    self.particles.reserve(new_particle_count);
3513
3514                    for i in 0..particles_per_height {
3515                        let height = (i as f32 / (particles_per_height as f32 - 1.0)) * 4.0;
3516                        for j in 0..particles_per_length {
3517                            let angle = theta + dtheta * j as f32;
3518                            let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
3519                            for k in 0..heartbeats {
3520                                // Sub tick dt
3521                                let dt = (k as f32 / heartbeats as f32) * dt;
3522                                let distance = distance + speed * dt;
3523                                let pos1 = pos + distance * direction - Vec3::unit_z()
3524                                    + Vec3::unit_z() * height;
3525                                let pos2 = pos1 + direction;
3526                                let time = time + dt as f64;
3527
3528                                self.particles.push(Particle::new_directed(
3529                                    Duration::from_secs_f32(0.5),
3530                                    time,
3531                                    ParticleMode::Poison,
3532                                    pos1,
3533                                    pos2,
3534                                    scene_data,
3535                                ));
3536                            }
3537                        }
3538                    }
3539                },
3540                FrontendSpecifier::Ink => {
3541                    // 1 particle per unit length of arc
3542                    let particles_per_length = arc_length as usize;
3543                    let dtheta = radians / particles_per_length as f32;
3544                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
3545                    // lower duration = more particles
3546                    let heartbeats = self
3547                        .scheduler
3548                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
3549
3550                    // Reserves capacity for new particles
3551                    let new_particle_count = particles_per_length * heartbeats as usize;
3552                    self.particles.reserve(new_particle_count);
3553
3554                    for i in 0..particles_per_length {
3555                        let angle = theta + dtheta * i as f32;
3556                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
3557                        for j in 0..heartbeats {
3558                            // Sub tick dt
3559                            let dt = (j as f32 / heartbeats as f32) * dt;
3560                            let distance = distance + speed * dt;
3561                            let pos1 = pos + distance * direction - Vec3::unit_z();
3562                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
3563                            let time = time + dt as f64;
3564
3565                            self.particles.push(Particle::new_directed(
3566                                Duration::from_secs_f32(0.5),
3567                                time,
3568                                ParticleMode::Ink,
3569                                pos1,
3570                                pos2,
3571                                scene_data,
3572                            ));
3573                        }
3574                    }
3575                },
3576                FrontendSpecifier::IceSpikes | FrontendSpecifier::Ice => {
3577                    // 1 / 3 the size of terrain voxel
3578                    let scale = 1.0 / 3.0;
3579                    let scaled_distance = distance / scale;
3580                    let scaled_speed = speed / scale;
3581
3582                    // 1 particle per scaled unit length of arc
3583                    let particles_per_length = (0.25 * arc_length / scale) as usize;
3584                    let dtheta = radians / particles_per_length as f32;
3585                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
3586                    // lower duration = more particles
3587                    let heartbeats = self
3588                        .scheduler
3589                        .heartbeats(Duration::from_secs_f32(3.0 / scaled_speed));
3590
3591                    // Reserves capacity for new particles
3592                    let new_particle_count = particles_per_length * heartbeats as usize;
3593                    self.particles.reserve(new_particle_count);
3594                    // higher wave when wave doesn't require ground
3595                    let wave = if matches!(shockwave.properties.dodgeable, Dodgeable::Jump) {
3596                        0.5
3597                    } else {
3598                        8.0
3599                    };
3600                    // Used to make taller the further out spikes are
3601                    let height_scale = wave + 1.5 * percent;
3602                    for i in 0..particles_per_length {
3603                        let angle = theta + dtheta * i as f32;
3604                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
3605                        for j in 0..heartbeats {
3606                            // Sub tick dt
3607                            let dt = (j as f32 / heartbeats as f32) * dt;
3608                            let scaled_distance = scaled_distance + scaled_speed * dt;
3609                            let mut pos1 = pos + (scaled_distance * direction).floor() * scale;
3610                            let time = time + dt as f64;
3611
3612                            // Arbitrary number chosen that is large enough to be able to accurately
3613                            // place particles most of the time, but also not too big as to make ray
3614                            // be too large (for performance reasons)
3615                            let half_ray_length = 10.0;
3616                            let mut last_air = false;
3617                            // TODO: Optimize ray to only be cast at most once per block per tick if
3618                            // it becomes an issue.
3619                            // From imbris:
3620                            //      each ray is ~2 us
3621                            //      at 30 FPS, it peaked at 113 rays in a tick
3622                            //      total time was 240 us (although potentially half that is
3623                            //          overhead from the profiling of each ray)
3624                            let _ = terrain
3625                                .ray(
3626                                    pos1 + Vec3::unit_z() * half_ray_length,
3627                                    pos1 - Vec3::unit_z() * half_ray_length,
3628                                )
3629                                .for_each(|block: &Block, pos: Vec3<i32>| {
3630                                    if block.is_solid() && block.get_sprite().is_none() {
3631                                        if last_air {
3632                                            pos1 = pos1.xy().with_z(pos.z as f32 + 1.0);
3633                                            last_air = false;
3634                                        }
3635                                    } else {
3636                                        last_air = true;
3637                                    }
3638                                })
3639                                .cast();
3640
3641                            let get_positions = |a| {
3642                                let pos1 = match a {
3643                                    2 => pos1 + Vec3::unit_x() * scale,
3644                                    3 => pos1 - Vec3::unit_x() * scale,
3645                                    4 => pos1 + Vec3::unit_y() * scale,
3646                                    5 => pos1 - Vec3::unit_y() * scale,
3647                                    _ => pos1,
3648                                };
3649                                let pos2 = if a == 1 {
3650                                    pos1 + Vec3::unit_z() * 5.0 * height_scale
3651                                } else {
3652                                    pos1 + Vec3::unit_z() * 1.0 * height_scale
3653                                };
3654                                (pos1, pos2)
3655                            };
3656
3657                            for a in 1..=5 {
3658                                let (pos1, pos2) = get_positions(a);
3659                                self.particles.push(Particle::new_directed(
3660                                    Duration::from_secs_f32(0.5),
3661                                    time,
3662                                    ParticleMode::IceSpikes,
3663                                    pos1,
3664                                    pos2,
3665                                    scene_data,
3666                                ));
3667                            }
3668                        }
3669                    }
3670                },
3671            }
3672        }
3673    }
3674
3675    fn upload_particles(&mut self, renderer: &mut Renderer) {
3676        prof_span!("ParticleMgr::upload_particles");
3677        let all_cpu_instances = self
3678            .particles
3679            .iter()
3680            .map(|p| p.instance)
3681            .collect::<Vec<ParticleInstance>>();
3682
3683        // TODO: optimise buffer writes
3684        let gpu_instances = renderer.create_instances(&all_cpu_instances);
3685
3686        self.instances = gpu_instances;
3687    }
3688
3689    pub fn render<'a>(&'a self, drawer: &mut ParticleDrawer<'_, 'a>, scene_data: &SceneData) {
3690        prof_span!("ParticleMgr::render");
3691        if scene_data.particles_enabled {
3692            let model = &self
3693                .model_cache
3694                .get(DEFAULT_MODEL_KEY)
3695                .expect("Expected particle model in cache");
3696
3697            drawer.draw(model, &self.instances);
3698        }
3699    }
3700
3701    pub fn particle_count(&self) -> usize { self.instances.count() }
3702
3703    pub fn particle_count_visible(&self) -> usize { self.instances.count() }
3704}
3705
3706fn default_instances(renderer: &mut Renderer) -> Instances<ParticleInstance> {
3707    let empty_vec = Vec::new();
3708
3709    renderer.create_instances(&empty_vec)
3710}
3711
3712const DEFAULT_MODEL_KEY: &str = "voxygen.voxel.particle";
3713
3714fn default_cache(renderer: &mut Renderer) -> HashMap<&'static str, Model<ParticleVertex>> {
3715    let mut model_cache = HashMap::new();
3716
3717    model_cache.entry(DEFAULT_MODEL_KEY).or_insert_with(|| {
3718        let vox = DotVox::load_expect(DEFAULT_MODEL_KEY);
3719
3720        // NOTE: If we add texturing we may eventually try to share it among all
3721        // particles in a single atlas.
3722        let max_texture_size = renderer.max_texture_size();
3723        let max_size = Vec2::from(u16::try_from(max_texture_size).unwrap_or(u16::MAX));
3724        let mut greedy = GreedyMesh::new(max_size, crate::mesh::greedy::general_config());
3725
3726        let segment = Segment::from_vox_model_index(&vox.read().0, 0);
3727        let segment_size = segment.size();
3728        let mut mesh = generate_mesh_base_vol_particle(segment, &mut greedy).0;
3729        // Center particle vertices around origin
3730        for vert in mesh.vertices_mut() {
3731            vert.pos[0] -= segment_size.x as f32 / 2.0;
3732            vert.pos[1] -= segment_size.y as f32 / 2.0;
3733            vert.pos[2] -= segment_size.z as f32 / 2.0;
3734        }
3735
3736        // NOTE: Ignoring coloring / lighting for now.
3737        drop(greedy);
3738
3739        renderer
3740            .create_model(&mesh)
3741            .expect("Failed to create particle model")
3742    });
3743
3744    model_cache
3745}
3746
3747/// Accumulates heartbeats to be consumed on the next tick.
3748struct HeartbeatScheduler {
3749    /// Duration = Heartbeat Frequency/Intervals
3750    /// f64 = Last update time
3751    /// u8 = number of heartbeats since last update
3752    /// - if it's more frequent then tick rate, it could be 1 or more.
3753    /// - if it's less frequent then tick rate, it could be 1 or 0.
3754    /// - if it's equal to the tick rate, it could be between 2 and 0, due to
3755    ///   delta time variance etc.
3756    timers: HashMap<Duration, (f64, u8)>,
3757
3758    last_known_time: f64,
3759}
3760
3761impl HeartbeatScheduler {
3762    pub fn new() -> Self {
3763        HeartbeatScheduler {
3764            timers: HashMap::new(),
3765            last_known_time: 0.0,
3766        }
3767    }
3768
3769    /// updates the last elapsed times and elapsed counts
3770    /// this should be called once, and only once per tick.
3771    pub fn maintain(&mut self, now: f64) {
3772        prof_span!("HeartbeatScheduler::maintain");
3773        self.last_known_time = now;
3774
3775        for (frequency, (last_update, heartbeats)) in self.timers.iter_mut() {
3776            // the number of frequency cycles that have occurred.
3777            let total_heartbeats = (now - *last_update) / frequency.as_secs_f64();
3778
3779            // exclude partial frequency cycles
3780            let full_heartbeats = total_heartbeats.floor();
3781
3782            *heartbeats = full_heartbeats as u8;
3783
3784            // the remaining partial frequency cycle, as a decimal.
3785            let partial_heartbeat = total_heartbeats - full_heartbeats;
3786
3787            // the remaining partial frequency cycle, as a unit of time(f64).
3788            let partial_heartbeat_as_time = frequency.mul_f64(partial_heartbeat).as_secs_f64();
3789
3790            // now minus the left over heart beat count precision as seconds,
3791            // Note: we want to preserve incomplete heartbeats, and roll them
3792            // over into the next update.
3793            *last_update = now - partial_heartbeat_as_time;
3794        }
3795    }
3796
3797    /// returns the number of times this duration has elapsed since the last
3798    /// tick:
3799    ///   - if it's more frequent then tick rate, it could be 1 or more.
3800    ///   - if it's less frequent then tick rate, it could be 1 or 0.
3801    ///   - if it's equal to the tick rate, it could be between 2 and 0, due to
3802    ///     delta time variance.
3803    pub fn heartbeats(&mut self, frequency: Duration) -> u8 {
3804        prof_span!("HeartbeatScheduler::heartbeats");
3805        let last_known_time = self.last_known_time;
3806
3807        self.timers
3808            .entry(frequency)
3809            .or_insert_with(|| (last_known_time, 0))
3810            .1
3811    }
3812
3813    pub fn clear(&mut self) { self.timers.clear() }
3814}
3815
3816#[derive(Clone, Copy)]
3817struct Particle {
3818    alive_until: f64, // created_at + lifespan
3819    instance: ParticleInstance,
3820}
3821
3822impl Particle {
3823    fn new(
3824        lifespan: Duration,
3825        time: f64,
3826        mode: ParticleMode,
3827        pos: Vec3<f32>,
3828        scene_data: &SceneData,
3829    ) -> Self {
3830        Particle {
3831            alive_until: time + lifespan.as_secs_f64(),
3832            instance: ParticleInstance::new(
3833                time,
3834                lifespan.as_secs_f32(),
3835                mode,
3836                pos,
3837                scene_data.wind_vel,
3838            ),
3839        }
3840    }
3841
3842    fn new_directed(
3843        lifespan: Duration,
3844        time: f64,
3845        mode: ParticleMode,
3846        pos1: Vec3<f32>,
3847        pos2: Vec3<f32>,
3848        scene_data: &SceneData,
3849    ) -> Self {
3850        Particle {
3851            alive_until: time + lifespan.as_secs_f64(),
3852            instance: ParticleInstance::new_directed(
3853                time,
3854                lifespan.as_secs_f32(),
3855                mode,
3856                pos1,
3857                pos2,
3858                scene_data.wind_vel,
3859            ),
3860        }
3861    }
3862
3863    fn new_directed_with_collision(
3864        lifespan: Duration,
3865        time: f64,
3866        mode: ParticleMode,
3867        pos1: Vec3<f32>,
3868        pos2: Vec3<f32>,
3869        scene_data: &SceneData,
3870        distance: impl Fn(Vec3<f32>, Vec3<f32>) -> f32,
3871    ) -> Self {
3872        let dir = pos2 - pos1;
3873        let end_distance = pos1.distance(pos2);
3874        let (end_pos, lifespawn) = if end_distance > 0.1 {
3875            let ratio = distance(pos1, pos2) / end_distance;
3876            (pos1 + ratio * dir, lifespan.mul_f32(ratio))
3877        } else {
3878            (pos2, lifespan)
3879        };
3880
3881        Self::new_directed(lifespawn, time, mode, pos1, end_pos, scene_data)
3882    }
3883}