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

djeedai / bevy_hanabi / 26726689214

31 May 2026 10:46PM UTC coverage: 57.642% (+0.05%) from 57.588%
26726689214

Pull #531

github

web-flow
Merge 1d1f100a8 into bfc546e6a
Pull Request #531: Add fallible `SpawnerSettings` constructor

18 of 22 new or added lines in 1 file covered. (81.82%)

33 existing lines in 1 file now uncovered.

4816 of 8355 relevant lines covered (57.64%)

197.96 hits per line

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

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

3
use bevy::{ecs::resource::Resource, log::trace, math::FloatOrd, prelude::*, reflect::Reflect};
4
use rand::{
5
    distr::{uniform::SampleUniform, Distribution, Uniform},
6
    SeedableRng,
7
};
8
use rand_pcg::Pcg32;
9
use serde::{Deserialize, Serialize};
10
use thiserror::Error;
11

12
use crate::{
13
    CompiledParticleEffect, EffectAsset, EffectSimulation, ParticleEffect, SimulationCondition,
14
};
15

16
/// An RNG to be used in the CPU for the particle system engine
17
pub(crate) fn new_rng() -> Pcg32 {
20✔
18
    let mut rng = rand::rng();
40✔
19
    let mut seed = [0u8; 16];
40✔
20
    seed.copy_from_slice(
40✔
21
        &Uniform::new_inclusive(0, u128::MAX)
40✔
22
            .unwrap()
40✔
23
            .sample(&mut rng)
40✔
24
            .to_le_bytes(),
20✔
25
    );
26
    Pcg32::from_seed(seed)
40✔
27
}
28

29
/// An RNG resource
30
#[derive(Resource)]
31
pub struct Random(pub Pcg32);
32

33
/// Utility trait to help implementing [`std::hash::Hash`] for [`CpuValue`] of
34
/// floating-point type.
35
pub trait FloatHash: PartialEq {
36
    fn hash_f32<H: Hasher>(&self, state: &mut H);
37
}
38

39
impl FloatHash for f32 {
40
    fn hash_f32<H: Hasher>(&self, state: &mut H) {
×
41
        FloatOrd(*self).hash(state);
×
42
    }
43
}
44

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

52
impl FloatHash for Vec3 {
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
    }
58
}
59

60
impl FloatHash for Vec4 {
61
    fn hash_f32<H: Hasher>(&self, state: &mut H) {
×
62
        FloatOrd(self.x).hash(state);
×
63
        FloatOrd(self.y).hash(state);
×
64
        FloatOrd(self.z).hash(state);
×
65
        FloatOrd(self.w).hash(state);
×
66
    }
67
}
68

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

94
impl<T: Copy + FromReflect + Default> Default for CpuValue<T> {
95
    fn default() -> Self {
2✔
96
        Self::Single(T::default())
2✔
97
    }
98
}
99

100
impl<T: Copy + FromReflect + SampleUniform> CpuValue<T> {
101
    /// Sample the value.
102
    /// - For [`CpuValue::Single`], always return the same single value.
103
    /// - For [`CpuValue::Uniform`], use the given pseudo-random number
104
    ///   generator to generate a random sample.
105
    pub fn sample(&self, rng: &mut Pcg32) -> T {
97✔
106
        match self {
97✔
107
            Self::Single(x) => *x,
97✔
108
            Self::Uniform((a, b)) => Uniform::new_inclusive(*a, *b).unwrap().sample(rng),
×
109
        }
110
    }
111
}
112

113
impl<T: Copy + FromReflect + PartialOrd> CpuValue<T> {
114
    /// Returns the range of allowable values in the form `[minimum, maximum]`.
115
    /// For [`CpuValue::Single`], both values are the same.
116
    pub fn range(&self) -> [T; 2] {
50✔
117
        match self {
50✔
118
            Self::Single(x) => [*x; 2],
41✔
119
            Self::Uniform((a, b)) => {
9✔
120
                if a <= b {
9✔
121
                    [*a, *b]
8✔
122
                } else {
123
                    [*b, *a]
1✔
124
                }
125
            }
126
        }
127
    }
128
}
129

130
impl<T: Copy + FromReflect> From<T> for CpuValue<T> {
131
    fn from(t: T) -> Self {
136✔
132
        Self::Single(t)
136✔
133
    }
134
}
135

136
impl<T: Copy + FromReflect> From<[T; 2]> for CpuValue<T> {
137
    fn from(t: [T; 2]) -> Self {
×
138
        Self::Uniform((t[0], t[1]))
×
139
    }
140
}
141

142
impl<T: Copy + FromReflect> From<(T, T)> for CpuValue<T> {
143
    fn from(t: (T, T)) -> Self {
×
144
        Self::Uniform(t)
×
145
    }
146
}
147

148
impl<T: Copy + FromReflect + FloatHash> Eq for CpuValue<T> {}
149

150
impl<T: Copy + FromReflect + FloatHash> Hash for CpuValue<T> {
151
    fn hash<H: Hasher>(&self, state: &mut H) {
×
152
        match self {
×
153
            CpuValue::Single(f) => {
×
154
                1_u8.hash(state);
×
155
                f.hash_f32(state);
×
156
            }
157
            CpuValue::Uniform((a, b)) => {
×
158
                2_u8.hash(state);
×
159
                a.hash_f32(state);
×
160
                b.hash_f32(state);
×
161
            }
162
        }
163
    }
164
}
165

166
/// Error type for [`SpawnerSettings::try_new()`].
167
#[derive(Debug, Clone, Copy, Error)]
168
pub enum SpawnerSettingsError {
169
    #[error("Spawn period [{min}:{max}] is invalid")]
170
    InvalidPeriod { min: f32, max: f32 },
171

172
    #[error("Spawn period is infinite")]
173
    InfinitePeriod,
174
}
175

176
/// Settings for an [`EffectSpawner`].
177
///
178
/// A [`SpawnerSettings`] represents the settings of an [`EffectSpawner`].
179
///
180
/// The spawning logic is based around the concept of _cycles_. A spawner
181
/// defines a pattern of particle spawning as the repetition of a number of unit
182
/// cycles. Each cycle is composed of a period of emission, followed by a period
183
/// of rest (idling). Both periods can be of zero duration.
184
///
185
/// The settings are:
186
///
187
/// - `count` is the number of particles to spawn over a single cycle, during
188
///   the emission period. It can evaluate to negative or zero random values, in
189
///   which case no particle is spawned during the current cycle.
190
/// - `spawn_duration` is the duration of the spawn part of a single cycle. If
191
///   this is <= 0, then the particles spawn all at once exactly at the same
192
///   instant at the beginning of the cycle.
193
/// - `period` is the period of a cycle. If this is <= `spawn_duration`, then
194
///   the spawner spawns a steady stream of particles. This is ignored if
195
///   `cycle_count == 1`.
196
/// - `cycle_count` is the number of cycles, that is the number of times this
197
///   spawn-rest pattern occurs. Set this to `0` to repeat forever.
198
///
199
/// ```txt
200
///  <----------- period ----------->
201
///  <- spawn_duration ->
202
/// |********************|-----------|
203
///      spawn 'count'        wait
204
///        particles
205
/// ```
206
///
207
/// Most settings are stored as a [`CpuValue`] to allow some randomizing. If
208
/// using a random distribution, the value is resampled each cycle.
209
///
210
/// Note that the "burst" semantic here doesn't strictly mean a one-off
211
/// emission, since that emission is spread over a number of simulation
212
/// frames that total a duration of `spawn_duration`. If you want a strict
213
/// single-frame burst, simply set the `spawn_duration` to zero; this is
214
/// what [`once()`] does.
215
///
216
/// [`once()`]: Self::once
217
#[derive(Debug, Clone, Copy, PartialEq, Reflect, Serialize, Deserialize)]
218
#[reflect(Default)]
219
pub struct SpawnerSettings {
220
    /// Number of particles to spawn over [`spawn_duration`].
221
    ///
222
    /// [`spawn_duration`]: Self::spawn_duration
223
    count: CpuValue<f32>,
224

225
    /// Time over which to spawn [`count`], in seconds.
226
    ///
227
    /// [`count`]: Self::count
228
    spawn_duration: CpuValue<f32>,
229

230
    /// Time between bursts of the particle system, in seconds.
231
    ///
232
    /// If this is [`spawn_duration`] or less, the system spawns a steady stream
233
    /// of particles.
234
    ///
235
    /// [`spawn_duration`]: Self::spawn_duration
236
    period: CpuValue<f32>,
237

238
    /// Number of cycles the spawner is active before completing.
239
    ///
240
    /// Each cycle lasts for `period`. A value of `0` means "infinite", that is
241
    /// the spanwe emits particle forever as long as it's active.
242
    cycle_count: u32,
243

244
    /// Whether the [`EffectSpawner`] is active at startup.
245
    ///
246
    /// The value is used to initialize [`EffectSpawner::active`].
247
    ///
248
    /// [`EffectSpawner::active`]: crate::EffectSpawner::active
249
    starts_active: bool,
250

251
    /// Whether the [`EffectSpawner`] immediately starts emitting particles.
252
    emit_on_start: bool,
253
}
254

