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

djeedai / bevy_hanabi / 13872470419

15 Mar 2025 11:36AM UTC coverage: 40.087% (+0.08%) from 40.012%
13872470419

Pull #434

github

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

54 of 66 new or added lines in 1 file covered. (81.82%)

1 existing line in 1 file now uncovered.

3221 of 8035 relevant lines covered (40.09%)

18.7 hits per line

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

63.1
/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 {
69✔
98
        match self {
69✔
99
            Self::Single(x) => *x,
69✔
100
            Self::Uniform((a, b)) => Uniform::new_inclusive(*a, *b).sample(rng),
×
101
        }
102
    }
103
}
104

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

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

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

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

146
/// 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 [`spawn_duration`] or less, the system spawns a steady stream
177
    /// of particles.
178
    ///
179
    /// [`spawn_duration`]: Spawner::spawn_duration
180
    period: CpuValue<f32>,
181

182
    /// Number of cycles the spawner is active before completing.
183
    ///
184
    /// Each cycle lasts for `period`. A value of `0` means "infinite", that is
185
    /// the spanwe emits particle forever as long as it's active.
186
    cycle_count: u32,
187

188
    /// Whether the spawner is active at startup.
189
    ///
190
    /// The value is used to initialize [`EffectSpawner::active`].
191
    ///
192
    /// [`EffectSpawner::active`]: crate::EffectSpawner::active
193
    starts_active: bool,
194
}
195

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

