• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

djeedai / bevy_hanabi / 13882237780

16 Mar 2025 10:01AM UTC coverage: 40.144% (+0.1%) from 40.012%
13882237780

Pull #434

github

web-flow
Merge d5f01d014 into a97ab1e92
Pull Request #434: Add spawner cycle count

74 of 93 new or added lines in 2 files covered. (79.57%)

2 existing lines in 1 file now uncovered.

3232 of 8051 relevant lines covered (40.14%)

18.67 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

63.55
/src/spawn.rs
1
use std::hash::{Hash, Hasher};
2

3
use bevy::{ecs::system::Resource, math::FloatOrd, prelude::*, reflect::Reflect};
4
use rand::{
5
    distributions::{uniform::SampleUniform, Distribution, Uniform},
6
    SeedableRng,
7
};
8
use rand_pcg::Pcg32;
9
use serde::{Deserialize, Serialize};
10

11
use crate::{EffectAsset, EffectSimulation, ParticleEffect, SimulationCondition};
12

13
/// An RNG to be used in the CPU for the particle system engine
14
pub(crate) fn new_rng() -> Pcg32 {
17✔
15
    let mut rng = rand::thread_rng();
17✔
16
    let mut seed = [0u8; 16];
17✔
17
    seed.copy_from_slice(&Uniform::from(0..=u128::MAX).sample(&mut rng).to_le_bytes());
17✔
18
    Pcg32::from_seed(seed)
17✔
19
}
20

21
/// An RNG resource
22
#[derive(Resource)]
23
pub struct Random(pub Pcg32);
24

25
/// Utility trait to help implementing [`std::hash::Hash`] for [`CpuValue`] of
26
/// floating-point type.
27
pub trait FloatHash: PartialEq {
28
    fn hash_f32<H: Hasher>(&self, state: &mut H);
29
}
30

31
impl FloatHash for f32 {
32
    fn hash_f32<H: Hasher>(&self, state: &mut H) {
×
33
        FloatOrd(*self).hash(state);
×
34
    }
35
}
36

37
impl FloatHash for Vec2 {
38
    fn hash_f32<H: Hasher>(&self, state: &mut H) {
×
39
        FloatOrd(self.x).hash(state);
×
40
        FloatOrd(self.y).hash(state);
×
41
    }
42
}
43

44
impl FloatHash for Vec3 {
45
    fn hash_f32<H: Hasher>(&self, state: &mut H) {
×
46
        FloatOrd(self.x).hash(state);
×
47
        FloatOrd(self.y).hash(state);
×
48
        FloatOrd(self.z).hash(state);
×
49
    }
50
}
51

52
impl FloatHash for Vec4 {
53
    fn hash_f32<H: Hasher>(&self, state: &mut H) {
×
54
        FloatOrd(self.x).hash(state);
×
55
        FloatOrd(self.y).hash(state);
×
56
        FloatOrd(self.z).hash(state);
×
57
        FloatOrd(self.w).hash(state);
×
58
    }
59
}
60

61
/// A constant or random value evaluated on CPU.
62
///
63
/// This enum represents a value which is either constant, or randomly sampled
64
/// according to a given probability distribution.
65
///
66
/// Not to be confused with [`graph::Value`]. This [`CpuValue`] is a legacy type
67
/// that will be eventually replaced with a [`graph::Value`] once evaluation of
68
/// the latter can be emulated on CPU, which is required for use
69
/// with the [`SpawnerSettings`].
70
///
71
/// [`graph::Value`]: crate::graph::Value
72
#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Reflect)]
73
#[non_exhaustive]
74
pub enum CpuValue<T: Copy + FromReflect> {
75
    /// Single constant value.
76
    Single(T),
77
    /// Random value distributed uniformly between two inclusive bounds.
78
    ///
79
    /// The minimum bound must be less than or equal to the maximum one,
80
    /// otherwise some methods like [`sample()`] will panic.
81
    ///
82
    /// [`sample()`]: crate::CpuValue::sample
83
    Uniform((T, T)),
84
}
85

86
impl<T: Copy + FromReflect + Default> Default for CpuValue<T> {
87
    fn default() -> Self {
2✔
88
        Self::Single(T::default())
2✔
89
    }
90
}
91

92
impl<T: Copy + FromReflect + SampleUniform> CpuValue<T> {
93
    /// Sample the value.
94
    /// - For [`CpuValue::Single`], always return the same single value.
95
    /// - For [`CpuValue::Uniform`], use the given pseudo-random number
96
    ///   generator to generate a random sample.
97
    pub fn sample(&self, rng: &mut Pcg32) -> T {
69✔
98
        match self {
69✔
99
            Self::Single(x) => *x,
69✔
100
            Self::Uniform((a, b)) => Uniform::new_inclusive(*a, *b).sample(rng),
×
101
        }
102
    }
103
}
104

105
impl<T: Copy + FromReflect + PartialOrd> CpuValue<T> {
106
    /// Returns the range of allowable values in the form `[minimum, maximum]`.
107
    /// For [`CpuValue::Single`], both values are the same.
108
    pub fn range(&self) -> [T; 2] {
94✔
109
        match self {
94✔
110
            Self::Single(x) => [*x; 2],
87✔
111
            Self::Uniform((a, b)) => {
7✔
112
                if a <= b {
7✔
113
                    [*a, *b]
6✔
114
                } else {
115
                    [*b, *a]
1✔
116
                }
117
            }
118
        }
119
    }
120
}
121

122
impl<T: Copy + FromReflect> From<T> for CpuValue<T> {
123
    fn from(t: T) -> Self {
102✔
124
        Self::Single(t)
102✔
125
    }
126
}
127

128
impl<T: Copy + FromReflect + FloatHash> Eq for CpuValue<T> {}
129

130
impl<T: Copy + FromReflect + FloatHash> Hash for CpuValue<T> {
131
    fn hash<H: Hasher>(&self, state: &mut H) {
×
132
        match self {
×
133
            CpuValue::Single(f) => {
×
134
                1_u8.hash(state);
×
135
                f.hash_f32(state);
×
136
            }
137
            CpuValue::Uniform((a, b)) => {
×
138
                2_u8.hash(state);
×
139
                a.hash_f32(state);
×
140
                b.hash_f32(state);
×
141
            }
142
        }
143
    }
144
}
145

