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

djeedai / bevy_hanabi / 18260856969

05 Oct 2025 03:39PM UTC coverage: 66.606% (+0.03%) from 66.58%
18260856969

push

github

web-flow
Upgrade to Bevy v0.17 (#502)

31 of 38 new or added lines in 11 files covered. (81.58%)

2 existing lines in 1 file now uncovered.

5120 of 7687 relevant lines covered (66.61%)

148.45 hits per line

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

63.82
/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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

214
    /// Time over which to spawn [`count`], in seconds.
215
    ///
216
    /// [`count`]: Self::count
217
    spawn_duration: CpuValue<f32>,
218

219
    /// Time between bursts of the particle system, in seconds.
220
    ///
221
    /// If this is [`spawn_duration`] or less, the system spawns a steady stream
222
    /// of particles.
223
    ///
224
    /// [`spawn_duration`]: Self::spawn_duration
225
    period: CpuValue<f32>,
226

227
    /// Number of cycles the spawner is active before completing.
228
    ///
229
    /// Each cycle lasts for `period`. A value of `0` means "infinite", that is
230
    /// the spanwe emits particle forever as long as it's active.
231
    cycle_count: u32,
232

233
    /// Whether the [`EffectSpawner`] is active at startup.
234
    ///
235
    /// The value is used to initialize [`EffectSpawner::active`].
236
    ///
237
    /// [`EffectSpawner::active`]: crate::EffectSpawner::active
238
    starts_active: bool,
239

240
    /// Whether the [`EffectSpawner`] immediately starts emitting particles.
241
    emit_on_start: bool,
242
}
243

244
impl Default for SpawnerSettings {
245
    fn default() -> Self {
19✔
246
        Self::once(1.0f32.into())
38✔
247
    }
248
}
249

