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_mut_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 let Self { particles, .. }: &Self = self;
218 for particle in particles {
219 let t: f64 = if particle.get_lifetime() > 0.0 {
220 (particle.get_age() / particle.get_lifetime()).min(1.0)
221 } else {
222 1.0
223 };
224 let color: Color = config.get_color_start().lerp(config.get_color_end(), t);
225 let radius: f64 = Numeric::lerp(config.get_size_start(), config.get_size_end(), t);
226 if radius <= 0.0 || color.get_alpha() <= 0.0 {
227 continue;
228 }
229 let bucket: u8 = Self::quantize(&color);
230 let quantized_color: Color = PARTICLE_PALETTE[bucket as usize];
231 draw_list.fill_circle(particle.get_position(), radius, quantized_color);
232 }
233 }
234
235 /// Returns the number of currently live particles.
236 ///
237 /// # Returns
238 ///
239 /// - `usize` - The live particle count.
240 pub fn alive_count(&self) -> usize {
241 let Self { particles, .. }: &Self = self;
242 particles.len()
243 }
244
245 /// Picks the palette index nearest to the given color by squared
246 /// Euclidean distance in 4-channel RGBA space.
247 ///
248 /// Used by the render path to snap each particle's interpolated color
249 /// to one of `PARTICLE_PALETTE_SIZE` preset colors. Quantizing collapses
250 /// the smooth color gradient produced by `Color::lerp` into a small set
251 /// of buckets, so many particles share the same `DrawCommand::FillCircle::color`
252 /// and the DrawList replay loop can coalesce `set_fill_style_str` calls
253 /// across the entire batch instead of switching style for every particle.
254 ///
255 /// # Arguments
256 ///
257 /// - `&Color` - The particle color to quantize.
258 ///
259 /// # Returns
260 ///
261 /// - `u8` - The index into `PARTICLE_PALETTE` of the nearest entry. The
262 /// result fits in `u8` because `PARTICLE_PALETTE_SIZE` is 32.
263 pub(crate) fn quantize(color: &Color) -> u8 {
264 let target_red: f64 = color.get_red();
265 let target_green: f64 = color.get_green();
266 let target_blue: f64 = color.get_blue();
267 let target_alpha: f64 = color.get_alpha();
268 let mut best_index: usize = 0;
269 let mut best_distance: f64 = f64::INFINITY;
270 for (index, candidate) in PARTICLE_PALETTE.iter().enumerate() {
271 let delta_red: f64 = candidate.get_red() - target_red;
272 let delta_green: f64 = candidate.get_green() - target_green;
273 let delta_blue: f64 = candidate.get_blue() - target_blue;
274 let delta_alpha: f64 = candidate.get_alpha() - target_alpha;
275 let distance: f64 = delta_red * delta_red
276 + delta_green * delta_green
277 + delta_blue * delta_blue
278 + delta_alpha * delta_alpha;
279 if distance < best_distance {
280 best_distance = distance;
281 best_index = index;
282 }
283 }
284 best_index as u8
285 }
286
287 /// Removes all live particles without changing the active flag.
288 pub fn clear(&mut self) {
289 self.get_mut_particles().clear();
290 self.set_emit_accumulator(0.0);
291 }
292
293 /// Spawns a single particle with randomized direction, speed, and
294 /// lifetime sampled from the configuration ranges.
295 fn spawn_particle(&mut self) {
296 let config: ParticleConfig = self.get_config();
297 let half_spread: f64 = config.get_spread() / 2.0;
298 let angle: f64 = config.get_angle() + self.get_mut_rng().range(-half_spread, half_spread);
299 let speed: f64 = self
300 .get_mut_rng()
301 .range(config.get_speed_min(), config.get_speed_max());
302 let lifetime: f64 = self
303 .get_mut_rng()
304 .range(config.get_lifetime_min(), config.get_lifetime_max())
305 .max(EPSILON);
306 let position: Vector2D = self.get_position();
307 let particle: Particle = Particle::new(
308 position,
309 Vector2D::from_angle(angle).scaled(speed),
310 0.0,
311 lifetime,
312 );
313 self.get_mut_particles().push(particle);
314 }
315}
316
317/// Forwards `ParticleEmitter::update` through the [`Updatable`] trait so
318/// emitters can participate in the same generic update loop as entities,
319/// animators, scenes, and physics worlds.
320impl Updatable for ParticleEmitter {
321 /// Advances the simulation by `delta_time` seconds.
322 ///
323 /// # Arguments
324 ///
325 /// - `f64` - Seconds elapsed since the previous update.
326 fn update(&mut self, delta_time: f64) {
327 ParticleEmitter::update(self, delta_time);
328 }
329}