255
impl Default for SpawnerSettings {
256
    fn default() -> Self {
19✔
257
        Self::once(1.0f32.into())
38✔
258
    }
259
}
260

261
impl SpawnerSettings {
262
    /// Create settings from individual values.
263
    ///
264
    /// This is the _raw_ constructor. In general you should prefer using one of
265
    /// the utility constructors [`once()`], [`burst()`], or [`rate()`],
266
    /// which will ensure the control parameters are set consistently relative
267
    /// to each other.
268
    ///
269
    /// # Panics
270
    ///
271
    /// Panics if `period` can produce a negative number (the sample range lower
272
    /// bound is negative), unless the cycle count is exactly 1, in which case
273
    /// `period` is ignored.
274
    ///
275
    /// Panics if `period` can only produce 0 (the sample range upper bound
276
    /// is not strictly positive), unless the cycle count is exactly 1, in which
277
    /// case `period` is ignored.
278
    ///
279
    /// Panics if any value is infinite.
280
    ///
281
    /// # Example
282
    ///
283
    /// ```
284
    /// # use bevy_hanabi::SpawnerSettings;
285
    /// // Spawn 32 particles over 3 seconds, then pause for 7 seconds (10 - 3),
286
    /// // doing that 5 times in total.
287
    /// let spawner = SpawnerSettings::new(32.0.into(), 3.0.into(), 10.0.into(), 5);
288
    /// ```
289
    ///
290
    /// [`once()`]: Self::once
291
    /// [`burst()`]: Self::burst
292
    /// [`rate()`]: Self::rate
293
    pub fn new(
43✔
294
        count: CpuValue<f32>,
295
        spawn_duration: CpuValue<f32>,
296
        period: CpuValue<f32>,
297
        cycle_count: u32,
298
    ) -> Self {
299
        match Self::try_new(count, spawn_duration, period, cycle_count) {
172✔
300
            Ok(s) => s,
40✔
301
            Err(err) => match err {
6✔
302
                SpawnerSettingsError::InvalidPeriod { min, max } => {
4✔
303
                    if min < 0. {
2✔
304
                        panic!("`period` must not generate negative numbers (period.min was {min}, expected >= 0).");
1✔
305
                    } else {
306
                        panic!("`period` must be able to generate a positive number (period.max was {max}, expected > 0).");
1✔
307
                    }
308
                }
309
                SpawnerSettingsError::InfinitePeriod => panic!("`period` {period:?} has an infinite bound. If upgrading from a previous version, use `cycle_count = 1` instead for a single-cycle burst.",),
1✔
310
            },
311
        }
312
    }
313

314
    /// Try to create settings from individual values.
315
    ///
316
    /// Same as [`new()`], but fallible (returns an error) on invalid arugments
317
    /// instead of panicking.
318
    ///
319
    /// [`new()`]: Self::new
320
    pub fn try_new(
47✔
321
        count: CpuValue<f32>,
322
        spawn_duration: CpuValue<f32>,
323
        period: CpuValue<f32>,
324
        cycle_count: u32,
325
    ) -> Result<Self, SpawnerSettingsError> {
326
        let range = period.range();
141✔
327
        if cycle_count != 1 {
47✔
328
            if range[0] < 0. || range[1] <= 0. {
40✔
329
                return Err(SpawnerSettingsError::InvalidPeriod {
4✔
330
                    min: range[0],
4✔
331
                    max: range[1],
4✔
332
                });
333
            }
334
        }
335
        if !range[0].is_finite() || !range[1].is_finite() {
85✔
336
            return Err(SpawnerSettingsError::InfinitePeriod);
3✔
337
        }
338

339
        Ok(Self {
40✔
340
            count,
80✔
341
            spawn_duration,
80✔
342
            period,
40✔
343
            cycle_count,
40✔
344
            starts_active: true,
40✔
345
            emit_on_start: true,
40✔
346
        })
347
    }
348

349
    /// Set whether the [`EffectSpawner`] immediately starts to emit particle
350
    /// when the [`ParticleEffect`] is spawned into the ECS world.
351
    ///
352
    /// If set to `false`, then [`EffectSpawner::has_completed()`] will return
353
    /// `true` after spawning the component, and the spawner needs to be
354
    /// [`EffectSpawner::reset()`] before it can spawn particles. This is
355
    /// useful to spawn a particle effect instance immediately, but only start
356
    /// emitting particles when an event occurs (collision, user input, any
357
    /// other game logic...).
358
    ///
359
    /// Because a spawner repeating forever never completes, this has no effect
360
    /// if [`is_forever()`] is `true`. To start/stop spawning with those
361
    /// effects, use [`EffectSpawner::active`] instead.
362
    ///
363
    /// [`is_forever()`]: Self::is_forever
364
    pub fn with_emit_on_start(mut self, emit_on_start: bool) -> Self {
×
UNCOV
365
        self.emit_on_start = emit_on_start;
×
UNCOV
366
        self
×
367
    }
368

369
    /// Set whether the [`EffectSpawner`] immediately starts to emit particle
370
    /// when the [`ParticleEffect`] is spawned into the ECS world.
371
    ///
372
    /// See [with_emit_on_start()] for details.
373
    ///
374
    /// [with_emit_on_start()]: Self::with_emit_on_start
NEW
375
    pub fn set_emit_on_start(&mut self, emit_on_start: bool) {
×
NEW
376
        self.emit_on_start = emit_on_start;
×
377
    }
378

379
    /// Get whether the [`EffectSpawner`] immediately starts to emit particle
380
    /// when the [`ParticleEffect`] is spawned into the ECS world.
381
    ///
382
    /// See [with_emit_on_start()] for details.
383
    ///
384
    /// [with_emit_on_start()]: Self::with_emit_on_start
NEW
385
    pub fn emits_on_start(&self) -> bool {
×
NEW
386
        self.emit_on_start
×
387
    }
388

389
    /// Create settings to spawn a burst of particles once.
390
    ///
391
    /// The burst of particles is spawned all at once in the same frame. After
392
    /// that, the spawner idles, waiting to be manually reset via
393
    /// [`EffectSpawner::reset()`].
394
    ///
395
    /// This is a convenience for:
396
    ///
397
    /// ```
398
    /// # use bevy_hanabi::{SpawnerSettings, CpuValue};
399
    /// # let count = CpuValue::Single(1.);
400
    /// SpawnerSettings::new(count, 0.0.into(), 0.0.into(), 1);
401
    /// ```
402
    ///
403
    /// # Example
404
    ///
405
    /// ```
406
    /// # use bevy_hanabi::SpawnerSettings;
407
    /// // Spawn 32 particles in a burst once immediately on creation.
408
    /// let spawner = SpawnerSettings::once(32.0.into());
409
    /// ```
410
    pub fn once(count: CpuValue<f32>) -> Self {
26✔
411
        Self::new(count, 0.0.into(), 0.0.into(), 1)
104✔
412
    }
413

414
    /// Get whether the spawner has a single cycle.
415
    ///
416
    /// This is true if the cycle count is exactly equal to 1.
417
    pub fn is_once(&self) -> bool {
39✔
418
        self.cycle_count == 1
39✔
419
    }
420

421
    /// Get whether the spawner has an infinite number of cycles.
422
    ///
423
    /// This is true if the cycle count is exactly equal to 0.
424
    pub fn is_forever(&self) -> bool {
370✔
425
        self.cycle_count == 0
370✔
426
    }
427

428
    /// Create settings to spawn a continuous stream of particles.
429
    ///
430
    /// The particle spawn `rate` is expressed in particles per second.
431
    /// Fractional values are accumulated each frame.
432
    ///
433
    /// This is a convenience for:
434
    ///
435
    /// ```
436
    /// # use bevy_hanabi::{SpawnerSettings, CpuValue};
437
    /// # let rate = CpuValue::Single(1.);
438
    /// SpawnerSettings::new(rate, 1.0.into(), 1.0.into(), 0);
439
    /// ```
440
    ///
441
    /// # Example
442
    ///
443
    /// ```
444
    /// # use bevy_hanabi::SpawnerSettings;
445
    /// // Spawn 10 particles per second, indefinitely.
446
    /// let spawner = SpawnerSettings::rate(10.0.into());
447
    /// ```
448
    pub fn rate(rate: CpuValue<f32>) -> Self {
12✔
449
        Self::new(rate, 1.0.into(), 1.0.into(), 0)
48✔
450
    }
451

452
    /// Create settings to spawn particles in bursts.
453
    ///
454
    /// The settings define an infinite number of cycles where `count` particles
455
    /// are spawned at the beginning of the cycle, then the spawner waits
456
    /// `period` seconds, and repeats forever.
457
    ///
458
    /// This is a convenience for:
459
    ///
460
    /// ```
461
    /// # use bevy_hanabi::{SpawnerSettings, CpuValue};
462
    /// # let count = CpuValue::Single(1.);
463
    /// # let period = CpuValue::Single(1.);
464
    /// SpawnerSettings::new(count, 0.0.into(), period, 0);
465
    /// ```
466
    ///
467
    /// # Example
468
    ///
469
    /// ```
470
    /// # use bevy_hanabi::SpawnerSettings;
471
    /// // Spawn a burst of 5 particles every 3 seconds, indefinitely.
472
    /// let spawner = SpawnerSettings::burst(5.0.into(), 3.0.into());
473
    /// ```
474
    pub fn burst(count: CpuValue<f32>, period: CpuValue<f32>) -> Self {
1✔
475
        Self::new(count, 0.0.into(), period, 0)
4✔
476
    }
477

478
    /// Set the number of particles that are spawned each cycle.
479
    pub fn with_count(mut self, count: CpuValue<f32>) -> Self {
×
UNCOV
480
        self.count = count;
×
UNCOV
481
        self
×
482
    }
483

484
    /// Set the number of particles that are spawned each cycle.
UNCOV
485
    pub fn set_count(&mut self, count: CpuValue<f32>) {
×
UNCOV
486
        self.count = count;
×
487
    }
488

489
    /// Get the number of particles that are spawned each cycle.
UNCOV
490
    pub fn count(&self) -> CpuValue<f32> {
×
UNCOV
491
        self.count
×
492
    }
493

494
    /// Set the duration, in seconds, of the spawn part each cycle.
495
    pub fn with_spawn_duration(mut self, spawn_duration: CpuValue<f32>) -> Self {
×
UNCOV
496
        self.spawn_duration = spawn_duration;
×
UNCOV
497
        self
×
498
    }
499

500
    /// Set the duration, in seconds, of the spawn part each cycle.
UNCOV
501
    pub fn set_spawn_duration(&mut self, spawn_duration: CpuValue<f32>) {
×
UNCOV
502
        self.spawn_duration = spawn_duration;
×
503
    }
504

505
    /// Get the duration, in seconds, of the spawn part each cycle.
UNCOV
506
    pub fn spawn_duration(&self) -> CpuValue<f32> {
×
UNCOV
507
        self.spawn_duration
×
508
    }
509

510
    /// Set the duration of a single spawn cycle, in seconds.
511
    ///
512
    /// A spawn cycle includes the [`spawn_duration()`] value, and any extra
513
    /// wait time (if larger than spawn time).
514
    ///
515
    /// # Panics
516
    ///
517
    /// Panics if the period is infinite.
518
    ///
519
    /// [`spawn_duration()`]: Self::spawn_duration
520
    pub fn with_period(mut self, period: CpuValue<f32>) -> Self {
×
UNCOV
521
        assert!(
×
UNCOV
522
            period.range()[0].is_finite() && period.range()[1].is_finite(),
×
523
            "`period` {:?} has an infinite bound. If upgrading from a previous version, use `cycle_count = 1` instead for a single-cycle burst.",
524
            period
525
        );
UNCOV
526
        self.period = period;
×
UNCOV
527
        self
×
528
    }
529

530
    /// Set the duration of a single spawn cycle, in seconds.
531
    ///
532
    /// A spawn cycle includes the [`spawn_duration()`] value, and any extra
533
    /// wait time (if larger than spawn time).
534
    ///
535
    /// # Panics
536
    ///
537
    /// Panics if the period is infinite.
538
    ///
539
    /// [`spawn_duration()`]: Self::spawn_duration
540
    pub fn set_period(&mut self, period: CpuValue<f32>) {
×
UNCOV
541
        assert!(
×
UNCOV
542
            period.range()[0].is_finite() && period.range()[1].is_finite(),
×
543
            "`period` {:?} has an infinite bound. If upgrading from a previous version, use `cycle_count = 1` instead for a single-cycle burst.",
544
            period
545
        );
UNCOV
546
        self.period = period;
×
547
    }
548

549
    /// Get the duration of a single spawn cycle, in seconds.
550
    ///
551
    /// A spawn cycle includes the [`spawn_duration()`] value, and any extra
552
    /// wait time (if larger than spawn time).
553
    ///
554
    /// [`spawn_duration()`]: Self::spawn_duration
UNCOV
555
    pub fn period(&self) -> CpuValue<f32> {
×
UNCOV
556
        self.period
×
557
    }
558

559
    /// Set the number of cycles to spawn for.
560
    ///
561
    /// A spawn cycle includes the [`spawn_duration()`] value, and any extra
562
    /// wait time (if larger than spawn time). It lasts for [`period()`].
563
    ///
564
    /// [`spawn_duration()`]: Self::spawn_duration
565
    /// [`period()`]: Self::period
566
    pub fn with_cycle_count(mut self, cycle_count: u32) -> Self {
×
UNCOV
567
        self.cycle_count = cycle_count;
×
UNCOV
568
        self
×
569
    }
570

571
    /// Set the number of cycles to spawn for.
572
    ///
573
    /// A spawn cycle includes the [`spawn_duration()`] value, and any extra
574
    /// wait time (if larger than spawn time). It lasts for [`period()`].
575
    ///
576
    /// [`spawn_duration()`]: Self::spawn_duration
577
    /// [`period()`]: Self::period
UNCOV
578
    pub fn set_cycle_count(&mut self, cycle_count: u32) {
×
UNCOV
579
        self.cycle_count = cycle_count;
×
580
    }
581

582
    /// Get the number of cycles to spawn for.
583
    ///
584
    /// A spawn cycle includes the [`spawn_duration()`] value, and any extra
585
    /// wait time (if larger than spawn time). It lasts for [`period()`].
586
    ///
587
    /// [`spawn_duration()`]: Self::spawn_duration
588
    /// [`period()`]: Self::period
589
    pub fn cycle_count(&self) -> u32 {
29✔
590
        self.cycle_count
29✔
591
    }
592

593
    /// Sets whether the spawner starts active when the effect is instantiated.
594
    ///
595
    /// This value will be transfered to the active state of the
596
    /// [`EffectSpawner`] once it's instantiated. Inactive spawners do not spawn
597
    /// any particle.
598
    pub fn with_starts_active(mut self, starts_active: bool) -> Self {
2✔
599
        self.starts_active = starts_active;
2✔
600
        self
2✔
601
    }
602

603
    /// Set whether the spawner starts active when the effect is instantiated.
604
    ///
605
    /// This value will be transfered to the active state of the
606
    /// [`EffectSpawner`] once it's instantiated. Inactive spawners do not spawn
607
    /// any particle.
UNCOV
608
    pub fn set_starts_active(&mut self, starts_active: bool) {
×
UNCOV
609
        self.starts_active = starts_active;
×
610
    }
611

612
    /// Get whether the spawner starts active when the effect is instantiated.
613
    ///
614
    /// This value will be transfered to the active state of the
615
    /// [`EffectSpawner`] once it's instantiated. Inactive spawners do not spawn
616
    /// any particle.
617
    pub fn starts_active(&self) -> bool {
16✔
618
        self.starts_active
16✔
619
    }
620
}
621

