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

djeedai / bevy_hanabi / 13640457354

03 Mar 2025 09:09PM UTC coverage: 40.055% (-6.7%) from 46.757%
13640457354

push

github

web-flow
Hierarchical effects and GPU spawn event (#424)

This change introduces hierarchical effects, the ability of an effect to
be parented to another effect through the `EffectParent` component.
Child effects can inherit attributes from their parent when spawned
during the init pass, but are otherwise independent effects. They
replace the old group system, which is entirely removed. The parent
effect can emit GPU spawn events, which are consumed by the child effect
to spawn particles instead of the traditional CPU spawn count. Those GPU
spawn events currently are just the ID of the parent particles, to allow
read-only access to its attribute in _e.g._ the new
`InheritAttributeModifier`.

The ribbon/trail system is also reworked. The atomic linked list based
on `Attribute::PREV` and `Attribute::NEXT` is abandoned, and replaced
with an explicit sort compute pass which orders particles by
`Attribute::RIBBON_ID` first, and `Attribute::AGE` next. The ribbon ID
is any `u32` value unique to each ribbon/trail. Sorting particles by age
inside a given ribbon/trail allows avoiding the edge case where a
particle in the middle of a trail dies, leaving a gap in the list.

A migration guide is provided from v0.14 to the upcoming v0.15 which
will include this change, due to the large change of behavior and APIs.

409 of 2997 new or added lines in 17 files covered. (13.65%)

53 existing lines in 11 files now uncovered.

3208 of 8009 relevant lines covered (40.05%)

18.67 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

146
/// Spawner defining how new particles are emitted.
147
///
148
/// The spawner defines how new particles are emitted and when. Each time the
149
/// spawner ticks, it calculates a number of particles to emit for this frame.
150
/// This spawn count is passed to the GPU for the init compute pass to actually
151
/// allocate the new particles and initialize them. The number of particles to
152
/// spawn is stored as a floating-point number, and any remainder accumulates
153
/// for the next emitting.
154
///
155
/// Once per frame the [`tick_spawners()`] system will add the component if
156
/// it's missing, cloning the [`Spawner`] from the source [`EffectAsset`], then
157
/// tick that [`Spawner`] stored in the component. The resulting number of
158
/// particles to spawn for the frame is then stored into
159
/// [`EffectSpawner::spawn_count`]. You can override that value to manually
160
/// control each frame how many particles are spawned.
161
#[derive(Debug, Clone, Copy, PartialEq, Reflect, Serialize, Deserialize)]
162
#[reflect(Default)]
163
pub struct Spawner {
164
    /// Number of particles to spawn over [`spawn_duration`].
165
    ///
166
    /// [`spawn_duration`]: Spawner::spawn_duration
167
    count: CpuValue<f32>,
168

169
    /// Time over which to spawn [`count`], in seconds.
170
    ///
171
    /// [`count`]: Spawner::count
172
    spawn_duration: CpuValue<f32>,
173

174
    /// Time between bursts of the particle system, in seconds.
175
    ///
176
    /// If this is infinity, there's only one burst.
177
    /// If this is [`spawn_duration`] or less, the system spawns a steady stream
178
    /// of particles.
179
    ///
180
    /// [`spawn_duration`]: Spawner::spawn_duration
181
    period: CpuValue<f32>,
182

183
    /// Whether the spawner is active at startup.
184
    ///
185
    /// The value is used to initialize [`EffectSpawner::active`].
186
    ///
187
    /// [`EffectSpawner::active`]: crate::EffectSpawner::active
188
    starts_active: bool,
189

190
    /// Whether the burst of a once-style spawner triggers immediately when the
191
    /// spawner becomes active.
192
    ///
193
    /// If `false`, the spawner doesn't do anything until
194
    /// [`EffectSpawner::reset()`] is called.
195
    starts_immediately: bool,
196
}
197

198
impl Default for Spawner {
199
    fn default() -> Self {
15✔
200
        Self::once(1.0f32.into(), true)
15✔
201
    }
202
}
203

204
impl Spawner {
205
    /// Create a spawner with a given count, time, and period.
206
    ///
207
    /// This is the _raw_ constructor. In general you should prefer using one of
208
    /// the utility constructors [`once()`], [`burst()`], or [`rate()`],
209
    /// which will ensure the control parameters are set consistently relative
210
    /// to each other.
211
    ///
212
    /// The control parameters are:
213
    ///
214
    /// - `count` is the number of particles to spawn over `spawn_duration` in a
215
    ///   burst. It can generate negative or zero random values, in which case
216
    ///   no particle is spawned during the current frame.
217
    /// - `spawn_duration` is how long to spawn particles for. If this is <= 0,
218
    ///   then the particles spawn all at once exactly at the same instant.
219
    /// - `period` is the amount of time between bursts of particles. If this is
220
    ///   <= `spawn_duration`, then the spawner spawns a steady stream of
221
    ///   particles. If this is infinity, then there is a single burst.
222
    ///
223
    /// ```txt
224
    ///  <----------- period ----------->
225
    ///  <- spawn_duration ->
226
    /// |********************|-----------|
227
    ///      spawn 'count'        wait
228
    ///        particles
229
    /// ```
230
    ///
231
    /// Note that the "burst" semantic here doesn't strictly mean a one-off
232
    /// emission, since that emission is spread over a number of simulation
233
    /// frames that total a duration of `spawn_duration`. If you want a strict
234
    /// single-frame burst, simply set the `spawn_duration` to zero; this is
235
    /// what [`once()`] does.
236
    ///
237
    /// The `period` can be (positive) infinity; in that case, the spawner only
238
    /// spawns a single time. This is equivalent to using [`once()`].
239
    ///
240
    /// # Panics
241
    ///
242
    /// Panics if `period` can produce a negative number (the sample range lower
243
    /// bound is negative), or can only produce 0 (the sample range upper bound
244
    /// is not strictly positive).
245
    ///
246
    /// # Example
247
    ///
248
    /// ```
249
    /// # use bevy_hanabi::Spawner;
250
    /// // Spawn 32 particles over 3 seconds, then pause for 7 seconds (10 - 3),
251
    /// // and repeat.
252
    /// let spawner = Spawner::new(32.0.into(), 3.0.into(), 10.0.into());
253
    /// ```
254
    ///
255
    /// [`once()`]: crate::Spawner::once
256
    /// [`burst()`]: crate::Spawner::burst
257
    /// [`rate()`]: crate::Spawner::rate
258
    pub fn new(count: CpuValue<f32>, spawn_duration: CpuValue<f32>, period: CpuValue<f32>) -> Self {
34✔
259
        assert!(
34✔
260
            period.range()[0] >= 0.,
34✔
261
            "`period` must not generate negative numbers (period.min was {}, expected >= 0).",
1✔
262
            period.range()[0]
1✔
263
        );
264
        assert!(
33✔
265
            period.range()[1] > 0.,
33✔
266
            "`period` must be able to generate a positive number (period.max was {}, expected > 0).",
1✔
267
            period.range()[1]
1✔
268
        );
269

270
        Self {
271
            count,
272
            spawn_duration,
273
            period,
274
            starts_active: true,
275
            starts_immediately: true,
276
        }
277
    }
278

279
    /// Create a spawner that spawns a burst of particles once.
280
    ///
281
    /// The burst of particles is spawned all at once in the same frame. After
282
    /// that, the spawner idles, waiting to be manually reset via
283
    /// [`EffectSpawner::reset()`].
284
    ///
285
    /// If `spawn_immediately` is `false`, this waits until
286
    /// [`EffectSpawner::reset()`] before spawning a burst of particles.
287
    ///
288
    /// When `spawn_immediately == true`, this spawns a burst immediately on
289
    /// activation. In that case, this is a convenience for:
290
    ///
291
    /// ```
292
    /// # use bevy_hanabi::{Spawner, CpuValue};
293
    /// # let count = CpuValue::Single(1.);
294
    /// Spawner::new(count, 0.0.into(), f32::INFINITY.into());
295
    /// ```
296
    ///
297
    /// # Example
298
    ///
299
    /// ```
300
    /// # use bevy_hanabi::Spawner;
301
    /// // Spawn 32 particles in a burst once immediately on creation.
302
    /// let spawner = Spawner::once(32.0.into(), true);
303
    /// ```
304
    ///
305
    /// [`reset()`]: crate::Spawner::reset
306
    pub fn once(count: CpuValue<f32>, spawn_immediately: bool) -> Self {
21✔
307
        let mut spawner = Self::new(count, 0.0.into(), f32::INFINITY.into());
21✔
308
        spawner.starts_immediately = spawn_immediately;
21✔
309
        spawner
21✔
310
    }
311

312
    /// Get whether this spawner emits a single burst.
313
    pub fn is_once(&self) -> bool {
18✔
314
        if let CpuValue::Single(f) = self.period {
36✔
315
            f.is_infinite()
316
        } else {
317
            false
×
318
        }
319
    }
320

321
    /// Create a spawner that spawns particles at `rate`, accumulated each
322
    /// frame. `rate` is in particles per second.
323
    ///
324
    /// This is a convenience for:
325
    ///
326
    /// ```
327
    /// # use bevy_hanabi::{Spawner, CpuValue};
328
    /// # let rate = CpuValue::Single(1.);
329
    /// Spawner::new(rate, 1.0.into(), 1.0.into());
330
    /// ```
331
    ///
332
    /// # Example
333
    ///
334
    /// ```
335
    /// # use bevy_hanabi::Spawner;
336
    /// // Spawn 10 particles per second, indefinitely.
337
    /// let spawner = Spawner::rate(10.0.into());
338
    /// ```
339
    pub fn rate(rate: CpuValue<f32>) -> Self {
9✔
340
        Self::new(rate, 1.0.into(), 1.0.into())
9✔
341
    }
342

343
    /// Create a spawner that spawns `count` particles, waits `period` seconds,
344
    /// and repeats forever.
345
    ///
346
    /// This is a convenience for:
347
    ///
348
    /// ```
349
    /// # use bevy_hanabi::{Spawner, CpuValue};
350
    /// # let count = CpuValue::Single(1.);
351
    /// # let period = CpuValue::Single(1.);
352
    /// Spawner::new(count, 0.0.into(), period);
353
    /// ```
354
    ///
355
    /// # Example
356
    ///
357
    /// ```
358
    /// # use bevy_hanabi::Spawner;
359
    /// // Spawn a burst of 5 particles every 3 seconds, indefinitely.
360
    /// let spawner = Spawner::burst(5.0.into(), 3.0.into());
361
    /// ```
362
    pub fn burst(count: CpuValue<f32>, period: CpuValue<f32>) -> Self {
1✔
363
        Self::new(count, 0.0.into(), period)
1✔
364
    }
365

366
    /// Set the number of particles that are spawned each cycle.
367
    pub fn with_count(mut self, count: CpuValue<f32>) -> Self {
×
368
        self.count = count;
×
369
        self
×
370
    }
371

372
    /// Set the number of particles that are spawned each cycle.
373
    pub fn set_count(&mut self, count: CpuValue<f32>) {
×
374
        self.count = count;
×
375
    }
376

377
    /// Get the number of particles that are spawned each cycle.
378
    pub fn count(&self) -> CpuValue<f32> {
×
379
        self.count
×
380
    }
381

382
    /// Set the duration, in seconds, of the spawn time each cycle.
383
    pub fn with_spawn_time(mut self, spawn_duration: CpuValue<f32>) -> Self {
×
384
        self.spawn_duration = spawn_duration;
×
385
        self
×
386
    }
387

388
    /// Set the duration, in seconds, of the spawn time each cycle.
389
    pub fn set_spawn_time(&mut self, spawn_duration: CpuValue<f32>) {
×
390
        self.spawn_duration = spawn_duration;
×
391
    }
392

393
    /// Get the duration, in seconds, of spawn time each cycle.
394
    pub fn spawn_duration(&self) -> CpuValue<f32> {
×
395
        self.spawn_duration
×
396
    }
397

398
    /// Set the duration of a spawn cycle, in seconds.
399
    ///
400
    /// A spawn cycles includes the [`spawn_duration()`] value, and any extra
401
    /// wait time (if larger than spawn time).
402
    ///
403
    /// [`spawn_duration()`]: Self::spawn_duration
404
    pub fn with_period(mut self, period: CpuValue<f32>) -> Self {
×
405
        self.period = period;
×
406
        self
×
407
    }
408

409
    /// Set the duration of the spawn cycle, in seconds.
410
    ///
411
    /// A spawn cycles includes the [`spawn_duration()`] value, and any extra
412
    /// wait time (if larger than spawn time).
413
    ///
414
    /// [`spawn_duration()`]: Self::spawn_duration
415
    pub fn set_period(&mut self, period: CpuValue<f32>) {
×
416
        self.period = period;
×
417
    }
418

419
    /// Get the duration of the spawn cycle, in seconds.
420
    ///
421
    /// A spawn cycles includes the [`spawn_duration()`] value, and any extra
422
    /// wait time (if larger than spawn time).
423
    ///
424
    /// [`spawn_duration()`]: Self::spawn_duration
425
    pub fn period(&self) -> CpuValue<f32> {
×
426
        self.period
×
427
    }
428

429
    /// Sets whether the spawner starts active when the effect is instantiated.
430
    ///
431
    /// This value will be transfered to the active state of the
432
    /// [`EffectSpawner`] once it's instantiated. Inactive spawners do not spawn
433
    /// any particle.
434
    pub fn with_starts_active(mut self, starts_active: bool) -> Self {
1✔
435
        self.starts_active = starts_active;
1✔
436
        self
1✔
437
    }
438

439
    /// Set whether the spawner starts active when the effect is instantiated.
440
    ///
441
    /// This value will be transfered to the active state of the
442
    /// [`EffectSpawner`] once it's instantiated. Inactive spawners do not spawn
443
    /// any particle.
444
    pub fn set_starts_active(&mut self, starts_active: bool) {
×
445
        self.starts_active = starts_active;
×
446
    }
447

448
    /// Get whether the spawner starts active when the effect is instantiated.
449
    ///
450
    /// This value will be transfered to the active state of the
451
    /// [`EffectSpawner`] once it's instantiated. Inactive spawners do not spawn
452
    /// any particle.
453
    pub fn starts_active(&self) -> bool {
11✔
454
        self.starts_active
11✔
455
    }
456
}
457

458
/// Runtime structure maintaining the state of the spawner for a particle group.
459
#[derive(Debug, Default, Clone, Copy, PartialEq, Component, Reflect)]
460
#[reflect(Component)]
461
pub struct EffectSpawner {
462
    /// The spawner configuration extracted from the [`EffectAsset`], or
463
    /// directly overriden by the user.
464
    spawner: Spawner,
465

466
    /// Accumulated time since last spawn, in seconds.
467
    time: f32,
468

469
    /// Sampled value of `spawn_duration` until `period` is reached. This is the
470
    /// duration of the "active" period during which we spawn particles, as
471
    /// opposed to the "wait" period during which we do nothing until the next
472
    /// spawn cycle.
473
    spawn_duration: f32,
474

475
    /// Sampled value of the time period, in seconds, until the next spawn
476
    /// cycle.
477
    period: f32,
478

479
    /// Number of particles to spawn this frame.
480
    ///
481
    /// This value is normally updated by calling [`tick()`], which
482
    /// automatically happens once per frame when the [`tick_initializers()`]
483
    /// system runs in the [`PostUpdate`] schedule.
484
    ///
485
    /// You can manually assign this value to override the one calculated by
486
    /// [`tick()`]. Note in this case that you need to override the value after
487
    /// the automated one was calculated, by ordering your system
488
    /// after [`tick_initializers()`] or [`EffectSystems::TickSpawners`].
489
    ///
490
    /// [`tick()`]: crate::EffectSpawner::tick
491
    /// [`EffectSystems::TickSpawners`]: crate::EffectSystems::TickSpawners
492
    pub spawn_count: u32,
493

494
    /// Fractional remainder of particle count to spawn.
495
    ///
496
    /// This is accumulated each tick, and the integral part is added to
497
    /// `spawn_count`. The reminder gets saved for next frame.
498
    spawn_remainder: f32,
499

500
    /// Whether the spawner is active. Defaults to `true`. An inactive spawner
501
    /// doesn't tick (no particle spawned, no internal time updated).
502
    active: bool,
503
}
504

505
impl EffectSpawner {
506
    /// Create a new spawner state from a [`Spawner`].
507
    pub fn new(spawner: &Spawner) -> Self {
11✔
508
        Self {
509
            spawner: *spawner,
11✔
510
            time: if spawner.is_once() && !spawner.starts_immediately {
16✔
511
                1. // anything > 0
512
            } else {
513
                0.
514
            },
515
            spawn_duration: 0.,
516
            period: 0.,
517
            spawn_count: 0,
518
            spawn_remainder: 0.,
519
            active: spawner.starts_active(),
11✔
520
        }
521
    }
522

523
    /// Set whether the spawner is active.
524
    ///
525
    /// Inactive spawners do not tick, and therefore do not spawn any particle.
526
    pub fn with_active(mut self, active: bool) -> Self {
×
527
        self.active = active;
×
528
        self
×
529
    }
530

531
    /// Set whether the spawner is active.
532
    ///
533
    /// Inactive spawners do not tick, and therefore do not spawn any particle.
534
    pub fn set_active(&mut self, active: bool) {
4✔
535
        self.active = active;
4✔
536
    }
537

538
    /// Get whether the spawner is active.
539
    ///
540
    /// Inactive spawners do not tick, and therefore do not spawn any particle.
541
    pub fn is_active(&self) -> bool {
4✔
542
        self.active
4✔
543
    }
544

545
    /// Get the spawner configuration in use.
546
    ///
547
    /// The effective [`Spawner`] used is either the override specified in the
548
    /// associated [`ParticleEffect`] instance, or the fallback one specified in
549
    /// underlying [`EffectAsset`].
550
    pub fn spawner(&self) -> &Spawner {
×
551
        &self.spawner
×
552
    }
553

554
    /// Reset the spawner state.
555
    ///
556
    /// This resets the internal spawner time to zero, and restarts any internal
557
    /// particle counter.
558
    ///
559
    /// Use this, for example, to immediately spawn some particles in a spawner
560
    /// constructed with [`Spawner::once`].
561
    ///
562
    /// [`Spawner::once`]: crate::Spawner::once
563
    pub fn reset(&mut self) {
2✔
564
        self.time = 0.;
2✔
565
        self.period = 0.;
2✔
566
        self.spawn_count = 0;
2✔
567
        self.spawn_remainder = 0.;
2✔
568
    }
569

570
    /// Tick the spawner to calculate the number of particles to spawn this
571
    /// frame.
572
    ///
573
    /// The frame delta time `dt` is added to the current spawner time, before
574
    /// the spawner calculates the number of particles to spawn.
575
    ///
576
    /// This method is called automatically by [`tick_initializers()`] during
577
    /// the [`PostUpdate`], so you normally don't have to call it yourself
578
    /// manually.
579
    ///
580
    /// # Returns
581
    ///
582
    /// The integral number of particles to spawn this frame. Any fractional
583
    /// remainder is saved for the next call.
584
    pub fn tick(&mut self, mut dt: f32, rng: &mut Pcg32) -> u32 {
35✔
585
        if !self.active {
35✔
586
            self.spawn_count = 0;
3✔
587
            return 0;
3✔
588
        }
589

590
        // The limit can be reached multiple times, so use a loop
591
        loop {
592
            if self.period == 0.0 {
51✔
593
                self.resample(rng);
13✔
594
                continue;
13✔
595
            }
596

597
            let new_time = self.time + dt;
38✔
598
            if self.time <= self.spawn_duration {
38✔
599
                // If the spawn time is very small, close to zero, spawn all particles
600
                // immediately in one burst over a single frame.
601
                self.spawn_remainder += if self.spawn_duration < 1e-5f32.max(dt / 100.0) {
33✔
602
                    self.spawner.count.sample(rng)
9✔
603
                } else {
604
                    // Spawn an amount of particles equal to the fraction of time the current frame
605
                    // spans compared to the total burst duration.
606
                    self.spawner.count.sample(rng) * (new_time.min(self.spawn_duration) - self.time)
24✔
607
                        / self.spawn_duration
24✔
608
                };
609
            }
610

611
            let old_time = self.time;
612
            self.time = new_time;
613

614
            if self.time >= self.period {
6✔
615
                dt -= self.period - old_time;
6✔
616
                self.time = 0.0; // dt will be added on in the next iteration
6✔
617
                self.resample(rng);
6✔
618
            } else {
619
                break;
32✔
620
            }
621
        }
622

623
        let count = self.spawn_remainder.floor();
32✔
624
        self.spawn_remainder -= count;
32✔
625
        self.spawn_count = count as u32;
32✔
626

627
        self.spawn_count
32✔
628
    }
629

630
    /// Resamples the spawn time and period.
631
    fn resample(&mut self, rng: &mut Pcg32) {
19✔
632
        self.period = self.spawner.period.sample(rng);
19✔
633
        self.spawn_duration = self
19✔
634
            .spawner
19✔
635
            .spawn_duration
19✔
636
            .sample(rng)
19✔
637
            .clamp(0.0, self.period);
19✔
638
    }
639
}
640

641
/// Tick all the [`EffectSpawner`] components.
642
///
643
/// This system runs in the [`PostUpdate`] stage, after the visibility system
644
/// has updated the [`InheritedVisibility`] of each effect instance (see
645
/// [`VisibilitySystems::VisibilityPropagate`]). Hidden instances are not
646
/// updated, unless the [`EffectAsset::simulation_condition`]
647
/// is set to [`SimulationCondition::Always`]. If no [`InheritedVisibility`] is
648
/// present, the effect is assumed to be visible.
649
///
650
/// Note that by that point the [`ViewVisibility`] is not yet calculated, and it
651
/// may happen that spawners are ticked but no effect is visible in any view
652
/// even though some are "visible" (active) in the [`World`]. The actual
653
/// per-view culling of invisible (not in view) effects is performed later on
654
/// the render world.
655
///
656
/// Once the system determined that the effect instance needs to be simulated
657
/// this frame, it ticks the effect's spawner by calling
658
/// [`EffectSpawner::tick()`], adding a new [`EffectSpawner`] component if it
659
/// doesn't already exist on the same entity as the [`ParticleEffect`].
660
///
661
/// [`VisibilitySystems::VisibilityPropagate`]: bevy::render::view::VisibilitySystems::VisibilityPropagate
662
/// [`EffectAsset::simulation_condition`]: crate::EffectAsset::simulation_condition
663
pub fn tick_spawners(
3✔
664
    mut commands: Commands,
665
    time: Res<Time<EffectSimulation>>,
666
    effects: Res<Assets<EffectAsset>>,
667
    mut rng: ResMut<Random>,
668
    mut query: Query<(
669
        Entity,
670
        &ParticleEffect,
671
        Option<&InheritedVisibility>,
672
        Option<&mut EffectSpawner>,
673
    )>,
674
) {
675
    trace!("tick_spawners()");
3✔
676

677
    let dt = time.delta_secs();
3✔
678

679
    for (entity, effect, maybe_inherited_visibility, maybe_spawner) in query.iter_mut() {
3✔
680
        let Some(asset) = effects.get(&effect.handle) else {
6✔
681
            trace!(
×
682
                "Effect asset with handle {:?} is not available; skipped initializers tick.",
×
683
                effect.handle
684
            );
685
            continue;
×
686
        };
687

688
        if asset.simulation_condition == SimulationCondition::WhenVisible
689
            && !maybe_inherited_visibility
2✔
690
                .map(|iv| iv.get())
6✔
691
                .unwrap_or(true)
2✔
692
        {
693
            trace!(
1✔
694
                "Effect asset with handle {:?} is not visible, and simulates only WhenVisible; skipped initializers tick.",
×
695
                effect.handle
696
            );
697
            continue;
1✔
698
        }
699

NEW
700
        if let Some(mut effect_spawner) = maybe_spawner {
×
NEW
701
            effect_spawner.tick(dt, &mut rng.0);
×
UNCOV
702
            continue;
×
703
        }
704

705
        let effect_spawner = {
2✔
706
            let mut effect_spawner = EffectSpawner::new(&asset.spawner);
2✔
707
            effect_spawner.tick(dt, &mut rng.0);
2✔
708
            effect_spawner
2✔
709
        };
710
        commands.entity(entity).insert(effect_spawner);
2✔
711
    }
712
}
713

714
#[cfg(test)]
715
mod test {
716
    use std::time::Duration;
717

718
    use bevy::{
719
        asset::{
720
            io::{
721
                memory::{Dir, MemoryAssetReader},
722
                AssetSourceBuilder, AssetSourceBuilders, AssetSourceId,
723
            },
724
            AssetServerMode,
725
        },
726
        render::view::{VisibilityPlugin, VisibilitySystems},
727
        tasks::{IoTaskPool, TaskPoolBuilder},
728
    };
729

730
    use super::*;
731
    use crate::Module;
732

733
    #[test]
734
    fn test_range_single() {
735
        let value = CpuValue::Single(1.0);
736
        assert_eq!(value.range(), [1.0, 1.0]);
737
    }
738

739
    #[test]
740
    fn test_range_uniform() {
741
        let value = CpuValue::Uniform((1.0, 3.0));
742
        assert_eq!(value.range(), [1.0, 3.0]);
743
    }
744

745
    #[test]
746
    fn test_range_uniform_reverse() {
747
        let value = CpuValue::Uniform((3.0, 1.0));
748
        assert_eq!(value.range(), [1.0, 3.0]);
749
    }
750

751
    #[test]
752
    fn test_new() {
753
        let rng = &mut new_rng();
754
        // 3 particles over 3 seconds, pause 7 seconds (total 10 seconds period).
755
        let spawner = Spawner::new(3.0.into(), 3.0.into(), 10.0.into());
756
        let mut spawner = EffectSpawner::new(&spawner);
757
        let count = spawner.tick(2.0, rng); // t = 2s
758
        assert_eq!(count, 2);
759
        let count = spawner.tick(5.0, rng); // t = 7s
760
        assert_eq!(count, 1);
761
        let count = spawner.tick(8.0, rng); // t = 15s
762
        assert_eq!(count, 3);
763
    }
764

765
    #[test]
766
    #[should_panic]
767
    fn test_new_panic_negative_period() {
768
        let _ = Spawner::new(3.0.into(), 1.0.into(), CpuValue::Uniform((-1., 1.)));
769
    }
770

771
    #[test]
772
    #[should_panic]
773
    fn test_new_panic_zero_period() {
774
        let _ = Spawner::new(3.0.into(), 1.0.into(), CpuValue::Uniform((0., 0.)));
775
    }
776

777
    #[test]
778
    fn test_once() {
779
        let rng = &mut new_rng();
780
        let spawner = Spawner::once(5.0.into(), true);
781
        assert!(spawner.is_once());
782
        let mut spawner = EffectSpawner::new(&spawner);
783
        let count = spawner.tick(0.001, rng);
784
        assert_eq!(count, 5);
785
        let count = spawner.tick(100.0, rng);
786
        assert_eq!(count, 0);
787
    }
788

789
    #[test]
790
    fn test_once_reset() {
791
        let rng = &mut new_rng();
792
        let spawner = Spawner::once(5.0.into(), true);
793
        assert!(spawner.is_once());
794
        let mut spawner = EffectSpawner::new(&spawner);
795
        spawner.tick(1.0, rng);
796
        spawner.reset();
797
        let count = spawner.tick(1.0, rng);
798
        assert_eq!(count, 5);
799
    }
800

801
    #[test]
802
    fn test_once_not_immediate() {
803
        let rng = &mut new_rng();
804
        let spawner = Spawner::once(5.0.into(), false);
805
        assert!(spawner.is_once());
806
        let mut spawner = EffectSpawner::new(&spawner);
807
        let count = spawner.tick(1.0, rng);
808
        assert_eq!(count, 0);
809
        spawner.reset();
810
        let count = spawner.tick(1.0, rng);
811
        assert_eq!(count, 5);
812
    }
813

814
    #[test]
815
    fn test_rate() {
816
        let rng = &mut new_rng();
817
        let spawner = Spawner::rate(5.0.into());
818
        assert!(!spawner.is_once());
819
        let mut spawner = EffectSpawner::new(&spawner);
820
        // Slightly over 1.0 to avoid edge case
821
        let count = spawner.tick(1.01, rng);
822
        assert_eq!(count, 5);
823
        let count = spawner.tick(0.4, rng);
824
        assert_eq!(count, 2);
825
    }
826

827
    #[test]
828
    fn test_rate_active() {
829
        let rng = &mut new_rng();
830
        let spawner = Spawner::rate(5.0.into());
831
        assert!(!spawner.is_once());
832
        let mut spawner = EffectSpawner::new(&spawner);
833
        spawner.tick(1.01, rng);
834
        spawner.set_active(false);
835
        assert!(!spawner.is_active());
836
        let count = spawner.tick(0.4, rng);
837
        assert_eq!(count, 0);
838
        spawner.set_active(true);
839
        assert!(spawner.is_active());
840
        let count = spawner.tick(0.4, rng);
841
        assert_eq!(count, 2);
842
    }
843

844
    #[test]
845
    fn test_rate_accumulate() {
846
        let rng = &mut new_rng();
847
        let spawner = Spawner::rate(5.0.into());
848
        assert!(!spawner.is_once());
849
        let mut spawner = EffectSpawner::new(&spawner);
850
        // 13 ticks instead of 12 to avoid edge case
851
        let count = (0..13).map(|_| spawner.tick(1.0 / 60.0, rng)).sum::<u32>();
852
        assert_eq!(count, 1);
853
    }
854

855
    #[test]
856
    fn test_burst() {
857
        let rng = &mut new_rng();
858
        let spawner = Spawner::burst(5.0.into(), 2.0.into());
859
        assert!(!spawner.is_once());
860
        let mut spawner = EffectSpawner::new(&spawner);
861
        let count = spawner.tick(1.0, rng);
862
        assert_eq!(count, 5);
863
        let count = spawner.tick(4.0, rng);
864
        assert_eq!(count, 10);
865
        let count = spawner.tick(0.1, rng);
866
        assert_eq!(count, 0);
867
    }
868

869
    #[test]
870
    fn test_with_active() {
871
        let rng = &mut new_rng();
872
        let spawner = Spawner::rate(5.0.into()).with_starts_active(false);
873
        let mut spawner = EffectSpawner::new(&spawner);
874
        assert!(!spawner.is_active());
875
        let count = spawner.tick(1., rng);
876
        assert_eq!(count, 0);
877
        spawner.set_active(false); // no-op
878
        let count = spawner.tick(1., rng);
879
        assert_eq!(count, 0);
880
        spawner.set_active(true);
881
        assert!(spawner.is_active());
882
        let count = spawner.tick(1., rng);
883
        assert_eq!(count, 5);
884
    }
885

886
    fn make_test_app() -> App {
887
        IoTaskPool::get_or_init(|| {
888
            TaskPoolBuilder::default()
889
                .num_threads(1)
890
                .thread_name("Hanabi test IO Task Pool".to_string())
891
                .build()
892
        });
893

894
        let mut app = App::new();
895

896
        let watch_for_changes = false;
897
        let mut builders = app
898
            .world_mut()
899
            .get_resource_or_insert_with::<AssetSourceBuilders>(Default::default);
900
        let dir = Dir::default();
901
        let dummy_builder = AssetSourceBuilder::default()
902
            .with_reader(move || Box::new(MemoryAssetReader { root: dir.clone() }));
903
        builders.insert(AssetSourceId::Default, dummy_builder);
904
        let sources = builders.build_sources(watch_for_changes, false);
905
        let asset_server =
906
            AssetServer::new(sources, AssetServerMode::Unprocessed, watch_for_changes);
907

908
        app.insert_resource(asset_server);
909
        // app.add_plugins(DefaultPlugins);
910
        app.init_asset::<Mesh>();
911
        app.add_plugins(VisibilityPlugin);
912
        app.init_resource::<Time<EffectSimulation>>();
913
        app.insert_resource(Random(new_rng()));
914
        app.init_asset::<EffectAsset>();
915
        app.add_systems(
916
            PostUpdate,
917
            tick_spawners.after(VisibilitySystems::CheckVisibility),
918
        );
919

920
        app
921
    }
922

923
    /// Test case for `tick_initializers()`.
924
    struct TestCase {
925
        /// Initial entity visibility on spawn. If `None`, do not add a
926
        /// [`Visibility`] component.
927
        visibility: Option<Visibility>,
928

929
        /// Spawner assigned to the `EffectAsset`.
930
        asset_spawner: Spawner,
931
    }
932

933
    impl TestCase {
934
        fn new(visibility: Option<Visibility>, asset_spawner: Spawner) -> Self {
935
            Self {
936
                visibility,
937
                asset_spawner,
938
            }
939
        }
940
    }
941

942
    #[test]
943
    fn test_tick_spawners() {
944
        let asset_spawner = Spawner::once(32.0.into(), true);
945

946
        for test_case in &[
947
            TestCase::new(None, asset_spawner),
948
            TestCase::new(Some(Visibility::Hidden), asset_spawner),
949
            TestCase::new(Some(Visibility::Visible), asset_spawner),
950
        ] {
951
            let mut app = make_test_app();
952

953
            let (effect_entity, handle) = {
954
                let world = app.world_mut();
955

956
                // Add effect asset
957
                let mut assets = world.resource_mut::<Assets<EffectAsset>>();
958
                let mut asset = EffectAsset::new(64, test_case.asset_spawner, Module::default());
959
                asset.simulation_condition = if test_case.visibility.is_some() {
960
                    SimulationCondition::WhenVisible
961
                } else {
962
                    SimulationCondition::Always
963
                };
964
                let handle = assets.add(asset);
965

966
                // Spawn particle effect
967
                let entity = if let Some(visibility) = test_case.visibility {
968
                    world
969
                        .spawn((
970
                            visibility,
971
                            InheritedVisibility::default(),
972
                            ParticleEffect {
973
                                handle: handle.clone(),
974
                                #[cfg(feature = "2d")]
975
                                z_layer_2d: None,
976
                            },
977
                        ))
978
                        .id()
979
                } else {
980
                    world
981
                        .spawn((ParticleEffect {
982
                            handle: handle.clone(),
983
                            #[cfg(feature = "2d")]
984
                            z_layer_2d: None,
985
                        },))
986
                        .id()
987
                };
988

989
                // Spawn a camera, otherwise ComputedVisibility stays at HIDDEN
990
                world.spawn(Camera3d::default());
991

992
                (entity, handle)
993
            };
994

995
            // Tick once
996
            let cur_time = {
997
                // Make sure to increment the current time so that the spawners spawn something.
998
                // Note that `Time` has this weird behavior where the common quantities like
999
                // `Time::delta_secs()` only update after the *second* update. So we tick the
1000
                // `Time` twice here to enforce this.
1001
                let mut time = app.world_mut().resource_mut::<Time<EffectSimulation>>();
1002
                time.advance_by(Duration::from_millis(16));
1003
                time.elapsed()
1004
            };
1005
            app.update();
1006

1007
            let world = app.world_mut();
1008

1009
            // Check the state of the components after `tick_initializers()` ran
1010
            if let Some(test_visibility) = test_case.visibility {
1011
                // Simulated-when-visible effect (SimulationCondition::WhenVisible)
1012

1013
                let (entity, visibility, inherited_visibility, particle_effect, effect_spawner) =
1014
                    world
1015
                        .query::<(
1016
                            Entity,
1017
                            &Visibility,
1018
                            &InheritedVisibility,
1019
                            &ParticleEffect,
1020
                            Option<&EffectSpawner>,
1021
                        )>()
1022
                        .iter(world)
1023
                        .next()
1024
                        .unwrap();
1025
                assert_eq!(entity, effect_entity);
1026
                assert_eq!(visibility, test_visibility);
1027
                assert_eq!(
1028
                    inherited_visibility.get(),
1029
                    test_visibility == Visibility::Visible
1030
                );
1031
                assert_eq!(particle_effect.handle, handle);
1032
                if inherited_visibility.get() {
1033
                    // If visible, `tick_initializers()` spawns the EffectSpawner and ticks it
1034
                    assert!(effect_spawner.is_some());
1035
                    let effect_spawner = effect_spawner.unwrap();
1036
                    let actual_spawner = effect_spawner.spawner;
1037

1038
                    // Check the spawner ticked
1039
                    assert!(effect_spawner.active);
1040
                    assert_eq!(effect_spawner.spawn_remainder, 0.);
1041
                    assert_eq!(effect_spawner.time, cur_time.as_secs_f32());
1042

1043
                    assert_eq!(actual_spawner, test_case.asset_spawner);
1044
                    assert_eq!(effect_spawner.spawn_count, 32);
1045
                } else {
1046
                    // If not visible, `tick_initializers()` skips the effect entirely so won't
1047
                    // spawn an `EffectSpawner` for it
1048
                    assert!(effect_spawner.is_none());
1049
                }
1050
            } else {
1051
                // Always-simulated effect (SimulationCondition::Always)
1052

1053
                let (entity, particle_effect, effect_spawners) = world
1054
                    .query::<(Entity, &ParticleEffect, Option<&EffectSpawner>)>()
1055
                    .iter(world)
1056
                    .next()
1057
                    .unwrap();
1058
                assert_eq!(entity, effect_entity);
1059
                assert_eq!(particle_effect.handle, handle);
1060

1061
                assert!(effect_spawners.is_some());
1062
                let effect_spawner = effect_spawners.unwrap();
1063
                let actual_spawner = effect_spawner.spawner;
1064

1065
                // Check the spawner ticked
1066
                assert!(effect_spawner.active);
1067
                assert_eq!(effect_spawner.spawn_remainder, 0.);
1068
                assert_eq!(effect_spawner.time, cur_time.as_secs_f32());
1069

1070
                assert_eq!(actual_spawner, test_case.asset_spawner);
1071
                assert_eq!(effect_spawner.spawn_count, 32);
1072
            }
1073
        }
1074
    }
1075
}
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