202
impl Spawner {
203
    /// Create a spawner.
204
    ///
205
    /// This is the _raw_ constructor. In general you should prefer using one of
206
    /// the utility constructors [`once()`], [`burst()`], or [`rate()`],
207
    /// which will ensure the control parameters are set consistently relative
208
    /// to each other.
209
    ///
210
    /// The control parameters are:
211
    ///
212
    /// - `count` is the number of particles to spawn over `spawn_duration` in a
213
    ///   burst. It can generate negative or zero random values, in which case
214
    ///   no particle is spawned during the current frame.
215
    /// - `spawn_duration` is how long to spawn particles for. If this is <= 0,
216
    ///   then the particles spawn all at once exactly at the same instant.
217
    /// - `period` is the amount of time between bursts of particles. If this is
218
    ///   <= `spawn_duration`, then the spawner spawns a steady stream of
219
    ///   particles. This is ignored if `cycle_count == 1`.
220
    /// - `cycle_count` is the number of times this pattern occurs. Set this to
221
    ///   `0` to repeat forever while the spawner is active.
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
    /// # Panics
238
    ///
239
    /// Panics if `period` can produce a negative number (the sample range lower
240
    /// bound is negative), unless the cycle count is exactly 1, in which case
241
    /// the period is irrelevant.
242
    ///
243
    /// Panics if `period` can only produce 0 (the sample range upper bound
244
    /// is not strictly positive), unless the cycle count is exactly 1, in which
245
    /// case the period is irrelevant.
246
    ///
247
    /// Panics any value is infinite.
248
    ///
249
    /// # Example
250
    ///
251
    /// ```
252
    /// # use bevy_hanabi::Spawner;
253
    /// // Spawn 32 particles over 3 seconds, then pause for 7 seconds (10 - 3),
254
    /// // doing that 5 times in total.
255
    /// let spawner = Spawner::new(32.0.into(), 3.0.into(), 10.0.into(), 5);
256
    /// ```
257
    ///
258
    /// [`once()`]: crate::Spawner::once
259
    /// [`burst()`]: crate::Spawner::burst
260
    /// [`rate()`]: crate::Spawner::rate
261
    pub fn new(
34✔
262
        count: CpuValue<f32>,
263
        spawn_duration: CpuValue<f32>,
264
        period: CpuValue<f32>,
265
        cycle_count: u32,
266
    ) -> Self {
267
        assert!(
34✔
268
            cycle_count == 1 || period.range()[0] >= 0.,
47✔
269
            "`period` must not generate negative numbers (period.min was {}, expected >= 0).",
1✔
270
            period.range()[0]
1✔
271
        );
272
        assert!(
33✔
273
            cycle_count == 1 || period.range()[1] > 0.,
45✔
274
            "`period` must be able to generate a positive number (period.max was {}, expected > 0).",
1✔
275
            period.range()[1]
1✔
276
        );
277
        assert!(
32✔
278
            period.range()[0].is_finite() && period.range()[1].is_finite(),
64✔
NEW
279
            "`period` {:?} has an infinite bound. If upgrading from a previous version, use `cycle_count = 1` instead for a single-cycle burst.",
×
280
            period
281
        );
282

283
        Self {
284
            count,
285
            spawn_duration,
286
            period,
287
            cycle_count,
288
            starts_active: true,
289
        }
290
    }
291

292
    /// Create a spawner that spawns a burst of particles once.
293
    ///
294
    /// The burst of particles is spawned all at once in the same frame. After
295
    /// that, the spawner deactivates, waiting to be manually reset via
296
    /// [`EffectSpawner::reset()`] and re-activated with
297
    /// [`EffectSpawner::set_active(true)`].
298
    ///
299
    /// This is a convenience for:
300
    ///
301
    /// ```
302
    /// # use bevy_hanabi::{Spawner, CpuValue};
303
    /// # let count = CpuValue::Single(1.);
304
    /// Spawner::new(count, 0.0.into(), 0.0.into(), 1);
305
    /// ```
306
    ///
307
    /// # Example
308
    ///
309
    /// ```
310
    /// # use bevy_hanabi::Spawner;
311
    /// // Spawn 32 particles in a burst once immediately on creation.
312
    /// let spawner = Spawner::once(32.0.into(), true);
313
    /// ```
314
    ///
315
    /// [`EffectSpawner::set_active(true)`]: crate::EffectSpawner::set_active
316
    pub fn once(count: CpuValue<f32>) -> Self {
21✔
317
        Self::new(count, 0.0.into(), 0.0.into(), 1)
21✔
318
    }
319

320
    /// Get whether this spawner emits a single burst.
321
    ///
322
    /// This is true if the cycle count is exactly equal to 1.
323
    pub fn is_once(&self) -> bool {
26✔
324
        self.cycle_count == 1
26✔
325
    }
326

327
    /// Get whether this spawner emits particles forever while active.
328
    ///
329
    /// This is true if the cycle count is exactly equal to 0.
330
    pub fn is_forever(&self) -> bool {
56✔
331
        self.cycle_count == 0
56✔
332
    }
333

334
    /// Create a spawner that spawns particles at `rate`, accumulated each
335
    /// frame. `rate` is in particles per second.
336
    ///
337
    /// This is a convenience for:
338
    ///
339
    /// ```
340
    /// # use bevy_hanabi::{Spawner, CpuValue};
341
    /// # let rate = CpuValue::Single(1.);
342
    /// Spawner::new(rate, 1.0.into(), 1.0.into(), 0);
343
    /// ```
344
    ///
345
    /// # Example
346
    ///
347
    /// ```
348
    /// # use bevy_hanabi::Spawner;
349
    /// // Spawn 10 particles per second, indefinitely.
350
    /// let spawner = Spawner::rate(10.0.into());
351
    /// ```
352
    pub fn rate(rate: CpuValue<f32>) -> Self {
9✔
353
        Self::new(rate, 1.0.into(), 1.0.into(), 0)
9✔
354
    }
355

356
    /// Create a spawner that spawns `count` particles, waits `period` seconds,
357
    /// and repeats forever.
358
    ///
359
    /// This is a convenience for:
360
    ///
361
    /// ```
362
    /// # use bevy_hanabi::{Spawner, CpuValue};
363
    /// # let count = CpuValue::Single(1.);
364
    /// # let period = CpuValue::Single(1.);
365
    /// Spawner::new(count, 0.0.into(), period, 0);
366
    /// ```
367
    ///
368
    /// # Example
369
    ///
370
    /// ```
371
    /// # use bevy_hanabi::Spawner;
372
    /// // Spawn a burst of 5 particles every 3 seconds, indefinitely.
373
    /// let spawner = Spawner::burst(5.0.into(), 3.0.into());
374
    /// ```
375
    pub fn burst(count: CpuValue<f32>, period: CpuValue<f32>) -> Self {
1✔
376
        Self::new(count, 0.0.into(), period, 0)
1✔
377
    }
378

379
    /// Set the number of particles that are spawned each cycle.
380
    pub fn with_count(mut self, count: CpuValue<f32>) -> Self {
×
381
        self.count = count;
×
382
        self
×
383
    }
384

385
    /// Set the number of particles that are spawned each cycle.
386
    pub fn set_count(&mut self, count: CpuValue<f32>) {
×
387
        self.count = count;
×
388
    }
389

390
    /// Get the number of particles that are spawned each cycle.
391
    pub fn count(&self) -> CpuValue<f32> {
×
392
        self.count
×
393
    }
394

395
    /// Set the duration, in seconds, of the spawn time each cycle.
396
    pub fn with_spawn_time(mut self, spawn_duration: CpuValue<f32>) -> Self {
×
397
        self.spawn_duration = spawn_duration;
×
398
        self
×
399
    }
400

401
    /// Set the duration, in seconds, of the spawn time each cycle.
402
    pub fn set_spawn_time(&mut self, spawn_duration: CpuValue<f32>) {
×
403
        self.spawn_duration = spawn_duration;
×
404
    }
405

406
    /// Get the duration, in seconds, of spawn time each cycle.
407
    pub fn spawn_duration(&self) -> CpuValue<f32> {
×
408
        self.spawn_duration
×
409
    }
410

411
    /// Set the duration of a spawn cycle, in seconds.
412
    ///
413
    /// A spawn cycle includes the [`spawn_duration()`] value, and any extra
414
    /// wait time (if larger than spawn time).
415
    ///
416
    /// # Panics
417
    ///
418
    /// Panics if the period is infinite.
419
    ///
420
    /// [`spawn_duration()`]: Self::spawn_duration
421
    pub fn with_period(mut self, period: CpuValue<f32>) -> Self {
×
NEW
422
        assert!(
×
NEW
423
            period.range()[0].is_finite() && period.range()[1].is_finite(),
×
NEW
424
            "`period` {:?} has an infinite bound. If upgrading from a previous version, use `cycle_count = 1` instead for a single-cycle burst.",
×
425
            period
426
        );
427
        self.period = period;
×
428
        self
×
429
    }
430

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

450
    /// Get the duration of the spawn cycle, in seconds.
451
    ///
452
    /// A spawn cycle includes the [`spawn_duration()`] value, and any extra
453
    /// wait time (if larger than spawn time).
454
    ///
455
    /// [`spawn_duration()`]: Self::spawn_duration
456
    pub fn period(&self) -> CpuValue<f32> {
×
457
        self.period
×
458
    }
459

460
    /// Set the number of cycles to spawn for.
461
    ///
462
    /// A spawn cycle includes the [`spawn_duration()`] value, and any extra
463
    /// wait time (if larger than spawn time). It lasts for [`period()`].
464
    ///
465
    /// [`spawn_duration()`]: Self::spawn_duration
466
    /// [`period()`]: Self::period
NEW
467
    pub fn with_cycle_count(mut self, cycle_count: u32) -> Self {
×
NEW
468
        self.cycle_count = cycle_count;
×
NEW
469
        self
×
470
    }
471

472
    /// Set the number of cycles to spawn for.
473
    ///
474
    /// A spawn cycle includes the [`spawn_duration()`] value, and any extra
475
    /// wait time (if larger than spawn time). It lasts for [`period()`].
476
    ///
477
    /// [`spawn_duration()`]: Self::spawn_duration
478
    /// [`period()`]: Self::period
NEW
479
    pub fn set_cycle_count(&mut self, cycle_count: u32) {
×
NEW
480
        self.cycle_count = cycle_count;
×
481
    }
482

483
    /// Get the number of cycles to spawn for.
484
    ///
485
    /// A spawn cycle includes the [`spawn_duration()`] value, and any extra
486
    /// wait time (if larger than spawn time). It lasts for [`period()`].
487
    ///
488
    /// [`spawn_duration()`]: Self::spawn_duration
489
    /// [`period()`]: Self::period
490
    pub fn cycle_count(&self) -> u32 {
25✔
491
        self.cycle_count
25✔
492
    }
493

494
    /// Sets whether the spawner starts active when the effect is instantiated.
495
    ///
496
    /// This value will be transfered to the active state of the
497
    /// [`EffectSpawner`] once it's instantiated. Inactive spawners do not spawn
498
    /// any particle.
499
    pub fn with_starts_active(mut self, starts_active: bool) -> Self {
2✔
500
        self.starts_active = starts_active;
2✔
501
        self
2✔
502
    }
503

504
    /// Set whether the spawner starts active when the effect is instantiated.
505
    ///
506
    /// This value will be transfered to the active state of the
507
    /// [`EffectSpawner`] once it's instantiated. Inactive spawners do not spawn
508
    /// any particle.
509
    pub fn set_starts_active(&mut self, starts_active: bool) {
×
510
        self.starts_active = starts_active;
×
511
    }
512

513
    /// Get whether the spawner starts active when the effect is instantiated.
514
    ///
515
    /// This value will be transfered to the active state of the
516
    /// [`EffectSpawner`] once it's instantiated. Inactive spawners do not spawn
517
    /// any particle.
518
    pub fn starts_active(&self) -> bool {
13✔
519
        self.starts_active
13✔
520
    }
521
}
522