622
/// Runtime state machine for CPU particle spawning.
623
///
624
/// The spawner defines how new particles are emitted and when. Each time the
625
/// spawner ticks, it calculates a number of particles to emit for this frame,
626
/// based on its [`SpawnerSettings`]. This spawn count is passed to the GPU for
627
/// the init compute pass to actually allocate the new particles and initialize
628
/// them. The number of particles to spawn is stored as a floating-point number,
629
/// and any remainder accumulates for the next tick.
630
///
631
/// Spawners are used to control from CPU when particles are spawned. To use GPU
632
/// spawn events instead, and spawn particles based on events occurring on
633
/// existing particles in other effects, see [`EffectParent`]. Those two
634
/// mechanisms (CPU and GPU spawner) are mutually exclusive.
635
///
636
/// Once per frame the [`tick_spawners()`] system will add the [`EffectSpawner`]
637
/// component if it's missing, cloning the [`SpawnerSettings`] from the source
638
/// [`EffectAsset`] to initialize it. After that, it ticks the
639
/// [`SpawnerSettings`] stored in the component. The resulting number of
640
/// particles to spawn for the frame is then stored into
641
/// [`EffectSpawner::spawn_count`]. You can override that value to manually
642
/// control each frame how many particles are spawned, instead of using the
643
/// logic of [`SpawnerSettings`].
644
///
645
/// [`EffectParent`]: crate::EffectParent
646
#[derive(Debug, Default, Clone, Copy, PartialEq, Component, Reflect)]
647
#[reflect(Component)]
648
pub struct EffectSpawner {
649
    /// The spawner settings extracted from the [`EffectAsset`], or directly
650
    /// overriden by the user.
651
    pub settings: SpawnerSettings,
652

653
    /// Accumulated time for the current (partial) cycle, in seconds.
654
    cycle_time: f32,
655

656
    /// Number of cycles already completed.
657
    completed_cycle_count: u32,
658

659
    /// Sampled value of `spawn_duration` until `period` is reached. This is the
660
    /// duration of the "active" period during which we spawn particles, as
661
    /// opposed to the "wait" period during which we do nothing until the next
662
    /// spawn cycle.
663
    sampled_spawn_duration: f32,
664

665
    /// Sampled value of the time period, in seconds, until the next spawn
666
    /// cycle.
667
    sampled_period: f32,
668

669
    /// Sampled value of the number of particles to spawn per `spawn_duration`.
670
    sampled_count: f32,
671

672
    /// Number of particles to spawn this frame.
673
    ///
674
    /// This value is normally updated by calling [`tick()`], which
675
    /// automatically happens once per frame when the [`tick_spawners()`]
676
    /// system runs in the [`PostUpdate`] schedule.
677
    ///
678
    /// You can manually assign this value to override the one calculated by
679
    /// [`tick()`]. Note in this case that you need to override the value after
680
    /// the automated one was calculated, by ordering your system
681
    /// after [`tick_spawners()`] or [`EffectSystems::TickSpawners`].
682
    ///
683
    /// [`tick()`]: crate::EffectSpawner::tick
684
    /// [`EffectSystems::TickSpawners`]: crate::EffectSystems::TickSpawners
685
    pub spawn_count: u32,
686

687
    /// Fractional remainder of particle count to spawn.
688
    ///
689
    /// This is accumulated each tick, and the integral part is added to
690
    /// `spawn_count`. The reminder gets saved for next frame.
691
    spawn_remainder: f32,
692

693
    /// Whether the spawner is active. Defaults to
694
    /// [`SpawnerSettings::starts_active()`]. An inactive spawner
695
    /// doesn't tick (no particle spawned, no internal state updated).
696
    pub active: bool,
697
}
698

