Skip to main content

euv_engine/particle/
impl.rs

1use super::*;
2
3/// Implements deterministic pseudo-random number generation for `ParticleRng`.
4impl ParticleRng {
5    /// Creates a generator from the given seed. A zero seed is replaced with
6    /// the default seed, since xorshift degenerates at zero.
7    ///
8    /// # Arguments
9    ///
10    /// - `u64` - The seed value.
11    ///
12    /// # Returns
13    ///
14    /// - `ParticleRng` - The seeded generator.
15    pub fn with_seed(seed: u64) -> ParticleRng {
16        if seed == 0 {
17            ParticleRng::new(PARTICLE_DEFAULT_RNG_SEED)
18        } else {
19            ParticleRng::new(seed)
20        }
21    }
22
23    /// Advances the generator and returns the next 64-bit value.
24    ///
25    /// # Returns
26    ///
27    /// - `u64` - The next pseudo-random value.
28    pub fn next_u64(&mut self) -> u64 {
29        let mut state: u64 = self.get_state();
30        state ^= state >> 12;
31        state ^= state << 25;
32        state ^= state >> 27;
33        self.set_state(state);
34        state.wrapping_mul(0x2545_F491_4F6C_DD1D)
35    }
36
37    /// Returns the next pseudo-random value uniformly distributed in [0.0, 1.0).
38    ///
39    /// # Returns
40    ///
41    /// - `f64` - The next value in the unit interval.
42    pub fn next_f64(&mut self) -> f64 {
43        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
44    }
45
46    /// Returns the next pseudo-random value uniformly distributed in [min, max).
47    ///
48    /// # Arguments
49    ///
50    /// - `f64` - The inclusive lower bound.
51    ///
52    /// # Returns
53    ///
54    /// - `f64` - The next value in the given range.
55    pub fn range(&mut self, min: f64, max: f64) -> f64 {
56        min + (max - min) * self.next_f64()
57    }
58}
59
60/// Implements `Default` for `ParticleRng` using the default seed.
61impl Default for ParticleRng {
62    /// Constructs a default [`ParticleRng`] value.
63    ///
64    /// # Returns
65    ///
66    /// - `ParticleRng` - A default-constructed instance with the documented initial state.
67    fn default() -> ParticleRng {
68        ParticleRng::with_seed(PARTICLE_DEFAULT_RNG_SEED)
69    }
70}
71
72/// Implements `Default` for `ParticleConfig` with sensible engine defaults.
73impl Default for ParticleConfig {
74    /// Constructs a default [`ParticleConfig`] value.
75    ///
76    /// # Returns
77    ///
78    /// - `ParticleConfig` - A default-constructed instance with the documented initial state.
79    fn default() -> ParticleConfig {
80        ParticleConfig {
81            emission_rate: PARTICLE_DEFAULT_EMISSION_RATE,
82            max_particles: PARTICLE_DEFAULT_MAX_COUNT,
83            lifetime_min: PARTICLE_DEFAULT_LIFETIME_MIN,
84            lifetime_max: PARTICLE_DEFAULT_LIFETIME_MAX,
85            speed_min: PARTICLE_DEFAULT_SPEED_MIN,
86            speed_max: PARTICLE_DEFAULT_SPEED_MAX,
87            angle: PARTICLE_DEFAULT_ANGLE,
88            spread: PARTICLE_DEFAULT_SPREAD,
89            gravity: Vector2D::zero(),
90            color_start: Color::white(),
91            color_end: Color::transparent(),
92            size_start: PARTICLE_DEFAULT_SIZE_START,
93            size_end: PARTICLE_DEFAULT_SIZE_END,
94        }
95    }
96}
97
98/// Implements creation, simulation, and rendering for `ParticleEmitter`.
99impl ParticleEmitter {
100    /// Creates an active emitter at the given position with the given config.
101    ///
102    /// # Arguments
103    ///
104    /// - `Vector2D` - The world-space emission point.
105    /// - `ParticleConfig` - The emitter configuration.
106    ///
107    /// # Returns
108    ///
109    /// - `ParticleEmitter` - The new emitter.
110    pub fn create(position: Vector2D, config: ParticleConfig) -> ParticleEmitter {
111        let mut emitter: ParticleEmitter = ParticleEmitter::new(position, config);
112        emitter.set_active(true);
113        emitter
114    }
115
116    /// Creates an active emitter at the given position using the default
117    /// configuration.
118    ///
119    /// # Arguments
120    ///
121    /// - `Vector2D` - The world-space emission point.
122    ///
123    /// # Returns
124    ///
125    /// - `ParticleEmitter` - The new emitter.
126    pub fn with_defaults(position: Vector2D) -> ParticleEmitter {
127        ParticleEmitter::create(position, ParticleConfig::default())
128    }
129
130    /// Advances the emitter by the given delta time: spawns new particles
131    /// from the emission budget while active, integrates motion and gravity,
132    /// and removes expired particles.
133    ///
134    /// Integration and expiry share a single sweep over the live particles.
135    /// A read cursor integrates each particle and a write cursor compacts the
136    /// survivors downward, so the whole update costs one pass instead of the
137    /// integrate pass plus a separate `retain` pass. Survivors keep their
138    /// relative order: a dead slot is only ever refilled by swapping a live
139    /// particle into it, and the element that gets displaced lands past the
140    /// final truncation point. The compaction also moves no memory when
141    /// nothing expired, where `retain` still walks the whole vector.
142    ///
143    /// # Arguments
144    ///
145    /// - `f64` - The time elapsed since the last update, in seconds.
146    pub fn update(&mut self, delta_time: f64) {
147        let delta_time: f64 = delta_time.max(0.0);
148        let config: ParticleConfig = self.get_config();
149        if self.get_active() {
150            *self.get_mut_emit_accumulator() += config.get_emission_rate() * delta_time;
151            let mut spawn_count: usize = self.get_emit_accumulator() as usize;
152            let capacity: usize = config
153                .get_max_particles()
154                .saturating_sub(self.get_mut_particles().len());
155            spawn_count = spawn_count.min(capacity);
156            *self.get_mut_emit_accumulator() -= spawn_count as f64;
157            for _ in 0..spawn_count {
158                self.spawn_particle();
159            }
160        }
161        let gravity_step: Vector2D = config.get_gravity().scaled(delta_time);
162        let particles: &mut Vec<Particle> = self.get_mut_particles();
163        let mut survivors: usize = 0;
164        for index in 0..particles.len() {
165            let expired: bool = {
166                let particle: &mut Particle = &mut particles[index];
167                *particle.get_mut_velocity() += gravity_step;
168                let velocity: Vector2D = particle.get_velocity();
169                *particle.get_mut_position() += velocity.scaled(delta_time);
170                *particle.get_mut_age() += delta_time;
171                particle.get_age() >= particle.get_lifetime()
172            };
173            if !expired {
174                if survivors != index {
175                    particles.swap(survivors, index);
176                }
177                survivors += 1;
178            }
179        }
180        particles.truncate(survivors);
181    }
182
183    /// Spawns the given number of particles immediately, regardless of the
184    /// active flag, clamped to the remaining particle capacity.
185    ///
186    /// # Arguments
187    ///
188    /// - `usize` - The number of particles to spawn.
189    pub fn burst(&mut self, count: usize) {
190        let capacity: usize = self
191            .get_config()
192            .get_max_particles()
193            .saturating_sub(self.get_particles().len());
194        for _ in 0..count.min(capacity) {
195            self.spawn_particle();
196        }
197    }
198
199    /// Records all live particles into the given draw list as filled circles.
200    ///
201    /// Each particle's color and radius are interpolated between the
202    /// configured start and end values by its normalized age, and the
203    /// color is then snapped to the nearest entry in `PARTICLE_PALETTE`
204    /// via `quantize`. Quantizing is visually nearly identical for
205    /// gradient particles (32-bucket quantization of a smooth gradient is
206    /// imperceptible at canvas resolution) and dramatically increases
207    /// the number of particles that share the same `DrawCommand::FillCircle::color`
208    /// field, so the DrawList replay loop coalesces `set_fill_style_str`
209    /// calls across the entire batch instead of switching style for every
210    /// particle.
211    ///
212    /// # Arguments
213    ///
214    /// - `&mut DrawList` - The draw list to record commands into.
215    pub fn render(&self, draw_list: &mut DrawList) {
216        let config: ParticleConfig = self.get_config();
217        for particle in self.get_particles().iter() {
218            let t: f64 = if particle.get_lifetime() > 0.0 {
219                (particle.get_age() / particle.get_lifetime()).min(1.0)
220            } else {
221                1.0
222            };
223            let color: Color = config.get_color_start().lerp(config.get_color_end(), t);
224            let radius: f64 = Numeric::lerp(config.get_size_start(), config.get_size_end(), t);
225            if radius <= 0.0 || color.get_alpha() <= 0.0 {
226                continue;
227            }
228            let bucket: u8 = Self::quantize(&color);
229            let quantized_color: Color = PARTICLE_PALETTE[bucket as usize];
230            draw_list.fill_circle(particle.get_position(), radius, quantized_color);
231        }
232    }
233
234    /// Returns the number of currently live particles.
235    ///
236    /// # Returns
237    ///
238    /// - `usize` - The live particle count.
239    pub fn alive_count(&self) -> usize {
240        self.get_particles().len()
241    }
242
243    /// Picks the palette index nearest to the given color by squared
244    /// Euclidean distance in 4-channel RGBA space.
245    ///
246    /// Used by the render path to snap each particle's interpolated color
247    /// to one of `PARTICLE_PALETTE_SIZE` preset colors. Quantizing collapses
248    /// the smooth color gradient produced by `Color::lerp` into a small set
249    /// of buckets, so many particles share the same `DrawCommand::FillCircle::color`
250    /// and the DrawList replay loop can coalesce `set_fill_style_str` calls
251    /// across the entire batch instead of switching style for every particle.
252    ///
253    /// # Arguments
254    ///
255    /// - `&Color` - The particle color to quantize.
256    ///
257    /// # Returns
258    ///
259    /// - `u8` - The index into `PARTICLE_PALETTE` of the nearest entry. The
260    ///   result fits in `u8` because `PARTICLE_PALETTE_SIZE` is 32.
261    pub(crate) fn quantize(color: &Color) -> u8 {
262        let target_red: f64 = color.get_red();
263        let target_green: f64 = color.get_green();
264        let target_blue: f64 = color.get_blue();
265        let target_alpha: f64 = color.get_alpha();
266        let mut best_index: usize = 0;
267        let mut best_distance: f64 = f64::INFINITY;
268        for (index, candidate) in PARTICLE_PALETTE.iter().enumerate() {
269            let delta_red: f64 = candidate.get_red() - target_red;
270            let delta_green: f64 = candidate.get_green() - target_green;
271            let delta_blue: f64 = candidate.get_blue() - target_blue;
272            let delta_alpha: f64 = candidate.get_alpha() - target_alpha;
273            let distance: f64 = delta_red * delta_red
274                + delta_green * delta_green
275                + delta_blue * delta_blue
276                + delta_alpha * delta_alpha;
277            if distance < best_distance {
278                best_distance = distance;
279                best_index = index;
280            }
281        }
282        best_index as u8
283    }
284
285    /// Removes all live particles without changing the active flag.
286    pub fn clear(&mut self) {
287        self.get_mut_particles().clear();
288        self.set_emit_accumulator(0.0);
289    }
290
291    /// Spawns a single particle with randomized direction, speed, and
292    /// lifetime sampled from the configuration ranges.
293    fn spawn_particle(&mut self) {
294        let config: ParticleConfig = self.get_config();
295        let half_spread: f64 = config.get_spread() / 2.0;
296        let angle: f64 = config.get_angle() + self.get_mut_rng().range(-half_spread, half_spread);
297        let speed: f64 = self
298            .get_mut_rng()
299            .range(config.get_speed_min(), config.get_speed_max());
300        let lifetime: f64 = self
301            .get_mut_rng()
302            .range(config.get_lifetime_min(), config.get_lifetime_max())
303            .max(EPSILON);
304        let position: Vector2D = self.get_position();
305        let particle: Particle = Particle::new(
306            position,
307            Vector2D::from_angle(angle).scaled(speed),
308            0.0,
309            lifetime,
310        );
311        self.get_mut_particles().push(particle);
312    }
313}
314
315/// Forwards `ParticleEmitter::update` through the [`Updatable`] trait so
316/// emitters can participate in the same generic update loop as entities,
317/// animators, scenes, and physics worlds.
318impl Updatable for ParticleEmitter {
319    /// Advances the simulation by `delta_time` seconds.
320    ///
321    /// # Arguments
322    ///
323    /// - `f64` - Seconds elapsed since the previous update.
324    fn update(&mut self, delta_time: f64) {
325        ParticleEmitter::update(self, delta_time);
326    }
327}