146
/// Settings for an [`EffectSpawner`].
147
///
148
/// A [`SpawnerSettings`] represents the settings of an [`EffectSpawner`].
149
///
150
/// The spawning logic is based around the concept of _cycles_. A spawner
151
/// defines a pattern of particle spawning as the repetition of a number of unit
152
/// cycles. Each cycle is composed of a period of emission, followed by a period
153
/// of rest (idling). Both periods can be of zero duration.
154
///
155
/// The settings are:
156
///
157
/// - `count` is the number of particles to spawn over a single cycle, during
158
///   the emission period. It can evaluate to negative or zero random values, in
159
///   which case no particle is spawned during the current cycle.
160
/// - `spawn_duration` is the duration of the spawn part of a single cycle. If
161
///   this is <= 0, then the particles spawn all at once exactly at the same
162
///   instant at the beginning of the cycle.
163
/// - `period` is the period of a cycle. If this is <= `spawn_duration`, then
164
///   the spawner spawns a steady stream of particles. This is ignored if
165
///   `cycle_count == 1`.
166
/// - `cycle_count` is the number of cycles, that is the number of times this
167
///   spawn-rest pattern occurs. Set this to `0` to repeat forever.
168
///
169
/// ```txt
170
///  <----------- period ----------->
171
///  <- spawn_duration ->
172
/// |********************|-----------|
173
///      spawn 'count'        wait
174
///        particles
175
/// ```
176
///
177
/// Most settings are stored as a [`CpuValue`] to allow some randomizing. If
178
/// using a random distribution, the value is resampled each cycle.
179
///
180
/// Note that the "burst" semantic here doesn't strictly mean a one-off
181
/// emission, since that emission is spread over a number of simulation
182
/// frames that total a duration of `spawn_duration`. If you want a strict
183
/// single-frame burst, simply set the `spawn_duration` to zero; this is
184
/// what [`once()`] does.
185
///
186
/// [`once()`]: Self::once
187
#[derive(Debug, Clone, Copy, PartialEq, Reflect, Serialize, Deserialize)]
188
#[reflect(Default)]
189
pub struct SpawnerSettings {
190
    /// Number of particles to spawn over [`spawn_duration`].
191
    ///
192
    /// [`spawn_duration`]: Self::spawn_duration
193
    count: CpuValue<f32>,
194

195
    /// Time over which to spawn [`count`], in seconds.
196
    ///
197
    /// [`count`]: Self::count
198
    spawn_duration: CpuValue<f32>,
199

200
    /// Time between bursts of the particle system, in seconds.
201
    ///
202
    /// If this is [`spawn_duration`] or less, the system spawns a steady stream
203
    /// of particles.
204
    ///
205
    /// [`spawn_duration`]: Self::spawn_duration
206
    period: CpuValue<f32>,
207

208
    /// Number of cycles the spawner is active before completing.
209
    ///
210
    /// Each cycle lasts for `period`. A value of `0` means "infinite", that is
211
    /// the spanwe emits particle forever as long as it's active.
212
    cycle_count: u32,
213

214
    /// Whether the [`EffectSpawner`] is active at startup.
215
    ///
216
    /// The value is used to initialize [`EffectSpawner::active`].
217
    ///
218
    /// [`EffectSpawner::active`]: crate::EffectSpawner::active
219
    starts_active: bool,
220

221
    /// Whether the [`EffectSpawner`] immediately starts emitting particles.
222
    emit_on_start: bool,
223
}
224

225
impl Default for SpawnerSettings {
226
    fn default() -> Self {
15✔
227
        Self::once(1.0f32.into())
15✔
228
    }
229
}
230