699
impl EffectSpawner {
700
    /// Create a new spawner.
701
    pub fn new(settings: &SpawnerSettings) -> Self {
14✔
702
        Self {
703
            settings: *settings,
14✔
704
            cycle_time: 0.,
705
            completed_cycle_count: if settings.emit_on_start || settings.is_forever() {
14✔
706
                // Infinitely repeating effects always start at cycle #0.
707
                0
708
            } else {
709
                // Start at last cycle. This means has_completed() is true.
710
                settings.cycle_count()
711
            },
712
            sampled_spawn_duration: 0.,
713
            sampled_period: 0.,
714
            sampled_count: 0.,
715
            spawn_count: 0,
716
            spawn_remainder: 0.,
717
            active: settings.starts_active(),
28✔
718
        }
719
    }
720

721
    /// Set whether the spawner is active.
722
    ///
723
    /// Inactive spawners do not tick, and therefore do not spawn any particle.
724
    /// Their internal state do not update.
725
    pub fn with_active(mut self, active: bool) -> Self {
×
UNCOV
726
        self.active = active;
×
UNCOV
727
        self
×
728
    }
729

730
    /// Get the time relative to the beginning of the current cycle.
731
    #[inline]
732
    pub fn cycle_time(&self) -> f32 {
3✔
733
        self.cycle_time
3✔
734
    }
735

736
    /// Get the spawn duration for the current cycle.
737
    ///
738
    /// This value can change every cycle if [`SpawnerSettings::spawn_duration`]
739
    /// is a randomly distributed value.
740
    #[inline]
741
    pub fn cycle_spawn_duration(&self) -> f32 {
3✔
742
        self.sampled_spawn_duration
3✔
743
    }
744

745
    /// Get the period of the current cycle.
746
    ///
747
    /// This value can change every cycle if [`SpawnerSettings::period`] is a
748
    /// randomly distributed value. If the effect spawns only once, and
749
    /// therefore its cycle period is ignored, this returns `0`.
750
    #[inline]
751
    pub fn cycle_period(&self) -> f32 {
3✔
752
        if self.settings.is_once() {
6✔
UNCOV
753
            0.
×
754
        } else {
755
            self.sampled_period
3✔
756
        }
757
    }
758

759
    /// Get the progress ratio in 0..1 of the current cycle.
760
    ///
761
    /// This is the ratio of the [`cycle_time()`] over [`cycle_period()`]. If
762
    /// the effect spawns only once, and therefore its cycle period is
763
    /// ignored, this returns `0`.
764
    ///
765
    /// [`cycle_time()`]: Self::cycle_time
766
    /// [`cycle_period()`]: Self::cycle_period
767
    #[inline]
768
    pub fn cycle_ratio(&self) -> f32 {
3✔
769
        if self.settings.is_once() {
6✔
UNCOV
770
            0.
×
771
        } else {
772
            self.cycle_time / self.sampled_period
3✔
773
        }
774
    }
775

776
    /// Get the number of particles to spawn during the current cycle
777
    ///
778
    /// This value can change every cycle if [`SpawnerSettings::count`] is a
779
    /// randomly distributed value.
780
    #[inline]
781
    pub fn cycle_spawn_count(&self) -> f32 {
3✔
782
        self.sampled_count
3✔
783
    }
784

785
    /// Get the number of completed cycles since last [`reset()`].
786
    ///
787
    /// The value loops back if the pattern repeats forever
788
    /// ([`SpawnerSettings::is_forever()`] is `true`).
789
    ///
790
    /// [`reset()`]: Self::reset
791
    #[inline]
792
    pub fn completed_cycle_count(&self) -> u32 {
5✔
793
        self.completed_cycle_count
5✔
794
    }
795

796
    /// Get whether the spawner has completed.
797
    ///
798
    /// A spawner has completed if it already ticked through its maximum number
799
    /// of cycles. It can be reset back to its original state with [`reset()`].
800
    /// A spawner repeating forever never completes.
801
    ///
802
    /// [`reset()`]: Self::reset
803
    #[inline]
804
    pub fn has_completed(&self) -> bool {
6✔
805
        !self.settings.is_forever() && (self.completed_cycle_count >= self.settings.cycle_count())
12✔
806
    }
807

808
    /// Reset the spawner state.
809
    ///
810
    /// This resets the internal spawner time and cycle count to zero.
811
    ///
812
    /// Use this, for example, to immediately spawn some particles in a spawner
813
    /// constructed with [`SpawnerSettings::once`].
814
    ///
815
    /// [`SpawnerSettings::once`]: crate::SpawnerSettings::once
816
    pub fn reset(&mut self) {
2✔
817
        self.cycle_time = 0.;
2✔
818
        self.completed_cycle_count = 0;
2✔
819
        self.sampled_spawn_duration = 0.;
2✔
820
        self.sampled_period = 0.;
2✔
821
        self.sampled_count = 0.;
2✔
822
        self.spawn_count = 0;
2✔
823
        self.spawn_remainder = 0.;
2✔
824
    }
825

826
    /// Tick the spawner to calculate the number of particles to spawn this
827
    /// frame.
828
    ///
829
    /// The frame delta time `dt` is added to the current spawner time, before
830
    /// the spawner calculates the number of particles to spawn.
831
    ///
832
    /// This method is called automatically by [`tick_spawners()`] during the
833
    /// [`PostUpdate`], so you normally don't have to call it yourself
834
    /// manually.
835
    ///
836
    /// # Returns
837
    ///
838
    /// The integral number of particles to spawn this frame. Any fractional
839
    /// remainder is saved for the next call.
840
    pub fn tick(&mut self, mut dt: f32, rng: &mut Pcg32) -> u32 {
347✔
841
        // If inactive, or if the finite number of cycles has been completed, then we're
842
        // done.
843
        if !self.active
347✔
844
            || (!self.settings.is_forever()
343✔
845
                && (self.completed_cycle_count >= self.settings.cycle_count()))
14✔
846
        {
847
            self.spawn_count = 0;
7✔
848
            return 0;
849
        }
850

851
        // Use a loop in case the timestep dt spans multiple cycles
852
        loop {
853
            // Check if this is a new cycle which needs resampling
854
            if self.sampled_period == 0.0 {
351✔
855
                if self.settings.is_once() {
59✔
856
                    self.sampled_spawn_duration = self.settings.spawn_duration.sample(rng);
21✔
857
                    // Period is unchecked, should be ignored (could sample to <= 0). Use the spawn
858
                    // duration, but ensure we have something > 0 as a marker that we've resampled.
859
                    self.sampled_period = self.sampled_spawn_duration.max(1e-12);
7✔
860
                } else {
861
                    self.sampled_period = self.settings.period.sample(rng);
19✔
862
                    assert!(self.sampled_period > 0.);
863
                    self.sampled_spawn_duration = self
38✔
864
                        .settings
38✔
865
                        .spawn_duration
38✔
866
                        .sample(rng)
57✔
867
                        .clamp(0., self.sampled_period);
19✔
868
                }
869
                self.sampled_spawn_duration = self.settings.spawn_duration.sample(rng);
26✔
870
                self.sampled_count = self.settings.count.sample(rng).max(0.);
871
            }
872

873
            let new_time = self.cycle_time + dt;
351✔
874

875
            // If inside the spawn period, accumulate some particle spawn count
876
            if self.cycle_time <= self.sampled_spawn_duration {
877
                // If the spawn time is very small, close to zero, spawn all particles
878
                // immediately in one burst over a single frame.
879
                self.spawn_remainder += if self.sampled_spawn_duration < 1e-5f32.max(dt / 100.0) {
694✔
880
                    self.sampled_count
10✔
881
                } else {
882
                    // Spawn an amount of particles equal to the fraction of time the current frame
883
                    // spans compared to the total burst duration.
884
                    let ratio = ((new_time.min(self.sampled_spawn_duration) - self.cycle_time)
337✔
885
                        / self.sampled_spawn_duration)
886
                        .clamp(0., 1.);
887
                    self.sampled_count * ratio
888
                };
889
            }
890

891
            // Increment current time
892
            self.cycle_time = new_time;
893

894
            // Check for cycle completion
895
            if self.cycle_time >= self.sampled_period {
896
                dt = self.cycle_time - self.sampled_period;
19✔
897
                self.cycle_time = 0.0;
19✔
898
                self.completed_cycle_count += 1;
19✔
899

900
                // Mark as "need resampling"
901
                self.sampled_period = 0.0;
19✔
902

903
                // If this was the last cycle, we're done
904
                if !self.settings.is_forever()
19✔
905
                    && (self.completed_cycle_count >= self.settings.cycle_count())
9✔
906
                {
907
                    // Don't deactivate quite yet, otherwise we'll miss the spawns for this frame
908
                    break;
8✔
909
                }
910
            } else {
911
                // We're done for this frame
912
                break;
332✔
913
            }
914
        }
915

916
        // Extract integral number of particles to spawn this frame, keep remainder for
917
        // next one
918
        let count = self.spawn_remainder.floor();
340✔
919
        self.spawn_remainder -= count;
920
        self.spawn_count = count as u32;
921

922
        self.spawn_count
923
    }
924
}
925