523
/// Runtime structure maintaining the state of the spawner for a particle group.
524
#[derive(Debug, Default, Clone, Copy, PartialEq, Component, Reflect)]
525
#[reflect(Component)]
526
pub struct EffectSpawner {
527
    /// The spawner configuration extracted from the [`EffectAsset`], or
528
    /// directly overriden by the user.
529
    pub spawner: Spawner,
530

531
    /// Accumulated time for the current (partial) cycle, in seconds.
532
    cycle_time: f32,
533

534
    /// Number of cycles already completed.
535
    completed_cycle_count: u32,
536

537
    /// Sampled value of `spawn_duration` until `period` is reached. This is the
538
    /// duration of the "active" period during which we spawn particles, as
539
    /// opposed to the "wait" period during which we do nothing until the next
540
    /// spawn cycle.
541
    sampled_spawn_duration: f32,
542

543
    /// Sampled value of the time period, in seconds, until the next spawn
544
    /// cycle.
545
    sampled_period: f32,
546

547
    /// Sampled value of the number of particles to spawn per `spawn_duration`.
548
    sampled_count: f32,
549

550
    /// Number of particles to spawn this frame.
551
    ///
552
    /// This value is normally updated by calling [`tick()`], which
553
    /// automatically happens once per frame when the [`tick_initializers()`]
554
    /// system runs in the [`PostUpdate`] schedule.
555
    ///
556
    /// You can manually assign this value to override the one calculated by
557
    /// [`tick()`]. Note in this case that you need to override the value after
558
    /// the automated one was calculated, by ordering your system
559
    /// after [`tick_spawners()`] or [`EffectSystems::TickSpawners`].
560
    ///
561
    /// [`tick()`]: crate::EffectSpawner::tick
562
    /// [`EffectSystems::TickSpawners`]: crate::EffectSystems::TickSpawners
563
    pub spawn_count: u32,
564

565
    /// Fractional remainder of particle count to spawn.
566
    ///
567
    /// This is accumulated each tick, and the integral part is added to
568
    /// `spawn_count`. The reminder gets saved for next frame.
569
    spawn_remainder: f32,
570

571
    /// Whether the spawner is active. Defaults to `true`. An inactive spawner
572
    /// doesn't tick (no particle spawned, no internal time updated).
573
    active: bool,
574
}
575