231
impl SpawnerSettings {
232
    /// Create settings from individual values.
233
    ///
234
    /// This is the _raw_ constructor. In general you should prefer using one of
235
    /// the utility constructors [`once()`], [`burst()`], or [`rate()`],
236
    /// which will ensure the control parameters are set consistently relative
237
    /// to each other.
238
    ///
239
    /// # Panics
240
    ///
241
    /// Panics if `period` can produce a negative number (the sample range lower
242
    /// bound is negative), unless the cycle count is exactly 1, in which case
243
    /// `period` is ignored.
244
    ///
245
    /// Panics if `period` can only produce 0 (the sample range upper bound
246
    /// is not strictly positive), unless the cycle count is exactly 1, in which
247
    /// case `period` is ignored.
248
    ///
249
    /// Panics if any value is infinite.
250
    ///
251
    /// # Example
252
    ///
253
    /// ```
254
    /// # use bevy_hanabi::SpawnerSettings;
255
    /// // Spawn 32 particles over 3 seconds, then pause for 7 seconds (10 - 3),
256
    /// // doing that 5 times in total.
257
    /// let spawner = SpawnerSettings::new(32.0.into(), 3.0.into(), 10.0.into(), 5);
258
    /// ```
259
    ///
260
    /// [`once()`]: Self::once
261
    /// [`burst()`]: Self::burst
262
    /// [`rate()`]: Self::rate
263
    pub fn new(
34✔
264
        count: CpuValue<f32>,
265
        spawn_duration: CpuValue<f32>,
266
        period: CpuValue<f32>,
267
        cycle_count: u32,
268
    ) -> Self {
269
        assert!(
34✔
270
            cycle_count == 1 || period.range()[0] >= 0.,
47✔
271
            "`period` must not generate negative numbers (period.min was {}, expected >= 0).",
1✔
272
            period.range()[0]
1✔
273
        );
274
        assert!(
33✔
275
            cycle_count == 1 || period.range()[1] > 0.,
45✔
276
            "`period` must be able to generate a positive number (period.max was {}, expected > 0).",
1✔
277
            period.range()[1]
1✔
278
        );
279
        assert!(
32✔
280
            period.range()[0].is_finite() && period.range()[1].is_finite(),
64✔
NEW
281
            "`period` {:?} has an infinite bound. If upgrading from a previous version, use `cycle_count = 1` instead for a single-cycle burst.",
×
282
            period
283
        );
284

285
        Self {
286
            count,
287
            spawn_duration,
288
            period,
289
            cycle_count,
290
            starts_active: true,
291
            emit_on_start: true,
292
        }
293
    }
294

295
    /// Set whether the [`EffectSpawner`] immediately starts to emit particle
296
    /// when the [`ParticleEffect`] is spawned into the ECS world.
297
    ///
298
    /// If set to `false`, then [`EffectSpawner::has_completed()`] will return
299
    /// `true` after spawning the component, and the spawner needs to be
300
    /// [`EffectSpawner::reset()`] before it can spawn particles. This is
301
    /// useful to spawn a particle effect instance immediately, but only start
302
    /// emitting particles when an event occurs (collision, user input, any
303
    /// other game logic...).
304
    ///
305
    /// Because a spawner repeating forever never completes, this has no effect
306
    /// if [`is_forever()`] is `true`. To start/stop spawning with those
307
    /// effects, use [`EffectSpawner::active`] instead.
308
    ///
309
    /// [`is_forever()`]: Self::is_forever
NEW
310
    pub fn with_emit_on_start(mut self, emit_on_start: bool) -> Self {
×
NEW
311
        self.emit_on_start = emit_on_start;
×
NEW
312
        self
×
313
    }
314

315
    /// Create settings to spawn a burst of particles once.
316
    ///
317
    /// The burst of particles is spawned all at once in the same frame. After
318
    /// that, the spawner idles, waiting to be manually reset via
319
    /// [`EffectSpawner::reset()`].
320
    ///
321
    /// This is a convenience for:
322
    ///
323
    /// ```
324
    /// # use bevy_hanabi::{SpawnerSettings, CpuValue};
325
    /// # let count = CpuValue::Single(1.);
326
    /// SpawnerSettings::new(count, 0.0.into(), 0.0.into(), 1);
327
    /// ```
328
    ///
329
    /// # Example
330
    ///
331
    /// ```
332
    /// # use bevy_hanabi::SpawnerSettings;
333
    /// // Spawn 32 particles in a burst once immediately on creation.
334
    /// let spawner = SpawnerSettings::once(32.0.into());
335
    /// ```
336
    pub fn once(count: CpuValue<f32>) -> Self {
21✔
337
        Self::new(count, 0.0.into(), 0.0.into(), 1)
21✔
338
    }
339

340
    /// Get whether the spawner has a single cycle.
341
    ///
342
    /// This is true if the cycle count is exactly equal to 1.
343
    pub fn is_once(&self) -> bool {
32✔
344
        self.cycle_count == 1
32✔
345
    }
346

347
    /// Get whether the spawner has an infinite number of cycles.
348
    ///
349
    /// This is true if the cycle count is exactly equal to 0.
350
    pub fn is_forever(&self) -> bool {
57✔
351
        self.cycle_count == 0
57✔
352
    }
353

354
    /// Create settings to spawn a continuous stream of particles.
355
    ///
356
    /// The particle spawn `rate` is expressed in particles per second.
357
    /// Fractional values are accumulated each frame.
358
    ///
359
    /// This is a convenience for:
360
    ///
361
    /// ```
362
    /// # use bevy_hanabi::{SpawnerSettings, CpuValue};
363
    /// # let rate = CpuValue::Single(1.);
364
    /// SpawnerSettings::new(rate, 1.0.into(), 1.0.into(), 0);
365
    /// ```
366
    ///
367
    /// # Example
368
    ///
369
    /// ```
370
    /// # use bevy_hanabi::SpawnerSettings;
371
    /// // Spawn 10 particles per second, indefinitely.
372
    /// let spawner = SpawnerSettings::rate(10.0.into());
373
    /// ```
374
    pub fn rate(rate: CpuValue<f32>) -> Self {
9✔
375
        Self::new(rate, 1.0.into(), 1.0.into(), 0)
9✔
376
    }
377

378
    /// Create settings to spawn particles in bursts.
379
    ///
380
    /// The settings define an infinite number of cycles where `count` particles
381
    /// are spawned at the beginning of the cycle, then the spawner waits
382
    /// `period` seconds, and repeats forever.
383
    ///
384
    /// This is a convenience for:
385
    ///
386
    /// ```
387
    /// # use bevy_hanabi::{SpawnerSettings, CpuValue};
388
    /// # let count = CpuValue::Single(1.);
389
    /// # let period = CpuValue::Single(1.);
390
    /// SpawnerSettings::new(count, 0.0.into(), period, 0);
391
    /// ```
392
    ///
393
    /// # Example
394
    ///
395
    /// ```
396
    /// # use bevy_hanabi::SpawnerSettings;
397
    /// // Spawn a burst of 5 particles every 3 seconds, indefinitely.
398
    /// let spawner = SpawnerSettings::burst(5.0.into(), 3.0.into());
399
    /// ```
400
    pub fn burst(count: CpuValue<f32>, period: CpuValue<f32>) -> Self {
1✔
401
        Self::new(count, 0.0.into(), period, 0)
1✔
402
    }
403

404
    /// Set the number of particles that are spawned each cycle.
405
    pub fn with_count(mut self, count: CpuValue<f32>) -> Self {
×
406
        self.count = count;
×
407
        self
×
408
    }
409

410
    /// Set the number of particles that are spawned each cycle.
411
    pub fn set_count(&mut self, count: CpuValue<f32>) {
×
412
        self.count = count;
×
413
    }
414

415
    /// Get the number of particles that are spawned each cycle.
416
    pub fn count(&self) -> CpuValue<f32> {
×
417
        self.count
×
418
    }
419

420
    /// Set the duration, in seconds, of the spawn part each cycle.
NEW
421
    pub fn with_spawn_duration(mut self, spawn_duration: CpuValue<f32>) -> Self {
×
422
        self.spawn_duration = spawn_duration;
×
423
        self
×
424
    }
425

426
    /// Set the duration, in seconds, of the spawn part each cycle.
NEW
427
    pub fn set_spawn_duration(&mut self, spawn_duration: CpuValue<f32>) {
×
UNCOV
428
        self.spawn_duration = spawn_duration;
×
429
    }
430

431
    /// Get the duration, in seconds, of the spawn part each cycle.
432
    pub fn spawn_duration(&self) -> CpuValue<f32> {
×
433
        self.spawn_duration
×
434
    }
435

436
    /// Set the duration of a single spawn cycle, in seconds.
437
    ///
438
    /// A spawn cycle includes the [`spawn_duration()`] value, and any extra
439
    /// wait time (if larger than spawn time).
440
    ///
441
    /// # Panics
442
    ///
443
    /// Panics if the period is infinite.
444
    ///
445
    /// [`spawn_duration()`]: Self::spawn_duration
446
    pub fn with_period(mut self, period: CpuValue<f32>) -> Self {
×
NEW
447
        assert!(
×
NEW
448
            period.range()[0].is_finite() && period.range()[1].is_finite(),
×
NEW
449
            "`period` {:?} has an infinite bound. If upgrading from a previous version, use `cycle_count = 1` instead for a single-cycle burst.",
×
450
            period
451
        );
452
        self.period = period;
×
453
        self
×
454
    }
455

456
    /// Set the duration of a single spawn cycle, in seconds.
457
    ///
458
    /// A spawn cycle includes the [`spawn_duration()`] value, and any extra
459
    /// wait time (if larger than spawn time).
460
    ///
461
    /// # Panics
462
    ///
463
    /// Panics if the period is infinite.
464
    ///
465
    /// [`spawn_duration()`]: Self::spawn_duration
466
    pub fn set_period(&mut self, period: CpuValue<f32>) {
×
NEW
467
        assert!(
×
NEW
468
            period.range()[0].is_finite() && period.range()[1].is_finite(),
×
NEW
469
            "`period` {:?} has an infinite bound. If upgrading from a previous version, use `cycle_count = 1` instead for a single-cycle burst.",
×
470
            period
471
        );
UNCOV
472
        self.period = period;
×
473
    }
474

475
    /// Get the duration of a single spawn cycle, in seconds.
476
    ///
477
    /// A spawn cycle includes the [`spawn_duration()`] value, and any extra
478
    /// wait time (if larger than spawn time).
479
    ///
480
    /// [`spawn_duration()`]: Self::spawn_duration
481
    pub fn period(&self) -> CpuValue<f32> {
×
482
        self.period
×
483
    }
484

485
    /// Set the number of cycles to spawn for.
486
    ///
487
    /// A spawn cycle includes the [`spawn_duration()`] value, and any extra
488
    /// wait time (if larger than spawn time). It lasts for [`period()`].
489
    ///
490
    /// [`spawn_duration()`]: Self::spawn_duration
491
    /// [`period()`]: Self::period
NEW
492
    pub fn with_cycle_count(mut self, cycle_count: u32) -> Self {
×
NEW
493
        self.cycle_count = cycle_count;
×
NEW
494
        self
×
495
    }
496

497
    /// Set the number of cycles to spawn for.
498
    ///
499
    /// A spawn cycle includes the [`spawn_duration()`] value, and any extra
500
    /// wait time (if larger than spawn time). It lasts for [`period()`].
501
    ///
502
    /// [`spawn_duration()`]: Self::spawn_duration
503
    /// [`period()`]: Self::period
NEW
504
    pub fn set_cycle_count(&mut self, cycle_count: u32) {
×
NEW
505
        self.cycle_count = cycle_count;
×
506
    }
507

508
    /// Get the number of cycles to spawn for.
509
    ///
510
    /// A spawn cycle includes the [`spawn_duration()`] value, and any extra
511
    /// wait time (if larger than spawn time). It lasts for [`period()`].
512
    ///
513
    /// [`spawn_duration()`]: Self::spawn_duration
514
    /// [`period()`]: Self::period
515
    pub fn cycle_count(&self) -> u32 {
29✔
516
        self.cycle_count
29✔
517
    }
518

519
    /// Sets whether the spawner starts active when the effect is instantiated.
520
    ///
521
    /// This value will be transfered to the active state of the
522
    /// [`EffectSpawner`] once it's instantiated. Inactive spawners do not spawn
523
    /// any particle.
524
    pub fn with_starts_active(mut self, starts_active: bool) -> Self {
2✔
525
        self.starts_active = starts_active;
2✔
526
        self
2✔
527
    }
528

529
    /// Set whether the spawner starts active when the effect is instantiated.
530
    ///
531
    /// This value will be transfered to the active state of the
532
    /// [`EffectSpawner`] once it's instantiated. Inactive spawners do not spawn
533
    /// any particle.
534
    pub fn set_starts_active(&mut self, starts_active: bool) {
×
535
        self.starts_active = starts_active;
×
536
    }
537

538
    /// Get whether the spawner starts active when the effect is instantiated.
539
    ///
540
    /// This value will be transfered to the active state of the
541
    /// [`EffectSpawner`] once it's instantiated. Inactive spawners do not spawn
542
    /// any particle.
543
    pub fn starts_active(&self) -> bool {
13✔
544
        self.starts_active
13✔
545
    }
546
}
547

