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, trail::TOOL_TRAIL_MANIFEST},
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::PyroclasmCharge { .. } => {},
498            Outcome::FlamethrowerCharge { pos }
499            | Outcome::FuseCharge { pos }
500            | Outcome::FireBreathCharge { pos } => {
501                self.particles.push(Particle::new_directed(
502                    Duration::from_secs_f32(rng.random_range(0.1..0.2)),
503                    time,
504                    ParticleMode::CampfireFire,
505                    *pos + Vec3::new(0.0, 0.0, 1.2),
506                    *pos + Vec3::new(0.0, 0.0, 1.5 + 0.5 * rng.random_range(0.0..0.2)),
507                    scene_data,
508                ));
509            },
510            Outcome::TerracottaStatueCharge { pos } => {
511                self.particles.push(Particle::new_directed(
512                    Duration::from_secs_f32(rng.random_range(0.1..0.2)),
513                    time,
514                    ParticleMode::FireworkYellow,
515                    *pos + Vec3::new(0.0, 0.0, 4.0),
516                    *pos + Vec3::new(0.0, 0.0, 5.0 + 0.5 * rng.random_range(0.3..0.8)),
517                    scene_data,
518                ));
519            },
520            Outcome::Death { pos, .. } => {
521                self.particles.resize_with(self.particles.len() + 40, || {
522                    Particle::new(
523                        Duration::from_millis(400 + rng.random_range(0..100)),
524                        time,
525                        ParticleMode::Death,
526                        *pos + Vec3::unit_z()
527                            + Vec3::<f32>::zero()
528                                .map(|_| rng.random_range(-0.1..0.1))
529                                .normalized(),
530                        scene_data,
531                    )
532                });
533            },
534            Outcome::GroundDig { pos, .. } => {
535                self.particles.resize_with(self.particles.len() + 12, || {
536                    Particle::new(
537                        Duration::from_millis(200),
538                        time,
539                        ParticleMode::BigShrapnel,
540                        *pos,
541                        scene_data,
542                    )
543                });
544            },
545            Outcome::TeleportedByPortal { pos, .. } => {
546                self.particles.resize_with(self.particles.len() + 80, || {
547                    Particle::new_directed(
548                        Duration::from_millis(500),
549                        time,
550                        ParticleMode::CultistFlame,
551                        *pos,
552                        pos + Vec3::unit_z()
553                            + Vec3::zero()
554                                .map(|_: f32| rng.random_range(-0.1..0.1))
555                                .normalized()
556                                * 2.0,
557                        scene_data,
558                    )
559                });
560            },
561            Outcome::ClayGolemDash { pos, .. } => {
562                self.particles.resize_with(self.particles.len() + 100, || {
563                    Particle::new(
564                        Duration::from_millis(1000),
565                        time,
566                        ParticleMode::ClayShrapnel,
567                        *pos,
568                        scene_data,
569                    )
570                });
571            },
572            Outcome::HeadLost { uid, head } => {
573                if let Some(entity) = scene_data
574                    .state
575                    .ecs()
576                    .read_resource::<IdMaps>()
577                    .uid_entity(*uid)
578                    && let Some(pos) = scene_data.state.read_component_copied::<Pos>(entity)
579                {
580                    let heads = figure_mgr.get_heads(scene_data, entity);
581                    let head_pos = pos.0 + heads.get(*head).copied().unwrap_or_default();
582
583                    self.particles.resize_with(self.particles.len() + 40, || {
584                        Particle::new(
585                            Duration::from_millis(1000),
586                            time,
587                            ParticleMode::Death,
588                            head_pos
589                                + Vec3::<f32>::zero()
590                                    .map(|_| rng.random_range(-0.1..0.1))
591                                    .normalized(),
592                            scene_data,
593                        )
594                    });
595                };
596            },
597            Outcome::Splash {
598                vel,
599                pos,
600                mass,
601                kind,
602            } => {
603                let mode = match kind {
604                    comp::fluid_dynamics::LiquidKind::Water => ParticleMode::WaterFoam,
605                    comp::fluid_dynamics::LiquidKind::Lava => ParticleMode::CampfireFire,
606                };
607                let magnitude = (-vel.z).max(0.0);
608                let energy = mass * magnitude;
609                if energy > 0.0 {
610                    let count = ((0.6 * energy.sqrt()).ceil() as usize).min(500);
611                    let mut i = 0;
612                    let r = 0.5 / count as f32;
613                    self.particles
614                        .resize_with(self.particles.len() + count, || {
615                            let t = i as f32 / count as f32 + rng.random_range(-r..=r);
616                            i += 1;
617                            let angle = t * TAU;
618                            let s = angle.sin();
619                            let c = angle.cos();
620                            let energy = energy
621                                * f32::abs(
622                                    rng.random_range(0.0..1.0) + rng.random_range(0.0..1.0) - 0.5,
623                                );
624
625                            let axis = -Vec3::unit_z();
626                            let plane = Vec3::new(c, s, 0.0);
627
628                            let pos = *pos + plane * rng.random_range(0.0..0.5);
629
630                            let energy = energy.sqrt() * 0.5;
631
632                            let dir = plane * (1.0 + energy) - axis * energy;
633
634                            Particle::new_directed(
635                                Duration::from_millis(4000),
636                                time,
637                                mode,
638                                pos,
639                                pos + dir,
640                                scene_data,
641                            )
642                        });
643                }
644            },
645            Outcome::Transformation { pos } => {
646                self.particles.resize_with(self.particles.len() + 100, || {
647                    Particle::new(
648                        Duration::from_millis(1400),
649                        time,
650                        ParticleMode::Transformation,
651                        *pos,
652                        scene_data,
653                    )
654                });
655            },
656            Outcome::FirePillarIndicator { pos, radius } => {
657                self.particles.resize_with(
658                    self.particles.len() + radius.powi(2) as usize / 2,
659                    || {
660                        Particle::new_directed(
661                            Duration::from_millis(500),
662                            time,
663                            ParticleMode::FirePillarIndicator,
664                            *pos + 0.2 * Vec3::<f32>::unit_z(),
665                            // unit_x choosen arbitrarily, particle shader only uses the distance
666                            // between pos1 and pos2, not the actual position
667                            *pos + 0.2 * Vec3::<f32>::unit_z() + *radius * Vec3::unit_x(),
668                            scene_data,
669                        )
670                    },
671                );
672            },
673            Outcome::ProjectileShot { .. }
674            | Outcome::Beam { .. }
675            | Outcome::ExpChange { .. }
676            | Outcome::SkillPointGain { .. }
677            | Outcome::ComboChange { .. }
678            | Outcome::HealthChange { .. }
679            | Outcome::PoiseChange { .. }
680            | Outcome::Utterance { .. }
681            | Outcome::IceSpikes { .. }
682            | Outcome::IceCrack { .. }
683            | Outcome::Glider { .. }
684            | Outcome::Whoosh { .. }
685            | Outcome::Swoosh { .. }
686            | Outcome::Slash { .. }
687            | Outcome::Bleep { .. }
688            | Outcome::Charge { .. }
689            | Outcome::Steam { .. }
690            | Outcome::FireShockwave { .. }
691            | Outcome::PortalActivated { .. }
692            | Outcome::FromTheAshes { .. }
693            | Outcome::LaserBeam { .. } => {},
694        }
695    }
696
697    pub fn maintain(
698        &mut self,
699        renderer: &mut Renderer,
700        scene_data: &SceneData,
701        terrain: &Terrain<TerrainChunk>,
702        figure_mgr: &FigureMgr,
703        lights: &mut Vec<Light>,
704    ) {
705        prof_span!("ParticleMgr::maintain");
706        if scene_data.particles_enabled {
707            // update timings
708            self.scheduler.maintain(scene_data.state.get_time());
709
710            // remove dead Particle
711            self.particles
712                .retain(|p| p.alive_until > scene_data.state.get_time());
713
714            // add new Particle
715            self.maintain_equipment_particles(scene_data, figure_mgr);
716            self.maintain_body_particles(scene_data);
717            self.maintain_char_state_particles(scene_data, figure_mgr);
718            self.maintain_beam_particles(scene_data, lights);
719            self.maintain_block_particles(scene_data, terrain, figure_mgr);
720            self.maintain_shockwave_particles(scene_data);
721            self.maintain_aura_particles(scene_data);
722            self.maintain_buff_particles(scene_data);
723            self.maintain_fluid_particles(scene_data);
724            self.maintain_stance_particles(scene_data);
725            self.maintain_marker_particles(scene_data);
726            self.maintain_arcing_particles(scene_data);
727            self.maintain_pool_particles(scene_data);
728
729            self.upload_particles(renderer);
730        } else {
731            // remove all particle lifespans
732            if !self.particles.is_empty() {
733                self.particles.clear();
734                self.upload_particles(renderer);
735            }
736
737            // remove all timings
738            self.scheduler.clear();
739        }
740    }
741
742    fn maintain_equipment_particles(&mut self, scene_data: &SceneData, figure_mgr: &FigureMgr) {
743        prof_span!("ParticleMgr::maintain_armor_particles");
744        let ecs = scene_data.state.ecs();
745
746        for (entity, body, scale, inv, physics) in (
747            &ecs.entities(),
748            &ecs.read_storage::<Body>(),
749            ecs.read_storage::<Scale>().maybe(),
750            &ecs.read_storage::<Inventory>(),
751            &ecs.read_storage::<PhysicsState>(),
752        )
753            .join()
754        {
755            for item in inv.equipped_items() {
756                if let ItemDefinitionId::Simple(str) = item.item_definition_id() {
757                    match &*str {
758                        "common.items.armor.misc.head.pipe" => self.maintain_pipe_particles(
759                            scene_data, figure_mgr, entity, body, scale, physics,
760                        ),
761                        "common.items.npc_weapons.sword.gigas_fire_sword" => {
762                            if let Some(trail_points) = TOOL_TRAIL_MANIFEST.get(item) {
763                                self.maintain_gigas_fire_sword_particles(
764                                    scene_data,
765                                    figure_mgr,
766                                    trail_points,
767                                    entity,
768                                )
769                            }
770                        },
771                        _ => {},
772                    }
773                }
774            }
775        }
776    }
777
778    fn maintain_pipe_particles(
779        &mut self,
780        scene_data: &SceneData,
781        figure_mgr: &FigureMgr,
782        entity: Entity,
783        body: &Body,
784        scale: Option<&Scale>,
785        physics: &PhysicsState,
786    ) {
787        prof_span!("ParticleMgr::maintain_pipe_particles");
788        if physics
789            .in_liquid()
790            .is_none_or(|depth| body.eye_height(scale.map_or(1.0, |scale| scale.0)) > depth)
791        {
792            let Body::Humanoid(body) = body else {
793                return;
794            };
795            let Some(state) = figure_mgr.states.character_states.get(&entity) else {
796                return;
797            };
798
799            // TODO: compute offsets instead of hardcoding
800            use body::humanoid::{BodyType::*, Species::*};
801            let pipe_offset = match (body.species, body.body_type) {
802                (Orc, Male) => Vec3::new(5.5, 10.5, 0.0),
803                (Orc, Female) => Vec3::new(4.5, 10.0, -2.5),
804                (Human, Male) => Vec3::new(4.5, 12.0, -3.0),
805                (Human, Female) => Vec3::new(4.5, 11.5, -3.0),
806                (Elf, Male) => Vec3::new(4.5, 12.0, -3.0),
807                (Elf, Female) => Vec3::new(4.5, 9.5, -3.0),
808                (Dwarf, Male) => Vec3::new(4.5, 11.0, -4.0),
809                (Dwarf, Female) => Vec3::new(4.5, 11.0, -3.0),
810                (Draugr, Male) => Vec3::new(4.5, 9.5, -0.75),
811                (Draugr, Female) => Vec3::new(4.5, 9.5, -2.0),
812                (Danari, Male) => Vec3::new(4.5, 10.5, -1.25),
813                (Danari, Female) => Vec3::new(4.5, 10.5, -1.25),
814            };
815
816            let mut rng = rand::rng();
817            let dt = scene_data.state.get_delta_time();
818            if rng.random_bool((0.25 * dt as f64).min(1.0)) {
819                let time = scene_data.state.get_time();
820                self.particles.resize_with(self.particles.len() + 10, || {
821                    Particle::new(
822                        Duration::from_millis(1500),
823                        time,
824                        ParticleMode::PipeSmoke,
825                        state.wpos_of(state.computed_skeleton.head.mul_point(pipe_offset)),
826                        scene_data,
827                    )
828                });
829            }
830        }
831    }
832
833    fn maintain_gigas_fire_sword_particles(
834        &mut self,
835        scene_data: &SceneData,
836        figure_mgr: &FigureMgr,
837        trail_points: (Vec3<f32>, Vec3<f32>),
838        entity: Entity,
839    ) {
840        prof_span!("ParticleMgr::maintain_gigas_fire_sword_particles");
841        let Some(state) = figure_mgr.states.biped_large_states.get(&entity) else {
842            return;
843        };
844
845        let mut rng = rand::rng();
846        let time = scene_data.state.get_time();
847        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(10)) {
848            let blade_offset = trail_points.0
849                + rng.random_range(0.0..1.0_f32) * (trail_points.1 - trail_points.0)
850                + rng.random_range(-5.0..5.0) * Vec3::<f32>::unit_y()
851                + rng.random_range(-1.0..1.0) * Vec3::<f32>::unit_x();
852
853            let start_pos = state.wpos_of(state.computed_skeleton.main.mul_point(blade_offset));
854            let end_pos = start_pos + rng.random_range(1.0..2.0) * Vec3::<f32>::unit_z();
855
856            self.particles.push(Particle::new_directed(
857                Duration::from_millis(500),
858                time,
859                ParticleMode::FlameThrower,
860                start_pos,
861                end_pos,
862                scene_data,
863            ));
864        }
865    }
866
867    fn maintain_fluid_particles(&mut self, scene_data: &SceneData) {
868        prof_span!("ParticleMgr::maintain_fluid_particles");
869        let ecs = scene_data.state.ecs();
870        for (pos, vel, collider) in (
871            &ecs.read_storage::<Pos>(),
872            &ecs.read_storage::<Vel>(),
873            &ecs.read_storage::<comp::Collider>(),
874        )
875            .join()
876        {
877            // Point particles (like arrows) travelling at high velocity in water create
878            // cavitation bubbles
879            const CAVITATION_SPEED: f32 = 20.0;
880            if matches!(collider, comp::Collider::Point)
881                && let speed = vel.0.magnitude()
882                && speed > CAVITATION_SPEED
883                && scene_data
884                    .state
885                    .terrain()
886                    // Offset reduces bubbles appearing above the water
887                    .get((pos.0 + Vec3::unit_z()).as_())
888                    .is_ok_and(|b| b.kind() == BlockKind::Water)
889            {
890                let mut rng = rand::rng();
891                let time = scene_data.state.get_time();
892                let dt = scene_data.state.get_delta_time();
893                for _ in 0..self
894                    .scheduler
895                    .heartbeats(Duration::from_millis(1000 / speed.min(500.0) as u64))
896                {
897                    self.particles.push(Particle::new(
898                        Duration::from_secs(1),
899                        time,
900                        ParticleMode::Bubble,
901                        pos.0.map(|e| e + rng.random_range(-0.1..0.1))
902                            - vel.0 * dt * rng.random::<f32>(),
903                        scene_data,
904                    ));
905                }
906            }
907        }
908    }
909
910    fn maintain_body_particles(&mut self, scene_data: &SceneData) {
911        prof_span!("ParticleMgr::maintain_body_particles");
912        let ecs = scene_data.state.ecs();
913        for (body, interpolated, vel) in (
914            &ecs.read_storage::<Body>(),
915            &ecs.read_storage::<Interpolated>(),
916            ecs.read_storage::<Vel>().maybe(),
917        )
918            .join()
919        {
920            match body {
921                Body::Object(object::Body::CampfireLit) => {
922                    self.maintain_campfirelit_particles(scene_data, interpolated.pos, vel)
923                },
924                Body::Object(object::Body::BarrelOrgan) => {
925                    self.maintain_barrel_organ_particles(scene_data, interpolated.pos, vel)
926                },
927                Body::Object(object::Body::BoltFire) => {
928                    self.maintain_boltfire_particles(scene_data, interpolated.pos, vel)
929                },
930                Body::Object(object::Body::NapalmShot) => {
931                    self.maintain_napalmshot_particles(scene_data, interpolated.pos, vel)
932                },
933                Body::Object(object::Body::BoltFireBig) => {
934                    self.maintain_boltfirebig_particles(scene_data, interpolated.pos, vel)
935                },
936                Body::Object(object::Body::FireRainDrop) => {
937                    self.maintain_fireraindrop_particles(scene_data, interpolated.pos, vel)
938                },
939                Body::Object(object::Body::BoltNature) => {
940                    self.maintain_boltnature_particles(scene_data, interpolated.pos, vel)
941                },
942                Body::Object(object::Body::Tornado) => {
943                    self.maintain_tornado_particles(scene_data, interpolated.pos)
944                },
945                Body::Object(object::Body::FieryTornado) => {
946                    self.maintain_fiery_tornado_particles(scene_data, interpolated.pos)
947                },
948                Body::Object(object::Body::Mine) => {
949                    self.maintain_mine_particles(scene_data, interpolated.pos)
950                },
951                Body::Object(
952                    object::Body::Bomb
953                    | object::Body::FireworkBlue
954                    | object::Body::FireworkGreen
955                    | object::Body::FireworkPurple
956                    | object::Body::FireworkRed
957                    | object::Body::FireworkWhite
958                    | object::Body::FireworkYellow
959                    | object::Body::IronPikeBomb,
960                ) => self.maintain_bomb_particles(scene_data, interpolated.pos, vel),
961                Body::Object(object::Body::PortalActive) => {
962                    self.maintain_active_portal_particles(scene_data, interpolated.pos)
963                },
964                Body::Object(object::Body::Portal) => {
965                    self.maintain_portal_particles(scene_data, interpolated.pos)
966                },
967                Body::Object(object::Body::NapalmPool) => {
968                    self.maintain_napalmpool_particles(scene_data, interpolated.pos)
969                },
970                Body::Object(object::Body::PyroclasmBolt) => {
971                    self.maintain_pyroclasm_bolt_particles(scene_data, interpolated.pos, vel)
972                },
973                Body::BipedLarge(biped_large::Body {
974                    species: biped_large::Species::Gigasfire,
975                    ..
976                }) => self.maintain_fire_gigas_particles(scene_data, interpolated.pos),
977                _ => {},
978            }
979        }
980    }
981
982    fn maintain_pyroclasm_bolt_particles(
983        &mut self,
984        scene_data: &SceneData,
985        pos: Vec3<f32>,
986        vel: Option<&Vel>,
987    ) {
988        let time = scene_data.state.get_time();
989        let mut rng = rand::rng();
990        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
991
992        let fwd = vel
993            .map(|v| v.0)
994            .unwrap_or(Vec3::unit_y())
995            .try_normalized()
996            .unwrap_or(Vec3::unit_y());
997
998        self.particles
999            .resize_with(self.particles.len() + usize::from(heartbeats) * 6, || {
1000                let spread = Vec3::new(
1001                    rng.random_range(-0.2..0.2_f32),
1002                    rng.random_range(-0.2..0.2_f32),
1003                    rng.random_range(-0.2..0.2_f32),
1004                );
1005                let spawn = pos + spread;
1006                let trail_dir = (fwd - Vec3::unit_z() * 0.4).try_normalized().unwrap_or(fwd);
1007                let tail = spawn - trail_dir * rng.random_range(0.5..2.5_f32);
1008
1009                Particle::new_directed(
1010                    Duration::from_millis(150),
1011                    time,
1012                    ParticleMode::FlameThrower,
1013                    spawn,
1014                    tail,
1015                    scene_data,
1016                )
1017            });
1018    }
1019
1020    fn maintain_pyroclasm_charge_particles(
1021        &mut self,
1022        scene_data: &SceneData,
1023        pos: Vec3<f32>,
1024        progress: f32,
1025        height: f32,
1026        radius: f32,
1027    ) {
1028        let time = scene_data.state.get_time();
1029        let mut rng = rand::rng();
1030        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(8));
1031
1032        //[Height Modifier, Radius Modifier, Direction of rotation] for each circle of
1033        //[Height latitude.
1034        const LATITUDE_MODIFIERS: [(f32, f32, f32); 5] = [
1035            (-0.85, 0.50, 1.0),
1036            (-0.50, 0.80, -1.0),
1037            (0.00, 0.95, 1.0),
1038            (0.50, 0.80, -1.0),
1039            (0.85, 0.50, 1.0),
1040        ];
1041
1042        let scale = 1.0 - progress;
1043        let center_z = height;
1044
1045        for &(sin_lat, cos_lat, dir) in &LATITUDE_MODIFIERS {
1046            //Sphere implodes
1047            let r = radius * cos_lat * scale;
1048            let z = center_z + radius * sin_lat * (1.0 - progress);
1049
1050            //Bigger spheres have more/longer lasting particles so they do not look sparse.
1051            let particle_density_hb: usize = (radius * 4.0).floor() as usize;
1052            let particle_density_lifespan: u64 = (radius * 75.0).floor() as u64;
1053
1054            self.particles.resize_with(
1055                self.particles.len() + usize::from(heartbeats) * particle_density_hb,
1056                || {
1057                    let theta = rng.random_range(0.0..TAU);
1058                    let spawn = pos + Vec3::new(theta.cos() * r, theta.sin() * r, z);
1059                    let tangent = Vec3::new(-theta.sin() * dir, theta.cos() * dir, 0.06 * dir);
1060                    let end_pos = spawn + tangent * 0.55;
1061
1062                    let blue_prob = (progress / 0.9).clamp(0.0, 1.0) as f64;
1063
1064                    let mode = if rng.random_bool(1.0 - blue_prob) {
1065                        ParticleMode::FlameThrower
1066                    } else {
1067                        ParticleMode::FlamethrowerBlue
1068                    };
1069
1070                    //No wind interaction
1071                    let lifespan: Duration = Duration::from_millis(particle_density_lifespan);
1072                    Particle {
1073                        alive_until: time + lifespan.as_secs_f64(),
1074                        instance: ParticleInstance::new_directed(
1075                            time,
1076                            lifespan.as_secs_f32(),
1077                            mode,
1078                            spawn,
1079                            end_pos,
1080                            Vec2::zero(),
1081                        ),
1082                    }
1083                },
1084            );
1085        }
1086    }
1087
1088    fn maintain_napalmshot_particles(
1089        &mut self,
1090        scene_data: &SceneData,
1091        pos: Vec3<f32>,
1092        vel: Option<&Vel>,
1093    ) {
1094        prof_span!("ParticleMgr::maintain_napalmshot_particles");
1095        let time = scene_data.state.get_time();
1096        let dt = scene_data.state.get_delta_time();
1097        let mut rng = rand::rng();
1098
1099        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(20)) {
1100            self.particles.push(Particle::new(
1101                Duration::from_millis(150),
1102                time,
1103                ParticleMode::GunPowderSpark,
1104                pos.map(|e| e + rng.random_range(-0.2..0.2))
1105                    + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1106                scene_data,
1107            ));
1108        }
1109    }
1110
1111    fn maintain_napalmpool_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1112        prof_span!("ParticleMgr::maintain_napalmpool_particles");
1113        let time = scene_data.state.get_time();
1114        let mut rng = rand::rng();
1115
1116        // sparks
1117        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(50)) {
1118            self.particles.push(Particle::new(
1119                Duration::from_millis(1500),
1120                time,
1121                ParticleMode::GunPowderSpark,
1122                pos.map(|e| e + rng.random_range(-0.4..0.4)),
1123                scene_data,
1124            ));
1125        }
1126
1127        // smoke
1128        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(50)) {
1129            self.particles.push(Particle::new(
1130                Duration::from_millis(1500),
1131                time,
1132                ParticleMode::CampfireSmoke,
1133                pos.map(|e| e + rng.random_range(-0.4..0.4)),
1134                scene_data,
1135            ));
1136        }
1137    }
1138
1139    fn maintain_fire_gigas_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1140        let time = scene_data.state.get_time();
1141        let mut rng = rand::rng();
1142
1143        if rng.random_bool(0.05) {
1144            self.particles.resize_with(self.particles.len() + 1, || {
1145                let rand_offset = Vec3::new(
1146                    rng.random_range(-5.0..5.0),
1147                    rng.random_range(-5.0..5.0),
1148                    rng.random_range(7.0..15.0),
1149                );
1150
1151                Particle::new(
1152                    Duration::from_secs_f32(30.0),
1153                    time,
1154                    ParticleMode::FireGigasAsh,
1155                    pos + rand_offset,
1156                    scene_data,
1157                )
1158            });
1159        }
1160    }
1161
1162    fn maintain_hydra_tail_swipe_particles(
1163        &mut self,
1164        scene_data: &SceneData,
1165        figure_mgr: &FigureMgr,
1166        entity: Entity,
1167        pos: Vec3<f32>,
1168        body: &Body,
1169        state: &CharacterState,
1170        inventory: Option<&Inventory>,
1171    ) {
1172        let Some(ability_id) = state
1173            .ability_info()
1174            .and_then(|info| info.ability.map(|a| a.ability_id(Some(state), inventory)))
1175        else {
1176            return;
1177        };
1178
1179        if ability_id != Some("common.abilities.custom.hydra.tail_swipe") {
1180            return;
1181        }
1182
1183        let Some(stage_section) = state.stage_section() else {
1184            return;
1185        };
1186
1187        let particle_count = match stage_section {
1188            StageSection::Charge => 1,
1189            StageSection::Action => 10,
1190            _ => return,
1191        };
1192
1193        let Some(skeleton) = figure_mgr
1194            .states
1195            .quadruped_low_states
1196            .get(&entity)
1197            .map(|state| &state.computed_skeleton)
1198        else {
1199            return;
1200        };
1201        let Some(attr) = anim::quadruped_low::SkeletonAttr::try_from(body).ok() else {
1202            return;
1203        };
1204
1205        let start = (skeleton.tail_front * Vec4::unit_w()).xyz();
1206        let end = (skeleton.tail_rear * Vec4::new(0.0, -attr.tail_rear_length, 0.0, 1.0)).xyz();
1207
1208        let start = pos + start;
1209        let end = pos + end;
1210
1211        let time = scene_data.state.get_time();
1212        let mut rng = rand::rng();
1213
1214        self.particles.resize_with(
1215            self.particles.len()
1216                + particle_count * self.scheduler.heartbeats(Duration::from_millis(33)) as usize,
1217            || {
1218                let t = rng.random_range(0.0..1.0);
1219                let p = start * t + end * (1.0 - t) - Vec3::new(0.0, 0.0, 0.5);
1220
1221                Particle::new(
1222                    Duration::from_millis(500),
1223                    time,
1224                    ParticleMode::GroundShockwave,
1225                    p,
1226                    scene_data,
1227                )
1228            },
1229        );
1230    }
1231
1232    fn maintain_campfirelit_particles(
1233        &mut self,
1234        scene_data: &SceneData,
1235        pos: Vec3<f32>,
1236        vel: Option<&Vel>,
1237    ) {
1238        prof_span!("ParticleMgr::maintain_campfirelit_particles");
1239        let time = scene_data.state.get_time();
1240        let dt = scene_data.state.get_delta_time();
1241        let mut rng = rand::rng();
1242
1243        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(25)) {
1244            self.particles.push(Particle::new(
1245                Duration::from_millis(800),
1246                time,
1247                ParticleMode::CampfireFire,
1248                pos + Vec2::broadcast(())
1249                    .map(|_| rand::rng().random_range(-0.3..0.3))
1250                    .with_z(0.1),
1251                scene_data,
1252            ));
1253        }
1254
1255        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(50)) {
1256            self.particles.push(Particle::new(
1257                Duration::from_secs(10),
1258                time,
1259                ParticleMode::CampfireSmoke,
1260                pos.map(|e| e + rand::rng().random_range(-0.25..0.25))
1261                    + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1262                scene_data,
1263            ));
1264        }
1265    }
1266
1267    fn maintain_barrel_organ_particles(
1268        &mut self,
1269        scene_data: &SceneData,
1270        pos: Vec3<f32>,
1271        vel: Option<&Vel>,
1272    ) {
1273        prof_span!("ParticleMgr::maintain_barrel_organ_particles");
1274        let time = scene_data.state.get_time();
1275        let dt = scene_data.state.get_delta_time();
1276        let mut rng = rand::rng();
1277
1278        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(20)) {
1279            self.particles.push(Particle::new(
1280                Duration::from_millis(250),
1281                time,
1282                ParticleMode::BarrelOrgan,
1283                pos,
1284                scene_data,
1285            ));
1286
1287            self.particles.push(Particle::new(
1288                Duration::from_secs(10),
1289                time,
1290                ParticleMode::BarrelOrgan,
1291                pos.map(|e| e + rand::rng().random_range(-0.25..0.25))
1292                    + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1293                scene_data,
1294            ));
1295        }
1296    }
1297
1298    fn maintain_boltfire_particles(
1299        &mut self,
1300        scene_data: &SceneData,
1301        pos: Vec3<f32>,
1302        vel: Option<&Vel>,
1303    ) {
1304        prof_span!("ParticleMgr::maintain_boltfire_particles");
1305        let time = scene_data.state.get_time();
1306        let dt = scene_data.state.get_delta_time();
1307        let mut rng = rand::rng();
1308
1309        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(4)) {
1310            self.particles.push(Particle::new(
1311                Duration::from_millis(500),
1312                time,
1313                ParticleMode::CampfireFire,
1314                pos.map(|e| e + rng.random_range(-0.25..0.25))
1315                    + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1316                scene_data,
1317            ));
1318            self.particles.push(Particle::new(
1319                Duration::from_secs(1),
1320                time,
1321                ParticleMode::CampfireSmoke,
1322                pos.map(|e| e + rng.random_range(-0.25..0.25))
1323                    + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1324                scene_data,
1325            ));
1326        }
1327    }
1328
1329    fn maintain_boltfirebig_particles(
1330        &mut self,
1331        scene_data: &SceneData,
1332        pos: Vec3<f32>,
1333        vel: Option<&Vel>,
1334    ) {
1335        prof_span!("ParticleMgr::maintain_boltfirebig_particles");
1336        let time = scene_data.state.get_time();
1337        let dt = scene_data.state.get_delta_time();
1338        let mut rng = rand::rng();
1339
1340        // fire
1341        self.particles.resize_with(
1342            self.particles.len() + usize::from(self.scheduler.heartbeats(Duration::from_millis(2))),
1343            || {
1344                Particle::new(
1345                    Duration::from_millis(500),
1346                    time,
1347                    ParticleMode::CampfireFire,
1348                    pos.map(|e| e + rng.random_range(-0.25..0.25))
1349                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1350                    scene_data,
1351                )
1352            },
1353        );
1354
1355        // smoke
1356        self.particles.resize_with(
1357            self.particles.len() + usize::from(self.scheduler.heartbeats(Duration::from_millis(5))),
1358            || {
1359                Particle::new(
1360                    Duration::from_secs(2),
1361                    time,
1362                    ParticleMode::CampfireSmoke,
1363                    pos.map(|e| e + rng.random_range(-0.25..0.25))
1364                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt),
1365                    scene_data,
1366                )
1367            },
1368        );
1369    }
1370
1371    fn maintain_fireraindrop_particles(
1372        &mut self,
1373        scene_data: &SceneData,
1374        pos: Vec3<f32>,
1375        vel: Option<&Vel>,
1376    ) {
1377        prof_span!("ParticleMgr::maintain_fireraindrop_particles");
1378        let time = scene_data.state.get_time();
1379        let dt = scene_data.state.get_delta_time();
1380        let mut rng = rand::rng();
1381
1382        // trace
1383        self.particles.resize_with(
1384            self.particles.len()
1385                + usize::from(self.scheduler.heartbeats(Duration::from_millis(100))),
1386            || {
1387                Particle::new(
1388                    Duration::from_millis(300),
1389                    time,
1390                    ParticleMode::FieryDropletTrace,
1391                    pos.map(|e| e + rng.random_range(-0.25..0.25))
1392                        + Vec3::new(0.0, 0.0, 0.5)
1393                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1394                    scene_data,
1395                )
1396            },
1397        );
1398    }
1399
1400    fn maintain_boltnature_particles(
1401        &mut self,
1402        scene_data: &SceneData,
1403        pos: Vec3<f32>,
1404        vel: Option<&Vel>,
1405    ) {
1406        let time = scene_data.state.get_time();
1407        let dt = scene_data.state.get_delta_time();
1408        let mut rng = rand::rng();
1409
1410        // nature
1411        self.particles.resize_with(
1412            self.particles.len() + usize::from(self.scheduler.heartbeats(Duration::from_millis(2))),
1413            || {
1414                Particle::new(
1415                    Duration::from_millis(500),
1416                    time,
1417                    ParticleMode::CampfireSmoke,
1418                    pos.map(|e| e + rng.random_range(-0.25..0.25))
1419                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1420                    scene_data,
1421                )
1422            },
1423        );
1424    }
1425
1426    fn maintain_tornado_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1427        let time = scene_data.state.get_time();
1428        let mut rng = rand::rng();
1429
1430        // air particles
1431        self.particles.resize_with(
1432            self.particles.len() + usize::from(self.scheduler.heartbeats(Duration::from_millis(5))),
1433            || {
1434                Particle::new(
1435                    Duration::from_millis(1000),
1436                    time,
1437                    ParticleMode::Tornado,
1438                    pos.map(|e| e + rng.random_range(-0.25..0.25)),
1439                    scene_data,
1440                )
1441            },
1442        );
1443    }
1444
1445    fn maintain_fiery_tornado_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1446        let time = scene_data.state.get_time();
1447        let mut rng = rand::rng();
1448
1449        // air particles
1450        self.particles.resize_with(
1451            self.particles.len() + usize::from(self.scheduler.heartbeats(Duration::from_millis(5))),
1452            || {
1453                Particle::new(
1454                    Duration::from_millis(1000),
1455                    time,
1456                    ParticleMode::FieryTornado,
1457                    pos.map(|e| e + rng.random_range(-0.25..0.25)),
1458                    scene_data,
1459                )
1460            },
1461        );
1462    }
1463
1464    fn maintain_bomb_particles(
1465        &mut self,
1466        scene_data: &SceneData,
1467        pos: Vec3<f32>,
1468        vel: Option<&Vel>,
1469    ) {
1470        prof_span!("ParticleMgr::maintain_bomb_particles");
1471        let time = scene_data.state.get_time();
1472        let dt = scene_data.state.get_delta_time();
1473        let mut rng = rand::rng();
1474
1475        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(10)) {
1476            // sparks
1477            self.particles.push(Particle::new(
1478                Duration::from_millis(1500),
1479                time,
1480                ParticleMode::GunPowderSpark,
1481                pos,
1482                scene_data,
1483            ));
1484
1485            // smoke
1486            self.particles.push(Particle::new(
1487                Duration::from_secs(2),
1488                time,
1489                ParticleMode::CampfireSmoke,
1490                pos + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1491                scene_data,
1492            ));
1493        }
1494    }
1495
1496    fn maintain_active_portal_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1497        prof_span!("ParticleMgr::maintain_active_portal_particles");
1498
1499        let time = scene_data.state.get_time();
1500        let mut rng = rand::rng();
1501
1502        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(5)) {
1503            let outer_pos =
1504                pos + (Vec2::unit_x().rotated_z(rng.random_range((0.)..PI * 2.)) * 2.7).with_z(0.);
1505
1506            self.particles.push(Particle::new_directed(
1507                Duration::from_secs_f32(rng.random_range(0.4..0.8)),
1508                time,
1509                ParticleMode::CultistFlame,
1510                outer_pos,
1511                outer_pos + Vec3::unit_z() * rng.random_range(5.0..7.0),
1512                scene_data,
1513            ));
1514        }
1515    }
1516
1517    fn maintain_portal_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1518        prof_span!("ParticleMgr::maintain_portal_particles");
1519
1520        let time = scene_data.state.get_time();
1521        let mut rng = rand::rng();
1522
1523        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(150)) {
1524            let outer_pos = pos
1525                + (Vec2::unit_x().rotated_z(rng.random_range((0.)..PI * 2.))
1526                    * rng.random_range(1.0..2.9))
1527                .with_z(0.);
1528
1529            self.particles.push(Particle::new_directed(
1530                Duration::from_secs_f32(rng.random_range(0.5..3.0)),
1531                time,
1532                ParticleMode::CultistFlame,
1533                outer_pos,
1534                outer_pos + Vec3::unit_z() * rng.random_range(3.0..4.0),
1535                scene_data,
1536            ));
1537        }
1538    }
1539
1540    fn maintain_mine_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1541        prof_span!("ParticleMgr::maintain_mine_particles");
1542        let time = scene_data.state.get_time();
1543
1544        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(1)) {
1545            // sparks
1546            self.particles.push(Particle::new(
1547                Duration::from_millis(25),
1548                time,
1549                ParticleMode::GunPowderSpark,
1550                pos,
1551                scene_data,
1552            ));
1553        }
1554    }
1555
1556    fn maintain_char_state_particles(&mut self, scene_data: &SceneData, figure_mgr: &FigureMgr) {
1557        prof_span!("ParticleMgr::maintain_char_state_particles");
1558        let state = scene_data.state;
1559        let ecs = state.ecs();
1560        let time = state.get_time();
1561        let dt = scene_data.state.get_delta_time();
1562        let mut rng = rand::rng();
1563
1564        for (
1565            entity,
1566            interpolated,
1567            vel,
1568            character_state,
1569            body,
1570            ori,
1571            character_activity,
1572            physics,
1573            inventory,
1574        ) in (
1575            &ecs.entities(),
1576            &ecs.read_storage::<Interpolated>(),
1577            ecs.read_storage::<Vel>().maybe(),
1578            &ecs.read_storage::<CharacterState>(),
1579            &ecs.read_storage::<Body>(),
1580            &ecs.read_storage::<Ori>(),
1581            &ecs.read_storage::<CharacterActivity>(),
1582            &ecs.read_storage::<PhysicsState>(),
1583            ecs.read_storage::<Inventory>().maybe(),
1584        )
1585            .join()
1586        {
1587            match character_state {
1588                CharacterState::Boost(_) => {
1589                    self.particles.resize_with(
1590                        self.particles.len()
1591                            + usize::from(self.scheduler.heartbeats(Duration::from_millis(10))),
1592                        || {
1593                            Particle::new(
1594                                Duration::from_millis(250),
1595                                time,
1596                                ParticleMode::PortalFizz,
1597                                // Output particles from broom, not from character ass
1598                                interpolated.pos
1599                                    - ori.to_horizontal().look_dir().to_vec()
1600                                    - vel.map_or(Vec3::zero(), |v| v.0 * dt * rng.random::<f32>()),
1601                                scene_data,
1602                            )
1603                        },
1604                    );
1605                },
1606                CharacterState::BasicMelee(c) => {
1607                    if let Some(specifier) = c.static_data.frontend_specifier {
1608                        match specifier {
1609                            states::basic_melee::FrontendSpecifier::FlameTornado => {
1610                                if matches!(c.stage_section, StageSection::Action) {
1611                                    let time = scene_data.state.get_time();
1612                                    let mut rng = rand::rng();
1613                                    self.particles.resize_with(
1614                                        self.particles.len()
1615                                            + 10
1616                                            + usize::from(
1617                                                self.scheduler.heartbeats(Duration::from_millis(5)),
1618                                            ),
1619                                        || {
1620                                            Particle::new(
1621                                                Duration::from_millis(1000),
1622                                                time,
1623                                                ParticleMode::FlameTornado,
1624                                                interpolated
1625                                                    .pos
1626                                                    .map(|e| e + rng.random_range(-0.25..0.25)),
1627                                                scene_data,
1628                                            )
1629                                        },
1630                                    );
1631                                }
1632                            },
1633                            states::basic_melee::FrontendSpecifier::FireGigasWhirlwind => {
1634                                if matches!(c.stage_section, StageSection::Action) {
1635                                    let time = scene_data.state.get_time();
1636                                    let mut rng = rand::rng();
1637                                    self.particles.resize_with(
1638                                        self.particles.len()
1639                                            + 3
1640                                            + usize::from(
1641                                                self.scheduler.heartbeats(Duration::from_millis(5)),
1642                                            ),
1643                                        || {
1644                                            Particle::new(
1645                                                Duration::from_millis(600),
1646                                                time,
1647                                                ParticleMode::FireGigasWhirlwind,
1648                                                interpolated
1649                                                    .pos
1650                                                    .map(|e| e + rng.random_range(-0.25..0.25))
1651                                                    + 3.0 * Vec3::<f32>::unit_z(),
1652                                                scene_data,
1653                                            )
1654                                        },
1655                                    );
1656                                }
1657                            },
1658                        }
1659                    }
1660                },
1661                CharacterState::RapidMelee(c) => {
1662                    if let Some(specifier) = c.static_data.frontend_specifier {
1663                        match specifier {
1664                            states::rapid_melee::FrontendSpecifier::CultistVortex => {
1665                                if matches!(c.stage_section, StageSection::Action) {
1666                                    let range = c.static_data.melee_constructor.range;
1667                                    // Particles for vortex
1668                                    let heartbeats =
1669                                        self.scheduler.heartbeats(Duration::from_millis(3));
1670                                    self.particles.resize_with(
1671                                        self.particles.len()
1672                                            + range.powi(2) as usize * usize::from(heartbeats)
1673                                                / 150,
1674                                        || {
1675                                            let rand_dist =
1676                                                range * (1.0 - rng.random::<f32>().powi(10));
1677                                            let init_pos = Vec3::new(
1678                                                2.0 * rng.random::<f32>() - 1.0,
1679                                                2.0 * rng.random::<f32>() - 1.0,
1680                                                0.0,
1681                                            )
1682                                            .normalized()
1683                                                * rand_dist
1684                                                + interpolated.pos
1685                                                + Vec3::unit_z() * 0.05;
1686                                            Particle::new_directed(
1687                                                Duration::from_millis(900),
1688                                                time,
1689                                                ParticleMode::CultistFlame,
1690                                                init_pos,
1691                                                interpolated.pos,
1692                                                scene_data,
1693                                            )
1694                                        },
1695                                    );
1696                                    // Particles for lifesteal effect
1697                                    for (_entity_b, interpolated_b, body_b, _health_b) in (
1698                                        &ecs.entities(),
1699                                        &ecs.read_storage::<Interpolated>(),
1700                                        &ecs.read_storage::<Body>(),
1701                                        &ecs.read_storage::<comp::Health>(),
1702                                    )
1703                                        .join()
1704                                        .filter(|(e, _, _, h)| !h.is_dead && entity != *e)
1705                                    {
1706                                        if interpolated.pos.distance_squared(interpolated_b.pos)
1707                                            < range.powi(2)
1708                                        {
1709                                            let heartbeats = self
1710                                                .scheduler
1711                                                .heartbeats(Duration::from_millis(20));
1712                                            self.particles.resize_with(
1713                                                self.particles.len()
1714                                                    + range.powi(2) as usize
1715                                                        * usize::from(heartbeats)
1716                                                        / 150,
1717                                                || {
1718                                                    let start_pos = interpolated_b.pos
1719                                                        + Vec3::unit_z() * body_b.height() * 0.5
1720                                                        + Vec3::<f32>::zero()
1721                                                            .map(|_| rng.random_range(-1.0..1.0))
1722                                                            .normalized()
1723                                                            * 1.0;
1724                                                    Particle::new_directed(
1725                                                        Duration::from_millis(900),
1726                                                        time,
1727                                                        ParticleMode::CultistFlame,
1728                                                        start_pos,
1729                                                        interpolated.pos
1730                                                            + Vec3::unit_z() * body.height() * 0.5,
1731                                                        scene_data,
1732                                                    )
1733                                                },
1734                                            );
1735                                        }
1736                                    }
1737                                }
1738                            },
1739                            states::rapid_melee::FrontendSpecifier::IceWhirlwind => {
1740                                if matches!(c.stage_section, StageSection::Action) {
1741                                    let time = scene_data.state.get_time();
1742                                    let mut rng = rand::rng();
1743                                    self.particles.resize_with(
1744                                        self.particles.len()
1745                                            + 3
1746                                            + usize::from(
1747                                                self.scheduler.heartbeats(Duration::from_millis(5)),
1748                                            ),
1749                                        || {
1750                                            Particle::new(
1751                                                Duration::from_millis(1000),
1752                                                time,
1753                                                ParticleMode::IceWhirlwind,
1754                                                interpolated
1755                                                    .pos
1756                                                    .map(|e| e + rng.random_range(-0.25..0.25)),
1757                                                scene_data,
1758                                            )
1759                                        },
1760                                    );
1761                                }
1762                            },
1763                            states::rapid_melee::FrontendSpecifier::ElephantVacuum => {
1764                                if matches!(c.stage_section, StageSection::Action) {
1765                                    let time = scene_data.state.get_time();
1766                                    let mut rng = rand::rng();
1767
1768                                    let (end_radius, max_range) =
1769                                        if let CharacterState::RapidMelee(data) = character_state {
1770                                            let max_range =
1771                                                data.static_data.melee_constructor.range;
1772                                            (
1773                                                max_range
1774                                                    * (data.static_data.melee_constructor.angle
1775                                                        / 2.0
1776                                                        * PI
1777                                                        / 180.0)
1778                                                        .tan(),
1779                                                max_range,
1780                                            )
1781                                        } else {
1782                                            (0.0, 0.0)
1783                                        };
1784                                    let ori = ori.look_vec();
1785                                    let body_radius = body.max_radius() * 1.4;
1786                                    let body_offsets_z = body.height() * 0.4;
1787                                    let beam_offsets = Vec3::new(
1788                                        body_radius * ori.x * 1.1,
1789                                        body_radius * ori.y * 1.1,
1790                                        body_offsets_z,
1791                                    );
1792
1793                                    let (from, to) = (Vec3::<f32>::unit_z(), ori);
1794                                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
1795
1796                                    self.particles.resize_with(
1797                                        self.particles.len()
1798                                            + 5
1799                                            + usize::from(
1800                                                self.scheduler.heartbeats(Duration::from_millis(5)),
1801                                            ),
1802                                        || {
1803                                            let trunk_pos = interpolated.pos + beam_offsets;
1804
1805                                            let range = rng.random_range(0.05..=max_range);
1806                                            let radius = rng
1807                                                .random_range(0.0..=end_radius * range / max_range);
1808                                            let theta = rng.random_range(0.0..2.0 * PI);
1809
1810                                            Particle::new_directed(
1811                                                Duration::from_millis(300),
1812                                                time,
1813                                                ParticleMode::ElephantVacuum,
1814                                                trunk_pos
1815                                                    + m * Vec3::new(
1816                                                        radius * theta.cos(),
1817                                                        radius * theta.sin(),
1818                                                        range,
1819                                                    ),
1820                                                trunk_pos,
1821                                                scene_data,
1822                                            )
1823                                        },
1824                                    );
1825                                }
1826                            },
1827                        }
1828                    }
1829                },
1830                CharacterState::RapidRanged(repeater) => {
1831                    if let Some(specifier) = repeater.static_data.specifier {
1832                        match specifier {
1833                            states::rapid_ranged::FrontendSpecifier::FireRainPhoenix => {
1834                                // base, dark clouds
1835                                self.particles.resize_with(
1836                                    self.particles.len()
1837                                        + 2 * usize::from(
1838                                            self.scheduler.heartbeats(Duration::from_millis(25)),
1839                                        ),
1840                                    || {
1841                                        let rand_pos = {
1842                                            let theta = rng.random::<f32>() * TAU;
1843                                            let radius = repeater
1844                                                .static_data
1845                                                .options
1846                                                .offset
1847                                                .map(|offset| offset.radius)
1848                                                .unwrap_or_default()
1849                                                * rng.random::<f32>().sqrt();
1850                                            let x = radius * theta.sin();
1851                                            let y = radius * theta.cos();
1852                                            Vec2::new(x, y) + interpolated.pos.xy()
1853                                        };
1854                                        let pos1 = rand_pos.with_z(
1855                                            repeater
1856                                                .static_data
1857                                                .options
1858                                                .offset
1859                                                .map(|offset| offset.height)
1860                                                .unwrap_or_default()
1861                                                + interpolated.pos.z
1862                                                + 2.0 * rng.random::<f32>(),
1863                                        );
1864                                        Particle::new_directed(
1865                                            Duration::from_secs_f32(3.0),
1866                                            time,
1867                                            ParticleMode::PhoenixCloud,
1868                                            pos1,
1869                                            pos1 + Vec3::new(7.09, 4.09, 18.09),
1870                                            scene_data,
1871                                        )
1872                                    },
1873                                );
1874                                self.particles.resize_with(
1875                                    self.particles.len()
1876                                        + 2 * usize::from(
1877                                            self.scheduler.heartbeats(Duration::from_millis(25)),
1878                                        ),
1879                                    || {
1880                                        let rand_pos = {
1881                                            let theta = rng.random::<f32>() * TAU;
1882                                            let radius = repeater
1883                                                .static_data
1884                                                .options
1885                                                .offset
1886                                                .map(|offset| offset.radius)
1887                                                .unwrap_or_default()
1888                                                * rng.random::<f32>().sqrt();
1889                                            let x = radius * theta.sin();
1890                                            let y = radius * theta.cos();
1891                                            Vec2::new(x, y) + interpolated.pos.xy()
1892                                        };
1893                                        let pos1 = rand_pos.with_z(
1894                                            repeater
1895                                                .static_data
1896                                                .options
1897                                                .offset
1898                                                .map(|offset| offset.height)
1899                                                .unwrap_or_default()
1900                                                + interpolated.pos.z
1901                                                + 1.5 * rng.random::<f32>(),
1902                                        );
1903                                        Particle::new_directed(
1904                                            Duration::from_secs_f32(2.5),
1905                                            time,
1906                                            ParticleMode::PhoenixCloud,
1907                                            pos1,
1908                                            pos1 + Vec3::new(10.025, 4.025, 17.025),
1909                                            scene_data,
1910                                        )
1911                                    },
1912                                );
1913                            },
1914                            states::rapid_ranged::FrontendSpecifier::PyroclasmCharge {
1915                                height: z,
1916                                radius: r,
1917                            } => {
1918                                const TAIL_SECS: f32 = 1.0;
1919                                match repeater.stage_section {
1920                                    StageSection::Buildup => {
1921                                        let progress = (repeater.timer.as_secs_f32()
1922                                            / repeater.static_data.buildup_duration.as_secs_f32())
1923                                        .clamp(0.0, 1.0)
1924                                            * 0.9;
1925                                        self.maintain_pyroclasm_charge_particles(
1926                                            scene_data,
1927                                            interpolated.pos,
1928                                            progress,
1929                                            z,
1930                                            r,
1931                                        );
1932                                    },
1933                                    StageSection::Action => {
1934                                        if repeater.timer.as_secs_f32() < TAIL_SECS {
1935                                            self.maintain_pyroclasm_charge_particles(
1936                                                scene_data,
1937                                                interpolated.pos,
1938                                                0.9,
1939                                                z,
1940                                                r,
1941                                            );
1942                                        }
1943                                    },
1944                                    _ => {},
1945                                }
1946                            },
1947                        }
1948                    }
1949                },
1950                CharacterState::Blink(c) => {
1951                    if let Some(specifier) = c.static_data.frontend_specifier {
1952                        match specifier {
1953                            states::blink::FrontendSpecifier::CultistFlame => {
1954                                self.particles.resize_with(
1955                                    self.particles.len()
1956                                        + usize::from(
1957                                            self.scheduler.heartbeats(Duration::from_millis(10)),
1958                                        ),
1959                                    || {
1960                                        let center_pos =
1961                                            interpolated.pos + Vec3::unit_z() * body.height() / 2.0;
1962                                        let outer_pos = interpolated.pos
1963                                            + Vec3::new(
1964                                                2.0 * rng.random::<f32>() - 1.0,
1965                                                2.0 * rng.random::<f32>() - 1.0,
1966                                                0.0,
1967                                            )
1968                                            .normalized()
1969                                                * (body.max_radius() + 2.0)
1970                                            + Vec3::unit_z() * body.height() * rng.random::<f32>();
1971
1972                                        let (start_pos, end_pos) =
1973                                            if matches!(c.stage_section, StageSection::Buildup) {
1974                                                (outer_pos, center_pos)
1975                                            } else {
1976                                                (center_pos, outer_pos)
1977                                            };
1978
1979                                        Particle::new_directed(
1980                                            Duration::from_secs_f32(0.5),
1981                                            time,
1982                                            ParticleMode::CultistFlame,
1983                                            start_pos,
1984                                            end_pos,
1985                                            scene_data,
1986                                        )
1987                                    },
1988                                );
1989                            },
1990                            states::blink::FrontendSpecifier::FlameThrower => {
1991                                self.particles.resize_with(
1992                                    self.particles.len()
1993                                        + usize::from(
1994                                            self.scheduler.heartbeats(Duration::from_millis(10)),
1995                                        ),
1996                                    || {
1997                                        let center_pos =
1998                                            interpolated.pos + Vec3::unit_z() * body.height() / 2.0;
1999                                        let outer_pos = interpolated.pos
2000                                            + Vec3::new(
2001                                                2.0 * rng.random::<f32>() - 1.0,
2002                                                2.0 * rng.random::<f32>() - 1.0,
2003                                                0.0,
2004                                            )
2005                                            .normalized()
2006                                                * (body.max_radius() + 2.0)
2007                                            + Vec3::unit_z() * body.height() * rng.random::<f32>();
2008
2009                                        let (start_pos, end_pos) =
2010                                            if matches!(c.stage_section, StageSection::Buildup) {
2011                                                (outer_pos, center_pos)
2012                                            } else {
2013                                                (center_pos, outer_pos)
2014                                            };
2015
2016                                        Particle::new_directed(
2017                                            Duration::from_secs_f32(0.5),
2018                                            time,
2019                                            ParticleMode::FlameThrower,
2020                                            start_pos,
2021                                            end_pos,
2022                                            scene_data,
2023                                        )
2024                                    },
2025                                );
2026                            },
2027                        }
2028                    }
2029                },
2030                CharacterState::SelfBuff(c) => {
2031                    if let Some(specifier) = c.static_data.specifier {
2032                        match specifier {
2033                            states::self_buff::FrontendSpecifier::FromTheAshes => {
2034                                if matches!(c.stage_section, StageSection::Action) {
2035                                    let pos = interpolated.pos;
2036                                    self.particles.resize_with(
2037                                        self.particles.len()
2038                                            + 2 * usize::from(
2039                                                self.scheduler.heartbeats(Duration::from_millis(1)),
2040                                            ),
2041                                        || {
2042                                            let start_pos = pos + Vec3::unit_z() - 1.0;
2043                                            let end_pos = pos
2044                                                + Vec3::new(
2045                                                    4.0 * rng.random::<f32>() - 1.0,
2046                                                    4.0 * rng.random::<f32>() - 1.0,
2047                                                    0.0,
2048                                                )
2049                                                .normalized()
2050                                                    * 1.5
2051                                                + Vec3::unit_z()
2052                                                + 5.0 * rng.random::<f32>();
2053
2054                                            Particle::new_directed(
2055                                                Duration::from_secs_f32(0.5),
2056                                                time,
2057                                                ParticleMode::FieryBurst,
2058                                                start_pos,
2059                                                end_pos,
2060                                                scene_data,
2061                                            )
2062                                        },
2063                                    );
2064                                    self.particles.resize_with(
2065                                        self.particles.len()
2066                                            + usize::from(
2067                                                self.scheduler
2068                                                    .heartbeats(Duration::from_millis(10)),
2069                                            ),
2070                                        || {
2071                                            Particle::new(
2072                                                Duration::from_millis(650),
2073                                                time,
2074                                                ParticleMode::FieryBurstVortex,
2075                                                pos.map(|e| e + rng.random_range(-0.25..0.25))
2076                                                    + Vec3::new(0.0, 0.0, 1.0),
2077                                                scene_data,
2078                                            )
2079                                        },
2080                                    );
2081                                    self.particles.resize_with(
2082                                        self.particles.len()
2083                                            + usize::from(
2084                                                self.scheduler
2085                                                    .heartbeats(Duration::from_millis(40)),
2086                                            ),
2087                                        || {
2088                                            Particle::new(
2089                                                Duration::from_millis(1000),
2090                                                time,
2091                                                ParticleMode::FieryBurstSparks,
2092                                                pos.map(|e| e + rng.random_range(-0.25..0.25)),
2093                                                scene_data,
2094                                            )
2095                                        },
2096                                    );
2097                                    self.particles.resize_with(
2098                                        self.particles.len()
2099                                            + usize::from(
2100                                                self.scheduler
2101                                                    .heartbeats(Duration::from_millis(14)),
2102                                            ),
2103                                        || {
2104                                            let pos1 =
2105                                                pos.map(|e| e + rng.random_range(-0.25..0.25));
2106                                            Particle::new_directed(
2107                                                Duration::from_millis(1000),
2108                                                time,
2109                                                ParticleMode::FieryBurstAsh,
2110                                                pos1,
2111                                                Vec3::new(
2112                                                    4.5,    // radius of rand spawn
2113                                                    20.4,   // integer part - radius of the curve part, fractional part - relative time of setting particle on fire
2114                                                    8.58)   // height of the flight
2115                                                    + pos1,
2116                                                scene_data,
2117                                            )
2118                                        },
2119                                    );
2120                                }
2121                            },
2122                        }
2123                    }
2124                    use buff::BuffKind;
2125                    if c.static_data
2126                        .buffs
2127                        .iter()
2128                        .any(|buff_desc| matches!(buff_desc.kind, BuffKind::Frenzied))
2129                        && matches!(c.stage_section, StageSection::Action)
2130                    {
2131                        self.particles.resize_with(
2132                            self.particles.len()
2133                                + usize::from(self.scheduler.heartbeats(Duration::from_millis(5))),
2134                            || {
2135                                let start_pos = interpolated.pos
2136                                    + Vec3::new(
2137                                        body.max_radius(),
2138                                        body.max_radius(),
2139                                        body.height() / 2.0,
2140                                    )
2141                                    .map(|d| d * rng.random_range(-1.0..1.0));
2142                                let end_pos =
2143                                    interpolated.pos + (start_pos - interpolated.pos) * 6.0;
2144                                Particle::new_directed(
2145                                    Duration::from_secs(1),
2146                                    time,
2147                                    ParticleMode::Enraged,
2148                                    start_pos,
2149                                    end_pos,
2150                                    scene_data,
2151                                )
2152                            },
2153                        );
2154                    }
2155                },
2156                CharacterState::BasicBeam(beam) => {
2157                    let ori = *ori;
2158                    let _look_dir = *character_activity.look_dir.unwrap_or(ori.look_dir());
2159                    let dir = ori.look_dir(); //.with_z(look_dir.z);
2160                    let specifier = beam.static_data.specifier;
2161                    if specifier == beam::FrontendSpecifier::PhoenixLaser
2162                        && matches!(beam.stage_section, StageSection::Buildup)
2163                    {
2164                        self.particles.resize_with(
2165                            self.particles.len()
2166                                + 2 * usize::from(
2167                                    self.scheduler.heartbeats(Duration::from_millis(2)),
2168                                ),
2169                            || {
2170                                let mut left_right_alignment =
2171                                    dir.cross(Vec3::new(0.0, 0.0, 1.0)).normalized();
2172                                if rng.random_bool(0.5) {
2173                                    left_right_alignment *= -1.0;
2174                                }
2175                                let start = interpolated.pos
2176                                    + left_right_alignment * 4.0
2177                                    + dir.normalized() * 6.0;
2178                                let lifespan = Duration::from_secs_f32(0.5);
2179                                Particle::new_directed(
2180                                    lifespan,
2181                                    time,
2182                                    ParticleMode::PhoenixBuildUpAim,
2183                                    start,
2184                                    interpolated.pos
2185                                        + dir.normalized() * 3.0
2186                                        + left_right_alignment * 0.4
2187                                        + vel
2188                                            .map_or(Vec3::zero(), |v| v.0 * lifespan.as_secs_f32()),
2189                                    scene_data,
2190                                )
2191                            },
2192                        );
2193                    }
2194                },
2195                CharacterState::Glide(glide) => {
2196                    if let Some(Fluid::Air {
2197                        vel: air_vel,
2198                        elevation: _,
2199                    }) = physics.in_fluid
2200                    {
2201                        // Empirical observation is that air_vel is somewhere
2202                        // between 0.0 and 13.0, but we are extending to be sure
2203                        const MAX_AIR_VEL: f32 = 15.0;
2204                        const MIN_AIR_VEL: f32 = -2.0;
2205
2206                        let minmax_norm = |val, min, max| (val - min) / (max - min);
2207
2208                        let wind_speed = air_vel.0.magnitude();
2209
2210                        // Less means more frequent particles
2211                        let heartbeat = 200
2212                            - Lerp::lerp(
2213                                50u64,
2214                                150,
2215                                minmax_norm(wind_speed, MIN_AIR_VEL, MAX_AIR_VEL),
2216                            );
2217
2218                        let new_count = self.particles.len()
2219                            + usize::from(
2220                                self.scheduler.heartbeats(Duration::from_millis(heartbeat)),
2221                            );
2222
2223                        // More number, longer particles
2224                        let duration = Lerp::lerp(
2225                            0u64,
2226                            1000,
2227                            minmax_norm(wind_speed, MIN_AIR_VEL, MAX_AIR_VEL),
2228                        );
2229                        let duration = Duration::from_millis(duration);
2230
2231                        self.particles.resize_with(new_count, || {
2232                            let start_pos = interpolated.pos
2233                                + Vec3::new(
2234                                    body.max_radius(),
2235                                    body.max_radius(),
2236                                    body.height() / 2.0,
2237                                )
2238                                .map(|d| d * rng.random_range(-10.0..10.0));
2239
2240                            Particle::new_directed(
2241                                duration,
2242                                time,
2243                                ParticleMode::Airflow,
2244                                start_pos,
2245                                start_pos + air_vel.0,
2246                                scene_data,
2247                            )
2248                        });
2249
2250                        // When using the glide boost, emit particles
2251                        if let Some(states::glide::Boost::Forward(_)) = &glide.booster
2252                            && let Some(figure_state) =
2253                                figure_mgr.states.character_states.get(&entity)
2254                            && let Some(tp0) = figure_state.primary_abs_trail_points
2255                            && let Some(tp1) = figure_state.secondary_abs_trail_points
2256                        {
2257                            for _ in 0..self.scheduler.heartbeats(Duration::from_millis(5)) {
2258                                self.particles.push(Particle::new(
2259                                    Duration::from_secs(2),
2260                                    time,
2261                                    ParticleMode::EngineJet,
2262                                    ((tp0.0 + tp1.1) * 0.5)
2263                                        // TODO: This offset is used to position the particles at the engine outlet. Ideally, we'd have a way to configure this per-glider
2264                                        + Vec3::unit_z() * 0.5
2265                                        + Vec3::<f32>::zero().map(|_| rng.random_range(-0.25..0.25))
2266                                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
2267                                scene_data,
2268                                ));
2269                            }
2270                        }
2271                    }
2272                },
2273                CharacterState::Transform(data) => {
2274                    if matches!(data.stage_section, StageSection::Buildup)
2275                        && let Some(specifier) = data.static_data.specifier
2276                    {
2277                        match specifier {
2278                            states::transform::FrontendSpecifier::Evolve => {
2279                                self.particles.resize_with(
2280                                    self.particles.len()
2281                                        + usize::from(
2282                                            self.scheduler.heartbeats(Duration::from_millis(10)),
2283                                        ),
2284                                    || {
2285                                        let start_pos = interpolated.pos
2286                                            + (Vec2::unit_y()
2287                                                * rng.random::<f32>()
2288                                                * body.max_radius())
2289                                            .rotated_z(rng.random_range(0.0..(PI * 2.0)))
2290                                            .with_z(body.height() * rng.random::<f32>());
2291
2292                                        Particle::new_directed(
2293                                            Duration::from_millis(100),
2294                                            time,
2295                                            ParticleMode::BarrelOrgan,
2296                                            start_pos,
2297                                            start_pos + Vec3::unit_z() * 2.0,
2298                                            scene_data,
2299                                        )
2300                                    },
2301                                )
2302                            },
2303                            states::transform::FrontendSpecifier::Cursekeeper => {
2304                                self.particles.resize_with(
2305                                    self.particles.len()
2306                                        + usize::from(
2307                                            self.scheduler.heartbeats(Duration::from_millis(10)),
2308                                        ),
2309                                    || {
2310                                        let start_pos = interpolated.pos
2311                                            + (Vec2::unit_y()
2312                                                * rng.random::<f32>()
2313                                                * body.max_radius())
2314                                            .rotated_z(rng.random_range(0.0..(PI * 2.0)))
2315                                            .with_z(body.height() * rng.random::<f32>());
2316
2317                                        Particle::new_directed(
2318                                            Duration::from_millis(100),
2319                                            time,
2320                                            ParticleMode::FireworkPurple,
2321                                            start_pos,
2322                                            start_pos + Vec3::unit_z() * 2.0,
2323                                            scene_data,
2324                                        )
2325                                    },
2326                                )
2327                            },
2328                        }
2329                    }
2330                },
2331                CharacterState::ChargedMelee(_melee) => {
2332                    self.maintain_hydra_tail_swipe_particles(
2333                        scene_data,
2334                        figure_mgr,
2335                        entity,
2336                        interpolated.pos,
2337                        body,
2338                        character_state,
2339                        inventory,
2340                    );
2341                },
2342                CharacterState::DashMelee(s) => {
2343                    if matches!(s.stage_section, StageSection::Charge) {
2344                        match s.static_data.frontend_specifier {
2345                            Some(states::dash_melee::FrontendSpecifier::FireDash) => {
2346                                let look_dir = ori.to_horizontal().look_dir().to_vec();
2347                                let back_dir = -look_dir;
2348                                let time = scene_data.state.get_time();
2349                                let mut rng = rand::rng();
2350                                let heartbeats =
2351                                    self.scheduler.heartbeats(Duration::from_millis(5));
2352                                let pos = interpolated.pos + Vec3::unit_z() * 0.9;
2353
2354                                // Rotation matrix to align the Z-axis cone with back_dir
2355                                let m = Mat3::<f32>::rotation_from_to_3d(
2356                                    Vec3::<f32>::unit_z(),
2357                                    back_dir,
2358                                );
2359
2360                                let tail_angle: f32 = 0.4;
2361                                let tail_range = 3.5_f32;
2362                                self.particles.resize_with(
2363                                    self.particles.len() + usize::from(heartbeats) * 3,
2364                                    || {
2365                                        let phi = rng.random_range(0.0..tail_angle);
2366                                        let theta = rng.random_range(0.0..TAU);
2367                                        let offset_z = Vec3::new(
2368                                            phi.sin() * theta.cos(),
2369                                            phi.sin() * theta.sin(),
2370                                            phi.cos(),
2371                                        );
2372                                        let dir = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2373                                        let mode = if rng.random_bool(0.5) {
2374                                            ParticleMode::FlamethrowerBlue
2375                                        } else {
2376                                            ParticleMode::FlameThrower
2377                                        };
2378                                        Particle::new_directed(
2379                                            Duration::from_millis(300),
2380                                            time,
2381                                            mode,
2382                                            pos,
2383                                            pos + dir * tail_range,
2384                                            scene_data,
2385                                        )
2386                                    },
2387                                );
2388
2389                                let nose_pos = pos + look_dir * 3.0;
2390                                let nose_angle: f32 = 2.8; // 
2391                                let nose_range = 3.0_f32;
2392                                self.particles.resize_with(
2393                                    self.particles.len() + usize::from(heartbeats) * 6,
2394                                    || {
2395                                        let phi = rng.random_range(0.0..nose_angle);
2396                                        let theta = rng.random_range(0.0..TAU);
2397                                        let offset_z = Vec3::new(
2398                                            phi.sin() * theta.cos(),
2399                                            phi.sin() * theta.sin(),
2400                                            phi.cos(),
2401                                        );
2402                                        let dir = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2403                                        Particle::new_directed(
2404                                            Duration::from_millis(180),
2405                                            time,
2406                                            ParticleMode::FlameThrower,
2407                                            nose_pos,
2408                                            nose_pos + dir * nose_range,
2409                                            scene_data,
2410                                        )
2411                                    },
2412                                );
2413                            },
2414                            None => (),
2415                        }
2416                    }
2417                },
2418                _ => {},
2419            }
2420        }
2421    }
2422
2423    fn maintain_beam_particles(&mut self, scene_data: &SceneData, lights: &mut Vec<Light>) {
2424        let state = scene_data.state;
2425        let ecs = state.ecs();
2426        let time = state.get_time();
2427        let terrain = state.terrain();
2428        // Limit to 100 per tick, so at less than 10 FPS particle generation
2429        // work doesn't increase frame cost further.
2430        let tick_elapse = u32::from(self.scheduler.heartbeats(Duration::from_millis(1)).min(100));
2431        let mut rng = rand::rng();
2432
2433        for (beam, ori) in (&ecs.read_storage::<Beam>(), &ecs.read_storage::<Ori>()).join() {
2434            let particles_per_sec = (match beam.specifier {
2435                beam::FrontendSpecifier::Flamethrower
2436                | beam::FrontendSpecifier::Bubbles
2437                | beam::FrontendSpecifier::Steam
2438                | beam::FrontendSpecifier::Frost
2439                | beam::FrontendSpecifier::Poison
2440                | beam::FrontendSpecifier::Ink
2441                | beam::FrontendSpecifier::PhoenixLaser
2442                | beam::FrontendSpecifier::Gravewarden => 300.0,
2443                beam::FrontendSpecifier::FirePillar | beam::FrontendSpecifier::FlameWallPillar => {
2444                    40.0 * beam.end_radius.powi(2)
2445                },
2446                beam::FrontendSpecifier::LifestealBeam => 420.0,
2447                beam::FrontendSpecifier::Cultist => 960.0,
2448                beam::FrontendSpecifier::WebStrand => 180.0,
2449                beam::FrontendSpecifier::Lightning => 120.0,
2450                beam::FrontendSpecifier::FireGigasOverheat => 1600.0,
2451            }) / 1000.0;
2452
2453            let beam_tick_count = tick_elapse as f32 * particles_per_sec;
2454            let beam_tick_count = if rng.random_bool(f64::from(beam_tick_count.fract())) {
2455                beam_tick_count.ceil() as u32
2456            } else {
2457                beam_tick_count.floor() as u32
2458            };
2459
2460            if beam_tick_count == 0 {
2461                continue;
2462            }
2463
2464            let distributed_time = tick_elapse as f64 / (beam_tick_count * 1000) as f64;
2465            let angle = (beam.end_radius / beam.range).atan();
2466            let beam_dir = (beam.bezier.ctrl - beam.bezier.start)
2467                .try_normalized()
2468                .unwrap_or(*ori.look_dir());
2469            let raycast_distance = |from, to| terrain.ray(from, to).until(Block::is_solid).cast().0;
2470
2471            self.particles.reserve(beam_tick_count as usize);
2472            match beam.specifier {
2473                beam::FrontendSpecifier::Flamethrower => {
2474                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2475                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2476                    // Emit a light when using flames
2477                    if scene_data.flashing_lights_enabled {
2478                        lights.push(Light::new(
2479                            beam.bezier.start,
2480                            Rgb::new(1.0, 0.25, 0.05).map(|e| e * rng.random_range(0.8..1.2)),
2481                            2.0,
2482                        ));
2483                    }
2484
2485                    for i in 0..beam_tick_count {
2486                        let phi: f32 = rng.random_range(0.0..angle);
2487                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2488                        let offset_z =
2489                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2490                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2491                        self.particles.push(Particle::new_directed_with_collision(
2492                            Duration::from_secs_f64(beam.duration.0),
2493                            time + distributed_time * i as f64,
2494                            ParticleMode::FlameThrower,
2495                            beam.bezier.start,
2496                            beam.bezier.start + random_ori * beam.range,
2497                            scene_data,
2498                            raycast_distance,
2499                        ));
2500                    }
2501                },
2502                beam::FrontendSpecifier::FireGigasOverheat => {
2503                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2504                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2505                    // Emit a light when using flames
2506                    if scene_data.flashing_lights_enabled {
2507                        lights.push(Light::new(
2508                            beam.bezier.start,
2509                            Rgb::new(1.0, 0.25, 0.05).map(|e| e * rng.random_range(0.8..1.2)),
2510                            2.0,
2511                        ));
2512                    }
2513
2514                    for i in 0..beam_tick_count {
2515                        let phi: f32 = rng.random_range(0.0..angle);
2516                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2517                        let offset_z =
2518                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2519                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2520                        self.particles.push(Particle::new_directed_with_collision(
2521                            Duration::from_secs_f64(beam.duration.0),
2522                            time + distributed_time * i as f64,
2523                            ParticleMode::FireGigasOverheat,
2524                            beam.bezier.start,
2525                            beam.bezier.start + random_ori * beam.range,
2526                            scene_data,
2527                            raycast_distance,
2528                        ));
2529                    }
2530                },
2531                beam::FrontendSpecifier::FirePillar | beam::FrontendSpecifier::FlameWallPillar => {
2532                    // Emit a light when using flames
2533                    if scene_data.flashing_lights_enabled {
2534                        lights.push(Light::new(
2535                            beam.bezier.start,
2536                            Rgb::new(1.0, 0.25, 0.05).map(|e| e * rng.random_range(0.8..1.2)),
2537                            2.0,
2538                        ));
2539                    }
2540
2541                    for i in 0..beam_tick_count {
2542                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2543                        let radius = beam.start_radius * (1.0 - rng.random::<f32>().powi(8));
2544                        let offset = Vec3::new(radius * theta.cos(), radius * theta.sin(), 0.0);
2545                        self.particles.push(Particle::new_directed_with_collision(
2546                            Duration::from_secs_f64(beam.duration.0),
2547                            time + distributed_time * i as f64,
2548                            ParticleMode::FirePillar,
2549                            beam.bezier.start + offset,
2550                            beam.bezier.start + offset + beam.range * Vec3::unit_z(),
2551                            scene_data,
2552                            raycast_distance,
2553                        ));
2554                    }
2555                },
2556                beam::FrontendSpecifier::Cultist => {
2557                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2558                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2559                    // Emit a light when using flames
2560                    if scene_data.flashing_lights_enabled {
2561                        lights.push(Light::new(
2562                            beam.bezier.start,
2563                            Rgb::new(1.0, 0.0, 1.0).map(|e| e * rng.random_range(0.5..1.0)),
2564                            2.0,
2565                        ));
2566                    }
2567                    for i in 0..beam_tick_count {
2568                        let phi: f32 = rng.random_range(0.0..angle);
2569                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2570                        let offset_z =
2571                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2572                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2573                        self.particles.push(Particle::new_directed_with_collision(
2574                            Duration::from_secs_f64(beam.duration.0),
2575                            time + distributed_time * i as f64,
2576                            ParticleMode::CultistFlame,
2577                            beam.bezier.start,
2578                            beam.bezier.start + random_ori * beam.range,
2579                            scene_data,
2580                            raycast_distance,
2581                        ));
2582                    }
2583                },
2584                beam::FrontendSpecifier::LifestealBeam => {
2585                    // Emit a light when using lifesteal beam
2586                    if scene_data.flashing_lights_enabled {
2587                        lights.push(Light::new(beam.bezier.start, Rgb::new(0.8, 1.0, 0.5), 1.0));
2588                    }
2589
2590                    // If the beam is one straight line, we can run raycast one time.
2591                    let bezier_end = beam.bezier.start + beam_dir * beam.range;
2592                    let distance = raycast_distance(beam.bezier.start, bezier_end);
2593                    for i in 0..beam_tick_count {
2594                        self.particles.push(Particle::new_directed_with_collision(
2595                            Duration::from_secs_f64(beam.duration.0),
2596                            time + distributed_time * i as f64,
2597                            ParticleMode::LifestealBeam,
2598                            beam.bezier.start,
2599                            bezier_end,
2600                            scene_data,
2601                            |_from, _to| distance,
2602                        ));
2603                    }
2604                },
2605                beam::FrontendSpecifier::Gravewarden => {
2606                    for i in 0..beam_tick_count {
2607                        let mut offset = 0.5;
2608                        let side = Vec2::new(-beam_dir.y, beam_dir.x);
2609                        self.particles.resize_with(self.particles.len() + 2, || {
2610                            offset = -offset;
2611                            Particle::new_directed_with_collision(
2612                                Duration::from_secs_f64(beam.duration.0),
2613                                time + distributed_time * i as f64,
2614                                ParticleMode::Laser,
2615                                beam.bezier.start + beam_dir * 1.5 + side * offset,
2616                                beam.bezier.start + beam_dir * beam.range + side * offset,
2617                                scene_data,
2618                                raycast_distance,
2619                            )
2620                        });
2621                    }
2622                },
2623                beam::FrontendSpecifier::WebStrand => {
2624                    let bezier_end = beam.bezier.start + beam_dir * beam.range;
2625                    let distance = raycast_distance(beam.bezier.start, bezier_end);
2626                    for i in 0..beam_tick_count {
2627                        self.particles.push(Particle::new_directed_with_collision(
2628                            Duration::from_secs_f64(beam.duration.0),
2629                            time + distributed_time * i as f64,
2630                            ParticleMode::WebStrand,
2631                            beam.bezier.start,
2632                            bezier_end,
2633                            scene_data,
2634                            |_from, _to| distance,
2635                        ));
2636                    }
2637                },
2638                beam::FrontendSpecifier::Bubbles => {
2639                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2640                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2641                    for i in 0..beam_tick_count {
2642                        let phi: f32 = rng.random_range(0.0..angle);
2643                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2644                        let offset_z =
2645                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2646                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2647                        self.particles.push(Particle::new_directed_with_collision(
2648                            Duration::from_secs_f64(beam.duration.0),
2649                            time + distributed_time * i as f64,
2650                            ParticleMode::Bubbles,
2651                            beam.bezier.start,
2652                            beam.bezier.start + random_ori * beam.range,
2653                            scene_data,
2654                            raycast_distance,
2655                        ));
2656                    }
2657                },
2658                beam::FrontendSpecifier::Poison => {
2659                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2660                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2661                    for i in 0..beam_tick_count {
2662                        let phi: f32 = rng.random_range(0.0..angle);
2663                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2664                        let offset_z =
2665                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2666                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2667                        self.particles.push(Particle::new_directed_with_collision(
2668                            Duration::from_secs_f64(beam.duration.0),
2669                            time + distributed_time * i as f64,
2670                            ParticleMode::Poison,
2671                            beam.bezier.start,
2672                            beam.bezier.start + random_ori * beam.range,
2673                            scene_data,
2674                            raycast_distance,
2675                        ));
2676                    }
2677                },
2678                beam::FrontendSpecifier::Ink => {
2679                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2680                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2681                    for i in 0..beam_tick_count {
2682                        let phi: f32 = rng.random_range(0.0..angle);
2683                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2684                        let offset_z =
2685                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2686                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2687                        self.particles.push(Particle::new_directed_with_collision(
2688                            Duration::from_secs_f64(beam.duration.0),
2689                            time + distributed_time * i as f64,
2690                            ParticleMode::Bubbles,
2691                            beam.bezier.start,
2692                            beam.bezier.start + random_ori * beam.range,
2693                            scene_data,
2694                            raycast_distance,
2695                        ));
2696                    }
2697                },
2698                beam::FrontendSpecifier::Steam => {
2699                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2700                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2701                    for i in 0..beam_tick_count {
2702                        let phi: f32 = rng.random_range(0.0..angle);
2703                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2704                        let offset_z =
2705                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2706                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2707                        self.particles.push(Particle::new_directed_with_collision(
2708                            Duration::from_secs_f64(beam.duration.0),
2709                            time + distributed_time * i as f64,
2710                            ParticleMode::Steam,
2711                            beam.bezier.start,
2712                            beam.bezier.start + random_ori * beam.range,
2713                            scene_data,
2714                            raycast_distance,
2715                        ));
2716                    }
2717                },
2718                beam::FrontendSpecifier::Lightning => {
2719                    let bezier_end = beam.bezier.start + beam_dir * beam.range;
2720                    let distance = raycast_distance(beam.bezier.start, bezier_end);
2721                    for i in 0..beam_tick_count {
2722                        self.particles.push(Particle::new_directed_with_collision(
2723                            Duration::from_secs_f64(beam.duration.0),
2724                            time + distributed_time * i as f64,
2725                            ParticleMode::Lightning,
2726                            beam.bezier.start,
2727                            bezier_end,
2728                            scene_data,
2729                            |_from, _to| distance,
2730                        ));
2731                    }
2732                },
2733                beam::FrontendSpecifier::Frost => {
2734                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2735                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2736                    for i in 0..beam_tick_count {
2737                        let phi: f32 = rng.random_range(0.0..angle);
2738                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2739                        let offset_z =
2740                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2741                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2742                        self.particles.push(Particle::new_directed_with_collision(
2743                            Duration::from_secs_f64(beam.duration.0),
2744                            time + distributed_time * i as f64,
2745                            ParticleMode::Ice,
2746                            beam.bezier.start,
2747                            beam.bezier.start + random_ori * beam.range,
2748                            scene_data,
2749                            raycast_distance,
2750                        ));
2751                    }
2752                },
2753                beam::FrontendSpecifier::PhoenixLaser => {
2754                    let bezier_end = beam.bezier.start + beam_dir * beam.range;
2755                    let distance = raycast_distance(beam.bezier.start, bezier_end);
2756                    for i in 0..beam_tick_count {
2757                        self.particles.push(Particle::new_directed_with_collision(
2758                            Duration::from_secs_f64(beam.duration.0),
2759                            time + distributed_time * i as f64,
2760                            ParticleMode::PhoenixBeam,
2761                            beam.bezier.start,
2762                            bezier_end,
2763                            scene_data,
2764                            |_from, _to| distance,
2765                        ));
2766                    }
2767                },
2768            }
2769        }
2770    }
2771
2772    fn maintain_aura_particles(&mut self, scene_data: &SceneData) {
2773        let state = scene_data.state;
2774        let ecs = state.ecs();
2775        let time = state.get_time();
2776        let mut rng = rand::rng();
2777        let dt = scene_data.state.get_delta_time();
2778
2779        for (interp, pos, auras, body_maybe) in (
2780            ecs.read_storage::<Interpolated>().maybe(),
2781            &ecs.read_storage::<Pos>(),
2782            &ecs.read_storage::<comp::Auras>(),
2783            ecs.read_storage::<comp::Body>().maybe(),
2784        )
2785            .join()
2786        {
2787            let pos = interp.map_or(pos.0, |i| i.pos);
2788
2789            for (_, aura) in auras.auras.iter() {
2790                match aura.aura_kind {
2791                    aura::AuraKind::Buff {
2792                        kind: buff::BuffKind::ProtectingWard,
2793                        ..
2794                    } => {
2795                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2796                        self.particles.resize_with(
2797                            self.particles.len()
2798                                + aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
2799                            || {
2800                                let rand_dist = aura.radius * (1.0 - rng.random::<f32>().powi(100));
2801                                let init_pos = Vec3::new(rand_dist, 0_f32, 0_f32);
2802                                let duration = Duration::from_secs_f64(
2803                                    aura.end_time
2804                                        .map_or(1.0, |end| end.0 - time)
2805                                        .clamp(0.0, 1.0),
2806                                );
2807                                Particle::new_directed(
2808                                    duration,
2809                                    time,
2810                                    ParticleMode::EnergyNature,
2811                                    pos,
2812                                    pos + init_pos,
2813                                    scene_data,
2814                                )
2815                            },
2816                        );
2817                    },
2818                    aura::AuraKind::Buff {
2819                        kind: buff::BuffKind::Regeneration,
2820                        ..
2821                    } => {
2822                        if auras.auras.iter().any(|(_, aura)| {
2823                            matches!(aura.aura_kind, aura::AuraKind::Buff {
2824                                kind: buff::BuffKind::ProtectingWard,
2825                                ..
2826                            })
2827                        }) {
2828                            // If same entity has both protecting ward and regeneration auras, skip
2829                            // particles for regeneration
2830                            continue;
2831                        }
2832                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2833                        self.particles.resize_with(
2834                            self.particles.len()
2835                                + aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
2836                            || {
2837                                let rand_dist = aura.radius * (1.0 - rng.random::<f32>().powi(100));
2838                                let init_pos = Vec3::new(rand_dist, 0_f32, 0_f32);
2839                                let duration = Duration::from_secs_f64(
2840                                    aura.end_time
2841                                        .map_or(1.0, |end| end.0 - time)
2842                                        .clamp(0.0, 1.0),
2843                                );
2844                                Particle::new_directed(
2845                                    duration,
2846                                    time,
2847                                    ParticleMode::EnergyHealing,
2848                                    pos,
2849                                    pos + init_pos,
2850                                    scene_data,
2851                                )
2852                            },
2853                        );
2854                    },
2855                    aura::AuraKind::Buff {
2856                        kind: buff::BuffKind::Burning,
2857                        ..
2858                    } => {
2859                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2860                        match aura.frontend_specifier {
2861                            Some(aura::Specifier::FieryAura) => {
2862                                //Flame Cloak
2863                                self.particles.resize_with(
2864                                    self.particles.len()
2865                                        + aura.radius.powi(2) as usize * usize::from(heartbeats)
2866                                            / 3,
2867                                    || {
2868                                        let orbit_speed = 1.0_f32;
2869                                        let theta = time as f32 * orbit_speed
2870                                            + rng.random::<f32>() * std::f32::consts::TAU;
2871                                        let r = aura.radius * (0.25 + rng.random::<f32>() * 0.2);
2872                                        let spawn_pos =
2873                                            (Vec2::new(r * theta.sin(), r * theta.cos())
2874                                                + pos.xy())
2875                                            .with_z(pos.z + rng.random::<f32>() * 0.5);
2876                                        let duration = Duration::from_secs_f64(
2877                                            aura.end_time
2878                                                .map_or(0.3, |end| (end.0 - time).clamp(0.0, 0.3)),
2879                                        );
2880                                        Particle::new(
2881                                            duration,
2882                                            time,
2883                                            ParticleMode::FlameCloakOrbit,
2884                                            spawn_pos,
2885                                            scene_data,
2886                                        )
2887                                    },
2888                                );
2889                            },
2890                            None => {
2891                                //Gnarling Totem Red
2892                                self.particles.resize_with(
2893                                    self.particles.len()
2894                                        + aura.radius.powi(2) as usize * usize::from(heartbeats)
2895                                            / 300,
2896                                    || {
2897                                        let rand_pos = {
2898                                            let theta = rng.random::<f32>() * TAU;
2899                                            let radius = aura.radius * rng.random::<f32>().sqrt();
2900                                            let x = radius * theta.sin();
2901                                            let y = radius * theta.cos();
2902                                            Vec2::new(x, y) + pos.xy()
2903                                        };
2904                                        let duration = Duration::from_secs_f64(
2905                                            aura.end_time
2906                                                .map_or(1.0, |end| end.0 - time)
2907                                                .clamp(0.0, 1.0),
2908                                        );
2909                                        Particle::new_directed(
2910                                            duration,
2911                                            time,
2912                                            ParticleMode::FlameThrower,
2913                                            rand_pos.with_z(pos.z),
2914                                            rand_pos.with_z(pos.z + 1.0),
2915                                            scene_data,
2916                                        )
2917                                    },
2918                                );
2919                            },
2920                            _ => {},
2921                        }
2922                    },
2923                    aura::AuraKind::Buff {
2924                        kind: buff::BuffKind::Hastened,
2925                        ..
2926                    } => {
2927                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2928                        self.particles.resize_with(
2929                            self.particles.len()
2930                                + aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
2931                            || {
2932                                let rand_dist = aura.radius * (1.0 - rng.random::<f32>().powi(100));
2933                                let init_pos = Vec3::new(rand_dist, 0_f32, 0_f32);
2934                                let duration = Duration::from_secs_f64(
2935                                    aura.end_time
2936                                        .map_or(1.0, |end| end.0 - time)
2937                                        .clamp(0.0, 1.0),
2938                                );
2939                                Particle::new_directed(
2940                                    duration,
2941                                    time,
2942                                    ParticleMode::EnergyBuffing,
2943                                    pos,
2944                                    pos + init_pos,
2945                                    scene_data,
2946                                )
2947                            },
2948                        );
2949                    },
2950                    aura::AuraKind::Buff {
2951                        kind: buff::BuffKind::Frozen,
2952                        ..
2953                    } => {
2954                        let is_new_aura = aura.data.duration.is_none_or(|max_dur| {
2955                            let rem_dur = aura.end_time.map_or(time, |e| e.0) - time;
2956                            rem_dur > max_dur.0 * 0.9
2957                        });
2958                        if is_new_aura {
2959                            let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2960                            self.particles.resize_with(
2961                                self.particles.len()
2962                                    + aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
2963                                || {
2964                                    let rand_angle = rng.random_range(0.0..TAU);
2965                                    let offset =
2966                                        Vec2::new(rand_angle.cos(), rand_angle.sin()) * aura.radius;
2967                                    let z_start = body_maybe
2968                                        .map_or(0.0, |b| rng.random_range(0.5..0.75) * b.height());
2969                                    let z_end = body_maybe
2970                                        .map_or(0.0, |b| rng.random_range(0.0..3.0) * b.height());
2971                                    Particle::new_directed(
2972                                        Duration::from_secs(3),
2973                                        time,
2974                                        ParticleMode::Ice,
2975                                        pos + Vec3::unit_z() * z_start,
2976                                        pos + offset.with_z(z_end),
2977                                        scene_data,
2978                                    )
2979                                },
2980                            );
2981                        }
2982                    },
2983                    aura::AuraKind::Buff {
2984                        kind: buff::BuffKind::Heatstroke,
2985                        ..
2986                    } => {
2987                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2988                        self.particles.resize_with(
2989                            self.particles.len()
2990                                + aura.radius.powi(2) as usize * usize::from(heartbeats) / 900,
2991                            || {
2992                                let rand_dist = aura.radius * (1.0 - rng.random::<f32>().powi(100));
2993                                let init_pos = Vec3::new(rand_dist, 0_f32, 0_f32);
2994                                let duration = Duration::from_secs_f64(
2995                                    aura.end_time
2996                                        .map_or(1.0, |end| end.0 - time)
2997                                        .clamp(0.0, 1.0),
2998                                );
2999                                Particle::new_directed(
3000                                    duration,
3001                                    time,
3002                                    ParticleMode::EnergyPhoenix,
3003                                    pos,
3004                                    pos + init_pos,
3005                                    scene_data,
3006                                )
3007                            },
3008                        );
3009
3010                        let num_particles = aura.radius.powi(2) * dt / 50.0;
3011                        let num_particles = num_particles.floor() as usize
3012                            + usize::from(rng.random_bool(f64::from(num_particles % 1.0)));
3013                        self.particles
3014                            .resize_with(self.particles.len() + num_particles, || {
3015                                let rand_pos = {
3016                                    let theta = rng.random::<f32>() * TAU;
3017                                    let radius = aura.radius * rng.random::<f32>().sqrt();
3018                                    let x = radius * theta.sin();
3019                                    let y = radius * theta.cos();
3020                                    Vec2::new(x, y) + pos.xy()
3021                                };
3022                                let duration = Duration::from_secs_f64(
3023                                    aura.end_time
3024                                        .map_or(1.0, |end| end.0 - time)
3025                                        .clamp(0.0, 1.0),
3026                                );
3027                                Particle::new_directed(
3028                                    duration,
3029                                    time,
3030                                    ParticleMode::FieryBurstAsh,
3031                                    pos,
3032                                    Vec3::new(
3033                                                    0.0,    // radius of rand spawn
3034                                                    20.0,   // integer part - radius of the curve part, fractional part - relative time of setting particle on fire
3035                                                    5.5)    // height of the flight
3036                                                    + rand_pos.with_z(pos.z),
3037                                    scene_data,
3038                                )
3039                            });
3040                    },
3041                    _ => {},
3042                }
3043            }
3044        }
3045    }
3046
3047    fn maintain_buff_particles(&mut self, scene_data: &SceneData) {
3048        let state = scene_data.state;
3049        let ecs = state.ecs();
3050        let time = state.get_time();
3051        let mut rng = rand::rng();
3052
3053        for (interp, pos, buffs, body, ori, scale) in (
3054            ecs.read_storage::<Interpolated>().maybe(),
3055            &ecs.read_storage::<Pos>(),
3056            &ecs.read_storage::<comp::Buffs>(),
3057            &ecs.read_storage::<Body>(),
3058            &ecs.read_storage::<Ori>(),
3059            ecs.read_storage::<Scale>().maybe(),
3060        )
3061            .join()
3062        {
3063            let pos = interp.map_or(pos.0, |i| i.pos);
3064
3065            for (buff_kind, buff_keys) in buffs
3066                .kinds
3067                .iter()
3068                .filter_map(|(kind, keys)| keys.as_ref().map(|keys| (kind, keys)))
3069            {
3070                use buff::BuffKind;
3071                match buff_kind {
3072                    BuffKind::Cursed | BuffKind::Burning => {
3073                        self.particles.resize_with(
3074                            self.particles.len()
3075                                + usize::from(self.scheduler.heartbeats(Duration::from_millis(15))),
3076                            || {
3077                                let start_pos = pos
3078                                    + Vec3::unit_z() * body.height() * 0.25
3079                                    + Vec3::<f32>::zero()
3080                                        .map(|_| rng.random_range(-1.0..1.0))
3081                                        .normalized()
3082                                        * 0.25;
3083                                let end_pos = start_pos
3084                                    + Vec3::unit_z() * body.height()
3085                                    + Vec3::<f32>::zero()
3086                                        .map(|_| rng.random_range(-1.0..1.0))
3087                                        .normalized();
3088                                Particle::new_directed(
3089                                    Duration::from_secs(1),
3090                                    time,
3091                                    if matches!(buff_kind, BuffKind::Cursed) {
3092                                        ParticleMode::CultistFlame
3093                                    } else {
3094                                        ParticleMode::FlameThrower
3095                                    },
3096                                    start_pos,
3097                                    end_pos,
3098                                    scene_data,
3099                                )
3100                            },
3101                        );
3102                    },
3103                    BuffKind::PotionSickness => {
3104                        let mut multiplicity = 0;
3105                        // Only show particles for potion sickness at the beginning, after the
3106                        // drinking animation finishes
3107                        if buff_keys.0
3108                            .iter()
3109                            .filter_map(|key| buffs.buffs.get(*key))
3110                            .any(|buff| {
3111                                matches!(buff.elapsed(Time(time)), dur if (1.0..=1.5).contains(&dur.0))
3112                            })
3113                        {
3114                            multiplicity = 1;
3115                        }
3116                        self.particles.resize_with(
3117                            self.particles.len()
3118                                + multiplicity
3119                                    * usize::from(
3120                                        self.scheduler.heartbeats(Duration::from_millis(25)),
3121                                    ),
3122                            || {
3123                                let start_pos = pos
3124                                    + Vec3::unit_z() * body.eye_height(scale.map_or(1.0, |s| s.0));
3125                                let (radius, theta) = (
3126                                    rng.random_range(0.0f32..1.0).sqrt(),
3127                                    rng.random_range(0.0..TAU),
3128                                );
3129                                let end_pos = pos
3130                                    + *ori.look_dir()
3131                                    + Vec3::<f32>::new(
3132                                        radius * theta.cos(),
3133                                        radius * theta.sin(),
3134                                        0.0,
3135                                    ) * 0.25;
3136                                Particle::new_directed(
3137                                    Duration::from_secs(1),
3138                                    time,
3139                                    ParticleMode::PotionSickness,
3140                                    start_pos,
3141                                    end_pos,
3142                                    scene_data,
3143                                )
3144                            },
3145                        );
3146                    },
3147                    BuffKind::Frenzied => {
3148                        self.particles.resize_with(
3149                            self.particles.len()
3150                                + usize::from(self.scheduler.heartbeats(Duration::from_millis(15))),
3151                            || {
3152                                let start_pos = pos
3153                                    + Vec3::new(
3154                                        body.max_radius(),
3155                                        body.max_radius(),
3156                                        body.height() / 2.0,
3157                                    )
3158                                    .map(|d| d * rng.random_range(-1.0..1.0));
3159                                let end_pos = start_pos
3160                                    + Vec3::unit_z() * body.height()
3161                                    + Vec3::<f32>::zero()
3162                                        .map(|_| rng.random_range(-1.0..1.0))
3163                                        .normalized();
3164                                Particle::new_directed(
3165                                    Duration::from_secs(1),
3166                                    time,
3167                                    ParticleMode::Enraged,
3168                                    start_pos,
3169                                    end_pos,
3170                                    scene_data,
3171                                )
3172                            },
3173                        );
3174                    },
3175                    BuffKind::Polymorphed => {
3176                        let mut multiplicity = 0;
3177                        // Only show particles for polymorph at the beginning, after the
3178                        // drinking animation finishes
3179                        if buff_keys.0
3180                            .iter()
3181                            .filter_map(|key| buffs.buffs.get(*key))
3182                            .any(|buff| {
3183                                matches!(buff.elapsed(Time(time)), dur if (0.1..=0.3).contains(&dur.0))
3184                            })
3185                        {
3186                            multiplicity = 1;
3187                        }
3188                        self.particles.resize_with(
3189                            self.particles.len()
3190                                + multiplicity
3191                                    * self.scheduler.heartbeats(Duration::from_millis(3)) as usize,
3192                            || {
3193                                let start_pos = pos
3194                                    + Vec3::unit_z() * body.eye_height(scale.map_or(1.0, |s| s.0))
3195                                        / 2.0;
3196                                let end_pos = start_pos
3197                                    + Vec3::<f32>::zero()
3198                                        .map(|_| rng.random_range(-1.0..1.0))
3199                                        .normalized()
3200                                        * 5.0;
3201
3202                                Particle::new_directed(
3203                                    Duration::from_secs(2),
3204                                    time,
3205                                    ParticleMode::Explosion,
3206                                    start_pos,
3207                                    end_pos,
3208                                    scene_data,
3209                                )
3210                            },
3211                        )
3212                    },
3213                    _ => {},
3214                }
3215            }
3216        }
3217    }
3218
3219    fn maintain_block_particles(
3220        &mut self,
3221        scene_data: &SceneData,
3222        terrain: &Terrain<TerrainChunk>,
3223        figure_mgr: &FigureMgr,
3224    ) {
3225        prof_span!("ParticleMgr::maintain_block_particles");
3226        let dt = scene_data.state.ecs().fetch::<DeltaTime>().0;
3227        let time = scene_data.state.get_time();
3228        let player_pos = scene_data
3229            .state
3230            .read_component_copied::<Interpolated>(scene_data.viewpoint_entity)
3231            .map(|i| i.pos)
3232            .unwrap_or_default();
3233        let player_chunk = player_pos.xy().map2(TerrainChunk::RECT_SIZE, |e, sz| {
3234            (e.floor() as i32).div_euclid(sz as i32)
3235        });
3236
3237        struct BlockParticles<'a> {
3238            // The function to select the blocks of interest that we should emit from
3239            blocks: fn(&'a BlocksOfInterest) -> BlockParticleSlice<'a>,
3240            // The range, in chunks, that the particles should be generated in from the player
3241            range: usize,
3242            // The emission rate, per block per second, of the generated particles
3243            rate: f32,
3244            // The number of seconds that each particle should live for
3245            lifetime: f32,
3246            // The visual mode of the generated particle
3247            mode: ParticleMode,
3248            // Condition that must be true
3249            cond: fn(&SceneData) -> bool,
3250        }
3251
3252        enum BlockParticleSlice<'a> {
3253            Positions(&'a [Vec3<i32>]),
3254            PositionsAndDirs(&'a [(Vec3<i32>, Vec3<f32>)]),
3255        }
3256
3257        impl BlockParticleSlice<'_> {
3258            fn len(&self) -> usize {
3259                match self {
3260                    Self::Positions(blocks) => blocks.len(),
3261                    Self::PositionsAndDirs(blocks) => blocks.len(),
3262                }
3263            }
3264        }
3265
3266        let particles: &[BlockParticles] = &[
3267            BlockParticles {
3268                blocks: |boi| BlockParticleSlice::Positions(&boi.leaves),
3269                range: 4,
3270                rate: 0.0125,
3271                lifetime: 30.0,
3272                mode: ParticleMode::Leaf,
3273                cond: |_| true,
3274            },
3275            BlockParticles {
3276                blocks: |boi| BlockParticleSlice::Positions(&boi.drip),
3277                range: 4,
3278                rate: 0.004,
3279                lifetime: 20.0,
3280                mode: ParticleMode::Drip,
3281                cond: |_| true,
3282            },
3283            BlockParticles {
3284                blocks: |boi| BlockParticleSlice::Positions(&boi.fires),
3285                range: 2,
3286                rate: 50.0,
3287                lifetime: 0.5,
3288                mode: ParticleMode::CampfireFire,
3289                cond: |_| true,
3290            },
3291            BlockParticles {
3292                blocks: |boi| BlockParticleSlice::Positions(&boi.fire_bowls),
3293                range: 2,
3294                rate: 20.0,
3295                lifetime: 0.25,
3296                mode: ParticleMode::FireBowl,
3297                cond: |_| true,
3298            },
3299            BlockParticles {
3300                blocks: |boi| BlockParticleSlice::Positions(&boi.fireflies),
3301                range: 6,
3302                rate: 0.004,
3303                lifetime: 40.0,
3304                mode: ParticleMode::Firefly,
3305                cond: |sd| sd.state.get_day_period().is_dark(),
3306            },
3307            BlockParticles {
3308                blocks: |boi| BlockParticleSlice::Positions(&boi.flowers),
3309                range: 5,
3310                rate: 0.002,
3311                lifetime: 40.0,
3312                mode: ParticleMode::Firefly,
3313                cond: |sd| sd.state.get_day_period().is_dark(),
3314            },
3315            BlockParticles {
3316                blocks: |boi| BlockParticleSlice::Positions(&boi.beehives),
3317                range: 3,
3318                rate: 0.5,
3319                lifetime: 30.0,
3320                mode: ParticleMode::Bee,
3321                cond: |sd| sd.state.get_day_period().is_light(),
3322            },
3323            BlockParticles {
3324                blocks: |boi| BlockParticleSlice::Positions(&boi.snow),
3325                range: 4,
3326                rate: 0.025,
3327                lifetime: 15.0,
3328                mode: ParticleMode::Snow,
3329                cond: |_| true,
3330            },
3331            BlockParticles {
3332                blocks: |boi| BlockParticleSlice::PositionsAndDirs(&boi.one_way_walls),
3333                range: 2,
3334                rate: 12.0,
3335                lifetime: 1.5,
3336                mode: ParticleMode::PortalFizz,
3337                cond: |_| true,
3338            },
3339            BlockParticles {
3340                blocks: |boi| BlockParticleSlice::Positions(&boi.spores),
3341                range: 4,
3342                rate: 0.055,
3343                lifetime: 20.0,
3344                mode: ParticleMode::Spore,
3345                cond: |_| true,
3346            },
3347            BlockParticles {
3348                blocks: |boi| BlockParticleSlice::PositionsAndDirs(&boi.waterfall),
3349                range: 2,
3350                rate: 4.0,
3351                lifetime: 5.0,
3352                mode: ParticleMode::WaterFoam,
3353                cond: |_| true,
3354            },
3355            BlockParticles {
3356                blocks: |boi| BlockParticleSlice::Positions(&boi.train_smokes),
3357                range: 2,
3358                rate: 50.0,
3359                lifetime: 8.0,
3360                mode: ParticleMode::TrainSmoke,
3361                cond: |_| true,
3362            },
3363        ];
3364
3365        let ecs = scene_data.state.ecs();
3366        let mut rng = rand::rng();
3367        // Hard cap for performance reasons; Assuming that 25% of a chunk is covered in
3368        // lava or 32*32*0.25 = 256 TODO: Make this a setting?
3369        let cap = 512;
3370        for particles in particles.iter() {
3371            if !(particles.cond)(scene_data) {
3372                continue;
3373            }
3374
3375            for offset in Spiral2d::new().take((particles.range * 2 + 1).pow(2)) {
3376                let chunk_pos = player_chunk + offset;
3377
3378                terrain.get(chunk_pos).map(|chunk_data| {
3379                    let blocks = (particles.blocks)(&chunk_data.blocks_of_interest);
3380
3381                    let avg_particles = dt * (blocks.len() as f32 * particles.rate).min(cap as f32);
3382                    let particle_count = avg_particles.trunc() as usize
3383                        + (rng.random::<f32>() < avg_particles.fract()) as usize;
3384
3385                    self.particles
3386                        .resize_with(self.particles.len() + particle_count, || {
3387                            match blocks {
3388                                BlockParticleSlice::Positions(blocks) => {
3389                                    // Can't fail, resize only occurs if blocks > 0
3390                                    let block_pos = Vec3::from(
3391                                        chunk_pos * TerrainChunk::RECT_SIZE.map(|e| e as i32),
3392                                    ) + blocks.choose(&mut rng).copied().unwrap();
3393                                    Particle::new(
3394                                        Duration::from_secs_f32(particles.lifetime),
3395                                        time,
3396                                        particles.mode,
3397                                        block_pos.map(|e: i32| e as f32 + rng.random::<f32>()),
3398                                        scene_data,
3399                                    )
3400                                },
3401                                BlockParticleSlice::PositionsAndDirs(blocks) => {
3402                                    // Can't fail, resize only occurs if blocks > 0
3403                                    let (block_offset, particle_dir) =
3404                                        blocks.choose(&mut rng).copied().unwrap();
3405                                    let block_pos = Vec3::from(
3406                                        chunk_pos * TerrainChunk::RECT_SIZE.map(|e| e as i32),
3407                                    ) + block_offset;
3408                                    let particle_pos =
3409                                        block_pos.map(|e: i32| e as f32 + rng.random::<f32>());
3410                                    Particle::new_directed(
3411                                        Duration::from_secs_f32(particles.lifetime),
3412                                        time,
3413                                        particles.mode,
3414                                        particle_pos,
3415                                        particle_pos + particle_dir,
3416                                        scene_data,
3417                                    )
3418                                },
3419                            }
3420                        })
3421                });
3422            }
3423
3424            for (entity, body, interpolated, collider) in (
3425                &ecs.entities(),
3426                &ecs.read_storage::<comp::Body>(),
3427                &ecs.read_storage::<crate::ecs::comp::Interpolated>(),
3428                ecs.read_storage::<comp::Collider>().maybe(),
3429            )
3430                .join()
3431            {
3432                if let Some((blocks_of_interest, offset)) =
3433                    figure_mgr.get_blocks_of_interest(entity, body, collider)
3434                {
3435                    let mat = Mat4::from(interpolated.ori.to_quat())
3436                        .translated_3d(interpolated.pos)
3437                        * Mat4::translation_3d(offset);
3438
3439                    let blocks = (particles.blocks)(blocks_of_interest);
3440
3441                    let avg_particles = dt * blocks.len() as f32 * particles.rate;
3442                    let particle_count = avg_particles.trunc() as usize
3443                        + (rng.random::<f32>() < avg_particles.fract()) as usize;
3444
3445                    self.particles
3446                        .resize_with(self.particles.len() + particle_count, || {
3447                            match blocks {
3448                                BlockParticleSlice::Positions(blocks) => {
3449                                    let rel_pos = blocks
3450                                        .choose(&mut rng)
3451                                        .copied()
3452                                        // Can't fail, resize only occurs if blocks > 0
3453                                        .unwrap()
3454                                        .map(|e: i32| e as f32 + rng.random::<f32>());
3455                                    let wpos = mat.mul_point(rel_pos);
3456
3457                                    Particle::new(
3458                                        Duration::from_secs_f32(particles.lifetime),
3459                                        time,
3460                                        particles.mode,
3461                                        wpos,
3462                                        scene_data,
3463                                    )
3464                                },
3465                                BlockParticleSlice::PositionsAndDirs(blocks) => {
3466                                    // Can't fail, resize only occurs if blocks > 0
3467                                    let (block_offset, particle_dir) =
3468                                        blocks.choose(&mut rng).copied().unwrap();
3469                                    let particle_pos =
3470                                        block_offset.map(|e: i32| e as f32 + rng.random::<f32>());
3471                                    let wpos = mat.mul_point(particle_pos);
3472                                    Particle::new_directed(
3473                                        Duration::from_secs_f32(particles.lifetime),
3474                                        time,
3475                                        particles.mode,
3476                                        wpos,
3477                                        wpos + mat.mul_direction(particle_dir),
3478                                        scene_data,
3479                                    )
3480                                },
3481                            }
3482                        })
3483                }
3484            }
3485        }
3486        // smoke is more complex as it comes with varying rate and color
3487        {
3488            struct SmokeProperties {
3489                position: Vec3<i32>,
3490                strength: f32,
3491                dry_chance: f32,
3492            }
3493
3494            let range = 8_usize;
3495            let rate = 3.0 / 128.0;
3496            let lifetime = 40.0;
3497            let time_of_day = scene_data
3498                .state
3499                .get_time_of_day()
3500                .rem_euclid(24.0 * 60.0 * 60.0) as f32;
3501
3502            let smokers = Spiral2d::new()
3503                .take((range * 2 + 1).pow(2))
3504                .flat_map(|offset| {
3505                    let chunk_pos = player_chunk + offset;
3506                    let block_pos =
3507                        Vec3::<i32>::from(chunk_pos * TerrainChunk::RECT_SIZE.map(|e| e as i32));
3508                    terrain.get(chunk_pos).into_iter().flat_map(move |chunk| {
3509                        chunk.blocks_of_interest.smokers.iter().map(move |smoker| {
3510                            (
3511                                block_pos.as_::<f32>() + smoker.position.as_(),
3512                                smoker.kind,
3513                                chunk.blocks_of_interest.temperature,
3514                                chunk.blocks_of_interest.humidity,
3515                            )
3516                        })
3517                    })
3518                })
3519                .chain(
3520                    (
3521                        &ecs.entities(),
3522                        &ecs.read_storage::<comp::Body>(),
3523                        &ecs.read_storage::<crate::ecs::comp::Interpolated>(),
3524                        ecs.read_storage::<comp::Collider>().maybe(),
3525                    )
3526                        .join()
3527                        .flat_map(|(entity, body, interpolated, collider)| {
3528                            figure_mgr
3529                                .get_blocks_of_interest(entity, body, collider)
3530                                .into_iter()
3531                                .flat_map(|(boi, offset)| {
3532                                    let mat = Mat4::from(interpolated.ori.to_quat())
3533                                        .translated_3d(interpolated.pos)
3534                                        * Mat4::translation_3d(offset);
3535                                    boi.smokers.iter().map(move |smoker| {
3536                                        (
3537                                            mat.mul_point(smoker.position.as_::<f32>() + 0.5),
3538                                            smoker.kind,
3539                                            0.0, // TODO: Actual temperature & humidity
3540                                            0.5,
3541                                        )
3542                                    })
3543                                })
3544                        })
3545                        .collect::<Vec<_>>(),
3546                );
3547
3548            let mut smoke_properties: Vec<SmokeProperties> = Vec::new();
3549            let mut sum = 0.0_f32;
3550            for (pos, kind, temperature, humidity) in smokers {
3551                let (strength, dry_chance) = {
3552                    match kind {
3553                        FireplaceType::House => {
3554                            let prop = crate::scene::smoke_cycle::smoke_at_time(
3555                                pos.round().as_(),
3556                                temperature,
3557                                time_of_day,
3558                            );
3559                            (
3560                                prop.0,
3561                                if prop.1 {
3562                                    // fire started, dark smoke
3563                                    0.8 - humidity
3564                                } else {
3565                                    // fire continues, light smoke
3566                                    1.2 - humidity
3567                                },
3568                            )
3569                        },
3570                        FireplaceType::Workshop => (128.0, 1.0),
3571                    }
3572                };
3573                sum += strength;
3574                smoke_properties.push(SmokeProperties {
3575                    position: pos.round().as_(),
3576                    strength,
3577                    dry_chance,
3578                });
3579            }
3580            let avg_particles = dt * sum * rate;
3581
3582            let particle_count = avg_particles.trunc() as usize
3583                + (rng.random::<f32>() < avg_particles.fract()) as usize;
3584            let chosen = smoke_properties
3585                .sample_weighted(&mut rng, particle_count, |smoker| smoker.strength);
3586            if let Ok(chosen) = chosen {
3587                self.particles.extend(chosen.map(|smoker| {
3588                    Particle::new(
3589                        Duration::from_secs_f32(lifetime),
3590                        time,
3591                        if rng.random::<f32>() > smoker.dry_chance {
3592                            ParticleMode::BlackSmoke
3593                        } else {
3594                            ParticleMode::CampfireSmoke
3595                        },
3596                        smoker.position.map(|e: i32| e as f32 + rng.random::<f32>()),
3597                        scene_data,
3598                    )
3599                }));
3600            }
3601        }
3602    }
3603
3604    fn maintain_shockwave_particles(&mut self, scene_data: &SceneData) {
3605        let state = scene_data.state;
3606        let ecs = state.ecs();
3607        let time = state.get_time();
3608        let dt = scene_data.state.ecs().fetch::<DeltaTime>().0;
3609        let terrain = scene_data.state.ecs().fetch::<TerrainGrid>();
3610
3611        for (_entity, interp, pos, ori, shockwave) in (
3612            &ecs.entities(),
3613            ecs.read_storage::<Interpolated>().maybe(),
3614            &ecs.read_storage::<Pos>(),
3615            &ecs.read_storage::<Ori>(),
3616            &ecs.read_storage::<Shockwave>(),
3617        )
3618            .join()
3619        {
3620            let pos = interp.map_or(pos.0, |i| i.pos);
3621            let ori = interp.map_or(*ori, |i| i.ori);
3622
3623            let elapsed = time - shockwave.creation.unwrap_or(time);
3624            let speed = shockwave.properties.speed;
3625
3626            let percent = elapsed as f32 / shockwave.properties.duration.as_secs_f32();
3627
3628            let distance = speed * elapsed as f32;
3629
3630            let radians = shockwave.properties.angle.to_radians();
3631
3632            let ori_vec = ori.look_vec();
3633            let theta = ori_vec.y.atan2(ori_vec.x) - radians / 2.0;
3634            let dtheta = radians / distance;
3635
3636            // Number of particles derived from arc length (for new particles at least, old
3637            // can be converted later)
3638            let arc_length = distance * radians;
3639
3640            use shockwave::FrontendSpecifier;
3641            match shockwave.properties.specifier {
3642                FrontendSpecifier::Ground => {
3643                    let heartbeats = self.scheduler.heartbeats(Duration::from_millis(2));
3644                    for heartbeat in 0..heartbeats {
3645                        // 1 / 3 the size of terrain voxel
3646                        let scale = 1.0 / 3.0;
3647
3648                        let scaled_speed = speed * scale;
3649
3650                        let sub_tick_interpolation = scaled_speed * 1000.0 * heartbeat as f32;
3651
3652                        let distance = speed * (elapsed as f32 - sub_tick_interpolation);
3653
3654                        let particle_count_factor = radians / (3.0 * scale);
3655                        let new_particle_count = distance * particle_count_factor;
3656                        self.particles.reserve(new_particle_count as usize);
3657
3658                        for d in 0..(new_particle_count as i32) {
3659                            let arc_position = theta + dtheta * d as f32 / particle_count_factor;
3660
3661                            let position = pos
3662                                + distance * Vec3::new(arc_position.cos(), arc_position.sin(), 0.0);
3663
3664                            // Arbitrary number chosen that is large enough to be able to accurately
3665                            // place particles most of the time, but also not too big as to make ray
3666                            // be too large (for performance reasons)
3667                            let half_ray_length = 10.0;
3668                            let mut last_air = false;
3669                            // TODO: Optimize ray to only be cast at most once per block per tick if
3670                            // it becomes an issue.
3671                            // From imbris:
3672                            //      each ray is ~2 us
3673                            //      at 30 FPS, it peaked at 113 rays in a tick
3674                            //      total time was 240 us (although potentially half that is
3675                            //          overhead from the profiling of each ray)
3676                            let _ = terrain
3677                                .ray(
3678                                    position + Vec3::unit_z() * half_ray_length,
3679                                    position - Vec3::unit_z() * half_ray_length,
3680                                )
3681                                .for_each(|block: &Block, pos: Vec3<i32>| {
3682                                    if block.is_solid() && block.get_sprite().is_none() {
3683                                        if last_air {
3684                                            let position = position.xy().with_z(pos.z as f32 + 1.0);
3685
3686                                            let position_snapped =
3687                                                ((position / scale).floor() + 0.5) * scale;
3688
3689                                            self.particles.push(Particle::new(
3690                                                Duration::from_millis(250),
3691                                                time,
3692                                                ParticleMode::GroundShockwave,
3693                                                position_snapped,
3694                                                scene_data,
3695                                            ));
3696                                            last_air = false;
3697                                        }
3698                                    } else {
3699                                        last_air = true;
3700                                    }
3701                                })
3702                                .cast();
3703                        }
3704                    }
3705                },
3706                FrontendSpecifier::Fire => {
3707                    let heartbeats = self.scheduler.heartbeats(Duration::from_millis(2));
3708                    for _ in 0..heartbeats {
3709                        for d in 0..3 * distance as i32 {
3710                            let arc_position = theta + dtheta * d as f32 / 3.0;
3711
3712                            let position = pos
3713                                + distance * Vec3::new(arc_position.cos(), arc_position.sin(), 0.0);
3714
3715                            self.particles.push(Particle::new(
3716                                Duration::from_secs_f32((distance + 10.0) / 50.0),
3717                                time,
3718                                ParticleMode::FireShockwave,
3719                                position,
3720                                scene_data,
3721                            ));
3722                        }
3723                    }
3724                },
3725                FrontendSpecifier::FireLow => {
3726                    let heartbeats = self.scheduler.heartbeats(Duration::from_millis(2));
3727                    for heartbeat in 0..heartbeats {
3728                        // 1 / 3 the size of terrain voxel
3729                        let scale = 1.0 / 3.0;
3730
3731                        let scaled_speed = speed * scale;
3732
3733                        let sub_tick_interpolation = scaled_speed * 1000.0 * heartbeat as f32;
3734
3735                        let distance = speed * (elapsed as f32 - sub_tick_interpolation);
3736
3737                        let particle_count_factor = radians / (3.0 * scale);
3738                        let new_particle_count = distance * particle_count_factor;
3739                        self.particles.reserve(new_particle_count as usize);
3740
3741                        for d in 0..(new_particle_count as i32) {
3742                            let arc_position = theta + dtheta * d as f32 / particle_count_factor;
3743
3744                            let position = pos
3745                                + distance * Vec3::new(arc_position.cos(), arc_position.sin(), 0.0);
3746
3747                            // Arbitrary number chosen that is large enough to be able to accurately
3748                            // place particles most of the time, but also not too big as to make ray
3749                            // be too large (for performance reasons)
3750                            let half_ray_length = 10.0;
3751                            let mut last_air = false;
3752                            // TODO: Optimize ray to only be cast at most once per block per tick if
3753                            // it becomes an issue.
3754                            // From imbris:
3755                            //      each ray is ~2 us
3756                            //      at 30 FPS, it peaked at 113 rays in a tick
3757                            //      total time was 240 us (although potentially half that is
3758                            //          overhead from the profiling of each ray)
3759                            let _ = terrain
3760                                .ray(
3761                                    position + Vec3::unit_z() * half_ray_length,
3762                                    position - Vec3::unit_z() * half_ray_length,
3763                                )
3764                                .for_each(|block: &Block, pos: Vec3<i32>| {
3765                                    if block.is_solid() && block.get_sprite().is_none() {
3766                                        if last_air {
3767                                            let position = position.xy().with_z(pos.z as f32 + 1.0);
3768
3769                                            let position_snapped =
3770                                                ((position / scale).floor() + 0.5) * scale;
3771
3772                                            self.particles.push(Particle::new(
3773                                                Duration::from_millis(250),
3774                                                time,
3775                                                ParticleMode::FireLowShockwave,
3776                                                position_snapped,
3777                                                scene_data,
3778                                            ));
3779                                            last_air = false;
3780                                        }
3781                                    } else {
3782                                        last_air = true;
3783                                    }
3784                                })
3785                                .cast();
3786                        }
3787                    }
3788                },
3789                FrontendSpecifier::Water => {
3790                    // 1 particle per unit length of arc
3791                    let particles_per_length = arc_length as usize;
3792                    let dtheta = radians / particles_per_length as f32;
3793                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
3794                    // lower duration = more particles
3795                    let heartbeats = self
3796                        .scheduler
3797                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
3798
3799                    // Reserves capacity for new particles
3800                    let new_particle_count = particles_per_length * heartbeats as usize;
3801                    self.particles.reserve(new_particle_count);
3802
3803                    for i in 0..particles_per_length {
3804                        let angle = dtheta * i as f32;
3805                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
3806                        for j in 0..heartbeats {
3807                            // Sub tick dt
3808                            let dt = (j as f32 / heartbeats as f32) * dt;
3809                            let distance = distance + speed * dt;
3810                            let pos1 = pos + distance * direction - Vec3::unit_z();
3811                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
3812                            let time = time + dt as f64;
3813
3814                            self.particles.push(Particle::new_directed(
3815                                Duration::from_secs_f32(0.5),
3816                                time,
3817                                ParticleMode::Water,
3818                                pos1,
3819                                pos2,
3820                                scene_data,
3821                            ));
3822                        }
3823                    }
3824                },
3825                FrontendSpecifier::Lightning => {
3826                    // 1 particle per unit length of arc
3827                    let particles_per_length = arc_length as usize;
3828                    let dtheta = radians / particles_per_length as f32;
3829                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
3830                    // lower duration = more particles
3831                    let heartbeats = self
3832                        .scheduler
3833                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
3834
3835                    // Reserves capacity for new particles
3836                    let new_particle_count = particles_per_length * heartbeats as usize;
3837                    self.particles.reserve(new_particle_count);
3838
3839                    for i in 0..particles_per_length {
3840                        let angle = dtheta * i as f32;
3841                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
3842                        for j in 0..heartbeats {
3843                            // Sub tick dt
3844                            let dt = (j as f32 / heartbeats as f32) * dt;
3845                            let distance = distance + speed * dt;
3846                            let pos1 = pos + distance * direction - Vec3::unit_z();
3847                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
3848                            let time = time + dt as f64;
3849
3850                            self.particles.push(Particle::new_directed(
3851                                Duration::from_secs_f32(0.5),
3852                                time,
3853                                ParticleMode::Lightning,
3854                                pos1,
3855                                pos2,
3856                                scene_data,
3857                            ));
3858                        }
3859                    }
3860                },
3861                FrontendSpecifier::Steam => {
3862                    // 1 particle per unit length of arc
3863                    let particles_per_length = arc_length as usize;
3864                    let dtheta = radians / particles_per_length as f32;
3865                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
3866                    // lower duration = more particles
3867                    let heartbeats = self
3868                        .scheduler
3869                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
3870
3871                    // Reserves capacity for new particles
3872                    let new_particle_count = particles_per_length * heartbeats as usize;
3873                    self.particles.reserve(new_particle_count);
3874
3875                    for i in 0..particles_per_length {
3876                        let angle = dtheta * i as f32;
3877                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
3878                        for j in 0..heartbeats {
3879                            // Sub tick dt
3880                            let dt = (j as f32 / heartbeats as f32) * dt;
3881                            let distance = distance + speed * dt;
3882                            let pos1 = pos + distance * direction - Vec3::unit_z();
3883                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
3884                            let time = time + dt as f64;
3885
3886                            self.particles.push(Particle::new_directed(
3887                                Duration::from_secs_f32(0.5),
3888                                time,
3889                                ParticleMode::Steam,
3890                                pos1,
3891                                pos2,
3892                                scene_data,
3893                            ));
3894                        }
3895                    }
3896                },
3897                FrontendSpecifier::Poison => {
3898                    // 1 particle per unit length of arc
3899                    let particles_per_length = arc_length as usize;
3900                    let dtheta = radians / particles_per_length as f32;
3901                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
3902                    // lower duration = more particles
3903                    let heartbeats = self
3904                        .scheduler
3905                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
3906
3907                    // Reserves capacity for new particles
3908                    let new_particle_count = particles_per_length * heartbeats as usize;
3909                    self.particles.reserve(new_particle_count);
3910
3911                    for i in 0..particles_per_length {
3912                        let angle = theta + dtheta * i as f32;
3913                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
3914                        for j in 0..heartbeats {
3915                            // Sub tick dt
3916                            let dt = (j as f32 / heartbeats as f32) * dt;
3917                            let distance = distance + speed * dt;
3918                            let pos1 = pos + distance * direction - Vec3::unit_z();
3919                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
3920                            let time = time + dt as f64;
3921
3922                            self.particles.push(Particle::new_directed(
3923                                Duration::from_secs_f32(0.5),
3924                                time,
3925                                ParticleMode::Poison,
3926                                pos1,
3927                                pos2,
3928                                scene_data,
3929                            ));
3930                        }
3931                    }
3932                },
3933                FrontendSpecifier::AcidCloud => {
3934                    let particles_per_height = 5;
3935                    // 1 particle per unit length of arc
3936                    let particles_per_length = arc_length as usize;
3937                    let dtheta = radians / particles_per_length as f32;
3938                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
3939                    // lower duration = more particles
3940                    let heartbeats = self
3941                        .scheduler
3942                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
3943
3944                    // Reserves capacity for new particles
3945                    let new_particle_count =
3946                        particles_per_length * heartbeats as usize * particles_per_height;
3947                    self.particles.reserve(new_particle_count);
3948
3949                    for i in 0..particles_per_height {
3950                        let height = (i as f32 / (particles_per_height as f32 - 1.0)) * 4.0;
3951                        for j in 0..particles_per_length {
3952                            let angle = theta + dtheta * j as f32;
3953                            let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
3954                            for k in 0..heartbeats {
3955                                // Sub tick dt
3956                                let dt = (k as f32 / heartbeats as f32) * dt;
3957                                let distance = distance + speed * dt;
3958                                let pos1 = pos + distance * direction - Vec3::unit_z()
3959                                    + Vec3::unit_z() * height;
3960                                let pos2 = pos1 + direction;
3961                                let time = time + dt as f64;
3962
3963                                self.particles.push(Particle::new_directed(
3964                                    Duration::from_secs_f32(0.5),
3965                                    time,
3966                                    ParticleMode::Poison,
3967                                    pos1,
3968                                    pos2,
3969                                    scene_data,
3970                                ));
3971                            }
3972                        }
3973                    }
3974                },
3975                FrontendSpecifier::Ink => {
3976                    // 1 particle per unit length of arc
3977                    let particles_per_length = arc_length as usize;
3978                    let dtheta = radians / particles_per_length as f32;
3979                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
3980                    // lower duration = more particles
3981                    let heartbeats = self
3982                        .scheduler
3983                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
3984
3985                    // Reserves capacity for new particles
3986                    let new_particle_count = particles_per_length * heartbeats as usize;
3987                    self.particles.reserve(new_particle_count);
3988
3989                    for i in 0..particles_per_length {
3990                        let angle = theta + dtheta * i as f32;
3991                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
3992                        for j in 0..heartbeats {
3993                            // Sub tick dt
3994                            let dt = (j as f32 / heartbeats as f32) * dt;
3995                            let distance = distance + speed * dt;
3996                            let pos1 = pos + distance * direction - Vec3::unit_z();
3997                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
3998                            let time = time + dt as f64;
3999
4000                            self.particles.push(Particle::new_directed(
4001                                Duration::from_secs_f32(0.5),
4002                                time,
4003                                ParticleMode::Ink,
4004                                pos1,
4005                                pos2,
4006                                scene_data,
4007                            ));
4008                        }
4009                    }
4010                },
4011                FrontendSpecifier::IceSpikes | FrontendSpecifier::Ice => {
4012                    // 1 / 3 the size of terrain voxel
4013                    let scale = 1.0 / 3.0;
4014                    let scaled_distance = distance / scale;
4015                    let scaled_speed = speed / scale;
4016
4017                    // 1 particle per scaled unit length of arc
4018                    let particles_per_length = (0.25 * arc_length / scale) as usize;
4019                    let dtheta = radians / particles_per_length as f32;
4020                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
4021                    // lower duration = more particles
4022                    let heartbeats = self
4023                        .scheduler
4024                        .heartbeats(Duration::from_secs_f32(3.0 / scaled_speed));
4025
4026                    // Reserves capacity for new particles
4027                    let new_particle_count = particles_per_length * heartbeats as usize;
4028                    self.particles.reserve(new_particle_count);
4029                    // higher wave when wave doesn't require ground
4030                    let wave = if matches!(shockwave.properties.dodgeable, Dodgeable::Jump) {
4031                        0.5
4032                    } else {
4033                        8.0
4034                    };
4035                    // Used to make taller the further out spikes are
4036                    let height_scale = wave + 1.5 * percent;
4037                    for i in 0..particles_per_length {
4038                        let angle = theta + dtheta * i as f32;
4039                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
4040                        for j in 0..heartbeats {
4041                            // Sub tick dt
4042                            let dt = (j as f32 / heartbeats as f32) * dt;
4043                            let scaled_distance = scaled_distance + scaled_speed * dt;
4044                            let mut pos1 = pos + (scaled_distance * direction).floor() * scale;
4045                            let time = time + dt as f64;
4046
4047                            // Arbitrary number chosen that is large enough to be able to accurately
4048                            // place particles most of the time, but also not too big as to make ray
4049                            // be too large (for performance reasons)
4050                            let half_ray_length = 10.0;
4051                            let mut last_air = false;
4052                            // TODO: Optimize ray to only be cast at most once per block per tick if
4053                            // it becomes an issue.
4054                            // From imbris:
4055                            //      each ray is ~2 us
4056                            //      at 30 FPS, it peaked at 113 rays in a tick
4057                            //      total time was 240 us (although potentially half that is
4058                            //          overhead from the profiling of each ray)
4059                            let _ = terrain
4060                                .ray(
4061                                    pos1 + Vec3::unit_z() * half_ray_length,
4062                                    pos1 - Vec3::unit_z() * half_ray_length,
4063                                )
4064                                .for_each(|block: &Block, pos: Vec3<i32>| {
4065                                    if block.is_solid() && block.get_sprite().is_none() {
4066                                        if last_air {
4067                                            pos1 = pos1.xy().with_z(pos.z as f32 + 1.0);
4068                                            last_air = false;
4069                                        }
4070                                    } else {
4071                                        last_air = true;
4072                                    }
4073                                })
4074                                .cast();
4075
4076                            let get_positions = |a| {
4077                                let pos1 = match a {
4078                                    2 => pos1 + Vec3::unit_x() * scale,
4079                                    3 => pos1 - Vec3::unit_x() * scale,
4080                                    4 => pos1 + Vec3::unit_y() * scale,
4081                                    5 => pos1 - Vec3::unit_y() * scale,
4082                                    _ => pos1,
4083                                };
4084                                let pos2 = if a == 1 {
4085                                    pos1 + Vec3::unit_z() * 5.0 * height_scale
4086                                } else {
4087                                    pos1 + Vec3::unit_z() * 1.0 * height_scale
4088                                };
4089                                (pos1, pos2)
4090                            };
4091
4092                            for a in 1..=5 {
4093                                let (pos1, pos2) = get_positions(a);
4094                                self.particles.push(Particle::new_directed(
4095                                    Duration::from_secs_f32(0.5),
4096                                    time,
4097                                    ParticleMode::IceSpikes,
4098                                    pos1,
4099                                    pos2,
4100                                    scene_data,
4101                                ));
4102                            }
4103                        }
4104                    }
4105                },
4106            }
4107        }
4108    }
4109
4110    fn maintain_stance_particles(&mut self, scene_data: &SceneData) {
4111        let state = scene_data.state;
4112        let ecs = state.ecs();
4113        let time = state.get_time();
4114        let mut rng = rand::rng();
4115
4116        for (interp, pos, stance, body, ori) in (
4117            ecs.read_storage::<Interpolated>().maybe(),
4118            &ecs.read_storage::<Pos>(),
4119            &ecs.read_storage::<comp::Stance>(),
4120            &ecs.read_storage::<Body>(),
4121            &ecs.read_storage::<Ori>(),
4122        )
4123            .join()
4124        {
4125            let pos = interp.map_or(pos.0, |i| i.pos);
4126
4127            use comp::ability::{BowStance, Stance};
4128            match stance {
4129                Stance::Bow(BowStance::IgniteArrow) => {
4130                    self.particles.resize_with(
4131                        self.particles.len()
4132                            + usize::from(self.scheduler.heartbeats(Duration::from_millis(150))),
4133                        || {
4134                            let start_pos = pos
4135                                + Vec3::unit_z() * body.height() * 0.45
4136                                + ori.look_dir().xy().rotated_z(0.6) * body.front_radius() * 2.5
4137                                + Vec3::<f32>::zero()
4138                                    .map(|_| rng.random_range(-1.0..1.0))
4139                                    .normalized()
4140                                    * 0.05;
4141                            let end_pos = start_pos
4142                                + Vec3::unit_z() * 0.7
4143                                + Vec3::<f32>::zero()
4144                                    .map(|_| rng.random_range(-1.0..1.0))
4145                                    .normalized()
4146                                    * 0.05;
4147                            Particle::new_directed(
4148                                Duration::from_secs(1),
4149                                time,
4150                                ParticleMode::FlameThrower,
4151                                start_pos,
4152                                end_pos,
4153                                scene_data,
4154                            )
4155                        },
4156                    );
4157                },
4158                Stance::Bow(BowStance::DrenchArrow) => {
4159                    self.particles.resize_with(
4160                        self.particles.len()
4161                            + usize::from(self.scheduler.heartbeats(Duration::from_millis(500))),
4162                        || {
4163                            let start_pos = pos
4164                                + Vec3::unit_z() * body.height() * 0.45
4165                                + ori.look_dir().xy().rotated_z(0.6) * body.front_radius() * 2.5
4166                                + Vec3::<f32>::zero()
4167                                    .map(|_| rng.random_range(-1.0..1.0))
4168                                    .normalized()
4169                                    * 0.05;
4170                            let end_pos = start_pos - Vec3::unit_z() * 0.7
4171                                + Vec3::<f32>::zero()
4172                                    .map(|_| rng.random_range(-1.0..1.0))
4173                                    .normalized()
4174                                    * 0.05;
4175                            Particle::new_directed(
4176                                Duration::from_secs(1),
4177                                time,
4178                                ParticleMode::CultistFlame,
4179                                start_pos,
4180                                end_pos,
4181                                scene_data,
4182                            )
4183                        },
4184                    );
4185                },
4186                Stance::Bow(BowStance::FreezeArrow) => {
4187                    self.particles.resize_with(
4188                        self.particles.len()
4189                            + usize::from(self.scheduler.heartbeats(Duration::from_millis(400))),
4190                        || {
4191                            let start_pos = pos
4192                                + Vec3::unit_z() * body.height() * 0.45
4193                                + ori.look_dir().xy().rotated_z(0.6) * body.front_radius() * 2.5
4194                                + Vec3::<f32>::zero()
4195                                    .map(|_| rng.random_range(-1.0..1.0))
4196                                    .normalized()
4197                                    * 0.05;
4198                            let end_pos = start_pos
4199                                + Vec3::unit_z() * 1.0
4200                                + Vec3::<f32>::zero()
4201                                    .map(|_| rng.random_range(-1.0..1.0))
4202                                    .normalized()
4203                                    * 0.05;
4204                            Particle::new_directed(
4205                                Duration::from_millis(500),
4206                                time,
4207                                ParticleMode::Ice,
4208                                start_pos,
4209                                end_pos,
4210                                scene_data,
4211                            )
4212                        },
4213                    );
4214                },
4215                Stance::Bow(BowStance::JoltArrow) => {
4216                    self.particles.resize_with(
4217                        self.particles.len()
4218                            + usize::from(self.scheduler.heartbeats(Duration::from_millis(20))),
4219                        || {
4220                            let start_pos = pos
4221                                + Vec3::unit_z() * body.height() * 0.45
4222                                + ori.look_dir().xy().rotated_z(0.6) * body.front_radius() * 2.5
4223                                + Vec3::<f32>::zero()
4224                                    .map(|_| rng.random_range(-1.0..1.0))
4225                                    .normalized()
4226                                    * 0.2;
4227                            let end_pos = start_pos
4228                                + Vec3::<f32>::zero()
4229                                    .map(|_| rng.random_range(-1.0..1.0))
4230                                    .normalized()
4231                                    * 0.5;
4232                            Particle::new_directed(
4233                                Duration::from_millis(150),
4234                                time,
4235                                ParticleMode::ElectricSparks,
4236                                start_pos,
4237                                end_pos,
4238                                scene_data,
4239                            )
4240                        },
4241                    );
4242                },
4243                _ => {},
4244            }
4245        }
4246    }
4247
4248    fn maintain_marker_particles(&mut self, scene_data: &SceneData) {
4249        let state = scene_data.state;
4250        let ecs = state.ecs();
4251        let time = state.get_time();
4252        let mut rng = rand::rng();
4253
4254        for (interp, pos, vel, marker) in (
4255            ecs.read_storage::<Interpolated>().maybe(),
4256            &ecs.read_storage::<Pos>(),
4257            ecs.read_storage::<Vel>().maybe(),
4258            &ecs.read_storage::<comp::FrontendMarker>(),
4259        )
4260            .join()
4261        {
4262            let pos = interp.map_or(pos.0, |i| i.pos);
4263
4264            use comp::{FrontendMarker, visual::TorusMode};
4265            match marker {
4266                FrontendMarker::JoltArrow => {
4267                    self.particles.resize_with(
4268                        self.particles.len()
4269                            + usize::from(self.scheduler.heartbeats(Duration::from_millis(20))),
4270                        || {
4271                            let start_pos = pos
4272                                + Vec3::<f32>::zero()
4273                                    .map(|_| rng.random_range(-1.0..1.0))
4274                                    .normalized()
4275                                    * 0.2;
4276                            let end_pos = start_pos
4277                                + Vec3::<f32>::zero()
4278                                    .map(|_| rng.random_range(-1.0..1.0))
4279                                    .normalized()
4280                                    * 0.5;
4281                            Particle::new_directed(
4282                                Duration::from_millis(150),
4283                                time,
4284                                ParticleMode::ElectricSparks,
4285                                start_pos,
4286                                end_pos,
4287                                scene_data,
4288                            )
4289                        },
4290                    );
4291                },
4292                FrontendMarker::Torus(major_r, torus_mode) => {
4293                    let time = scene_data.state.get_time();
4294                    let mut rng = rand::rng();
4295                    let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
4296
4297                    //Torus' orientation
4298                    let fwd = vel
4299                        .map(|v| v.0)
4300                        .unwrap_or(Vec3::unit_y())
4301                        .try_normalized()
4302                        .unwrap_or(Vec3::unit_y());
4303                    let right = fwd
4304                        .cross(Vec3::unit_z())
4305                        .try_normalized()
4306                        .unwrap_or(Vec3::unit_x());
4307                    let up = right.cross(fwd);
4308
4309                    let major_r = *major_r;
4310                    let minor_r: f32 = major_r / 1.5;
4311                    let flame_reach: f32 = fwd.magnitude();
4312
4313                    self.particles.resize_with(
4314                        self.particles.len() + usize::from(heartbeats) * 8,
4315                        || {
4316                            let u = rng.random_range(0.0..TAU); // Angle around ring
4317                            let v = rng.random_range(0.0..TAU); // Angle within tube
4318
4319                            let radial = u.cos() * right + u.sin() * up;
4320                            let ring_center = pos + major_r * radial;
4321                            let tube_pos =
4322                                ring_center + minor_r * v.cos() * radial + minor_r * v.sin() * fwd;
4323
4324                            let mode = match torus_mode {
4325                                TorusMode::RedBlueFire => {
4326                                    if rng.random_bool(0.4) {
4327                                        ParticleMode::FlamethrowerBlue
4328                                    } else {
4329                                        ParticleMode::FlameThrower
4330                                    }
4331                                },
4332                            };
4333
4334                            // Zero wind velocity so the torus holds its shape regardless of wind.
4335                            let lifespan: Duration = Duration::from_millis(220);
4336                            Particle {
4337                                alive_until: time + lifespan.as_secs_f64(),
4338                                instance: ParticleInstance::new_directed(
4339                                    time,
4340                                    lifespan.as_secs_f32(),
4341                                    mode,
4342                                    tube_pos,
4343                                    tube_pos + radial * flame_reach,
4344                                    Vec2::zero(),
4345                                ),
4346                            }
4347                        },
4348                    );
4349                },
4350            }
4351        }
4352    }
4353
4354    fn maintain_arcing_particles(&mut self, scene_data: &SceneData) {
4355        let state = scene_data.state;
4356        let ecs = state.ecs();
4357        let time = state.get_time();
4358        let mut rng = rand::rng();
4359        let id_maps = ecs.read_resource::<IdMaps>();
4360
4361        for (interp, pos, arcing) in (
4362            ecs.read_storage::<Interpolated>().maybe(),
4363            &ecs.read_storage::<Pos>(),
4364            &ecs.read_storage::<comp::Arcing>(),
4365        )
4366            .join()
4367        {
4368            let pos = interp.map_or(pos.0, |i| i.pos);
4369            let body = arcing
4370                .hit_entities
4371                .last()
4372                .and_then(|uid| id_maps.uid_entity(*uid))
4373                .and_then(|e| ecs.read_storage::<Body>().get(e).copied());
4374            let height = body.map_or(2.0, |b| b.height());
4375            let radius = body.map_or(1.0, |b| b.max_radius());
4376            let pos = pos + Vec3::unit_z() * height / 2.0;
4377            self.particles.resize_with(
4378                self.particles.len()
4379                    + usize::from(self.scheduler.heartbeats(Duration::from_millis(5))),
4380                || {
4381                    let start_pos = pos
4382                        + Vec3::<f32>::zero()
4383                            .map(|_| rng.random_range(-1.0..1.0))
4384                            .normalized()
4385                            * radius;
4386                    let end_pos = start_pos
4387                        + Vec3::<f32>::zero()
4388                            .map(|_| rng.random_range(-1.0..1.0))
4389                            .normalized()
4390                            * (radius + 0.5);
4391                    Particle::new_directed(
4392                        Duration::from_millis(200),
4393                        time,
4394                        ParticleMode::ElectricSparks,
4395                        start_pos,
4396                        end_pos,
4397                        scene_data,
4398                    )
4399                },
4400            );
4401
4402            let num = arcing.hit_entities.len();
4403            if num > 1 && (time - arcing.last_arc_time.0 < arcing.properties.min_delay.0) {
4404                let last_pos = {
4405                    let last_hit = arcing
4406                        .hit_entities
4407                        .get(num - 2)
4408                        .and_then(|uid| id_maps.uid_entity(*uid));
4409                    let pos = last_hit.and_then(|e| ecs.read_storage::<Pos>().get(e).map(|p| p.0));
4410                    let height = last_hit
4411                        .and_then(|e| ecs.read_storage::<Body>().get(e).map(|b| b.height()))
4412                        .unwrap_or(2.0);
4413                    pos.map(|p| p + Vec3::unit_z() * height / 2.0)
4414                };
4415
4416                if let Some(last_pos) = last_pos {
4417                    let vector = last_pos - pos;
4418                    let dist = vector.magnitude();
4419                    let ctrl = pos + vector / 2.0 + Vec3::unit_z() * dist;
4420                    let bezier = QuadraticBezier3 {
4421                        start: last_pos,
4422                        ctrl,
4423                        end: pos,
4424                    };
4425                    let segments = (dist * 1.0).ceil() as i32 + 2;
4426                    for segment in 0..(segments - 1) {
4427                        let t_0 = segment as f32 / segments as f32;
4428                        let t_1 = (segment + 2) as f32 / segments as f32;
4429                        self.particles.resize_with(
4430                            self.particles.len()
4431                                + usize::from(self.scheduler.heartbeats(Duration::from_millis(30))),
4432                            || {
4433                                let start_pos = bezier.evaluate(t_0)
4434                                    + Vec3::<f32>::zero()
4435                                        .map(|_| rng.random_range(-1.0..1.0))
4436                                        .normalized()
4437                                        * 0.2;
4438                                let end_pos = bezier.evaluate(t_1)
4439                                    + Vec3::<f32>::zero()
4440                                        .map(|_| rng.random_range(-1.0..1.0))
4441                                        .normalized()
4442                                        * 0.2;
4443                                Particle::new_directed(
4444                                    Duration::from_millis(150),
4445                                    time,
4446                                    ParticleMode::ElectricSparks,
4447                                    start_pos,
4448                                    end_pos,
4449                                    scene_data,
4450                                )
4451                            },
4452                        );
4453                    }
4454                }
4455            }
4456        }
4457    }
4458
4459    fn maintain_pool_particles(&mut self, scene_data: &SceneData) {
4460        prof_span!("ParticleMgr::maintain_pool_particles");
4461        let state = scene_data.state;
4462        let ecs = state.ecs();
4463        let time = state.get_time();
4464        let mut rng = rand::rng();
4465
4466        for (interp, pos, pool) in (
4467            ecs.read_storage::<Interpolated>().maybe(),
4468            &ecs.read_storage::<Pos>(),
4469            &ecs.read_storage::<comp::Pool>(),
4470        )
4471            .join()
4472        {
4473            let pos = interp.map_or(pos.0, |i| i.pos);
4474            let radius = pool.properties.radius;
4475
4476            // Fire particles spread across the pool radius.
4477            self.particles.resize_with(
4478                self.particles.len()
4479                    + usize::from(self.scheduler.heartbeats(Duration::from_millis(20))),
4480                || {
4481                    Particle::new(
4482                        Duration::from_millis(700),
4483                        time,
4484                        ParticleMode::CampfireFire,
4485                        pos + Vec3::new(
4486                            rng.random_range(-radius..radius),
4487                            rng.random_range(-radius..radius),
4488                            0.1,
4489                        ),
4490                        scene_data,
4491                    )
4492                },
4493            );
4494
4495            // Smoke rises above the pool.
4496            self.particles.resize_with(
4497                self.particles.len()
4498                    + usize::from(self.scheduler.heartbeats(Duration::from_millis(60))),
4499                || {
4500                    Particle::new(
4501                        Duration::from_secs(6),
4502                        time,
4503                        ParticleMode::CampfireSmoke,
4504                        pos + Vec3::new(
4505                            rng.random_range(-radius * 0.6..radius * 0.6),
4506                            rng.random_range(-radius * 0.6..radius * 0.6),
4507                            rng.random_range(0.5..1.5),
4508                        ),
4509                        scene_data,
4510                    )
4511                },
4512            );
4513        }
4514    }
4515
4516    fn upload_particles(&mut self, renderer: &mut Renderer) {
4517        prof_span!("ParticleMgr::upload_particles");
4518        let all_cpu_instances = self
4519            .particles
4520            .iter()
4521            .map(|p| p.instance)
4522            .collect::<Vec<ParticleInstance>>();
4523
4524        // TODO: optimise buffer writes
4525        let gpu_instances = renderer.create_instances(&all_cpu_instances);
4526
4527        self.instances = gpu_instances;
4528    }
4529
4530    pub fn render<'a>(&'a self, drawer: &mut ParticleDrawer<'_, 'a>, scene_data: &SceneData) {
4531        prof_span!("ParticleMgr::render");
4532        if scene_data.particles_enabled {
4533            let model = &self
4534                .model_cache
4535                .get(DEFAULT_MODEL_KEY)
4536                .expect("Expected particle model in cache");
4537
4538            drawer.draw(model, &self.instances);
4539        }
4540    }
4541
4542    pub fn particle_count(&self) -> usize { self.instances.count() }
4543
4544    pub fn particle_count_visible(&self) -> usize { self.instances.count() }
4545}
4546
4547fn default_instances(renderer: &mut Renderer) -> Instances<ParticleInstance> {
4548    let empty_vec = Vec::new();
4549
4550    renderer.create_instances(&empty_vec)
4551}
4552
4553const DEFAULT_MODEL_KEY: &str = "voxygen.voxel.particle";
4554
4555fn default_cache(renderer: &mut Renderer) -> HashMap<&'static str, Model<ParticleVertex>> {
4556    let mut model_cache = HashMap::new();
4557
4558    model_cache.entry(DEFAULT_MODEL_KEY).or_insert_with(|| {
4559        let vox = DotVox::load_expect(DEFAULT_MODEL_KEY);
4560
4561        // NOTE: If we add texturing we may eventually try to share it among all
4562        // particles in a single atlas.
4563        let max_texture_size = renderer.max_texture_size();
4564        let max_size = Vec2::from(u16::try_from(max_texture_size).unwrap_or(u16::MAX));
4565        let mut greedy = GreedyMesh::new(max_size, crate::mesh::greedy::general_config());
4566
4567        let segment = Segment::from_vox_model_index(&vox.read().0, 0, None);
4568        let segment_size = segment.size();
4569        let mut mesh = generate_mesh_base_vol_particle(segment, &mut greedy).0;
4570        // Center particle vertices around origin
4571        for vert in mesh.vertices_mut() {
4572            vert.pos[0] -= segment_size.x as f32 / 2.0;
4573            vert.pos[1] -= segment_size.y as f32 / 2.0;
4574            vert.pos[2] -= segment_size.z as f32 / 2.0;
4575        }
4576
4577        // NOTE: Ignoring coloring / lighting for now.
4578        drop(greedy);
4579
4580        renderer
4581            .create_model(&mesh)
4582            .expect("Failed to create particle model")
4583    });
4584
4585    model_cache
4586}
4587
4588/// Accumulates heartbeats to be consumed on the next tick.
4589struct HeartbeatScheduler {
4590    /// Duration = Heartbeat Frequency/Intervals
4591    /// f64 = Last update time
4592    /// u8 = number of heartbeats since last update
4593    /// - if it's more frequent then tick rate, it could be 1 or more.
4594    /// - if it's less frequent then tick rate, it could be 1 or 0.
4595    /// - if it's equal to the tick rate, it could be between 2 and 0, due to
4596    ///   delta time variance etc.
4597    timers: HashMap<Duration, (f64, u8)>,
4598
4599    last_known_time: f64,
4600}
4601
4602impl HeartbeatScheduler {
4603    pub fn new() -> Self {
4604        HeartbeatScheduler {
4605            timers: HashMap::new(),
4606            last_known_time: 0.0,
4607        }
4608    }
4609
4610    /// updates the last elapsed times and elapsed counts
4611    /// this should be called once, and only once per tick.
4612    pub fn maintain(&mut self, now: f64) {
4613        prof_span!("HeartbeatScheduler::maintain");
4614        self.last_known_time = now;
4615
4616        for (frequency, (last_update, heartbeats)) in self.timers.iter_mut() {
4617            // the number of frequency cycles that have occurred.
4618            let total_heartbeats = (now - *last_update) / frequency.as_secs_f64();
4619
4620            // exclude partial frequency cycles
4621            let full_heartbeats = total_heartbeats.floor();
4622
4623            *heartbeats = full_heartbeats as u8;
4624
4625            // the remaining partial frequency cycle, as a decimal.
4626            let partial_heartbeat = total_heartbeats - full_heartbeats;
4627
4628            // the remaining partial frequency cycle, as a unit of time(f64).
4629            let partial_heartbeat_as_time = frequency.mul_f64(partial_heartbeat).as_secs_f64();
4630
4631            // now minus the left over heart beat count precision as seconds,
4632            // Note: we want to preserve incomplete heartbeats, and roll them
4633            // over into the next update.
4634            *last_update = now - partial_heartbeat_as_time;
4635        }
4636    }
4637
4638    /// returns the number of times this duration has elapsed since the last
4639    /// tick:
4640    ///   - if it's more frequent then tick rate, it could be 1 or more.
4641    ///   - if it's less frequent then tick rate, it could be 1 or 0.
4642    ///   - if it's equal to the tick rate, it could be between 2 and 0, due to
4643    ///     delta time variance.
4644    pub fn heartbeats(&mut self, frequency: Duration) -> u8 {
4645        prof_span!("HeartbeatScheduler::heartbeats");
4646        let last_known_time = self.last_known_time;
4647
4648        self.timers
4649            .entry(frequency)
4650            .or_insert_with(|| (last_known_time, 0))
4651            .1
4652    }
4653
4654    pub fn clear(&mut self) { self.timers.clear() }
4655}
4656
4657#[derive(Clone, Copy)]
4658struct Particle {
4659    alive_until: f64, // created_at + lifespan
4660    instance: ParticleInstance,
4661}
4662
4663impl Particle {
4664    fn new(
4665        lifespan: Duration,
4666        time: f64,
4667        mode: ParticleMode,
4668        pos: Vec3<f32>,
4669        scene_data: &SceneData,
4670    ) -> Self {
4671        Particle {
4672            alive_until: time + lifespan.as_secs_f64(),
4673            instance: ParticleInstance::new(
4674                time,
4675                lifespan.as_secs_f32(),
4676                mode,
4677                pos,
4678                scene_data.wind_vel,
4679            ),
4680        }
4681    }
4682
4683    fn new_directed(
4684        lifespan: Duration,
4685        time: f64,
4686        mode: ParticleMode,
4687        pos1: Vec3<f32>,
4688        pos2: Vec3<f32>,
4689        scene_data: &SceneData,
4690    ) -> Self {
4691        Particle {
4692            alive_until: time + lifespan.as_secs_f64(),
4693            instance: ParticleInstance::new_directed(
4694                time,
4695                lifespan.as_secs_f32(),
4696                mode,
4697                pos1,
4698                pos2,
4699                scene_data.wind_vel,
4700            ),
4701        }
4702    }
4703
4704    fn new_directed_with_collision(
4705        lifespan: Duration,
4706        time: f64,
4707        mode: ParticleMode,
4708        pos1: Vec3<f32>,
4709        pos2: Vec3<f32>,
4710        scene_data: &SceneData,
4711        distance: impl Fn(Vec3<f32>, Vec3<f32>) -> f32,
4712    ) -> Self {
4713        let dir = pos2 - pos1;
4714        let end_distance = pos1.distance(pos2);
4715        let (end_pos, lifespawn) = if end_distance > 0.1 {
4716            let ratio = distance(pos1, pos2) / end_distance;
4717            (pos1 + ratio * dir, lifespan.mul_f32(ratio))
4718        } else {
4719            (pos2, lifespan)
4720        };
4721
4722        Self::new_directed(lifespawn, time, mode, pos1, end_pos, scene_data)
4723    }
4724}