576
impl EffectSpawner {
577
    /// Create a new spawner state from a [`Spawner`].
578
    pub fn new(spawner: &Spawner) -> Self {
11✔
579
        Self {
580
            spawner: *spawner,
11✔
581
            cycle_time: 0.,
582
            completed_cycle_count: 0,
583
            sampled_spawn_duration: 0.,
584
            sampled_period: 0.,
585
            sampled_count: 0.,
586
            spawn_count: 0,
587
            spawn_remainder: 0.,
588
            active: spawner.starts_active(),
11✔
589
        }
590
    }
591

592
    /// Set whether the spawner is active.
593
    ///
594
    /// Inactive spawners do not tick, and therefore do not spawn any particle.
595
    pub fn with_active(mut self, active: bool) -> Self {
×
596
        self.active = active;
×
597
        self
×
598
    }
599

600
    /// Set whether the spawner is active.
601
    ///
602
    /// Inactive spawners do not tick, and therefore do not spawn any particle.
603
    pub fn set_active(&mut self, active: bool) {
6✔
604
        self.active = active;
6✔
605
    }
606

607
    /// Get whether the spawner is active.
608
    ///
609
    /// Inactive spawners do not tick, and therefore do not spawn any particle.
610
    pub fn is_active(&self) -> bool {
14✔
611
        self.active
14✔
612
    }
613

614
    /// Get the number of completed cycles since last [`reset()`].
615
    ///
616
    /// The value loops back if the pattern repeats forever
617
    /// ([`Spawner::is_forever()`] is `true`).
618
    ///
619
    /// [`reset()`]: Self::reset
620
    pub fn completed_cycle_count(&self) -> u32 {
5✔
621
        self.completed_cycle_count
5✔
622
    }
623

624
    /// Reset the spawner state.
625
    ///
626
    /// This resets the internal spawner time to zero, and restarts any internal
627
    /// particle counter.
628
    ///
629
    /// Use this, for example, to immediately spawn some particles in a spawner
630
    /// constructed with [`Spawner::once`].
631
    ///
632
    /// [`Spawner::once`]: crate::Spawner::once
633
    pub fn reset(&mut self) {
2✔
634
        self.cycle_time = 0.;
2✔
635
        self.completed_cycle_count = 0;
2✔
636
        self.sampled_spawn_duration = 0.;
2✔
637
        self.sampled_period = 0.;
2✔
638
        self.sampled_count = 0.;
2✔
639
        self.spawn_count = 0;
2✔
640
        self.spawn_remainder = 0.;
2✔
641
    }
642

643
    /// Tick the spawner to calculate the number of particles to spawn this
644
    /// frame.
645
    ///
646
    /// The frame delta time `dt` is added to the current spawner time, before
647
    /// the spawner calculates the number of particles to spawn.
648
    ///
649
    /// This method is called automatically by [`tick_initializers()`] during
650
    /// the [`PostUpdate`], so you normally don't have to call it yourself
651
    /// manually.
652
    ///
653
    /// # Returns
654
    ///
655
    /// The integral number of particles to spawn this frame. Any fractional
656
    /// remainder is saved for the next call.
657
    pub fn tick(&mut self, mut dt: f32, rng: &mut Pcg32) -> u32 {
40✔
658
        // Handle max cycle count here, which might have been reached last frame, but we
659
        // couldn't deactivate before the end of the frame.
660
        if !self.spawner.is_forever() && (self.completed_cycle_count >= self.spawner.cycle_count())
56✔
661
        {
662
            self.active = false;
3✔
663
        }
664

665
        if !self.active {
40✔
666
            self.spawn_count = 0;
8✔
667
            return 0;
8✔
668
        }
669

670
        // Use a loop in case the timestep dt spans multiple cycles
671
        loop {
672
            // Check if this is a new cycle which needs resampling
673
            if self.sampled_period == 0.0 {
38✔
674
                if self.spawner.is_once() {
26✔
675
                    self.sampled_spawn_duration = self.spawner.spawn_duration.sample(rng);
7✔
676
                    // Period is unchecked, should be ignored (could sample to <= 0). Use the spawn
677
                    // duration, but ensure we have something > 0 as a marker that we've resampled.
678
                    self.sampled_period = self.sampled_spawn_duration.max(1e-12);
7✔
679
                } else {
680
                    self.sampled_period = self.spawner.period.sample(rng);
12✔
681
                    assert!(self.sampled_period > 0.);
12✔
682
                    self.sampled_spawn_duration = self
12✔
683
                        .spawner
12✔
684
                        .spawn_duration
12✔
685
                        .sample(rng)
12✔
686
                        .clamp(0., self.sampled_period);
12✔
687
                }
688
                self.sampled_spawn_duration = self.spawner.spawn_duration.sample(rng);
19✔
689
                self.sampled_count = self.spawner.count.sample(rng).max(0.);
19✔
690
            }
691

692
            let new_time = self.cycle_time + dt;
38✔
693

694
            // If inside the spawn period, accumulate some particle spawn count
695
            if self.cycle_time <= self.sampled_spawn_duration {
38✔
696
                // If the spawn time is very small, close to zero, spawn all particles
697
                // immediately in one burst over a single frame.
698
                self.spawn_remainder += if self.sampled_spawn_duration < 1e-5f32.max(dt / 100.0) {
34✔
699
                    self.sampled_count
10✔
700
                } else {
701
                    // Spawn an amount of particles equal to the fraction of time the current frame
702
                    // spans compared to the total burst duration.
703
                    let ratio = ((new_time.min(self.sampled_spawn_duration) - self.cycle_time)
24✔
704
                        / self.sampled_spawn_duration)
24✔
705
                        .clamp(0., 1.);
24✔
706
                    self.sampled_count * ratio
24✔
707
                };
708
            }
709

710
            // Increment current time
711
            self.cycle_time = new_time;
712

713
            // Check for cycle completion
714
            if self.cycle_time >= self.sampled_period {
715
                dt = self.cycle_time - self.sampled_period;
14✔
716
                self.cycle_time = 0.0;
14✔
717
                self.completed_cycle_count += 1;
14✔
718

719
                // Mark as "need resampling"
720
                self.sampled_period = 0.0;
14✔
721

722
                // If this was the last cycle, we're done
723
                if !self.spawner.is_forever()
14✔
724
                    && (self.completed_cycle_count >= self.spawner.cycle_count())
9✔
725
                {
726
                    // Don't deactivate quite yet, otherwise we'll miss the spawns for this frame
727
                    break;
8✔
728
                }
729
            } else {
730
                // We're done for this frame
731
                break;
24✔
732
            }
733
        }
734

735
        // Extract integral number of particles to spawn this frame, keep remainder for
736
        // next one
737
        let count = self.spawn_remainder.floor();
32✔
738
        self.spawn_remainder -= count;
32✔
739
        self.spawn_count = count as u32;
32✔
740

741
        self.spawn_count
32✔
742
    }
743
}
744