548
/// Runtime state machine for CPU particle spawning.
549
///
550
/// The spawner defines how new particles are emitted and when. Each time the
551
/// spawner ticks, it calculates a number of particles to emit for this frame,
552
/// based on its [`SpawnerSettings`]. This spawn count is passed to the GPU for
553
/// the init compute pass to actually allocate the new particles and initialize
554
/// them. The number of particles to spawn is stored as a floating-point number,
555
/// and any remainder accumulates for the next tick.
556
///
557
/// Spawners are used to control from CPU when particles are spawned. To use GPU
558
/// spawn events instead, and spawn particles based on events occurring on
559
/// existing particles in other effects, see [`EffectParent`]. Those two
560
/// mechanisms (CPU and GPU spawner) are mutually exclusive.
561
///
562
/// Once per frame the [`tick_spawners()`] system will add the [`EffectSpawner`]
563
/// component if it's missing, cloning the [`SpawnerSettings`] from the source
564
/// [`EffectAsset`] to initialize it. After that, it ticks the
565
/// [`SpawnerSettings`] stored in the component. The resulting number of
566
/// particles to spawn for the frame is then stored into
567
/// [`EffectSpawner::spawn_count`]. You can override that value to manually
568
/// control each frame how many particles are spawned, instead of using the
569
/// logic of [`SpawnerSettings`].
570
///
571
/// [`EffectParent`]: crate::EffectParent
572
#[derive(Debug, Default, Clone, Copy, PartialEq, Component, Reflect)]
573
#[reflect(Component)]
574
pub struct EffectSpawner {
575
    /// The spawner settings extracted from the [`EffectAsset`], or directly
576
    /// overriden by the user.
577
    pub settings: SpawnerSettings,
578

579
    /// Accumulated time for the current (partial) cycle, in seconds.
580
    cycle_time: f32,
581

582
    /// Number of cycles already completed.
583
    completed_cycle_count: u32,
584

585
    /// Sampled value of `spawn_duration` until `period` is reached. This is the
586
    /// duration of the "active" period during which we spawn particles, as
587
    /// opposed to the "wait" period during which we do nothing until the next
588
    /// spawn cycle.
589
    sampled_spawn_duration: f32,
590

591
    /// Sampled value of the time period, in seconds, until the next spawn
592
    /// cycle.
593
    sampled_period: f32,
594

595
    /// Sampled value of the number of particles to spawn per `spawn_duration`.
596
    sampled_count: f32,
597

598
    /// Number of particles to spawn this frame.
599
    ///
600
    /// This value is normally updated by calling [`tick()`], which
601
    /// automatically happens once per frame when the [`tick_spawners()`]
602
    /// system runs in the [`PostUpdate`] schedule.
603
    ///
604
    /// You can manually assign this value to override the one calculated by
605
    /// [`tick()`]. Note in this case that you need to override the value after
606
    /// the automated one was calculated, by ordering your system
607
    /// after [`tick_spawners()`] or [`EffectSystems::TickSpawners`].
608
    ///
609
    /// [`tick()`]: crate::EffectSpawner::tick
610
    /// [`EffectSystems::TickSpawners`]: crate::EffectSystems::TickSpawners
611
    pub spawn_count: u32,
612

613
    /// Fractional remainder of particle count to spawn.
614
    ///
615
    /// This is accumulated each tick, and the integral part is added to
616
    /// `spawn_count`. The reminder gets saved for next frame.
617
    spawn_remainder: f32,
618

619
    /// Whether the spawner is active. Defaults to
620
    /// [`SpawnerSettings::starts_active()`]. An inactive spawner
621
    /// doesn't tick (no particle spawned, no internal state updated).
622
    pub active: bool,
623
}
624