926
/// Tick all the [`EffectSpawner`] components.
927
///
928
/// This system runs in the [`PostUpdate`] stage, after the visibility system
929
/// has updated the [`InheritedVisibility`] of each effect instance (see
930
/// [`VisibilitySystems::VisibilityPropagate`]). Hidden instances are not
931
/// updated, unless the [`EffectAsset::simulation_condition`]
932
/// is set to [`SimulationCondition::Always`]. If no [`InheritedVisibility`] is
933
/// present, the effect is assumed to be visible.
934
///
935
/// Note that by that point the [`ViewVisibility`] is not yet calculated, and it
936
/// may happen that spawners are ticked but no effect is visible in any view
937
/// even though some are "visible" (active) in the [`World`]. The actual
938
/// per-view culling of invisible (not in view) effects is performed later on
939
/// the render world.
940
///
941
/// Once the system determined that the effect instance needs to be simulated
942
/// this frame, it ticks the effect's spawner by calling
943
/// [`EffectSpawner::tick()`], adding a new [`EffectSpawner`] component if it
944
/// doesn't already exist on the same entity as the [`ParticleEffect`].
945
///
946
/// [`VisibilitySystems::VisibilityPropagate`]: bevy::camera::visibility::VisibilitySystems::VisibilityPropagate
947
/// [`EffectAsset::simulation_condition`]: crate::EffectAsset::simulation_condition
948
pub fn tick_spawners(
333✔
949
    mut commands: Commands,
950
    time: Res<Time<EffectSimulation>>,
951
    effects: Res<Assets<EffectAsset>>,
952
    mut rng: ResMut<Random>,
953
    mut query: Query<(
954
        Entity,
955
        &ParticleEffect,
956
        &CompiledParticleEffect,
957
        &InheritedVisibility,
958
        Option<&mut EffectSpawner>,
959
    )>,
960
) {
961
    #[cfg(feature = "trace")]
962
    let _span = bevy::log::info_span!("tick_spawners").entered();
999✔
963
    trace!("tick_spawners()");
333✔
964

965
    let dt = time.delta_secs();
666✔
966

967
    for (entity, effect, compiled_effect, inherited_visibility, maybe_spawner) in query.iter_mut() {
993✔
968
        // Skip effect which are not ready; this prevents ticking the spawner for an
969
        // effect not ready to consume those spawn commands.
970
        let mut can_tick = if compiled_effect.is_ready() {
971
            true
311✔
972
        } else {
973
            trace!("[Effect {entity:?}] Not ready; skipped spawner tick.");
16✔
974
            false
975
        };
976

977
        let Some(asset) = effects.get(&effect.handle) else {
317✔
978
            trace!(
10✔
979
                "Effect asset with handle {:?} is not available; skipped initializers tick.",
980
                effect.handle
981
            );
982
            continue;
10✔
983
        };
984

985
        if asset.simulation_condition == SimulationCondition::WhenVisible
986
            && !inherited_visibility.get()
316✔
987
        {
988
            trace!(
1✔
989
                "Effect asset with handle {:?} is not visible, and simulates only WhenVisible; skipped initializers tick.",
990
                effect.handle
991
            );
992
            can_tick = false;
993
        }
994

995
        if let Some(mut effect_spawner) = maybe_spawner {
312✔
996
            if can_tick {
308✔
997
                effect_spawner.tick(dt, &mut rng.0);
924✔
998
            }
999
        } else {
1000
            let mut effect_spawner = EffectSpawner::new(&asset.spawner);
15✔
1001
            if can_tick {
7✔
1002
                effect_spawner.tick(dt, &mut rng.0);
6✔
1003
            }
1004
            commands.entity(entity).insert(effect_spawner);
20✔
1005
        }
1006
    }
1007
}
1008