250
impl SpawnerSettings {
251
    /// Create settings from individual values.
252
    ///
253
    /// This is the _raw_ constructor. In general you should prefer using one of
254
    /// the utility constructors [`once()`], [`burst()`], or [`rate()`],
255
    /// which will ensure the control parameters are set consistently relative
256
    /// to each other.
257
    ///
258
    /// # Panics
259
    ///
260
    /// Panics if `period` can produce a negative number (the sample range lower
261
    /// bound is negative), unless the cycle count is exactly 1, in which case
262
    /// `period` is ignored.
263
    ///
264
    /// Panics if `period` can only produce 0 (the sample range upper bound
265
    /// is not strictly positive), unless the cycle count is exactly 1, in which
266
    /// case `period` is ignored.
267
    ///
268
    /// Panics if any value is infinite.
269
    ///
270
    /// # Example
271
    ///
272
    /// ```
273
    /// # use bevy_hanabi::SpawnerSettings;
274
    /// // Spawn 32 particles over 3 seconds, then pause for 7 seconds (10 - 3),
275
    /// // doing that 5 times in total.
276
    /// let spawner = SpawnerSettings::new(32.0.into(), 3.0.into(), 10.0.into(), 5);
277
    /// ```
278
    ///
279
    /// [`once()`]: Self::once
280
    /// [`burst()`]: Self::burst
281
    /// [`rate()`]: Self::rate
282
    pub fn new(
42✔
283
        count: CpuValue<f32>,
284
        spawn_duration: CpuValue<f32>,
285
        period: CpuValue<f32>,
286
        cycle_count: u32,
287
    ) -> Self {
288
        assert!(
42✔
289
            cycle_count == 1 || period.range()[0] >= 0.,
58✔
290
            "`period` must not generate negative numbers (period.min was {}, expected >= 0).",
1✔
291
            period.range()[0]
292
        );
293
        assert!(
41✔
294
            cycle_count == 1 || period.range()[1] > 0.,
15✔
295
            "`period` must be able to generate a positive number (period.max was {}, expected > 0).",
1✔
296
            period.range()[1]
297
        );
298
        assert!(
40✔
299
            period.range()[0].is_finite() && period.range()[1].is_finite(),
120✔
300
            "`period` {:?} has an infinite bound. If upgrading from a previous version, use `cycle_count = 1` instead for a single-cycle burst.",
×
301
            period
302
        );
303

304
        Self {
305
            count,
306
            spawn_duration,
307
            period,
308
            cycle_count,
309
            starts_active: true,
310
            emit_on_start: true,
311
        }
312
    }
313

314
    /// Set whether the [`EffectSpawner`] immediately starts to emit particle
315
    /// when the [`ParticleEffect`] is spawned into the ECS world.
316
    ///
317
    /// If set to `false`, then [`EffectSpawner::has_completed()`] will return
318
    /// `true` after spawning the component, and the spawner needs to be
319
    /// [`EffectSpawner::reset()`] before it can spawn particles. This is
320
    /// useful to spawn a particle effect instance immediately, but only start
321
    /// emitting particles when an event occurs (collision, user input, any
322
    /// other game logic...).
323
    ///
324
    /// Because a spawner repeating forever never completes, this has no effect
325
    /// if [`is_forever()`] is `true`. To start/stop spawning with those
326
    /// effects, use [`EffectSpawner::active`] instead.
327
    ///
328
    /// [`is_forever()`]: Self::is_forever
329
    pub fn with_emit_on_start(mut self, emit_on_start: bool) -> Self {
×
330
        self.emit_on_start = emit_on_start;
×
331
        self
×
332
    }
333

334
    /// Create settings to spawn a burst of particles once.
335
    ///
336
    /// The burst of particles is spawned all at once in the same frame. After
337
    /// that, the spawner idles, waiting to be manually reset via
338
    /// [`EffectSpawner::reset()`].
339
    ///
340
    /// This is a convenience for:
341
    ///
342
    /// ```
343
    /// # use bevy_hanabi::{SpawnerSettings, CpuValue};
344
    /// # let count = CpuValue::Single(1.);
345
    /// SpawnerSettings::new(count, 0.0.into(), 0.0.into(), 1);
346
    /// ```
347
    ///
348
    /// # Example
349
    ///
350
    /// ```
351
    /// # use bevy_hanabi::SpawnerSettings;
352
    /// // Spawn 32 particles in a burst once immediately on creation.
353
    /// let spawner = SpawnerSettings::once(32.0.into());
354
    /// ```
355
    pub fn once(count: CpuValue<f32>) -> Self {
26✔
356
        Self::new(count, 0.0.into(), 0.0.into(), 1)
104✔
357
    }
358

359
    /// Get whether the spawner has a single cycle.
360
    ///
361
    /// This is true if the cycle count is exactly equal to 1.
362
    pub fn is_once(&self) -> bool {
41✔
363
        self.cycle_count == 1
41✔
364
    }
365

366
    /// Get whether the spawner has an infinite number of cycles.
367
    ///
368
    /// This is true if the cycle count is exactly equal to 0.
369
    pub fn is_forever(&self) -> bool {
372✔
370
        self.cycle_count == 0
372✔
371
    }
372

373
    /// Create settings to spawn a continuous stream of particles.
374
    ///
375
    /// The particle spawn `rate` is expressed in particles per second.
376
    /// Fractional values are accumulated each frame.
377
    ///
378
    /// This is a convenience for:
379
    ///
380
    /// ```
381
    /// # use bevy_hanabi::{SpawnerSettings, CpuValue};
382
    /// # let rate = CpuValue::Single(1.);
383
    /// SpawnerSettings::new(rate, 1.0.into(), 1.0.into(), 0);
384
    /// ```
385
    ///
386
    /// # Example
387
    ///
388
    /// ```
389
    /// # use bevy_hanabi::SpawnerSettings;
390
    /// // Spawn 10 particles per second, indefinitely.
391
    /// let spawner = SpawnerSettings::rate(10.0.into());
392
    /// ```
393
    pub fn rate(rate: CpuValue<f32>) -> Self {
12✔
394
        Self::new(rate, 1.0.into(), 1.0.into(), 0)
48✔
395
    }
396

397
    /// Create settings to spawn particles in bursts.
398
    ///
399
    /// The settings define an infinite number of cycles where `count` particles
400
    /// are spawned at the beginning of the cycle, then the spawner waits
401
    /// `period` seconds, and repeats forever.
402
    ///
403
    /// This is a convenience for:
404
    ///
405
    /// ```
406
    /// # use bevy_hanabi::{SpawnerSettings, CpuValue};
407
    /// # let count = CpuValue::Single(1.);
408
    /// # let period = CpuValue::Single(1.);
409
    /// SpawnerSettings::new(count, 0.0.into(), period, 0);
410
    /// ```
411
    ///
412
    /// # Example
413
    ///
414
    /// ```
415
    /// # use bevy_hanabi::SpawnerSettings;
416
    /// // Spawn a burst of 5 particles every 3 seconds, indefinitely.
417
    /// let spawner = SpawnerSettings::burst(5.0.into(), 3.0.into());
418
    /// ```
419
    pub fn burst(count: CpuValue<f32>, period: CpuValue<f32>) -> Self {
1✔
420
        Self::new(count, 0.0.into(), period, 0)
4✔
421
    }
422

423
    /// Set the number of particles that are spawned each cycle.
424
    pub fn with_count(mut self, count: CpuValue<f32>) -> Self {
×
425
        self.count = count;
×
426
        self
×
427
    }
428

429
    /// Set the number of particles that are spawned each cycle.
430
    pub fn set_count(&mut self, count: CpuValue<f32>) {
×
431
        self.count = count;
×
432
    }
433

434
    /// Get the number of particles that are spawned each cycle.
435
    pub fn count(&self) -> CpuValue<f32> {
×
436
        self.count
×
437
    }
438

439
    /// Set the duration, in seconds, of the spawn part each cycle.
440
    pub fn with_spawn_duration(mut self, spawn_duration: CpuValue<f32>) -> Self {
×
441
        self.spawn_duration = spawn_duration;
×
442
        self
×
443
    }
444

445
    /// Set the duration, in seconds, of the spawn part each cycle.
446
    pub fn set_spawn_duration(&mut self, spawn_duration: CpuValue<f32>) {
×
447
        self.spawn_duration = spawn_duration;
×
448
    }
449

450
    /// Get the duration, in seconds, of the spawn part each cycle.
451
    pub fn spawn_duration(&self) -> CpuValue<f32> {
×
452
        self.spawn_duration
×
453
    }
454

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

475
    /// Set 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
    /// # Panics
481
    ///
482
    /// Panics if the period is infinite.
483
    ///
484
    /// [`spawn_duration()`]: Self::spawn_duration
485
    pub fn set_period(&mut self, period: CpuValue<f32>) {
×
486
        assert!(
×
487
            period.range()[0].is_finite() && period.range()[1].is_finite(),
×
488
            "`period` {:?} has an infinite bound. If upgrading from a previous version, use `cycle_count = 1` instead for a single-cycle burst.",
×
489
            period
490
        );
491
        self.period = period;
×
492
    }
493

494
    /// Get the duration of a single spawn cycle, in seconds.
495
    ///
496
    /// A spawn cycle includes the [`spawn_duration()`] value, and any extra
497
    /// wait time (if larger than spawn time).
498
    ///
499
    /// [`spawn_duration()`]: Self::spawn_duration
500
    pub fn period(&self) -> CpuValue<f32> {
×
501
        self.period
×
502
    }
503

504
    /// Set the number of cycles to spawn for.
505
    ///
506
    /// A spawn cycle includes the [`spawn_duration()`] value, and any extra
507
    /// wait time (if larger than spawn time). It lasts for [`period()`].
508
    ///
509
    /// [`spawn_duration()`]: Self::spawn_duration
510
    /// [`period()`]: Self::period
511
    pub fn with_cycle_count(mut self, cycle_count: u32) -> Self {
×
512
        self.cycle_count = cycle_count;
×
513
        self
×
514
    }
515

516
    /// Set the number of cycles to spawn for.
517
    ///
518
    /// A spawn cycle includes the [`spawn_duration()`] value, and any extra
519
    /// wait time (if larger than spawn time). It lasts for [`period()`].
520
    ///
521
    /// [`spawn_duration()`]: Self::spawn_duration
522
    /// [`period()`]: Self::period
523
    pub fn set_cycle_count(&mut self, cycle_count: u32) {
×
524
        self.cycle_count = cycle_count;
×
525
    }
526

527
    /// Get the number of cycles to spawn for.
528
    ///
529
    /// A spawn cycle includes the [`spawn_duration()`] value, and any extra
530
    /// wait time (if larger than spawn time). It lasts for [`period()`].
531
    ///
532
    /// [`spawn_duration()`]: Self::spawn_duration
533
    /// [`period()`]: Self::period
534
    pub fn cycle_count(&self) -> u32 {
29✔
535
        self.cycle_count
29✔
536
    }
537

538
    /// Sets 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 with_starts_active(mut self, starts_active: bool) -> Self {
2✔
544
        self.starts_active = starts_active;
2✔
545
        self
2✔
546
    }