625
impl EffectSpawner {
626
    /// Create a new spawner.
627
    pub fn new(settings: &SpawnerSettings) -> Self {
11✔
628
        Self {
629
            settings: *settings,
11✔
630
            cycle_time: 0.,
631
            completed_cycle_count: if settings.emit_on_start || settings.is_forever() {
11✔
632
                // Infinitely repeating effects always start at cycle #0.
633
                0
634
            } else {
635
                // Start at last cycle. This means has_completed() is true.
636
                settings.cycle_count()
637
            },
638
            sampled_spawn_duration: 0.,
639
            sampled_period: 0.,
640
            sampled_count: 0.,
641
            spawn_count: 0,
642
            spawn_remainder: 0.,
643
            active: settings.starts_active(),
11✔
644
        }
645
    }
646

647
    /// Set whether the spawner is active.
648
    ///
649
    /// Inactive spawners do not tick, and therefore do not spawn any particle.
650
    /// Their internal state do not update.
651
    pub fn with_active(mut self, active: bool) -> Self {
×
652
        self.active = active;
×
653
        self
×
654
    }
655

656
    /// Get the time relative to the beginning of the current cycle.
657
    #[inline]
658
    pub fn cycle_time(&self) -> f32 {
3✔
659
        self.cycle_time
3✔
660
    }
661

662
    /// Get the spawn duration for the current cycle.
663
    ///
664
    /// This value can change every cycle if [`SpawnerSettings::spawn_duration`]
665
    /// is a randomly distributed value.
666
    #[inline]
667
    pub fn cycle_spawn_duration(&self) -> f32 {
3✔
668
        self.sampled_spawn_duration
3✔
669
    }
670

671
    /// Get the period of the current cycle.
672
    ///
673
    /// This value can change every cycle if [`SpawnerSettings::period`] is a
674
    /// randomly distributed value. If the effect spawns only once, and
675
    /// therefore its cycle period is ignored, this returns `0`.
676
    #[inline]
677
    pub fn cycle_period(&self) -> f32 {
3✔
678
        if self.settings.is_once() {
3✔
NEW
679
            0.
×
680
        } else {
681
            self.sampled_period
3✔
682
        }
683
    }
684

685
    /// Get the progress ratio in 0..1 of the current cycle.
686
    ///
687
    /// This is the ratio of the [`cycle_time()`] over [`cycle_period()`]. If
688
    /// the effect spawns only once, and therefore its cycle period is
689
    /// ignored, this returns `0`.
690
    ///
691
    /// [`cycle_time()`]: Self::cycle_time
692
    /// [`cycle_period()`]: Self::cycle_period
693
    #[inline]
694
    pub fn cycle_ratio(&self) -> f32 {
3✔
695
        if self.settings.is_once() {
3✔
NEW
696
            0.
×
697
        } else {
698
            self.cycle_time / self.sampled_period
3✔
699
        }
700
    }
701

702
    /// Get the number of particles to spawn during the current cycle
703
    ///
704
    /// This value can change every cycle if [`SpawnerSettings::count`] is a
705
    /// randomly distributed value.
706
    #[inline]
707
    pub fn cycle_spawn_count(&self) -> f32 {
3✔
708
        self.sampled_count
3✔
709
    }
710

711
    /// Get the number of completed cycles since last [`reset()`].
712
    ///
713
    /// The value loops back if the pattern repeats forever
714
    /// ([`SpawnerSettings::is_forever()`] is `true`).
715
    ///
716
    /// [`reset()`]: Self::reset
717
    #[inline]
718
    pub fn completed_cycle_count(&self) -> u32 {
5✔
719
        self.completed_cycle_count
5✔
720
    }
721

722
    /// Get whether the spawner has completed.
723
    ///
724
    /// A spawner has completed if it already ticked through its maximum number
725
    /// of cycles. It can be reset back to its original state with [`reset()`].
726
    /// A spawner repeating forever never completes.
727
    ///
728
    /// [`reset()`]: Self::reset
729
    #[inline]
730
    pub fn has_completed(&self) -> bool {
6✔
731
        !self.settings.is_forever() && (self.completed_cycle_count >= self.settings.cycle_count())
12✔
732
    }
733

734
    /// Reset the spawner state.
735
    ///
736
    /// This resets the internal spawner time and cycle count to zero.
737
    ///
738
    /// Use this, for example, to immediately spawn some particles in a spawner
739
    /// constructed with [`SpawnerSettings::once`].
740
    ///
741
    /// [`SpawnerSettings::once`]: crate::SpawnerSettings::once
742
    pub fn reset(&mut self) {
2✔
743
        self.cycle_time = 0.;
2✔
744
        self.completed_cycle_count = 0;
2✔
745
        self.sampled_spawn_duration = 0.;
2✔
746
        self.sampled_period = 0.;
2✔
747
        self.sampled_count = 0.;
2✔
748
        self.spawn_count = 0;
2✔
749
        self.spawn_remainder = 0.;
2✔
750
    }
751

752
    /// Tick the spawner to calculate the number of particles to spawn this
753
    /// frame.
754
    ///
755
    /// The frame delta time `dt` is added to the current spawner time, before
756
    /// the spawner calculates the number of particles to spawn.
757
    ///
758
    /// This method is called automatically by [`tick_spawners()`] during the
759
    /// [`PostUpdate`], so you normally don't have to call it yourself
760
    /// manually.
761
    ///
762
    /// # Returns
763
    ///
764
    /// The integral number of particles to spawn this frame. Any fractional
765
    /// remainder is saved for the next call.
766
    pub fn tick(&mut self, mut dt: f32, rng: &mut Pcg32) -> u32 {
39✔
767
        // If inactive, or if the finite number of cycles has been completed, then we're
768
        // done.
769
        if !self.active
39✔
770
            || (!self.settings.is_forever()
35✔
771
                && (self.completed_cycle_count >= self.settings.cycle_count()))
14✔
772
        {
773
            self.spawn_count = 0;
7✔
774
            return 0;
7✔
775
        }
776

777
        // Use a loop in case the timestep dt spans multiple cycles
778
        loop {
779
            // Check if this is a new cycle which needs resampling
780
            if self.sampled_period == 0.0 {
38✔
781
                if self.settings.is_once() {
26✔
782
                    self.sampled_spawn_duration = self.settings.spawn_duration.sample(rng);
7✔
783
                    // Period is unchecked, should be ignored (could sample to <= 0). Use the spawn
784
                    // duration, but ensure we have something > 0 as a marker that we've resampled.
785
                    self.sampled_period = self.sampled_spawn_duration.max(1e-12);
7✔
786
                } else {
787
                    self.sampled_period = self.settings.period.sample(rng);
12✔
788
                    assert!(self.sampled_period > 0.);
12✔
789
                    self.sampled_spawn_duration = self
12✔
790
                        .settings
12✔
791
                        .spawn_duration
12✔
792
                        .sample(rng)
12✔
793
                        .clamp(0., self.sampled_period);
12✔
794
                }
795
                self.sampled_spawn_duration = self.settings.spawn_duration.sample(rng);
19✔
796
                self.sampled_count = self.settings.count.sample(rng).max(0.);
19✔
797
            }
798

799
            let new_time = self.cycle_time + dt;
38✔
800

801
            // If inside the spawn period, accumulate some particle spawn count
802
            if self.cycle_time <= self.sampled_spawn_duration {
38✔
803
                // If the spawn time is very small, close to zero, spawn all particles
804
                // immediately in one burst over a single frame.
805
                self.spawn_remainder += if self.sampled_spawn_duration < 1e-5f32.max(dt / 100.0) {
34✔
806
                    self.sampled_count
10✔
807
                } else {
808
                    // Spawn an amount of particles equal to the fraction of time the current frame
809
                    // spans compared to the total burst duration.
810
                    let ratio = ((new_time.min(self.sampled_spawn_duration) - self.cycle_time)
24✔
811
                        / self.sampled_spawn_duration)
24✔
812
                        .clamp(0., 1.);
24✔
813
                    self.sampled_count * ratio
24✔
814
                };
815
            }
816

817
            // Increment current time
818
            self.cycle_time = new_time;
819

820
            // Check for cycle completion
821
            if self.cycle_time >= self.sampled_period {
822
                dt = self.cycle_time - self.sampled_period;
14✔
823
                self.cycle_time = 0.0;
14✔
824
                self.completed_cycle_count += 1;
14✔
825

826
                // Mark as "need resampling"
827
                self.sampled_period = 0.0;
14✔
828

829
                // If this was the last cycle, we're done
830
                if !self.settings.is_forever()
14✔
831
                    && (self.completed_cycle_count >= self.settings.cycle_count())
9✔
832
                {
833
                    // Don't deactivate quite yet, otherwise we'll miss the spawns for this frame
834
                    break;
8✔
835
                }
836
            } else {
837
                // We're done for this frame
838
                break;
24✔
839
            }
840
        }
841

842
        // Extract integral number of particles to spawn this frame, keep remainder for
843
        // next one
844
        let count = self.spawn_remainder.floor();
32✔
845
        self.spawn_remainder -= count;
32✔
846
        self.spawn_count = count as u32;
32✔
847

848
        self.spawn_count
32✔
849
    }
850
}
851