1009
#[cfg(test)]
1010
mod test {
1011
    use std::time::Duration;
1012

1013
    use bevy::{
1014
        asset::{
1015
            io::{
1016
                memory::{Dir, MemoryAssetReader},
1017
                AssetSourceBuilder, AssetSourceBuilders, AssetSourceId,
1018
            },
1019
            AssetServerMode, UnapprovedPathMode,
1020
        },
1021
        camera::visibility::{VisibilityPlugin, VisibilitySystems},
1022
        tasks::{IoTaskPool, TaskPoolBuilder},
1023
    };
1024

1025
    use super::*;
1026
    use crate::Module;
1027

1028
    #[test]
1029
    fn test_range_single() {
1030
        let value = CpuValue::Single(1.0);
1031
        assert_eq!(value.range(), [1.0, 1.0]);
1032
    }
1033

1034
    #[test]
1035
    fn test_range_uniform() {
1036
        let value = CpuValue::Uniform((1.0, 3.0));
1037
        assert_eq!(value.range(), [1.0, 3.0]);
1038
    }
1039

1040
    #[test]
1041
    fn test_range_uniform_reverse() {
1042
        let value = CpuValue::Uniform((3.0, 1.0));
1043
        assert_eq!(value.range(), [1.0, 3.0]);
1044
    }
1045

1046
    #[test]
1047
    fn test_new() {
1048
        let rng = &mut new_rng();
1049
        // 3 particles over 3 seconds, pause 7 seconds (total 10 seconds period). 2
1050
        // cycles.
1051
        let spawner = SpawnerSettings::new(3.0.into(), 3.0.into(), 10.0.into(), 2);
1052
        let mut spawner = EffectSpawner::new(&spawner);
1053
        let count = spawner.tick(2., rng); // t = 2s
1054
        assert_eq!(count, 2);
1055
        assert!(spawner.active);
1056
        assert_eq!(spawner.cycle_time(), 2.);
1057
        assert_eq!(spawner.cycle_spawn_duration(), 3.);
1058
        assert_eq!(spawner.cycle_period(), 10.);
1059
        assert_eq!(spawner.cycle_ratio(), 0.2); // 2s / 10s
1060
        assert_eq!(spawner.cycle_spawn_count(), 3.);
1061
        assert_eq!(spawner.completed_cycle_count(), 0);
1062
        let count = spawner.tick(5., rng); // t = 7s
1063
        assert_eq!(count, 1);
1064
        assert!(spawner.active);
1065
        assert_eq!(spawner.cycle_time(), 7.);
1066
        assert_eq!(spawner.cycle_spawn_duration(), 3.);
1067
        assert_eq!(spawner.cycle_period(), 10.);
1068
        assert_eq!(spawner.cycle_ratio(), 0.7); // 7s / 10s
1069
        assert_eq!(spawner.cycle_spawn_count(), 3.);
1070
        assert_eq!(spawner.completed_cycle_count(), 0);
1071
        let count = spawner.tick(8., rng); // t = 15s
1072
        assert_eq!(count, 3);
1073
        assert!(spawner.active);
1074
        assert_eq!(spawner.cycle_time(), 5.); // 15. mod 10.
1075
        assert_eq!(spawner.cycle_spawn_duration(), 3.);
1076
        assert_eq!(spawner.cycle_period(), 10.);
1077
        assert_eq!(spawner.cycle_ratio(), 0.5); // 5s / 10s
1078
        assert_eq!(spawner.cycle_spawn_count(), 3.);
1079
        assert_eq!(spawner.completed_cycle_count(), 1);
1080
        let count = spawner.tick(10., rng); // t = 25s
1081
        assert_eq!(count, 0);
1082
        assert!(spawner.active);
1083
        assert_eq!(spawner.completed_cycle_count(), 2);
1084
        let count = spawner.tick(0.1, rng); // t = 25.1s
1085
        assert_eq!(count, 0);
1086
        assert!(spawner.active);
1087
        assert_eq!(spawner.completed_cycle_count(), 2);
1088
    }
1089

1090
    #[test]
1091
    #[should_panic]
1092
    fn test_new_panic_negative_period() {
1093
        let _ = SpawnerSettings::new(3.0.into(), 1.0.into(), CpuValue::Uniform((-1., 1.)), 0);
1094
    }
1095

1096
    #[test]
1097
    #[should_panic]
1098
    fn test_new_panic_zero_period() {
1099
        let _ = SpawnerSettings::new(3.0.into(), 1.0.into(), CpuValue::Uniform((0., 0.)), 0);
1100
    }
1101

1102
    #[test]
1103
    #[should_panic]
1104
    fn test_new_panic_infinite_period() {
1105
        let _ = SpawnerSettings::new(
1106
            3.0.into(),
1107
            1.0.into(),
1108
            CpuValue::Uniform((0., f32::INFINITY)),
1109
            0,
1110
        );
1111
    }
1112

1113
    #[test]
1114
    fn test_try_new_negative_period() {
1115
        assert!(matches!(
1116
            SpawnerSettings::try_new(3.0.into(), 1.0.into(), CpuValue::Uniform((-1., 1.)), 0),
1117
            Err(SpawnerSettingsError::InvalidPeriod { min: -1., max: 1. })
1118
        ));
1119
    }
1120

1121
    #[test]
1122
    fn test_try_new_zero_period() {
1123
        assert!(matches!(
1124
            SpawnerSettings::try_new(3.0.into(), 1.0.into(), CpuValue::Uniform((0., 0.)), 0),
1125
            Err(SpawnerSettingsError::InvalidPeriod { min: 0., max: 0. })
1126
        ));
1127
    }
1128

1129
    #[test]
1130
    fn test_try_new_infitie_period() {
1131
        assert!(matches!(
1132
            SpawnerSettings::try_new(
1133
                3.0.into(),
1134
                1.0.into(),
1135
                CpuValue::Uniform((0., f32::INFINITY)),
1136
                0
1137
            ),
1138
            Err(SpawnerSettingsError::InfinitePeriod)
1139
        ));
1140
        assert!(matches!(
1141
            SpawnerSettings::try_new(
1142
                3.0.into(),
1143
                1.0.into(),
1144
                CpuValue::Uniform((f32::INFINITY, f32::INFINITY)),
1145
                0
1146
            ),
1147
            Err(SpawnerSettingsError::InfinitePeriod)
1148
        ));
1149
    }
1150

1151
    #[test]
1152
    fn test_once() {
1153
        let rng = &mut new_rng();
1154
        let spawner = SpawnerSettings::once(5.0.into());
1155
        assert!(spawner.is_once());
1156
        let mut spawner = EffectSpawner::new(&spawner);
1157
        assert!(spawner.active);
1158
        let count = spawner.tick(0.001, rng);
1159
        assert_eq!(count, 5);
1160
        let count = spawner.tick(100.0, rng);
1161
        assert_eq!(count, 0);
1162
    }
1163

1164
    #[test]
1165
    fn test_once_reset() {
1166
        let rng = &mut new_rng();
1167
        let spawner = SpawnerSettings::once(5.0.into());
1168
        assert!(spawner.is_once());
1169
        assert!(spawner.starts_active());
1170
        let mut spawner = EffectSpawner::new(&spawner);
1171
        spawner.tick(1.0, rng);
1172
        spawner.reset();
1173
        let count = spawner.tick(1.0, rng);
1174
        assert_eq!(count, 5);
1175
    }
1176

1177
    #[test]
1178
    fn test_once_start_inactive() {
1179
        let rng = &mut new_rng();
1180

1181
        let spawner = SpawnerSettings::once(5.0.into()).with_starts_active(false);
1182
        assert!(spawner.is_once());
1183
        assert!(!spawner.starts_active());
1184
        let mut spawner = EffectSpawner::new(&spawner);
1185
        assert!(!spawner.has_completed());
1186

1187
        // Inactive; no-op
1188
        let count = spawner.tick(1.0, rng);
1189
        assert_eq!(count, 0);
1190
        assert!(!spawner.has_completed());
1191

1192
        spawner.active = true;
1193

1194
        // Active; spawns
1195
        let count = spawner.tick(1.0, rng);
1196
        assert_eq!(count, 5);
1197
        assert!(spawner.active);
1198
        assert!(spawner.has_completed()); // once(), so completes on first tick()
1199

1200
        // Completed; no-op
1201
        let count = spawner.tick(1.0, rng);
1202
        assert_eq!(count, 0);
1203
        assert!(spawner.active);
1204
        assert!(spawner.has_completed());
1205

1206
        // Reset internal state, still active
1207
        spawner.reset();
1208
        assert!(spawner.active);
1209
        assert!(!spawner.has_completed());
1210

1211
        let count = spawner.tick(1.0, rng);
1212
        assert_eq!(count, 5);
1213
        assert!(spawner.active);
1214
        assert!(spawner.has_completed());
1215
    }
1216

1217
    #[test]
1218
    fn test_rate() {
1219
        let rng = &mut new_rng();
1220
        let spawner = SpawnerSettings::rate(5.0.into());
1221
        assert!(!spawner.is_once());
1222
        assert!(spawner.is_forever());
1223
        let mut spawner = EffectSpawner::new(&spawner);
1224
        // Slightly over 1.0 to avoid edge case
1225
        let count = spawner.tick(1.01, rng);
1226
        assert_eq!(count, 5);
1227
        let count = spawner.tick(0.4, rng);
1228
        assert_eq!(count, 2);
1229
    }
1230

1231
    #[test]
1232
    fn test_rate_active() {
1233
        let rng = &mut new_rng();
1234
        let spawner = SpawnerSettings::rate(5.0.into());
1235
        assert!(!spawner.is_once());
1236
        let mut spawner = EffectSpawner::new(&spawner);
1237
        spawner.tick(1.01, rng);
1238
        spawner.active = false;
1239
        assert!(!spawner.active);
1240
        let count = spawner.tick(0.4, rng);
1241
        assert_eq!(count, 0);
1242
        spawner.active = true;
1243
        assert!(spawner.active);
1244
        let count = spawner.tick(0.4, rng);
1245
        assert_eq!(count, 2);
1246
    }
1247

1248
    #[test]
1249
    fn test_rate_accumulate() {
1250
        let rng = &mut new_rng();
1251
        let spawner = SpawnerSettings::rate(5.0.into());
1252
        assert!(!spawner.is_once());
1253
        let mut spawner = EffectSpawner::new(&spawner);
1254
        // 13 ticks instead of 12 to avoid edge case
1255
        let count = (0..13).map(|_| spawner.tick(1.0 / 60.0, rng)).sum::<u32>();
1256
        assert_eq!(count, 1);
1257
    }
1258

1259
    #[test]
1260
    fn test_burst() {
1261
        let rng = &mut new_rng();
1262
        let spawner = SpawnerSettings::burst(5.0.into(), 2.0.into());
1263
        assert!(!spawner.is_once());
1264
        assert!(spawner.is_forever());
1265
        let mut spawner = EffectSpawner::new(&spawner);
1266
        let count = spawner.tick(1.0, rng);
1267
        assert_eq!(count, 5);
1268
        let count = spawner.tick(4.0, rng);
1269
        assert_eq!(count, 10);
1270
        let count = spawner.tick(0.1, rng);
1271
        assert_eq!(count, 0);
1272
    }
1273

1274
    #[test]
1275
    fn test_with_active() {
1276
        let rng = &mut new_rng();
1277
        let spawner = SpawnerSettings::rate(5.0.into()).with_starts_active(false);
1278
        let mut spawner = EffectSpawner::new(&spawner);
1279
        assert!(!spawner.active);
1280
        let count = spawner.tick(1., rng);
1281
        assert_eq!(count, 0);
1282
        spawner.active = false; // no-op
1283
        let count = spawner.tick(1., rng);
1284
        assert_eq!(count, 0);
1285
        spawner.active = true;
1286
        assert!(spawner.active);
1287
        let count = spawner.tick(1., rng);
1288
        assert_eq!(count, 5);
1289
    }
1290

1291
    fn make_test_app() -> App {
1292
        IoTaskPool::get_or_init(|| {
1293
            TaskPoolBuilder::default()
1294
                .num_threads(1)
1295
                .thread_name("Hanabi test IO Task Pool".to_string())
1296
                .build()
1297
        });
1298

1299
        let mut app = App::new();
1300

1301
        let watch_for_changes = false;
1302
        let mut builders = app
1303
            .world_mut()
1304
            .get_resource_or_insert_with::<AssetSourceBuilders>(Default::default);
1305
        let dir = Dir::default();
1306
        let dummy_builder =
1307
            AssetSourceBuilder::new(move || Box::new(MemoryAssetReader { root: dir.clone() }));
1308
        builders.insert(AssetSourceId::Default, dummy_builder);
1309
        let sources = builders.build_sources(watch_for_changes, false);
1310
        let asset_server = AssetServer::new(
1311
            sources.into(),
1312
            AssetServerMode::Unprocessed,
1313
            watch_for_changes,
1314
            UnapprovedPathMode::Forbid,
1315
        );
1316

1317
        app.insert_resource(asset_server);
1318
        // app.add_plugins(DefaultPlugins);
1319
        app.init_asset::<Mesh>();
1320
        app.add_plugins(VisibilityPlugin);
1321
        app.init_resource::<Time<EffectSimulation>>();
1322
        app.insert_resource(Random(new_rng()));
1323
        app.init_asset::<EffectAsset>();
1324
        app.add_systems(
1325
            PostUpdate,
1326
            tick_spawners.after(VisibilitySystems::CheckVisibility),
1327
        );
1328

1329
        app
1330
    }
1331

1332
    /// Test case for `tick_spawners()`.
1333
    struct TestCase {
1334
        /// Initial entity visibility on spawn. If `None`, do not add a
1335
        /// [`Visibility`] component.
1336
        visibility: Option<Visibility>,
1337

1338
        /// Spawner settings assigned to the `EffectAsset`.
1339
        asset_spawner: SpawnerSettings,
1340
    }
1341

1342
    impl TestCase {
1343
        fn new(visibility: Option<Visibility>, asset_spawner: SpawnerSettings) -> Self {
1344
            Self {
1345
                visibility,
1346
                asset_spawner,
1347
            }
1348
        }
1349
    }
1350

1351
    #[test]
1352
    fn test_tick_spawners() {
1353
        let asset_spawner = SpawnerSettings::once(32.0.into());
1354

1355
        for test_case in &[
1356
            TestCase::new(None, asset_spawner),
1357
            TestCase::new(Some(Visibility::Hidden), asset_spawner),
1358
            TestCase::new(Some(Visibility::Visible), asset_spawner),
1359
        ] {
1360
            let mut app = make_test_app();
1361

1362
            let (effect_entity, handle) = {
1363
                let world = app.world_mut();
1364

1365
                // Add effect asset
1366
                let mut assets = world.resource_mut::<Assets<EffectAsset>>();
1367
                let mut asset = EffectAsset::new(64, test_case.asset_spawner, Module::default());
1368
                asset.simulation_condition = if test_case.visibility.is_some() {
1369
                    SimulationCondition::WhenVisible
1370
                } else {
1371
                    SimulationCondition::Always
1372
                };
1373
                let handle = assets.add(asset);
1374

1375
                // Spawn particle effect
1376
                let entity = if let Some(visibility) = test_case.visibility {
1377
                    world
1378
                        .spawn((
1379
                            visibility,
1380
                            InheritedVisibility::default(),
1381
                            ParticleEffect {
1382
                                handle: handle.clone(),
1383
                                ..default()
1384
                            },
1385
                            // Force-ready the effect as those tests don't initialize the render
1386
                            // world (headless), so the effect would never get ready otherwise.
1387
                            CompiledParticleEffect::default().with_ready_for_tests(),
1388
                        ))
1389
                        .id()
1390
                } else {
1391
                    world
1392
                        .spawn((
1393
                            ParticleEffect {
1394
                                handle: handle.clone(),
1395
                                ..default()
1396
                            },
1397
                            // Force-ready the effect as those tests don't initialize the render
1398
                            // world (headless), so the effect would never get ready otherwise.
1399
                            CompiledParticleEffect::default().with_ready_for_tests(),
1400
                        ))
1401
                        .id()
1402
                };
1403

1404
                // Spawn a camera, otherwise ComputedVisibility stays at HIDDEN
1405
                world.spawn(Camera3d::default());
1406

1407
                (entity, handle)
1408
            };
1409

1410
            // Tick once
1411
            let _cur_time = {
1412
                // Make sure to increment the current time so that the spawners spawn something.
1413
                // Note that `Time` has this weird behavior where the common quantities like
1414
                // `Time::delta_secs()` only update after the *second* update. So we tick the
1415
                // `Time` twice here to enforce this.
1416
                let mut time = app.world_mut().resource_mut::<Time<EffectSimulation>>();
1417
                time.advance_by(Duration::from_millis(16));
1418
                time.elapsed()
1419
            };
1420
            app.update();
1421

1422
            let world = app.world_mut();
1423

1424
            // Check the state of the components after `tick_spawners()` ran
1425
            if let Some(test_visibility) = test_case.visibility {
1426
                // Simulated-when-visible effect (SimulationCondition::WhenVisible)
1427

1428
                let (entity, visibility, inherited_visibility, particle_effect, effect_spawner) =
1429
                    world
1430
                        .query::<(
1431
                            Entity,
1432
                            &Visibility,
1433
                            &InheritedVisibility,
1434
                            &ParticleEffect,
1435
                            Option<&EffectSpawner>,
1436
                        )>()
1437
                        .iter(world)
1438
                        .next()
1439
                        .unwrap();
1440
                assert_eq!(entity, effect_entity);
1441
                assert_eq!(visibility, test_visibility);
1442
                assert_eq!(
1443
                    inherited_visibility.get(),
1444
                    test_visibility == Visibility::Visible
1445
                );
1446
                assert_eq!(particle_effect.handle, handle);
1447

1448
                // The EffectSpawner component is always spawned, even if not visible.
1449
                assert!(effect_spawner.is_some());
1450
                let effect_spawner = effect_spawner.unwrap();
1451
                let actual_spawner = effect_spawner.settings;
1452
                assert_eq!(actual_spawner, test_case.asset_spawner);
1453
                assert!(effect_spawner.active);
1454
                assert_eq!(effect_spawner.spawn_remainder, 0.);
1455
                assert_eq!(effect_spawner.cycle_time, 0.);
1456

1457
                if inherited_visibility.get() {
1458
                    // Check the spawner ticked
1459
                    assert_eq!(effect_spawner.completed_cycle_count, 1);
1460
                    assert_eq!(effect_spawner.spawn_count, 32);
1461
                } else {
1462
                    // Didn't tick
1463
                    assert_eq!(effect_spawner.completed_cycle_count, 0);
1464
                    assert_eq!(effect_spawner.spawn_count, 0);
1465
                }
1466
            } else {
1467
                // Always-simulated effect (SimulationCondition::Always)
1468

1469
                let (entity, particle_effect, effect_spawners) = world
1470
                    .query::<(Entity, &ParticleEffect, Option<&EffectSpawner>)>()
1471
                    .iter(world)
1472
                    .next()
1473
                    .unwrap();
1474
                assert_eq!(entity, effect_entity);
1475
                assert_eq!(particle_effect.handle, handle);
1476

1477
                assert!(effect_spawners.is_some());
1478
                let effect_spawner = effect_spawners.unwrap();
1479
                let actual_spawner = effect_spawner.settings;
1480

1481
                // Check the spawner ticked
1482
                assert!(effect_spawner.active); // will get deactivated next tick()
1483
                assert_eq!(effect_spawner.spawn_remainder, 0.);
1484
                assert_eq!(effect_spawner.cycle_time, 0.);
1485
                assert_eq!(effect_spawner.completed_cycle_count, 1);
1486
                assert_eq!(effect_spawner.spawn_count, 32);
1487

1488
                assert_eq!(actual_spawner, test_case.asset_spawner);
1489
            }
1490
        }
1491
    }
1492
}
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