547

548
    /// Set whether the spawner starts active when the effect is instantiated.
549
    ///
550
    /// This value will be transfered to the active state of the
551
    /// [`EffectSpawner`] once it's instantiated. Inactive spawners do not spawn
552
    /// any particle.
553
    pub fn set_starts_active(&mut self, starts_active: bool) {
×
554
        self.starts_active = starts_active;
×
555
    }
556

557
    /// Get whether the spawner starts active when the effect is instantiated.
558
    ///
559
    /// This value will be transfered to the active state of the
560
    /// [`EffectSpawner`] once it's instantiated. Inactive spawners do not spawn
561
    /// any particle.
562
    pub fn starts_active(&self) -> bool {
16✔
563
        self.starts_active
16✔
564
    }
565
}
566

567
/// Runtime state machine for CPU particle spawning.
568
///
569
/// The spawner defines how new particles are emitted and when. Each time the
570
/// spawner ticks, it calculates a number of particles to emit for this frame,
571
/// based on its [`SpawnerSettings`]. This spawn count is passed to the GPU for
572
/// the init compute pass to actually allocate the new particles and initialize
573
/// them. The number of particles to spawn is stored as a floating-point number,
574
/// and any remainder accumulates for the next tick.
575
///
576
/// Spawners are used to control from CPU when particles are spawned. To use GPU
577
/// spawn events instead, and spawn particles based on events occurring on
578
/// existing particles in other effects, see [`EffectParent`]. Those two
579
/// mechanisms (CPU and GPU spawner) are mutually exclusive.
580
///
581
/// Once per frame the [`tick_spawners()`] system will add the [`EffectSpawner`]
582
/// component if it's missing, cloning the [`SpawnerSettings`] from the source
583
/// [`EffectAsset`] to initialize it. After that, it ticks the
584
/// [`SpawnerSettings`] stored in the component. The resulting number of
585
/// particles to spawn for the frame is then stored into
586
/// [`EffectSpawner::spawn_count`]. You can override that value to manually
587
/// control each frame how many particles are spawned, instead of using the
588
/// logic of [`SpawnerSettings`].
589
///
590
/// [`EffectParent`]: crate::EffectParent
591
#[derive(Debug, Default, Clone, Copy, PartialEq, Component, Reflect)]
592
#[reflect(Component)]
593
pub struct EffectSpawner {
594
    /// The spawner settings extracted from the [`EffectAsset`], or directly
595
    /// overriden by the user.
596
    pub settings: SpawnerSettings,
597

598
    /// Accumulated time for the current (partial) cycle, in seconds.
599
    cycle_time: f32,
600

601
    /// Number of cycles already completed.
602
    completed_cycle_count: u32,
603

604
    /// Sampled value of `spawn_duration` until `period` is reached. This is the
605
    /// duration of the "active" period during which we spawn particles, as
606
    /// opposed to the "wait" period during which we do nothing until the next
607
    /// spawn cycle.
608
    sampled_spawn_duration: f32,
609

610
    /// Sampled value of the time period, in seconds, until the next spawn
611
    /// cycle.
612
    sampled_period: f32,
613

614
    /// Sampled value of the number of particles to spawn per `spawn_duration`.
615
    sampled_count: f32,
616

617
    /// Number of particles to spawn this frame.
618
    ///
619
    /// This value is normally updated by calling [`tick()`], which
620
    /// automatically happens once per frame when the [`tick_spawners()`]
621
    /// system runs in the [`PostUpdate`] schedule.
622
    ///
623
    /// You can manually assign this value to override the one calculated by
624
    /// [`tick()`]. Note in this case that you need to override the value after
625
    /// the automated one was calculated, by ordering your system
626
    /// after [`tick_spawners()`] or [`EffectSystems::TickSpawners`].
627
    ///
628
    /// [`tick()`]: crate::EffectSpawner::tick
629
    /// [`EffectSystems::TickSpawners`]: crate::EffectSystems::TickSpawners
630
    pub spawn_count: u32,
631

632
    /// Fractional remainder of particle count to spawn.
633
    ///
634
    /// This is accumulated each tick, and the integral part is added to
635
    /// `spawn_count`. The reminder gets saved for next frame.
636
    spawn_remainder: f32,
637

638
    /// Whether the spawner is active. Defaults to
639
    /// [`SpawnerSettings::starts_active()`]. An inactive spawner
640
    /// doesn't tick (no particle spawned, no internal state updated).
641
    pub active: bool,
642
}
643

644
impl EffectSpawner {
645
    /// Create a new spawner.
646
    pub fn new(settings: &SpawnerSettings) -> Self {
14✔
647
        Self {
648
            settings: *settings,
14✔
649
            cycle_time: 0.,
650
            completed_cycle_count: if settings.emit_on_start || settings.is_forever() {
14✔
651
                // Infinitely repeating effects always start at cycle #0.
652
                0
653
            } else {
654
                // Start at last cycle. This means has_completed() is true.
655
                settings.cycle_count()
656
            },
657
            sampled_spawn_duration: 0.,
658
            sampled_period: 0.,
659
            sampled_count: 0.,
660
            spawn_count: 0,
661
            spawn_remainder: 0.,
662
            active: settings.starts_active(),
28✔
663
        }
664
    }
665

666
    /// Set whether the spawner is active.
667
    ///
668
    /// Inactive spawners do not tick, and therefore do not spawn any particle.
669
    /// Their internal state do not update.
670
    pub fn with_active(mut self, active: bool) -> Self {
×
671
        self.active = active;
×
672
        self
×
673
    }
674

675
    /// Get the time relative to the beginning of the current cycle.
676
    #[inline]
677
    pub fn cycle_time(&self) -> f32 {
3✔
678
        self.cycle_time
3✔
679
    }
680

681
    /// Get the spawn duration for the current cycle.
682
    ///
683
    /// This value can change every cycle if [`SpawnerSettings::spawn_duration`]
684
    /// is a randomly distributed value.
685
    #[inline]
686
    pub fn cycle_spawn_duration(&self) -> f32 {
3✔
687
        self.sampled_spawn_duration
3✔
688
    }
689

690
    /// Get the period of the current cycle.
691
    ///
692
    /// This value can change every cycle if [`SpawnerSettings::period`] is a
693
    /// randomly distributed value. If the effect spawns only once, and
694
    /// therefore its cycle period is ignored, this returns `0`.
695
    #[inline]
696
    pub fn cycle_period(&self) -> f32 {
3✔
697
        if self.settings.is_once() {
6✔
698
            0.
×
699
        } else {
700
            self.sampled_period
3✔
701
        }
702
    }
703

704
    /// Get the progress ratio in 0..1 of the current cycle.
705
    ///
706
    /// This is the ratio of the [`cycle_time()`] over [`cycle_period()`]. If
707
    /// the effect spawns only once, and therefore its cycle period is
708
    /// ignored, this returns `0`.
709
    ///
710
    /// [`cycle_time()`]: Self::cycle_time
711
    /// [`cycle_period()`]: Self::cycle_period
712
    #[inline]
713
    pub fn cycle_ratio(&self) -> f32 {
3✔
714
        if self.settings.is_once() {
6✔
715
            0.
×
716
        } else {
717
            self.cycle_time / self.sampled_period
3✔
718
        }
719
    }
720

721
    /// Get the number of particles to spawn during the current cycle
722
    ///
723
    /// This value can change every cycle if [`SpawnerSettings::count`] is a
724
    /// randomly distributed value.
725
    #[inline]
726
    pub fn cycle_spawn_count(&self) -> f32 {
3✔
727
        self.sampled_count
3✔
728
    }
729

730
    /// Get the number of completed cycles since last [`reset()`].
731
    ///
732
    /// The value loops back if the pattern repeats forever
733
    /// ([`SpawnerSettings::is_forever()`] is `true`).
734
    ///
735
    /// [`reset()`]: Self::reset
736
    #[inline]
737
    pub fn completed_cycle_count(&self) -> u32 {
5✔
738
        self.completed_cycle_count
5✔
739
    }
740

741
    /// Get whether the spawner has completed.
742
    ///
743
    /// A spawner has completed if it already ticked through its maximum number
744
    /// of cycles. It can be reset back to its original state with [`reset()`].
745
    /// A spawner repeating forever never completes.
746
    ///
747
    /// [`reset()`]: Self::reset
748
    #[inline]
749
    pub fn has_completed(&self) -> bool {
6✔
750
        !self.settings.is_forever() && (self.completed_cycle_count >= self.settings.cycle_count())
12✔
751
    }
752

753
    /// Reset the spawner state.
754
    ///
755
    /// This resets the internal spawner time and cycle count to zero.
756
    ///
757
    /// Use this, for example, to immediately spawn some particles in a spawner
758
    /// constructed with [`SpawnerSettings::once`].
759
    ///
760
    /// [`SpawnerSettings::once`]: crate::SpawnerSettings::once
761
    pub fn reset(&mut self) {
2✔
762
        self.cycle_time = 0.;
2✔
763
        self.completed_cycle_count = 0;
2✔
764
        self.sampled_spawn_duration = 0.;
2✔
765
        self.sampled_period = 0.;
2✔
766
        self.sampled_count = 0.;
2✔
767
        self.spawn_count = 0;
2✔
768
        self.spawn_remainder = 0.;
2✔
769
    }
770

771
    /// Tick the spawner to calculate the number of particles to spawn this
772
    /// frame.
773
    ///
774
    /// The frame delta time `dt` is added to the current spawner time, before
775
    /// the spawner calculates the number of particles to spawn.
776
    ///
777
    /// This method is called automatically by [`tick_spawners()`] during the
778
    /// [`PostUpdate`], so you normally don't have to call it yourself
779
    /// manually.
780
    ///
781
    /// # Returns
782
    ///
783
    /// The integral number of particles to spawn this frame. Any fractional
784
    /// remainder is saved for the next call.
785
    pub fn tick(&mut self, mut dt: f32, rng: &mut Pcg32) -> u32 {
347✔
786
        // If inactive, or if the finite number of cycles has been completed, then we're
787
        // done.
788
        if !self.active
347✔
789
            || (!self.settings.is_forever()
343✔
790
                && (self.completed_cycle_count >= self.settings.cycle_count()))
14✔
791
        {
792
            self.spawn_count = 0;
7✔
793
            return 0;
794
        }
795

796
        // Use a loop in case the timestep dt spans multiple cycles
797
        loop {
798
            // Check if this is a new cycle which needs resampling
799
            if self.sampled_period == 0.0 {
353✔
800
                if self.settings.is_once() {
63✔
801
                    self.sampled_spawn_duration = self.settings.spawn_duration.sample(rng);
21✔
802
                    // Period is unchecked, should be ignored (could sample to <= 0). Use the spawn
803
                    // duration, but ensure we have something > 0 as a marker that we've resampled.
804
                    self.sampled_period = self.sampled_spawn_duration.max(1e-12);
7✔
805
                } else {
806
                    self.sampled_period = self.settings.period.sample(rng);
21✔
807
                    assert!(self.sampled_period > 0.);
808
                    self.sampled_spawn_duration = self
42✔
809
                        .settings
42✔
810
                        .spawn_duration
42✔
811
                        .sample(rng)
63✔
812
                        .clamp(0., self.sampled_period);
21✔
813
                }
814
                self.sampled_spawn_duration = self.settings.spawn_duration.sample(rng);
28✔
815
                self.sampled_count = self.settings.count.sample(rng).max(0.);
816
            }
817

818
            let new_time = self.cycle_time + dt;
353✔
819

820
            // If inside the spawn period, accumulate some particle spawn count
821
            if self.cycle_time <= self.sampled_spawn_duration {
822
                // If the spawn time is very small, close to zero, spawn all particles
823
                // immediately in one burst over a single frame.
824
                self.spawn_remainder += if self.sampled_spawn_duration < 1e-5f32.max(dt / 100.0) {
698✔
825
                    self.sampled_count
10✔
826
                } else {
827
                    // Spawn an amount of particles equal to the fraction of time the current frame
828
                    // spans compared to the total burst duration.
829
                    let ratio = ((new_time.min(self.sampled_spawn_duration) - self.cycle_time)
339✔
830
                        / self.sampled_spawn_duration)
831
                        .clamp(0., 1.);
832
                    self.sampled_count * ratio
833
                };
834
            }
835

836
            // Increment current time
837
            self.cycle_time = new_time;
838

839
            // Check for cycle completion
840
            if self.cycle_time >= self.sampled_period {
841
                dt = self.cycle_time - self.sampled_period;
21✔
842
                self.cycle_time = 0.0;
21✔
843
                self.completed_cycle_count += 1;
21✔
844

845
                // Mark as "need resampling"
846
                self.sampled_period = 0.0;
21✔
847

848
                // If this was the last cycle, we're done
849
                if !self.settings.is_forever()
21✔
850
                    && (self.completed_cycle_count >= self.settings.cycle_count())
9✔
851
                {
852
                    // Don't deactivate quite yet, otherwise we'll miss the spawns for this frame
853
                    break;
8✔
854
                }
855
            } else {
856
                // We're done for this frame
857
                break;
332✔
858
            }
859
        }
860

861
        // Extract integral number of particles to spawn this frame, keep remainder for
862
        // next one
863
        let count = self.spawn_remainder.floor();
340✔
864
        self.spawn_remainder -= count;
865
        self.spawn_count = count as u32;
866

867
        self.spawn_count
868
    }
869
}
870

871
/// Tick all the [`EffectSpawner`] components.
872
///
873
/// This system runs in the [`PostUpdate`] stage, after the visibility system
874
/// has updated the [`InheritedVisibility`] of each effect instance (see
875
/// [`VisibilitySystems::VisibilityPropagate`]). Hidden instances are not
876
/// updated, unless the [`EffectAsset::simulation_condition`]
877
/// is set to [`SimulationCondition::Always`]. If no [`InheritedVisibility`] is
878
/// present, the effect is assumed to be visible.
879
///
880
/// Note that by that point the [`ViewVisibility`] is not yet calculated, and it
881
/// may happen that spawners are ticked but no effect is visible in any view
882
/// even though some are "visible" (active) in the [`World`]. The actual
883
/// per-view culling of invisible (not in view) effects is performed later on
884
/// the render world.
885
///
886
/// Once the system determined that the effect instance needs to be simulated
887
/// this frame, it ticks the effect's spawner by calling
888
/// [`EffectSpawner::tick()`], adding a new [`EffectSpawner`] component if it
889
/// doesn't already exist on the same entity as the [`ParticleEffect`].
890
///
891
/// [`VisibilitySystems::VisibilityPropagate`]: bevy::render::view::VisibilitySystems::VisibilityPropagate
892
/// [`EffectAsset::simulation_condition`]: crate::EffectAsset::simulation_condition
893
pub fn tick_spawners(
333✔
894
    mut commands: Commands,
895
    time: Res<Time<EffectSimulation>>,
896
    effects: Res<Assets<EffectAsset>>,
897
    mut rng: ResMut<Random>,
898
    mut query: Query<(
899
        Entity,
900
        &ParticleEffect,
901
        &CompiledParticleEffect,
902
        &InheritedVisibility,
903
        Option<&mut EffectSpawner>,
904
    )>,
905
) {
906
    #[cfg(feature = "trace")]
907
    let _span = bevy::log::info_span!("tick_spawners").entered();
999✔
908
    trace!("tick_spawners()");
653✔
909

910
    let dt = time.delta_secs();
666✔
911

912
    for (entity, effect, compiled_effect, inherited_visibility, maybe_spawner) in query.iter_mut() {
993✔
913
        // Skip effect which are not ready; this prevents ticking the spawner for an
914
        // effect not ready to consume those spawn commands.
915
        let mut can_tick = if compiled_effect.is_ready() {
916
            true
311✔
917
        } else {
918
            trace!("[Effect {entity:?}] Not ready; skipped spawner tick.");
22✔
919
            false
920
        };
921

922
        let Some(asset) = effects.get(&effect.handle) else {
317✔
923
            trace!(
10✔
924
                "Effect asset with handle {:?} is not available; skipped initializers tick.",
×
925
                effect.handle
926
            );
927
            continue;
10✔
928
        };
929

930
        if asset.simulation_condition == SimulationCondition::WhenVisible
931
            && !inherited_visibility.get()
316✔
932
        {
933
            trace!(
1✔
934
                "Effect asset with handle {:?} is not visible, and simulates only WhenVisible; skipped initializers tick.",
×
935
                effect.handle
936
            );
937
            can_tick = false;
938
        }
939

940
        if let Some(mut effect_spawner) = maybe_spawner {
312✔
941
            if can_tick {
308✔
942
                effect_spawner.tick(dt, &mut rng.0);
924✔
943
            }
944
        } else {
945
            let mut effect_spawner = EffectSpawner::new(&asset.spawner);
15✔
946
            if can_tick {
7✔
947
                effect_spawner.tick(dt, &mut rng.0);
6✔
948
            }
949
            commands.entity(entity).insert(effect_spawner);
20✔
950
        }
951
    }
952
}
953

954
#[cfg(test)]
955
mod test {
956
    use std::time::Duration;
957

958
    use bevy::{
959
        asset::{
960
            io::{
961
                memory::{Dir, MemoryAssetReader},
962
                AssetSourceBuilder, AssetSourceBuilders, AssetSourceId,
963
            },
964
            AssetServerMode, UnapprovedPathMode,
965
        },
966
        camera::visibility::{VisibilityPlugin, VisibilitySystems},
967
        tasks::{IoTaskPool, TaskPoolBuilder},
968
    };
969

970
    use super::*;
971
    use crate::Module;
972

973
    #[test]
974
    fn test_range_single() {
975
        let value = CpuValue::Single(1.0);
976
        assert_eq!(value.range(), [1.0, 1.0]);
977
    }
978

979
    #[test]
980
    fn test_range_uniform() {
981
        let value = CpuValue::Uniform((1.0, 3.0));
982
        assert_eq!(value.range(), [1.0, 3.0]);
983
    }
984

985
    #[test]
986
    fn test_range_uniform_reverse() {
987
        let value = CpuValue::Uniform((3.0, 1.0));
988
        assert_eq!(value.range(), [1.0, 3.0]);
989
    }
990

991
    #[test]
992
    fn test_new() {
993
        let rng = &mut new_rng();
994
        // 3 particles over 3 seconds, pause 7 seconds (total 10 seconds period). 2
995
        // cycles.
996
        let spawner = SpawnerSettings::new(3.0.into(), 3.0.into(), 10.0.into(), 2);
997
        let mut spawner = EffectSpawner::new(&spawner);
998
        let count = spawner.tick(2., rng); // t = 2s
999
        assert_eq!(count, 2);
1000
        assert!(spawner.active);
1001
        assert_eq!(spawner.cycle_time(), 2.);
1002
        assert_eq!(spawner.cycle_spawn_duration(), 3.);
1003
        assert_eq!(spawner.cycle_period(), 10.);
1004
        assert_eq!(spawner.cycle_ratio(), 0.2); // 2s / 10s
1005
        assert_eq!(spawner.cycle_spawn_count(), 3.);
1006
        assert_eq!(spawner.completed_cycle_count(), 0);
1007
        let count = spawner.tick(5., rng); // t = 7s
1008
        assert_eq!(count, 1);
1009
        assert!(spawner.active);
1010
        assert_eq!(spawner.cycle_time(), 7.);
1011
        assert_eq!(spawner.cycle_spawn_duration(), 3.);
1012
        assert_eq!(spawner.cycle_period(), 10.);
1013
        assert_eq!(spawner.cycle_ratio(), 0.7); // 7s / 10s
1014
        assert_eq!(spawner.cycle_spawn_count(), 3.);
1015
        assert_eq!(spawner.completed_cycle_count(), 0);
1016
        let count = spawner.tick(8., rng); // t = 15s
1017
        assert_eq!(count, 3);
1018
        assert!(spawner.active);
1019
        assert_eq!(spawner.cycle_time(), 5.); // 15. mod 10.
1020
        assert_eq!(spawner.cycle_spawn_duration(), 3.);
1021
        assert_eq!(spawner.cycle_period(), 10.);
1022
        assert_eq!(spawner.cycle_ratio(), 0.5); // 5s / 10s
1023
        assert_eq!(spawner.cycle_spawn_count(), 3.);
1024
        assert_eq!(spawner.completed_cycle_count(), 1);
1025
        let count = spawner.tick(10., rng); // t = 25s
1026
        assert_eq!(count, 0);
1027
        assert!(spawner.active);
1028
        assert_eq!(spawner.completed_cycle_count(), 2);
1029
        let count = spawner.tick(0.1, rng); // t = 25.1s
1030
        assert_eq!(count, 0);
1031
        assert!(spawner.active);
1032
        assert_eq!(spawner.completed_cycle_count(), 2);
1033
    }
1034

1035
    #[test]
1036
    #[should_panic]
1037
    fn test_new_panic_negative_period() {
1038
        let _ = SpawnerSettings::new(3.0.into(), 1.0.into(), CpuValue::Uniform((-1., 1.)), 0);
1039
    }
1040

1041
    #[test]
1042
    #[should_panic]
1043
    fn test_new_panic_zero_period() {
1044
        let _ = SpawnerSettings::new(3.0.into(), 1.0.into(), CpuValue::Uniform((0., 0.)), 0);
1045
    }
1046

1047
    #[test]
1048
    fn test_once() {
1049
        let rng = &mut new_rng();
1050
        let spawner = SpawnerSettings::once(5.0.into());
1051
        assert!(spawner.is_once());
1052
        let mut spawner = EffectSpawner::new(&spawner);
1053
        assert!(spawner.active);
1054
        let count = spawner.tick(0.001, rng);
1055
        assert_eq!(count, 5);
1056
        let count = spawner.tick(100.0, rng);
1057
        assert_eq!(count, 0);
1058
    }
1059

1060
    #[test]
1061
    fn test_once_reset() {
1062
        let rng = &mut new_rng();
1063
        let spawner = SpawnerSettings::once(5.0.into());
1064
        assert!(spawner.is_once());
1065
        assert!(spawner.starts_active());
1066
        let mut spawner = EffectSpawner::new(&spawner);
1067
        spawner.tick(1.0, rng);
1068
        spawner.reset();
1069
        let count = spawner.tick(1.0, rng);
1070
        assert_eq!(count, 5);
1071
    }
1072

1073
    #[test]
1074
    fn test_once_start_inactive() {
1075
        let rng = &mut new_rng();
1076

1077
        let spawner = SpawnerSettings::once(5.0.into()).with_starts_active(false);
1078
        assert!(spawner.is_once());
1079
        assert!(!spawner.starts_active());
1080
        let mut spawner = EffectSpawner::new(&spawner);
1081
        assert!(!spawner.has_completed());
1082

1083
        // Inactive; no-op
1084
        let count = spawner.tick(1.0, rng);
1085
        assert_eq!(count, 0);
1086
        assert!(!spawner.has_completed());
1087

1088
        spawner.active = true;
1089

1090
        // Active; spawns
1091
        let count = spawner.tick(1.0, rng);
1092
        assert_eq!(count, 5);
1093
        assert!(spawner.active);
1094
        assert!(spawner.has_completed()); // once(), so completes on first tick()
1095

1096
        // Completed; no-op
1097
        let count = spawner.tick(1.0, rng);
1098
        assert_eq!(count, 0);
1099
        assert!(spawner.active);
1100
        assert!(spawner.has_completed());
1101

1102
        // Reset internal state, still active
1103
        spawner.reset();
1104
        assert!(spawner.active);
1105
        assert!(!spawner.has_completed());
1106

1107
        let count = spawner.tick(1.0, rng);
1108
        assert_eq!(count, 5);
1109
        assert!(spawner.active);
1110
        assert!(spawner.has_completed());
1111
    }
1112

1113
    #[test]
1114
    fn test_rate() {
1115
        let rng = &mut new_rng();
1116
        let spawner = SpawnerSettings::rate(5.0.into());
1117
        assert!(!spawner.is_once());
1118
        assert!(spawner.is_forever());
1119
        let mut spawner = EffectSpawner::new(&spawner);
1120
        // Slightly over 1.0 to avoid edge case
1121
        let count = spawner.tick(1.01, rng);
1122
        assert_eq!(count, 5);
1123
        let count = spawner.tick(0.4, rng);
1124
        assert_eq!(count, 2);
1125
    }
1126

1127
    #[test]
1128
    fn test_rate_active() {
1129
        let rng = &mut new_rng();
1130
        let spawner = SpawnerSettings::rate(5.0.into());
1131
        assert!(!spawner.is_once());
1132
        let mut spawner = EffectSpawner::new(&spawner);
1133
        spawner.tick(1.01, rng);
1134
        spawner.active = false;
1135
        assert!(!spawner.active);
1136
        let count = spawner.tick(0.4, rng);
1137
        assert_eq!(count, 0);
1138
        spawner.active = true;
1139
        assert!(spawner.active);
1140
        let count = spawner.tick(0.4, rng);
1141
        assert_eq!(count, 2);
1142
    }
1143

1144
    #[test]
1145
    fn test_rate_accumulate() {
1146
        let rng = &mut new_rng();
1147
        let spawner = SpawnerSettings::rate(5.0.into());
1148
        assert!(!spawner.is_once());
1149
        let mut spawner = EffectSpawner::new(&spawner);
1150
        // 13 ticks instead of 12 to avoid edge case
1151
        let count = (0..13).map(|_| spawner.tick(1.0 / 60.0, rng)).sum::<u32>();
1152
        assert_eq!(count, 1);
1153
    }
1154

1155
    #[test]
1156
    fn test_burst() {
1157
        let rng = &mut new_rng();
1158
        let spawner = SpawnerSettings::burst(5.0.into(), 2.0.into());
1159
        assert!(!spawner.is_once());
1160
        assert!(spawner.is_forever());
1161
        let mut spawner = EffectSpawner::new(&spawner);
1162
        let count = spawner.tick(1.0, rng);
1163
        assert_eq!(count, 5);
1164
        let count = spawner.tick(4.0, rng);
1165
        assert_eq!(count, 10);
1166
        let count = spawner.tick(0.1, rng);
1167
        assert_eq!(count, 0);
1168
    }
1169

1170
    #[test]
1171
    fn test_with_active() {
1172
        let rng = &mut new_rng();
1173
        let spawner = SpawnerSettings::rate(5.0.into()).with_starts_active(false);
1174
        let mut spawner = EffectSpawner::new(&spawner);
1175
        assert!(!spawner.active);
1176
        let count = spawner.tick(1., rng);
1177
        assert_eq!(count, 0);
1178
        spawner.active = false; // no-op
1179
        let count = spawner.tick(1., rng);
1180
        assert_eq!(count, 0);
1181
        spawner.active = true;
1182
        assert!(spawner.active);
1183
        let count = spawner.tick(1., rng);
1184
        assert_eq!(count, 5);
1185
    }
1186

1187
    fn make_test_app() -> App {
1188
        IoTaskPool::get_or_init(|| {
1189
            TaskPoolBuilder::default()
1190
                .num_threads(1)
1191
                .thread_name("Hanabi test IO Task Pool".to_string())
1192
                .build()
1193
        });
1194

1195
        let mut app = App::new();
1196

1197
        let watch_for_changes = false;
1198
        let mut builders = app
1199
            .world_mut()
1200
            .get_resource_or_insert_with::<AssetSourceBuilders>(Default::default);
1201
        let dir = Dir::default();
1202
        let dummy_builder = AssetSourceBuilder::default()
1203
            .with_reader(move || Box::new(MemoryAssetReader { root: dir.clone() }));
1204
        builders.insert(AssetSourceId::Default, dummy_builder);
1205
        let sources = builders.build_sources(watch_for_changes, false);
1206
        let asset_server = AssetServer::new(
1207
            sources,
1208
            AssetServerMode::Unprocessed,
1209
            watch_for_changes,
1210
            UnapprovedPathMode::Forbid,
1211
        );
1212

1213
        app.insert_resource(asset_server);
1214
        // app.add_plugins(DefaultPlugins);
1215
        app.init_asset::<Mesh>();
1216
        app.add_plugins(VisibilityPlugin);
1217
        app.init_resource::<Time<EffectSimulation>>();
1218
        app.insert_resource(Random(new_rng()));
1219
        app.init_asset::<EffectAsset>();
1220
        app.add_systems(
1221
            PostUpdate,
1222
            tick_spawners.after(VisibilitySystems::CheckVisibility),
1223
        );
1224

1225
        app
1226
    }
1227

1228
    /// Test case for `tick_spawners()`.
1229
    struct TestCase {
1230
        /// Initial entity visibility on spawn. If `None`, do not add a
1231
        /// [`Visibility`] component.
1232
        visibility: Option<Visibility>,
1233

1234
        /// Spawner settings assigned to the `EffectAsset`.
1235
        asset_spawner: SpawnerSettings,
1236
    }
1237

1238
    impl TestCase {
1239
        fn new(visibility: Option<Visibility>, asset_spawner: SpawnerSettings) -> Self {
1240
            Self {
1241
                visibility,
1242
                asset_spawner,
1243
            }
1244
        }
1245
    }
1246

1247
    #[test]
1248
    fn test_tick_spawners() {
1249
        let asset_spawner = SpawnerSettings::once(32.0.into());
1250

1251
        for test_case in &[
1252
            TestCase::new(None, asset_spawner),
1253
            TestCase::new(Some(Visibility::Hidden), asset_spawner),
1254
            TestCase::new(Some(Visibility::Visible), asset_spawner),
1255
        ] {
1256
            let mut app = make_test_app();
1257

1258
            let (effect_entity, handle) = {
1259
                let world = app.world_mut();
1260

1261
                // Add effect asset
1262
                let mut assets = world.resource_mut::<Assets<EffectAsset>>();
1263
                let mut asset = EffectAsset::new(64, test_case.asset_spawner, Module::default());
1264
                asset.simulation_condition = if test_case.visibility.is_some() {
1265
                    SimulationCondition::WhenVisible
1266
                } else {
1267
                    SimulationCondition::Always
1268
                };
1269
                let handle = assets.add(asset);
1270

1271
                // Spawn particle effect
1272
                let entity = if let Some(visibility) = test_case.visibility {
1273
                    world
1274
                        .spawn((
1275
                            visibility,
1276
                            InheritedVisibility::default(),
1277
                            ParticleEffect {
1278
                                handle: handle.clone(),
1279
                                ..default()
1280
                            },
1281
                            // Force-ready the effect as those tests don't initialize the render
1282
                            // world (headless), so the effect would never get ready otherwise.
1283
                            CompiledParticleEffect::default().with_ready_for_tests(),
1284
                        ))
1285
                        .id()
1286
                } else {
1287
                    world
1288
                        .spawn((
1289
                            ParticleEffect {
1290
                                handle: handle.clone(),
1291
                                ..default()
1292
                            },
1293
                            // Force-ready the effect as those tests don't initialize the render
1294
                            // world (headless), so the effect would never get ready otherwise.
1295
                            CompiledParticleEffect::default().with_ready_for_tests(),
1296
                        ))
1297
                        .id()
1298
                };
1299

1300
                // Spawn a camera, otherwise ComputedVisibility stays at HIDDEN
1301
                world.spawn(Camera3d::default());
1302

1303
                (entity, handle)
1304
            };
1305

1306
            // Tick once
1307
            let _cur_time = {
1308
                // Make sure to increment the current time so that the spawners spawn something.
1309
                // Note that `Time` has this weird behavior where the common quantities like
1310
                // `Time::delta_secs()` only update after the *second* update. So we tick the
1311
                // `Time` twice here to enforce this.
1312
                let mut time = app.world_mut().resource_mut::<Time<EffectSimulation>>();
1313
                time.advance_by(Duration::from_millis(16));
1314
                time.elapsed()
1315
            };
1316
            app.update();
1317

1318
            let world = app.world_mut();
1319

1320
            // Check the state of the components after `tick_spawners()` ran
1321
            if let Some(test_visibility) = test_case.visibility {
1322
                // Simulated-when-visible effect (SimulationCondition::WhenVisible)
1323

1324
                let (entity, visibility, inherited_visibility, particle_effect, effect_spawner) =
1325
                    world
1326
                        .query::<(
1327
                            Entity,
1328
                            &Visibility,
1329
                            &InheritedVisibility,
1330
                            &ParticleEffect,
1331
                            Option<&EffectSpawner>,
1332
                        )>()
1333
                        .iter(world)
1334
                        .next()
1335
                        .unwrap();
1336
                assert_eq!(entity, effect_entity);
1337
                assert_eq!(visibility, test_visibility);
1338
                assert_eq!(
1339
                    inherited_visibility.get(),
1340
                    test_visibility == Visibility::Visible
1341
                );
1342
                assert_eq!(particle_effect.handle, handle);
1343

1344
                // The EffectSpawner component is always spawned, even if not visible.
1345
                assert!(effect_spawner.is_some());
1346
                let effect_spawner = effect_spawner.unwrap();
1347
                let actual_spawner = effect_spawner.settings;
1348
                assert_eq!(actual_spawner, test_case.asset_spawner);
1349
                assert!(effect_spawner.active);
1350
                assert_eq!(effect_spawner.spawn_remainder, 0.);
1351
                assert_eq!(effect_spawner.cycle_time, 0.);
1352

1353
                if inherited_visibility.get() {
1354
                    // Check the spawner ticked
1355
                    assert_eq!(effect_spawner.completed_cycle_count, 1);
1356
                    assert_eq!(effect_spawner.spawn_count, 32);
1357
                } else {
1358
                    // Didn't tick
1359
                    assert_eq!(effect_spawner.completed_cycle_count, 0);
1360
                    assert_eq!(effect_spawner.spawn_count, 0);
1361
                }
1362
            } else {
1363
                // Always-simulated effect (SimulationCondition::Always)
1364

1365
                let (entity, particle_effect, effect_spawners) = world
1366
                    .query::<(Entity, &ParticleEffect, Option<&EffectSpawner>)>()
1367
                    .iter(world)
1368
                    .next()
1369
                    .unwrap();
1370
                assert_eq!(entity, effect_entity);
1371
                assert_eq!(particle_effect.handle, handle);
1372

1373
                assert!(effect_spawners.is_some());
1374
                let effect_spawner = effect_spawners.unwrap();
1375
                let actual_spawner = effect_spawner.settings;
1376

1377
                // Check the spawner ticked
1378
                assert!(effect_spawner.active); // will get deactivated next tick()
1379
                assert_eq!(effect_spawner.spawn_remainder, 0.);
1380
                assert_eq!(effect_spawner.cycle_time, 0.);
1381
                assert_eq!(effect_spawner.completed_cycle_count, 1);
1382
                assert_eq!(effect_spawner.spawn_count, 32);
1383

1384
                assert_eq!(actual_spawner, test_case.asset_spawner);
1385
            }
1386
        }
1387
    }
1388
}
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