852
/// Tick all the [`EffectSpawner`] components.
853
///
854
/// This system runs in the [`PostUpdate`] stage, after the visibility system
855
/// has updated the [`InheritedVisibility`] of each effect instance (see
856
/// [`VisibilitySystems::VisibilityPropagate`]). Hidden instances are not
857
/// updated, unless the [`EffectAsset::simulation_condition`]
858
/// is set to [`SimulationCondition::Always`]. If no [`InheritedVisibility`] is
859
/// present, the effect is assumed to be visible.
860
///
861
/// Note that by that point the [`ViewVisibility`] is not yet calculated, and it
862
/// may happen that spawners are ticked but no effect is visible in any view
863
/// even though some are "visible" (active) in the [`World`]. The actual
864
/// per-view culling of invisible (not in view) effects is performed later on
865
/// the render world.
866
///
867
/// Once the system determined that the effect instance needs to be simulated
868
/// this frame, it ticks the effect's spawner by calling
869
/// [`EffectSpawner::tick()`], adding a new [`EffectSpawner`] component if it
870
/// doesn't already exist on the same entity as the [`ParticleEffect`].
871
///
872
/// [`VisibilitySystems::VisibilityPropagate`]: bevy::render::view::VisibilitySystems::VisibilityPropagate
873
/// [`EffectAsset::simulation_condition`]: crate::EffectAsset::simulation_condition
874
pub fn tick_spawners(
3✔
875
    mut commands: Commands,
876
    time: Res<Time<EffectSimulation>>,
877
    effects: Res<Assets<EffectAsset>>,
878
    mut rng: ResMut<Random>,
879
    mut query: Query<(
880
        Entity,
881
        &ParticleEffect,
882
        &InheritedVisibility,
883
        Option<&mut EffectSpawner>,
884
    )>,
885
) {
886
    trace!("tick_spawners()");
3✔
887

888
    let dt = time.delta_secs();
3✔
889

890
    for (entity, effect, inherited_visibility, maybe_spawner) in query.iter_mut() {
3✔
891
        let Some(asset) = effects.get(&effect.handle) else {
6✔
892
            trace!(
×
893
                "Effect asset with handle {:?} is not available; skipped initializers tick.",
×
894
                effect.handle
895
            );
896
            continue;
×
897
        };
898

899
        if asset.simulation_condition == SimulationCondition::WhenVisible
900
            && !inherited_visibility.get()
2✔
901
        {
902
            trace!(
1✔
903
                "Effect asset with handle {:?} is not visible, and simulates only WhenVisible; skipped initializers tick.",
×
904
                effect.handle
905
            );
906
            continue;
1✔
907
        }
908

909
        if let Some(mut effect_spawner) = maybe_spawner {
×
910
            effect_spawner.tick(dt, &mut rng.0);
×
911
            continue;
×
912
        }
913

914
        let effect_spawner = {
2✔
915
            let mut effect_spawner = EffectSpawner::new(&asset.spawner);
2✔
916
            effect_spawner.tick(dt, &mut rng.0);
2✔
917
            effect_spawner
2✔
918
        };
919
        commands.entity(entity).insert(effect_spawner);
2✔
920
    }
921
}
922