745
/// Tick all the [`EffectSpawner`] components.
746
///
747
/// This system runs in the [`PostUpdate`] stage, after the visibility system
748
/// has updated the [`InheritedVisibility`] of each effect instance (see
749
/// [`VisibilitySystems::VisibilityPropagate`]). Hidden instances are not
750
/// updated, unless the [`EffectAsset::simulation_condition`]
751
/// is set to [`SimulationCondition::Always`]. If no [`InheritedVisibility`] is
752
/// present, the effect is assumed to be visible.
753
///
754
/// Note that by that point the [`ViewVisibility`] is not yet calculated, and it
755
/// may happen that spawners are ticked but no effect is visible in any view
756
/// even though some are "visible" (active) in the [`World`]. The actual
757
/// per-view culling of invisible (not in view) effects is performed later on
758
/// the render world.
759
///
760
/// Once the system determined that the effect instance needs to be simulated
761
/// this frame, it ticks the effect's spawner by calling
762
/// [`EffectSpawner::tick()`], adding a new [`EffectSpawner`] component if it
763
/// doesn't already exist on the same entity as the [`ParticleEffect`].
764
///
765
/// [`VisibilitySystems::VisibilityPropagate`]: bevy::render::view::VisibilitySystems::VisibilityPropagate
766
/// [`EffectAsset::simulation_condition`]: crate::EffectAsset::simulation_condition
767
pub fn tick_spawners(
3✔
768
    mut commands: Commands,
769
    time: Res<Time<EffectSimulation>>,
770
    effects: Res<Assets<EffectAsset>>,
771
    mut rng: ResMut<Random>,
772
    mut query: Query<(
773
        Entity,
774
        &ParticleEffect,
775
        &InheritedVisibility,
776
        Option<&mut EffectSpawner>,
777
    )>,
778
) {
779
    trace!("tick_spawners()");
3✔
780

781
    let dt = time.delta_secs();
3✔
782

783
    for (entity, effect, inherited_visibility, maybe_spawner) in query.iter_mut() {
3✔
784
        let Some(asset) = effects.get(&effect.handle) else {
6✔
785
            trace!(
×
786
                "Effect asset with handle {:?} is not available; skipped initializers tick.",
×
787
                effect.handle
788
            );
789
            continue;
×
790
        };
791

792
        if asset.simulation_condition == SimulationCondition::WhenVisible
793
            && !inherited_visibility.get()
2✔
794
        {
795
            trace!(
1✔
796
                "Effect asset with handle {:?} is not visible, and simulates only WhenVisible; skipped initializers tick.",
×
797
                effect.handle
798
            );
799
            continue;
1✔
800
        }
801

802
        if let Some(mut effect_spawner) = maybe_spawner {
×
803
            effect_spawner.tick(dt, &mut rng.0);
×
804
            continue;
×
805
        }
806

807
        let effect_spawner = {
2✔
808
            let mut effect_spawner = EffectSpawner::new(&asset.spawner);
2✔
809
            effect_spawner.tick(dt, &mut rng.0);
2✔
810
            effect_spawner
2✔
811
        };
812
        commands.entity(entity).insert(effect_spawner);
2✔
813
    }
814
}
815

816
#[cfg(test)]
817
mod test {
818
    use std::time::Duration;
819

820
    use bevy::{
821
        asset::{
822
            io::{
823
                memory::{Dir, MemoryAssetReader},
824
                AssetSourceBuilder, AssetSourceBuilders, AssetSourceId,
825
            },
826
            AssetServerMode,
827
        },
828
        render::view::{VisibilityPlugin, VisibilitySystems},
829
        tasks::{IoTaskPool, TaskPoolBuilder},
830
    };
831

832
    use super::*;
833
    use crate::Module;
834

835
    #[test]
836
    fn test_range_single() {
837
        let value = CpuValue::Single(1.0);
838
        assert_eq!(value.range(), [1.0, 1.0]);
839
    }
840

841
    #[test]
842
    fn test_range_uniform() {
843
        let value = CpuValue::Uniform((1.0, 3.0));
844
        assert_eq!(value.range(), [1.0, 3.0]);
845
    }
846

847
    #[test]
848
    fn test_range_uniform_reverse() {
849
        let value = CpuValue::Uniform((3.0, 1.0));
850
        assert_eq!(value.range(), [1.0, 3.0]);
851
    }
852

853
    #[test]
854
    fn test_new() {
855
        let rng = &mut new_rng();
856
        // 3 particles over 3 seconds, pause 7 seconds (total 10 seconds period). 2
857
        // cycles.
858
        let spawner = Spawner::new(3.0.into(), 3.0.into(), 10.0.into(), 2);
859
        let mut spawner = EffectSpawner::new(&spawner);
860
        let count = spawner.tick(2.0, rng); // t = 2s
861
        assert_eq!(count, 2);
862
        assert!(spawner.is_active());
863
        assert_eq!(spawner.completed_cycle_count(), 0);
864
        let count = spawner.tick(5.0, rng); // t = 7s
865
        assert_eq!(count, 1);
866
        assert!(spawner.is_active());
867
        assert_eq!(spawner.completed_cycle_count(), 0);
868
        let count = spawner.tick(8.0, rng); // t = 15s
869
        assert_eq!(count, 3);
870
        assert!(spawner.is_active());
871
        assert_eq!(spawner.completed_cycle_count(), 1);
872
        let count = spawner.tick(10.0, rng); // t = 25s
873
        assert_eq!(count, 0);
874
        assert!(spawner.is_active()); // still active for one frame
875
        assert_eq!(spawner.completed_cycle_count(), 2);
876
        let count = spawner.tick(0.1, rng); // t = 25.1s
877
        assert_eq!(count, 0);
878
        assert!(!spawner.is_active());
879
        assert_eq!(spawner.completed_cycle_count(), 2);
880
    }
881

882
    #[test]
883
    #[should_panic]
884
    fn test_new_panic_negative_period() {
885
        let _ = Spawner::new(3.0.into(), 1.0.into(), CpuValue::Uniform((-1., 1.)), 0);
886
    }
887

888
    #[test]
889
    #[should_panic]
890
    fn test_new_panic_zero_period() {
891
        let _ = Spawner::new(3.0.into(), 1.0.into(), CpuValue::Uniform((0., 0.)), 0);
892
    }
893

894
    #[test]
895
    fn test_once() {
896
        let rng = &mut new_rng();
897
        let spawner = Spawner::once(5.0.into());
898
        assert!(spawner.is_once());
899
        let mut spawner = EffectSpawner::new(&spawner);
900
        assert!(spawner.is_active());
901
        let count = spawner.tick(0.001, rng);
902
        assert_eq!(count, 5);
903
        let count = spawner.tick(100.0, rng);
904
        assert_eq!(count, 0);
905
    }
906

907
    #[test]
908
    fn test_once_reset() {
909
        let rng = &mut new_rng();
910
        let spawner = Spawner::once(5.0.into());
911
        assert!(spawner.is_once());
912
        assert!(spawner.starts_active());
913
        let mut spawner = EffectSpawner::new(&spawner);
914
        spawner.tick(1.0, rng);
915
        spawner.reset();
916
        let count = spawner.tick(1.0, rng);
917
        assert_eq!(count, 5);
918
    }
919

920
    #[test]
921
    fn test_once_start_inactive() {
922
        let rng = &mut new_rng();
923

924
        let spawner = Spawner::once(5.0.into()).with_starts_active(false);
925
        assert!(spawner.is_once());
926
        assert!(!spawner.starts_active());
927
        let mut spawner = EffectSpawner::new(&spawner);
928

929
        // Inactive; no-op
930
        let count = spawner.tick(1.0, rng);
931
        assert_eq!(count, 0);
932

933
        spawner.set_active(true);
934

935
        // Active; spawns
936
        let count = spawner.tick(1.0, rng);
937
        assert_eq!(count, 5);
938
        assert!(spawner.is_active());
939

940
        // Completed; deactivated itself
941
        let count = spawner.tick(1.0, rng);
942
        assert_eq!(count, 0);
943
        assert!(!spawner.is_active());
944

945
        // Reset internal state, but doesn't activate
946
        spawner.reset();
947
        let count = spawner.tick(1.0, rng);
948
        assert_eq!(count, 0);
949
        assert!(!spawner.is_active());
950

951
        // Now activate again
952
        spawner.set_active(true);
953
        let count = spawner.tick(1.0, rng);
954
        assert_eq!(count, 5);
955
        assert!(spawner.is_active());
956
    }
957

958
    #[test]
959
    fn test_rate() {
960
        let rng = &mut new_rng();
961
        let spawner = Spawner::rate(5.0.into());
962
        assert!(!spawner.is_once());
963
        assert!(spawner.is_forever());
964
        let mut spawner = EffectSpawner::new(&spawner);
965
        // Slightly over 1.0 to avoid edge case
966
        let count = spawner.tick(1.01, rng);
967
        assert_eq!(count, 5);
968
        let count = spawner.tick(0.4, rng);
969
        assert_eq!(count, 2);
970
    }
971

972
    #[test]
973
    fn test_rate_active() {
974
        let rng = &mut new_rng();
975
        let spawner = Spawner::rate(5.0.into());
976
        assert!(!spawner.is_once());
977
        let mut spawner = EffectSpawner::new(&spawner);
978
        spawner.tick(1.01, rng);
979
        spawner.set_active(false);
980
        assert!(!spawner.is_active());
981
        let count = spawner.tick(0.4, rng);
982
        assert_eq!(count, 0);
983
        spawner.set_active(true);
984
        assert!(spawner.is_active());
985
        let count = spawner.tick(0.4, rng);
986
        assert_eq!(count, 2);
987
    }
988

989
    #[test]
990
    fn test_rate_accumulate() {
991
        let rng = &mut new_rng();
992
        let spawner = Spawner::rate(5.0.into());
993
        assert!(!spawner.is_once());
994
        let mut spawner = EffectSpawner::new(&spawner);
995
        // 13 ticks instead of 12 to avoid edge case
996
        let count = (0..13).map(|_| spawner.tick(1.0 / 60.0, rng)).sum::<u32>();
997
        assert_eq!(count, 1);
998
    }
999

1000
    #[test]
1001
    fn test_burst() {
1002
        let rng = &mut new_rng();
1003
        let spawner = Spawner::burst(5.0.into(), 2.0.into());
1004
        assert!(!spawner.is_once());
1005
        assert!(spawner.is_forever());
1006
        let mut spawner = EffectSpawner::new(&spawner);
1007
        let count = spawner.tick(1.0, rng);
1008
        assert_eq!(count, 5);
1009
        let count = spawner.tick(4.0, rng);
1010
        assert_eq!(count, 10);
1011
        let count = spawner.tick(0.1, rng);
1012
        assert_eq!(count, 0);
1013
    }
1014

1015
    #[test]
1016
    fn test_with_active() {
1017
        let rng = &mut new_rng();
1018
        let spawner = Spawner::rate(5.0.into()).with_starts_active(false);
1019
        let mut spawner = EffectSpawner::new(&spawner);
1020
        assert!(!spawner.is_active());
1021
        let count = spawner.tick(1., rng);
1022
        assert_eq!(count, 0);
1023
        spawner.set_active(false); // no-op
1024
        let count = spawner.tick(1., rng);
1025
        assert_eq!(count, 0);
1026
        spawner.set_active(true);
1027
        assert!(spawner.is_active());
1028
        let count = spawner.tick(1., rng);
1029
        assert_eq!(count, 5);
1030
    }
1031

1032
    fn make_test_app() -> App {
1033
        IoTaskPool::get_or_init(|| {
1034
            TaskPoolBuilder::default()
1035
                .num_threads(1)
1036
                .thread_name("Hanabi test IO Task Pool".to_string())
1037
                .build()
1038
        });
1039

1040
        let mut app = App::new();
1041

1042
        let watch_for_changes = false;
1043
        let mut builders = app
1044
            .world_mut()
1045
            .get_resource_or_insert_with::<AssetSourceBuilders>(Default::default);
1046
        let dir = Dir::default();
1047
        let dummy_builder = AssetSourceBuilder::default()
1048
            .with_reader(move || Box::new(MemoryAssetReader { root: dir.clone() }));
1049
        builders.insert(AssetSourceId::Default, dummy_builder);
1050
        let sources = builders.build_sources(watch_for_changes, false);
1051
        let asset_server =
1052
            AssetServer::new(sources, AssetServerMode::Unprocessed, watch_for_changes);
1053

1054
        app.insert_resource(asset_server);
1055
        // app.add_plugins(DefaultPlugins);
1056
        app.init_asset::<Mesh>();
1057
        app.add_plugins(VisibilityPlugin);
1058
        app.init_resource::<Time<EffectSimulation>>();
1059
        app.insert_resource(Random(new_rng()));
1060
        app.init_asset::<EffectAsset>();
1061
        app.add_systems(
1062
            PostUpdate,
1063
            tick_spawners.after(VisibilitySystems::CheckVisibility),
1064
        );
1065

1066
        app
1067
    }
1068

1069
    /// Test case for `tick_initializers()`.
1070
    struct TestCase {
1071
        /// Initial entity visibility on spawn. If `None`, do not add a
1072
        /// [`Visibility`] component.
1073
        visibility: Option<Visibility>,
1074

1075
        /// Spawner assigned to the `EffectAsset`.
1076
        asset_spawner: Spawner,
1077
    }
1078

1079
    impl TestCase {
1080
        fn new(visibility: Option<Visibility>, asset_spawner: Spawner) -> Self {
1081
            Self {
1082
                visibility,
1083
                asset_spawner,
1084
            }
1085
        }
1086
    }
1087

1088
    #[test]
1089
    fn test_tick_spawners() {
1090
        let asset_spawner = Spawner::once(32.0.into());
1091

1092
        for test_case in &[
1093
            TestCase::new(None, asset_spawner),
1094
            TestCase::new(Some(Visibility::Hidden), asset_spawner),
1095
            TestCase::new(Some(Visibility::Visible), asset_spawner),
1096
        ] {
1097
            let mut app = make_test_app();
1098

1099
            let (effect_entity, handle) = {
1100
                let world = app.world_mut();
1101

1102
                // Add effect asset
1103
                let mut assets = world.resource_mut::<Assets<EffectAsset>>();
1104
                let mut asset = EffectAsset::new(64, test_case.asset_spawner, Module::default());
1105
                asset.simulation_condition = if test_case.visibility.is_some() {
1106
                    SimulationCondition::WhenVisible
1107
                } else {
1108
                    SimulationCondition::Always
1109
                };
1110
                let handle = assets.add(asset);
1111

1112
                // Spawn particle effect
1113
                let entity = if let Some(visibility) = test_case.visibility {
1114
                    world
1115
                        .spawn((
1116
                            visibility,
1117
                            InheritedVisibility::default(),
1118
                            ParticleEffect {
1119
                                handle: handle.clone(),
1120
                            },
1121
                        ))
1122
                        .id()
1123
                } else {
1124
                    world
1125
                        .spawn((ParticleEffect {
1126
                            handle: handle.clone(),
1127
                        },))
1128
                        .id()
1129
                };
1130

1131
                // Spawn a camera, otherwise ComputedVisibility stays at HIDDEN
1132
                world.spawn(Camera3d::default());
1133

1134
                (entity, handle)
1135
            };
1136

1137
            // Tick once
1138
            let _cur_time = {
1139
                // Make sure to increment the current time so that the spawners spawn something.
1140
                // Note that `Time` has this weird behavior where the common quantities like
1141
                // `Time::delta_secs()` only update after the *second* update. So we tick the
1142
                // `Time` twice here to enforce this.
1143
                let mut time = app.world_mut().resource_mut::<Time<EffectSimulation>>();
1144
                time.advance_by(Duration::from_millis(16));
1145
                time.elapsed()
1146
            };
1147
            app.update();
1148

1149
            let world = app.world_mut();
1150

1151
            // Check the state of the components after `tick_initializers()` ran
1152
            if let Some(test_visibility) = test_case.visibility {
1153
                // Simulated-when-visible effect (SimulationCondition::WhenVisible)
1154

1155
                let (entity, visibility, inherited_visibility, particle_effect, effect_spawner) =
1156
                    world
1157
                        .query::<(
1158
                            Entity,
1159
                            &Visibility,
1160
                            &InheritedVisibility,
1161
                            &ParticleEffect,
1162
                            Option<&EffectSpawner>,
1163
                        )>()
1164
                        .iter(world)
1165
                        .next()
1166
                        .unwrap();
1167
                assert_eq!(entity, effect_entity);
1168
                assert_eq!(visibility, test_visibility);
1169
                assert_eq!(
1170
                    inherited_visibility.get(),
1171
                    test_visibility == Visibility::Visible
1172
                );
1173
                assert_eq!(particle_effect.handle, handle);
1174
                if inherited_visibility.get() {
1175
                    // If visible, `tick_initializers()` spawns the EffectSpawner and ticks it
1176
                    assert!(effect_spawner.is_some());
1177
                    let effect_spawner = effect_spawner.unwrap();
1178
                    let actual_spawner = effect_spawner.spawner;
1179

1180
                    // Check the spawner ticked
1181
                    assert!(effect_spawner.active); // will get deactivated next tick()
1182
                    assert_eq!(effect_spawner.spawn_remainder, 0.);
1183
                    assert_eq!(effect_spawner.cycle_time, 0.);
1184
                    assert_eq!(effect_spawner.completed_cycle_count, 1);
1185
                    assert_eq!(effect_spawner.spawn_count, 32);
1186

1187
                    assert_eq!(actual_spawner, test_case.asset_spawner);
1188
                } else {
1189
                    // If not visible, `tick_initializers()` skips the effect entirely so won't
1190
                    // spawn an `EffectSpawner` for it
1191
                    assert!(effect_spawner.is_none());
1192
                }
1193
            } else {
1194
                // Always-simulated effect (SimulationCondition::Always)
1195

1196
                let (entity, particle_effect, effect_spawners) = world
1197
                    .query::<(Entity, &ParticleEffect, Option<&EffectSpawner>)>()
1198
                    .iter(world)
1199
                    .next()
1200
                    .unwrap();
1201
                assert_eq!(entity, effect_entity);
1202
                assert_eq!(particle_effect.handle, handle);
1203

1204
                assert!(effect_spawners.is_some());
1205
                let effect_spawner = effect_spawners.unwrap();
1206
                let actual_spawner = effect_spawner.spawner;
1207

1208
                // Check the spawner ticked
1209
                assert!(effect_spawner.active); // will get deactivated next tick()
1210
                assert_eq!(effect_spawner.spawn_remainder, 0.);
1211
                assert_eq!(effect_spawner.cycle_time, 0.);
1212
                assert_eq!(effect_spawner.completed_cycle_count, 1);
1213
                assert_eq!(effect_spawner.spawn_count, 32);
1214

1215
                assert_eq!(actual_spawner, test_case.asset_spawner);
1216
            }
1217
        }
1218
    }
1219
}
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