923
#[cfg(test)]
924
mod test {
925
    use std::time::Duration;
926

927
    use bevy::{
928
        asset::{
929
            io::{
930
                memory::{Dir, MemoryAssetReader},
931
                AssetSourceBuilder, AssetSourceBuilders, AssetSourceId,
932
            },
933
            AssetServerMode,
934
        },
935
        render::view::{VisibilityPlugin, VisibilitySystems},
936
        tasks::{IoTaskPool, TaskPoolBuilder},
937
    };
938

939
    use super::*;
940
    use crate::Module;
941

942
    #[test]
943
    fn test_range_single() {
944
        let value = CpuValue::Single(1.0);
945
        assert_eq!(value.range(), [1.0, 1.0]);
946
    }
947

948
    #[test]
949
    fn test_range_uniform() {
950
        let value = CpuValue::Uniform((1.0, 3.0));
951
        assert_eq!(value.range(), [1.0, 3.0]);
952
    }
953

954
    #[test]
955
    fn test_range_uniform_reverse() {
956
        let value = CpuValue::Uniform((3.0, 1.0));
957
        assert_eq!(value.range(), [1.0, 3.0]);
958
    }
959

960
    #[test]
961
    fn test_new() {
962
        let rng = &mut new_rng();
963
        // 3 particles over 3 seconds, pause 7 seconds (total 10 seconds period). 2
964
        // cycles.
965
        let spawner = SpawnerSettings::new(3.0.into(), 3.0.into(), 10.0.into(), 2);
966
        let mut spawner = EffectSpawner::new(&spawner);
967
        let count = spawner.tick(2., rng); // t = 2s
968
        assert_eq!(count, 2);
969
        assert!(spawner.active);
970
        assert_eq!(spawner.cycle_time(), 2.);
971
        assert_eq!(spawner.cycle_spawn_duration(), 3.);
972
        assert_eq!(spawner.cycle_period(), 10.);
973
        assert_eq!(spawner.cycle_ratio(), 0.2); // 2s / 10s
974
        assert_eq!(spawner.cycle_spawn_count(), 3.);
975
        assert_eq!(spawner.completed_cycle_count(), 0);
976
        let count = spawner.tick(5., rng); // t = 7s
977
        assert_eq!(count, 1);
978
        assert!(spawner.active);
979
        assert_eq!(spawner.cycle_time(), 7.);
980
        assert_eq!(spawner.cycle_spawn_duration(), 3.);
981
        assert_eq!(spawner.cycle_period(), 10.);
982
        assert_eq!(spawner.cycle_ratio(), 0.7); // 7s / 10s
983
        assert_eq!(spawner.cycle_spawn_count(), 3.);
984
        assert_eq!(spawner.completed_cycle_count(), 0);
985
        let count = spawner.tick(8., rng); // t = 15s
986
        assert_eq!(count, 3);
987
        assert!(spawner.active);
988
        assert_eq!(spawner.cycle_time(), 5.); // 15. mod 10.
989
        assert_eq!(spawner.cycle_spawn_duration(), 3.);
990
        assert_eq!(spawner.cycle_period(), 10.);
991
        assert_eq!(spawner.cycle_ratio(), 0.5); // 5s / 10s
992
        assert_eq!(spawner.cycle_spawn_count(), 3.);
993
        assert_eq!(spawner.completed_cycle_count(), 1);
994
        let count = spawner.tick(10., rng); // t = 25s
995
        assert_eq!(count, 0);
996
        assert!(spawner.active);
997
        assert_eq!(spawner.completed_cycle_count(), 2);
998
        let count = spawner.tick(0.1, rng); // t = 25.1s
999
        assert_eq!(count, 0);
1000
        assert!(spawner.active);
1001
        assert_eq!(spawner.completed_cycle_count(), 2);
1002
    }
1003

1004
    #[test]
1005
    #[should_panic]
1006
    fn test_new_panic_negative_period() {
1007
        let _ = SpawnerSettings::new(3.0.into(), 1.0.into(), CpuValue::Uniform((-1., 1.)), 0);
1008
    }
1009

1010
    #[test]
1011
    #[should_panic]
1012
    fn test_new_panic_zero_period() {
1013
        let _ = SpawnerSettings::new(3.0.into(), 1.0.into(), CpuValue::Uniform((0., 0.)), 0);
1014
    }
1015

1016
    #[test]
1017
    fn test_once() {
1018
        let rng = &mut new_rng();
1019
        let spawner = SpawnerSettings::once(5.0.into());
1020
        assert!(spawner.is_once());
1021
        let mut spawner = EffectSpawner::new(&spawner);
1022
        assert!(spawner.active);
1023
        let count = spawner.tick(0.001, rng);
1024
        assert_eq!(count, 5);
1025
        let count = spawner.tick(100.0, rng);
1026
        assert_eq!(count, 0);
1027
    }
1028

1029
    #[test]
1030
    fn test_once_reset() {
1031
        let rng = &mut new_rng();
1032
        let spawner = SpawnerSettings::once(5.0.into());
1033
        assert!(spawner.is_once());
1034
        assert!(spawner.starts_active());
1035
        let mut spawner = EffectSpawner::new(&spawner);
1036
        spawner.tick(1.0, rng);
1037
        spawner.reset();
1038
        let count = spawner.tick(1.0, rng);
1039
        assert_eq!(count, 5);
1040
    }
1041

1042
    #[test]
1043
    fn test_once_start_inactive() {
1044
        let rng = &mut new_rng();
1045

1046
        let spawner = SpawnerSettings::once(5.0.into()).with_starts_active(false);
1047
        assert!(spawner.is_once());
1048
        assert!(!spawner.starts_active());
1049
        let mut spawner = EffectSpawner::new(&spawner);
1050
        assert!(!spawner.has_completed());
1051

1052
        // Inactive; no-op
1053
        let count = spawner.tick(1.0, rng);
1054
        assert_eq!(count, 0);
1055
        assert!(!spawner.has_completed());
1056

1057
        spawner.active = true;
1058

1059
        // Active; spawns
1060
        let count = spawner.tick(1.0, rng);
1061
        assert_eq!(count, 5);
1062
        assert!(spawner.active);
1063
        assert!(spawner.has_completed()); // once(), so completes on first tick()
1064

1065
        // Completed; no-op
1066
        let count = spawner.tick(1.0, rng);
1067
        assert_eq!(count, 0);
1068
        assert!(spawner.active);
1069
        assert!(spawner.has_completed());
1070

1071
        // Reset internal state, still active
1072
        spawner.reset();
1073
        assert!(spawner.active);
1074
        assert!(!spawner.has_completed());
1075

1076
        let count = spawner.tick(1.0, rng);
1077
        assert_eq!(count, 5);
1078
        assert!(spawner.active);
1079
        assert!(spawner.has_completed());
1080
    }
1081

1082
    #[test]
1083
    fn test_rate() {
1084
        let rng = &mut new_rng();
1085
        let spawner = SpawnerSettings::rate(5.0.into());
1086
        assert!(!spawner.is_once());
1087
        assert!(spawner.is_forever());
1088
        let mut spawner = EffectSpawner::new(&spawner);
1089
        // Slightly over 1.0 to avoid edge case
1090
        let count = spawner.tick(1.01, rng);
1091
        assert_eq!(count, 5);
1092
        let count = spawner.tick(0.4, rng);
1093
        assert_eq!(count, 2);
1094
    }
1095

1096
    #[test]
1097
    fn test_rate_active() {
1098
        let rng = &mut new_rng();
1099
        let spawner = SpawnerSettings::rate(5.0.into());
1100
        assert!(!spawner.is_once());
1101
        let mut spawner = EffectSpawner::new(&spawner);
1102
        spawner.tick(1.01, rng);
1103
        spawner.active = false;
1104
        assert!(!spawner.active);
1105
        let count = spawner.tick(0.4, rng);
1106
        assert_eq!(count, 0);
1107
        spawner.active = true;
1108
        assert!(spawner.active);
1109
        let count = spawner.tick(0.4, rng);
1110
        assert_eq!(count, 2);
1111
    }
1112

1113
    #[test]
1114
    fn test_rate_accumulate() {
1115
        let rng = &mut new_rng();
1116
        let spawner = SpawnerSettings::rate(5.0.into());
1117
        assert!(!spawner.is_once());
1118
        let mut spawner = EffectSpawner::new(&spawner);
1119
        // 13 ticks instead of 12 to avoid edge case
1120
        let count = (0..13).map(|_| spawner.tick(1.0 / 60.0, rng)).sum::<u32>();
1121
        assert_eq!(count, 1);
1122
    }
1123

1124
    #[test]
1125
    fn test_burst() {
1126
        let rng = &mut new_rng();
1127
        let spawner = SpawnerSettings::burst(5.0.into(), 2.0.into());
1128
        assert!(!spawner.is_once());
1129
        assert!(spawner.is_forever());
1130
        let mut spawner = EffectSpawner::new(&spawner);
1131
        let count = spawner.tick(1.0, rng);
1132
        assert_eq!(count, 5);
1133
        let count = spawner.tick(4.0, rng);
1134
        assert_eq!(count, 10);
1135
        let count = spawner.tick(0.1, rng);
1136
        assert_eq!(count, 0);
1137
    }
1138

1139
    #[test]
1140
    fn test_with_active() {
1141
        let rng = &mut new_rng();
1142
        let spawner = SpawnerSettings::rate(5.0.into()).with_starts_active(false);
1143
        let mut spawner = EffectSpawner::new(&spawner);
1144
        assert!(!spawner.active);
1145
        let count = spawner.tick(1., rng);
1146
        assert_eq!(count, 0);
1147
        spawner.active = false; // no-op
1148
        let count = spawner.tick(1., rng);
1149
        assert_eq!(count, 0);
1150
        spawner.active = true;
1151
        assert!(spawner.active);
1152
        let count = spawner.tick(1., rng);
1153
        assert_eq!(count, 5);
1154
    }
1155

1156
    fn make_test_app() -> App {
1157
        IoTaskPool::get_or_init(|| {
1158
            TaskPoolBuilder::default()
1159
                .num_threads(1)
1160
                .thread_name("Hanabi test IO Task Pool".to_string())
1161
                .build()
1162
        });
1163

1164
        let mut app = App::new();
1165

1166
        let watch_for_changes = false;
1167
        let mut builders = app
1168
            .world_mut()
1169
            .get_resource_or_insert_with::<AssetSourceBuilders>(Default::default);
1170
        let dir = Dir::default();
1171
        let dummy_builder = AssetSourceBuilder::default()
1172
            .with_reader(move || Box::new(MemoryAssetReader { root: dir.clone() }));
1173
        builders.insert(AssetSourceId::Default, dummy_builder);
1174
        let sources = builders.build_sources(watch_for_changes, false);
1175
        let asset_server =
1176
            AssetServer::new(sources, AssetServerMode::Unprocessed, watch_for_changes);
1177

1178
        app.insert_resource(asset_server);
1179
        // app.add_plugins(DefaultPlugins);
1180
        app.init_asset::<Mesh>();
1181
        app.add_plugins(VisibilityPlugin);
1182
        app.init_resource::<Time<EffectSimulation>>();
1183
        app.insert_resource(Random(new_rng()));
1184
        app.init_asset::<EffectAsset>();
1185
        app.add_systems(
1186
            PostUpdate,
1187
            tick_spawners.after(VisibilitySystems::CheckVisibility),
1188
        );
1189

1190
        app
1191
    }
1192

1193
    /// Test case for `tick_spawners()`.
1194
    struct TestCase {
1195
        /// Initial entity visibility on spawn. If `None`, do not add a
1196
        /// [`Visibility`] component.
1197
        visibility: Option<Visibility>,
1198

1199
        /// Spawner settings assigned to the `EffectAsset`.
1200
        asset_spawner: SpawnerSettings,
1201
    }
1202

1203
    impl TestCase {
1204
        fn new(visibility: Option<Visibility>, asset_spawner: SpawnerSettings) -> Self {
1205
            Self {
1206
                visibility,
1207
                asset_spawner,
1208
            }
1209
        }
1210
    }
1211

1212
    #[test]
1213
    fn test_tick_spawners() {
1214
        let asset_spawner = SpawnerSettings::once(32.0.into());
1215

1216
        for test_case in &[
1217
            TestCase::new(None, asset_spawner),
1218
            TestCase::new(Some(Visibility::Hidden), asset_spawner),
1219
            TestCase::new(Some(Visibility::Visible), asset_spawner),
1220
        ] {
1221
            let mut app = make_test_app();
1222

1223
            let (effect_entity, handle) = {
1224
                let world = app.world_mut();
1225

1226
                // Add effect asset
1227
                let mut assets = world.resource_mut::<Assets<EffectAsset>>();
1228
                let mut asset = EffectAsset::new(64, test_case.asset_spawner, Module::default());
1229
                asset.simulation_condition = if test_case.visibility.is_some() {
1230
                    SimulationCondition::WhenVisible
1231
                } else {
1232
                    SimulationCondition::Always
1233
                };
1234
                let handle = assets.add(asset);
1235

1236
                // Spawn particle effect
1237
                let entity = if let Some(visibility) = test_case.visibility {
1238
                    world
1239
                        .spawn((
1240
                            visibility,
1241
                            InheritedVisibility::default(),
1242
                            ParticleEffect {
1243
                                handle: handle.clone(),
1244
                            },
1245
                        ))
1246
                        .id()
1247
                } else {
1248
                    world
1249
                        .spawn((ParticleEffect {
1250
                            handle: handle.clone(),
1251
                        },))
1252
                        .id()
1253
                };
1254

1255
                // Spawn a camera, otherwise ComputedVisibility stays at HIDDEN
1256
                world.spawn(Camera3d::default());
1257

1258
                (entity, handle)
1259
            };
1260

1261
            // Tick once
1262
            let _cur_time = {
1263
                // Make sure to increment the current time so that the spawners spawn something.
1264
                // Note that `Time` has this weird behavior where the common quantities like
1265
                // `Time::delta_secs()` only update after the *second* update. So we tick the
1266
                // `Time` twice here to enforce this.
1267
                let mut time = app.world_mut().resource_mut::<Time<EffectSimulation>>();
1268
                time.advance_by(Duration::from_millis(16));
1269
                time.elapsed()
1270
            };
1271
            app.update();
1272

1273
            let world = app.world_mut();
1274

1275
            // Check the state of the components after `tick_spawners()` ran
1276
            if let Some(test_visibility) = test_case.visibility {
1277
                // Simulated-when-visible effect (SimulationCondition::WhenVisible)
1278

1279
                let (entity, visibility, inherited_visibility, particle_effect, effect_spawner) =
1280
                    world
1281
                        .query::<(
1282
                            Entity,
1283
                            &Visibility,
1284
                            &InheritedVisibility,
1285
                            &ParticleEffect,
1286
                            Option<&EffectSpawner>,
1287
                        )>()
1288
                        .iter(world)
1289
                        .next()
1290
                        .unwrap();
1291
                assert_eq!(entity, effect_entity);
1292
                assert_eq!(visibility, test_visibility);
1293
                assert_eq!(
1294
                    inherited_visibility.get(),
1295
                    test_visibility == Visibility::Visible
1296
                );
1297
                assert_eq!(particle_effect.handle, handle);
1298
                if inherited_visibility.get() {
1299
                    // If visible, `tick_spawners()` spawns the EffectSpawner and ticks it
1300
                    assert!(effect_spawner.is_some());
1301
                    let effect_spawner = effect_spawner.unwrap();
1302
                    let actual_spawner = effect_spawner.settings;
1303

1304
                    // Check the spawner ticked
1305
                    assert!(effect_spawner.active); // will get deactivated next tick()
1306
                    assert_eq!(effect_spawner.spawn_remainder, 0.);
1307
                    assert_eq!(effect_spawner.cycle_time, 0.);
1308
                    assert_eq!(effect_spawner.completed_cycle_count, 1);
1309
                    assert_eq!(effect_spawner.spawn_count, 32);
1310

1311
                    assert_eq!(actual_spawner, test_case.asset_spawner);
1312
                } else {
1313
                    // If not visible, `tick_spawners()` skips the effect entirely so won't
1314
                    // spawn an `EffectSpawner` for it
1315
                    assert!(effect_spawner.is_none());
1316
                }
1317
            } else {
1318
                // Always-simulated effect (SimulationCondition::Always)
1319

1320
                let (entity, particle_effect, effect_spawners) = world
1321
                    .query::<(Entity, &ParticleEffect, Option<&EffectSpawner>)>()
1322
                    .iter(world)
1323
                    .next()
1324
                    .unwrap();
1325
                assert_eq!(entity, effect_entity);
1326
                assert_eq!(particle_effect.handle, handle);
1327

1328
                assert!(effect_spawners.is_some());
1329
                let effect_spawner = effect_spawners.unwrap();
1330
                let actual_spawner = effect_spawner.settings;
1331

1332
                // Check the spawner ticked
1333
                assert!(effect_spawner.active); // will get deactivated next tick()
1334
                assert_eq!(effect_spawner.spawn_remainder, 0.);
1335
                assert_eq!(effect_spawner.cycle_time, 0.);
1336
                assert_eq!(effect_spawner.completed_cycle_count, 1);
1337
                assert_eq!(effect_spawner.spawn_count, 32);
1338

1339
                assert_eq!(actual_spawner, test_case.asset_spawner);
1340
            }
1341
        }
1342
    }
1343
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc