1pub mod emitters;
8pub mod flock;
9pub mod gpu_particles;
10pub mod particle_render;
11
12use crate::glyph::{Glyph, RenderLayer};
13use crate::math::{MathFunction, ForceField, Falloff, AttractorType};
14use crate::math::fields::falloff_factor;
15use glam::{Vec2, Vec3, Vec4, Mat4};
16use std::collections::HashMap;
17
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
22pub struct ParticleFlags(pub u32);
23
24impl ParticleFlags {
25 pub const COLLIDES: Self = ParticleFlags(0x0001);
26 pub const GRAVITY: Self = ParticleFlags(0x0002);
27 pub const AFFECTED_BY_FIELDS: Self = ParticleFlags(0x0004);
28 pub const EMIT_ON_DEATH: Self = ParticleFlags(0x0008);
29 pub const ATTRACTOR: Self = ParticleFlags(0x0010);
30 pub const TRAIL_EMITTER: Self = ParticleFlags(0x0020);
31 pub const WORLD_SPACE: Self = ParticleFlags(0x0040);
32 pub const STRETCH: Self = ParticleFlags(0x0080);
33 pub const GPU_SIMULATED: Self = ParticleFlags(0x0100);
34
35 pub fn empty() -> Self { Self(0) }
36 pub fn contains(self, other: Self) -> bool { (self.0 & other.0) == other.0 }
37 pub fn insert(&mut self, other: Self) { self.0 |= other.0; }
38 pub fn remove(&mut self, other: Self) { self.0 &= !other.0; }
39}
40
41impl std::ops::BitOr for ParticleFlags {
42 type Output = Self;
43 fn bitor(self, rhs: Self) -> Self { Self(self.0 | rhs.0) }
44}
45
46impl std::ops::BitOrAssign for ParticleFlags {
47 fn bitor_assign(&mut self, rhs: Self) { self.0 |= rhs.0; }
48}
49
50#[derive(Clone)]
54pub struct MathParticle {
55 pub glyph: Glyph,
56 pub behavior: MathFunction,
57 pub trail: bool,
58 pub trail_length: u8,
59 pub trail_decay: f32,
60 pub interaction: ParticleInteraction,
61 pub origin: Vec3,
63 pub age: f32,
64 pub lifetime: f32,
65 pub velocity: Vec3,
66 pub acceleration: Vec3,
67 pub drag: f32,
68 pub spin: f32,
69 pub scale: f32,
70 pub scale_over_life: Option<ScaleCurve>,
71 pub color_over_life: Option<ColorGradient>,
72 pub size_over_life: Option<FloatCurve>,
73 pub group: Option<u32>,
74 pub sub_emitter: Option<Box<SubEmitterRef>>,
75 pub flags: ParticleFlags,
76 pub user_data: [f32; 4],
77}
78
79impl Default for MathParticle {
80 fn default() -> Self {
81 Self {
82 glyph: Glyph::default(),
83 behavior: MathFunction::Sine { amplitude: 1.0, frequency: 1.0, phase: 0.0 },
84 trail: false,
85 trail_length: 0,
86 trail_decay: 0.5,
87 interaction: ParticleInteraction::None,
88 origin: Vec3::ZERO,
89 age: 0.0,
90 lifetime: 2.0,
91 velocity: Vec3::ZERO,
92 acceleration: Vec3::ZERO,
93 drag: 0.01,
94 spin: 0.0,
95 scale: 1.0,
96 scale_over_life: None,
97 color_over_life: None,
98 size_over_life: None,
99 group: None,
100 sub_emitter: None,
101 flags: ParticleFlags::empty(),
102 user_data: [0.0; 4],
103 }
104 }
105}
106
107#[derive(Clone, Debug)]
109pub enum ParticleInteraction {
110 None,
111 Attract(f32),
112 Repel(f32),
113 Flock {
114 alignment: f32,
115 cohesion: f32,
116 separation: f32,
117 radius: f32,
118 },
119 Chain(f32),
121 Orbit { center: Vec3, radius: f32, speed: f32 },
123 Spring { target: Vec3, stiffness: f32, damping: f32 },
125}
126
127#[derive(Clone, Debug)]
129pub struct SubEmitterRef {
130 pub preset: Box<EmitterPreset>,
131 pub count: u8,
132 pub inherit_velocity: bool,
133 pub inherit_color: bool,
134}
135
136#[derive(Clone, Debug)]
140pub struct FloatCurve {
141 keys: Vec<(f32, f32)>, }
143
144impl FloatCurve {
145 pub fn new(keys: Vec<(f32, f32)>) -> Self {
146 let mut k = keys;
147 k.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
148 Self { keys: k }
149 }
150
151 pub fn constant(v: f32) -> Self { Self::new(vec![(0.0, v), (1.0, v)]) }
152 pub fn linear(from: f32, to: f32) -> Self { Self::new(vec![(0.0, from), (1.0, to)]) }
153 pub fn ease_in_out(from: f32, to: f32) -> Self {
154 Self::new(vec![(0.0, from), (0.5, (from + to) * 0.5), (1.0, to)])
155 }
156
157 pub fn evaluate(&self, t: f32) -> f32 {
158 if self.keys.is_empty() { return 0.0; }
159 if t <= self.keys[0].0 { return self.keys[0].1; }
160 if t >= self.keys[self.keys.len()-1].0 { return self.keys[self.keys.len()-1].1; }
161 for i in 1..self.keys.len() {
162 if t <= self.keys[i].0 {
163 let (t0, v0) = self.keys[i-1];
164 let (t1, v1) = self.keys[i];
165 let f = (t - t0) / (t1 - t0);
166 return v0 + (v1 - v0) * f;
167 }
168 }
169 self.keys.last().unwrap().1
170 }
171}
172
173#[derive(Clone, Debug)]
175pub struct ColorGradient {
176 keys: Vec<(f32, Vec4)>,
177}
178
179impl ColorGradient {
180 pub fn new(keys: Vec<(f32, Vec4)>) -> Self {
181 let mut k = keys;
182 k.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
183 Self { keys: k }
184 }
185
186 pub fn constant(c: Vec4) -> Self { Self::new(vec![(0.0, c), (1.0, c)]) }
187 pub fn fade_out(c: Vec4) -> Self {
188 Self::new(vec![(0.0, c), (0.8, c), (1.0, Vec4::new(c.x, c.y, c.z, 0.0))])
189 }
190 pub fn fire() -> Self {
191 Self::new(vec![
192 (0.0, Vec4::new(1.0, 1.0, 0.2, 1.0)),
193 (0.3, Vec4::new(1.0, 0.4, 0.0, 0.9)),
194 (0.7, Vec4::new(0.5, 0.1, 0.0, 0.5)),
195 (1.0, Vec4::new(0.2, 0.0, 0.0, 0.0)),
196 ])
197 }
198 pub fn plasma() -> Self {
199 Self::new(vec![
200 (0.0, Vec4::new(0.2, 0.0, 1.0, 1.0)),
201 (0.3, Vec4::new(0.8, 0.0, 1.0, 0.9)),
202 (0.7, Vec4::new(1.0, 0.2, 0.8, 0.5)),
203 (1.0, Vec4::new(1.0, 0.8, 1.0, 0.0)),
204 ])
205 }
206 pub fn electric() -> Self {
207 Self::new(vec![
208 (0.0, Vec4::new(0.5, 0.8, 1.0, 1.0)),
209 (0.5, Vec4::new(1.0, 1.0, 1.0, 1.0)),
210 (1.0, Vec4::new(0.3, 0.5, 1.0, 0.0)),
211 ])
212 }
213
214 pub fn evaluate(&self, t: f32) -> Vec4 {
215 if self.keys.is_empty() { return Vec4::ONE; }
216 if t <= self.keys[0].0 { return self.keys[0].1; }
217 if t >= self.keys[self.keys.len()-1].0 { return self.keys[self.keys.len()-1].1; }
218 for i in 1..self.keys.len() {
219 if t <= self.keys[i].0 {
220 let (t0, c0) = self.keys[i-1];
221 let (t1, c1) = self.keys[i];
222 let f = (t - t0) / (t1 - t0);
223 return c0 + (c1 - c0) * f;
224 }
225 }
226 self.keys.last().unwrap().1
227 }
228}
229
230#[derive(Clone, Debug)]
232pub struct ScaleCurve {
233 pub x: FloatCurve,
234 pub y: FloatCurve,
235}
236
237impl ScaleCurve {
238 pub fn uniform(from: f32, to: f32) -> Self {
239 Self { x: FloatCurve::linear(from, to), y: FloatCurve::linear(from, to) }
240 }
241 pub fn evaluate(&self, t: f32) -> Vec2 {
242 Vec2::new(self.x.evaluate(t), self.y.evaluate(t))
243 }
244}
245
246impl MathParticle {
249 pub fn is_alive(&self) -> bool { self.age < self.lifetime }
250
251 pub fn tick(&mut self, dt: f32) {
252 self.age += dt;
253 let life_frac = (self.age / self.lifetime).clamp(0.0, 1.0);
254
255 let dx = self.behavior.evaluate(self.age, self.origin.x);
257 let dy = self.behavior.evaluate(self.age + 1.0, self.origin.y);
258 let dz = self.behavior.evaluate(self.age + 2.0, self.origin.z);
259
260 self.velocity += self.acceleration * dt;
262 self.velocity *= 1.0 - (self.drag * dt).clamp(0.0, 1.0);
263
264 if self.flags.contains(ParticleFlags::WORLD_SPACE) {
265 self.glyph.position += self.velocity * dt;
266 } else {
267 self.glyph.position = self.origin + Vec3::new(dx, dy, dz) + self.velocity * dt * life_frac;
268 }
269
270 self.acceleration = Vec3::ZERO;
272
273 match &self.interaction {
275 ParticleInteraction::Orbit { center, radius, speed } => {
276 let theta = self.age * speed;
277 let offset = Vec3::new(theta.cos() * radius, 0.0, theta.sin() * radius);
278 self.glyph.position = *center + offset;
279 }
280 ParticleInteraction::Spring { target, stiffness, damping } => {
281 let delta = *target - self.glyph.position;
282 self.velocity += delta * *stiffness * dt;
283 self.velocity *= 1.0 - *damping * dt;
284 }
285 _ => {}
286 }
287
288 if let Some(ref grad) = self.color_over_life {
290 self.glyph.color = grad.evaluate(life_frac);
291 } else {
292 let fade = if life_frac > 0.7 { 1.0 - (life_frac - 0.7) / 0.3 } else { 1.0 };
294 self.glyph.color.w = fade;
295 }
296
297 if let Some(ref curve) = self.scale_over_life {
299 let s = curve.evaluate(life_frac);
300 self.scale = s.x;
301 }
302
303 if let Some(ref curve) = self.size_over_life {
305 let s = curve.evaluate(life_frac);
306 self.glyph.glow_radius = s;
307 self.glyph.emission = s * 0.8;
308 }
309
310 self.glyph.glow_radius = (self.glyph.glow_radius + self.spin * dt).max(0.0);
312 }
313}
314
315pub struct ParticlePool {
319 particles: Vec<Option<MathParticle>>,
320 free_slots: Vec<usize>,
321 pub stats: PoolStats,
322 pending_spawns: Vec<(Vec3, Vec3, Vec4, EmitterPreset)>,
324}
325
326#[derive(Debug, Clone, Default)]
328pub struct PoolStats {
329 pub alive: usize,
330 pub capacity: usize,
331 pub spawned: u64,
332 pub expired: u64,
333 pub dropped: u64,
334}
335
336impl ParticlePool {
337 pub fn new(capacity: usize) -> Self {
338 Self {
339 particles: vec![None; capacity],
340 free_slots: (0..capacity).rev().collect(),
341 stats: PoolStats { capacity, ..Default::default() },
342 pending_spawns: Vec::new(),
343 }
344 }
345
346 pub fn spawn(&mut self, particle: MathParticle) -> bool {
347 if let Some(slot) = self.free_slots.pop() {
348 self.particles[slot] = Some(particle);
349 self.stats.spawned += 1;
350 self.stats.alive += 1;
351 true
352 } else {
353 self.stats.dropped += 1;
354 false
355 }
356 }
357
358 pub fn tick(&mut self, dt: f32) {
359 let mut to_free = Vec::new();
360 for (i, slot) in self.particles.iter_mut().enumerate() {
361 if let Some(ref mut p) = slot {
362 p.tick(dt);
363 if !p.is_alive() {
364 if p.flags.contains(ParticleFlags::EMIT_ON_DEATH) {
366 if let Some(ref se) = p.sub_emitter.clone() {
367 let pos = p.glyph.position;
368 let vel = p.velocity;
369 let color = p.glyph.color;
370 for _ in 0..se.count {
371 }
374 }
375 }
376 to_free.push(i);
377 }
378 }
379 }
380 for i in to_free {
381 self.particles[i] = None;
382 self.free_slots.push(i);
383 self.stats.alive = self.stats.alive.saturating_sub(1);
384 self.stats.expired += 1;
385 }
386 }
387
388 pub fn apply_field(&mut self, field: &ForceField, time: f32) {
390 for slot in &mut self.particles {
391 if let Some(ref mut p) = slot {
392 if p.flags.contains(ParticleFlags::AFFECTED_BY_FIELDS) {
393 let force = field.force_at(p.glyph.position, p.glyph.mass, p.glyph.charge, time);
394 p.acceleration += force / p.glyph.mass.max(0.001);
395 }
396 }
397 }
398 }
399
400 pub fn apply_force(&mut self, force: Vec3) {
402 for slot in &mut self.particles {
403 if let Some(ref mut p) = slot {
404 p.acceleration += force;
405 }
406 }
407 }
408
409 pub fn apply_gravity(&mut self, g: f32) {
411 for slot in &mut self.particles {
412 if let Some(ref mut p) = slot {
413 if p.flags.contains(ParticleFlags::GRAVITY) {
414 p.acceleration.y -= g;
415 }
416 }
417 }
418 }
419
420 pub fn collide_floor(&mut self, restitution: f32) {
422 for slot in &mut self.particles {
423 if let Some(ref mut p) = slot {
424 if p.flags.contains(ParticleFlags::COLLIDES) && p.glyph.position.y < 0.0 {
425 p.glyph.position.y = 0.0;
426 p.velocity.y = -p.velocity.y * restitution;
427 }
428 }
429 }
430 }
431
432 pub fn clear(&mut self) {
434 for (i, slot) in self.particles.iter_mut().enumerate() {
435 if slot.is_some() {
436 *slot = None;
437 self.free_slots.push(i);
438 self.stats.alive = self.stats.alive.saturating_sub(1);
439 }
440 }
441 }
442
443 pub fn iter(&self) -> impl Iterator<Item = &MathParticle> {
444 self.particles.iter().filter_map(|s| s.as_ref())
445 }
446
447 pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut MathParticle> {
448 self.particles.iter_mut().filter_map(|s| s.as_mut())
449 }
450
451 pub fn count(&self) -> usize { self.stats.alive }
452 pub fn capacity(&self) -> usize { self.stats.capacity }
453 pub fn is_full(&self) -> bool { self.free_slots.is_empty() }
454
455 pub fn export_gpu_buffer(&self) -> Vec<f32> {
457 let mut buf = Vec::with_capacity(self.stats.alive * 7);
458 for slot in &self.particles {
459 if let Some(ref p) = slot {
460 buf.push(p.glyph.position.x);
461 buf.push(p.glyph.position.y);
462 buf.push(p.glyph.position.z);
463 buf.push(p.glyph.color.x);
464 buf.push(p.glyph.color.y);
465 buf.push(p.glyph.color.z);
466 buf.push(p.glyph.color.w);
467 }
468 }
469 buf
470 }
471}
472
473#[derive(Clone, Debug)]
477pub enum EmitterShape {
478 Point,
480 Sphere { radius: f32 },
482 Hemisphere { radius: f32 },
484 SphereVolume { radius: f32 },
486 Cone { angle: f32, length: f32 },
488 Box { half_extents: Vec3 },
490 Disk { radius: f32 },
492 Ring { inner: f32, outer: f32 },
494 Line { a: Vec3, b: Vec3 },
496 Mesh { sample_points: Vec<Vec3> },
498 Torus { major_radius: f32, minor_radius: f32 },
500}
501
502impl EmitterShape {
503 pub fn sample(&self, rng: &mut FastRng) -> Vec3 {
505 match self {
506 Self::Point => Vec3::ZERO,
507 Self::Sphere { radius } => {
508 let (p, _) = rng.unit_sphere();
509 p * *radius
510 }
511 Self::Hemisphere { radius } => {
512 let (mut p, _) = rng.unit_sphere();
513 p.y = p.y.abs();
514 p * *radius
515 }
516 Self::SphereVolume { radius } => {
517 let (p, _) = rng.unit_sphere();
518 p * *radius * rng.f32().cbrt()
519 }
520 Self::Cone { angle, length } => {
521 let r = rng.f32() * length;
522 let a = rng.f32() * std::f32::consts::TAU;
523 let rad = r * angle.to_radians().tan();
524 Vec3::new(a.cos() * rad, r, a.sin() * rad)
525 }
526 Self::Box { half_extents } => {
527 Vec3::new(
528 rng.range(-half_extents.x, half_extents.x),
529 rng.range(-half_extents.y, half_extents.y),
530 rng.range(-half_extents.z, half_extents.z),
531 )
532 }
533 Self::Disk { radius } => {
534 let r = rng.f32().sqrt() * radius;
535 let a = rng.f32() * std::f32::consts::TAU;
536 Vec3::new(a.cos() * r, 0.0, a.sin() * r)
537 }
538 Self::Ring { inner, outer } => {
539 let r = rng.range(*inner, *outer);
540 let a = rng.f32() * std::f32::consts::TAU;
541 Vec3::new(a.cos() * r, 0.0, a.sin() * r)
542 }
543 Self::Line { a, b } => {
544 let t = rng.f32();
545 *a + (*b - *a) * t
546 }
547 Self::Mesh { sample_points } => {
548 if sample_points.is_empty() { return Vec3::ZERO; }
549 sample_points[rng.range_u32(0, sample_points.len() as u32) as usize]
550 }
551 Self::Torus { major_radius, minor_radius } => {
552 let theta = rng.f32() * std::f32::consts::TAU;
553 let phi = rng.f32() * std::f32::consts::TAU;
554 let r = rng.f32() * minor_radius;
555 Vec3::new(
556 (major_radius + r * phi.cos()) * theta.cos(),
557 r * phi.sin(),
558 (major_radius + r * phi.cos()) * theta.sin(),
559 )
560 }
561 }
562 }
563
564 pub fn normal_at(&self, pos: Vec3) -> Vec3 {
566 match self {
567 Self::Sphere { .. } | Self::SphereVolume { .. } | Self::Hemisphere { .. } => {
568 if pos.length_squared() > 1e-6 { pos.normalize() } else { Vec3::Y }
569 }
570 Self::Cone { .. } => { Vec3::new(pos.x, 0.2, pos.z).normalize() }
571 Self::Disk { .. } | Self::Ring { .. } => Vec3::Y,
572 _ => Vec3::Y,
573 }
574 }
575}
576
577#[derive(Clone, Debug)]
581pub enum ParticleForce {
582 Constant { force: Vec3 },
584 Drag { coefficient: f32 },
586 PointForce { position: Vec3, strength: f32, falloff: Falloff },
588 Turbulence { strength: f32, frequency: f32, octaves: u8 },
590 Vortex { axis: Vec3, position: Vec3, strength: f32, falloff_radius: f32 },
592 WindBlast { direction: Vec3, min_speed: f32, max_speed: f32, gust_freq: f32 },
594 KillPlane { y: f32 },
596 Bounce { normal: Vec3, d: f32, restitution: f32 },
598 Noise { amplitude: Vec3 },
600 OrbitForce { center: Vec3, radius: f32, strength: f32 },
602}
603
604impl ParticleForce {
605 pub fn acceleration(&self, p: &MathParticle, time: f32, rng: &mut FastRng) -> Vec3 {
607 match self {
608 Self::Constant { force } => *force,
609 Self::Drag { coefficient } => -p.velocity * *coefficient,
610 Self::PointForce { position, strength, falloff } => {
611 let delta = *position - p.glyph.position;
612 let dist = delta.length();
613 if dist < 0.001 { return Vec3::ZERO; }
614 let dir = delta / dist;
615 let mag = falloff_factor(*falloff, dist, f32::MAX) * strength;
616 dir * mag
617 }
618 Self::Turbulence { strength, frequency, octaves: _ } => {
619 let pos = p.glyph.position * *frequency;
620 let nx = pseudo_noise3(pos + Vec3::new(0.0, 0.0, 0.0), time) * 2.0 - 1.0;
621 let ny = pseudo_noise3(pos + Vec3::new(100.0, 0.0, 0.0), time) * 2.0 - 1.0;
622 let nz = pseudo_noise3(pos + Vec3::new(200.0, 0.0, 0.0), time) * 2.0 - 1.0;
623 Vec3::new(nx, ny, nz) * *strength
624 }
625 Self::Vortex { axis, position, strength, falloff_radius } => {
626 let delta = p.glyph.position - *position;
627 let dist = delta.length();
628 if dist < 0.001 { return Vec3::ZERO; }
629 let tangent = axis.cross(delta).normalize();
630 let fo = (1.0 - (dist / falloff_radius).min(1.0)).powi(2);
631 tangent * *strength * fo
632 }
633 Self::WindBlast { direction, min_speed, max_speed, gust_freq } => {
634 let gust = ((time * gust_freq).sin() * 0.5 + 0.5) * (max_speed - min_speed) + min_speed;
635 direction.normalize_or_zero() * gust
636 }
637 Self::KillPlane { .. } => Vec3::ZERO, Self::Bounce { .. } => Vec3::ZERO, Self::Noise { amplitude } => {
640 Vec3::new(
641 rng.range(-amplitude.x, amplitude.x),
642 rng.range(-amplitude.y, amplitude.y),
643 rng.range(-amplitude.z, amplitude.z),
644 )
645 }
646 Self::OrbitForce { center, radius, strength } => {
647 let delta = p.glyph.position - *center;
648 let dist = delta.length();
649 if dist < 0.001 { return Vec3::ZERO; }
650 let target_dist = *radius;
651 let radial_dir = delta / dist;
652 let orbit_acc = (target_dist - dist) * *strength;
653 radial_dir * orbit_acc
654 }
655 }
656 }
657}
658
659pub struct ParticleSystem {
663 pub pool: ParticlePool,
664 pub forces: Vec<ParticleForce>,
665 pub position: Vec3,
666 pub transform: Mat4,
667 pub gravity: Vec3,
668 pub time: f32,
669 pub enabled: bool,
670 pub world_space: bool,
671 rng: FastRng,
672
673 trails: HashMap<usize, Vec<Vec3>>,
675 pub max_trail_len: usize,
676}
677
678impl ParticleSystem {
679 pub fn new(capacity: usize) -> Self {
680 Self {
681 pool: ParticlePool::new(capacity),
682 forces: Vec::new(),
683 position: Vec3::ZERO,
684 transform: Mat4::IDENTITY,
685 gravity: Vec3::new(0.0, -9.81, 0.0),
686 time: 0.0,
687 enabled: true,
688 world_space: true,
689 rng: FastRng::new(0xDEADBEEF),
690 trails: HashMap::new(),
691 max_trail_len: 16,
692 }
693 }
694
695 pub fn with_gravity(mut self, g: Vec3) -> Self { self.gravity = g; self }
696 pub fn with_position(mut self, p: Vec3) -> Self { self.position = p; self }
697 pub fn add_force(mut self, f: ParticleForce) -> Self { self.forces.push(f); self }
698
699 pub fn burst(&mut self, shape: &EmitterShape, count: u32, template: &ParticleTemplate) {
701 for _ in 0..count {
702 let local_pos = shape.sample(&mut self.rng);
703 let normal = shape.normal_at(local_pos);
704 let world_pos = self.position + local_pos;
705
706 let speed = template.speed.sample(&mut self.rng);
707 let life = template.lifetime.sample(&mut self.rng);
708 let spread = template.spread;
709 let dir = jitter_direction(normal, spread, &mut self.rng) * speed;
710
711 let color = template.gradient.evaluate(self.rng.f32());
712 let size = template.size.sample(&mut self.rng);
713
714 let mut p = MathParticle {
715 glyph: Glyph {
716 position: world_pos,
717 color,
718 emission: template.emission,
719 glow_color: Vec3::new(color.x, color.y, color.z),
720 glow_radius: size,
721 character: template.character,
722 layer: RenderLayer::Particle,
723 mass: template.mass,
724 ..Default::default()
725 },
726 behavior: template.behavior.clone(),
727 trail: template.trail,
728 trail_length: template.trail_length,
729 trail_decay: template.trail_decay,
730 interaction: template.interaction.clone(),
731 origin: world_pos,
732 age: 0.0,
733 lifetime: life,
734 velocity: dir,
735 acceleration: Vec3::ZERO,
736 drag: template.drag,
737 spin: self.rng.range(template.spin.0, template.spin.1),
738 scale: size,
739 scale_over_life: template.scale_over_life.clone(),
740 color_over_life: template.color_over_life.clone(),
741 size_over_life: template.size_over_life.clone(),
742 group: template.group,
743 sub_emitter: template.sub_emitter.clone(),
744 flags: template.flags,
745 user_data: [0.0; 4],
746 };
747 self.pool.spawn(p);
748 }
749 }
750
751 pub fn tick(&mut self, dt: f32) {
752 if !self.enabled { return; }
753 self.time += dt;
754
755 self.pool.apply_gravity(self.gravity.length());
757
758 let time = self.time;
760 let mut rng = FastRng::new(self.rng.next() ^ (self.time * 1000.0) as u64);
761 for force in &self.forces {
762 for slot in &mut self.pool.particles {
763 if let Some(ref mut p) = slot {
764 let acc = force.acceleration(p, time, &mut rng);
765 p.acceleration += acc;
766 }
767 }
768 }
769
770 for force in &self.forces {
772 match force {
773 ParticleForce::KillPlane { y } => {
774 for slot in &mut self.pool.particles {
775 if let Some(ref mut p) = slot {
776 if p.glyph.position.y < *y { p.age = p.lifetime + 1.0; }
777 }
778 }
779 }
780 ParticleForce::Bounce { normal, d, restitution } => {
781 let n = normal.normalize_or_zero();
782 for slot in &mut self.pool.particles {
783 if let Some(ref mut p) = slot {
784 let dist = n.dot(p.glyph.position) - d;
785 if dist < 0.0 {
786 p.glyph.position -= n * dist;
787 let vn = n * n.dot(p.velocity);
788 p.velocity -= vn * (1.0 + restitution);
789 }
790 }
791 }
792 }
793 _ => {}
794 }
795 }
796
797 self.pool.tick(dt);
798
799 for (i, slot) in self.pool.particles.iter().enumerate() {
801 if let Some(ref p) = slot {
802 if p.trail {
803 let trail = self.trails.entry(i).or_default();
804 trail.push(p.glyph.position);
805 if trail.len() > self.max_trail_len {
806 trail.remove(0);
807 }
808 }
809 } else {
810 self.trails.remove(&i);
811 }
812 }
813 }
814
815 pub fn trails(&self) -> &HashMap<usize, Vec<Vec3>> { &self.trails }
816
817 pub fn export_gpu_buffer(&self) -> Vec<f32> { self.pool.export_gpu_buffer() }
819}
820
821#[derive(Clone, Debug)]
825pub struct ParticleTemplate {
826 pub lifetime: RangeParam,
827 pub speed: RangeParam,
828 pub size: RangeParam,
829 pub spread: f32,
830 pub drag: f32,
831 pub mass: f32,
832 pub emission: f32,
833 pub spin: (f32, f32),
834 pub character: char,
835 pub trail: bool,
836 pub trail_length: u8,
837 pub trail_decay: f32,
838 pub behavior: MathFunction,
839 pub interaction: ParticleInteraction,
840 pub gradient: ColorGradient,
841 pub scale_over_life: Option<ScaleCurve>,
842 pub color_over_life: Option<ColorGradient>,
843 pub size_over_life: Option<FloatCurve>,
844 pub group: Option<u32>,
845 pub sub_emitter: Option<Box<SubEmitterRef>>,
846 pub flags: ParticleFlags,
847}
848
849impl Default for ParticleTemplate {
850 fn default() -> Self {
851 Self {
852 lifetime: RangeParam::constant(2.0),
853 speed: RangeParam::range(1.0, 5.0),
854 size: RangeParam::constant(1.0),
855 spread: 0.3,
856 drag: 0.02,
857 mass: 1.0,
858 emission: 0.7,
859 spin: (-2.0, 2.0),
860 character: '·',
861 trail: false,
862 trail_length: 8,
863 trail_decay: 0.8,
864 behavior: MathFunction::Sine { amplitude: 0.5, frequency: 1.0, phase: 0.0 },
865 interaction: ParticleInteraction::None,
866 gradient: ColorGradient::fade_out(Vec4::ONE),
867 scale_over_life: None,
868 color_over_life: None,
869 size_over_life: None,
870 group: None,
871 sub_emitter: None,
872 flags: ParticleFlags::GRAVITY,
873 }
874 }
875}
876
877impl ParticleTemplate {
878 pub fn fire() -> Self {
879 Self {
880 lifetime: RangeParam::range(0.6, 1.4),
881 speed: RangeParam::range(2.0, 6.0),
882 size: RangeParam::range(0.8, 1.6),
883 spread: 0.8,
884 drag: 0.05,
885 character: '▲',
886 emission: 1.0,
887 color_over_life: Some(ColorGradient::fire()),
888 size_over_life: Some(FloatCurve::linear(1.5, 0.1)),
889 flags: ParticleFlags::AFFECTED_BY_FIELDS,
890 ..Default::default()
891 }
892 }
893
894 pub fn smoke() -> Self {
895 Self {
896 lifetime: RangeParam::range(2.0, 4.0),
897 speed: RangeParam::range(0.3, 1.2),
898 size: RangeParam::range(1.0, 3.0),
899 spread: 0.5,
900 drag: 0.1,
901 character: '○',
902 emission: 0.1,
903 color_over_life: Some(ColorGradient::new(vec![
904 (0.0, Vec4::new(0.5, 0.5, 0.5, 0.8)),
905 (0.7, Vec4::new(0.3, 0.3, 0.3, 0.4)),
906 (1.0, Vec4::new(0.2, 0.2, 0.2, 0.0)),
907 ])),
908 size_over_life: Some(FloatCurve::linear(1.0, 4.0)),
909 flags: ParticleFlags::empty(),
910 ..Default::default()
911 }
912 }
913
914 pub fn electric_spark() -> Self {
915 Self {
916 lifetime: RangeParam::range(0.1, 0.4),
917 speed: RangeParam::range(8.0, 20.0),
918 size: RangeParam::constant(0.5),
919 spread: 1.5,
920 drag: 0.01,
921 character: '·',
922 emission: 1.2,
923 color_over_life: Some(ColorGradient::electric()),
924 flags: ParticleFlags::GRAVITY | ParticleFlags::COLLIDES,
925 ..Default::default()
926 }
927 }
928
929 pub fn plasma() -> Self {
930 Self {
931 lifetime: RangeParam::range(0.5, 1.5),
932 speed: RangeParam::range(3.0, 8.0),
933 size: RangeParam::range(0.8, 1.4),
934 spread: 0.4,
935 drag: 0.03,
936 character: '◉',
937 emission: 1.3,
938 color_over_life: Some(ColorGradient::plasma()),
939 flags: ParticleFlags::AFFECTED_BY_FIELDS,
940 ..Default::default()
941 }
942 }
943
944 pub fn rain() -> Self {
945 Self {
946 lifetime: RangeParam::range(1.0, 2.0),
947 speed: RangeParam::range(10.0, 20.0),
948 size: RangeParam::constant(0.3),
949 spread: 0.05,
950 drag: 0.0,
951 character: '|',
952 emission: 0.4,
953 flags: ParticleFlags::GRAVITY | ParticleFlags::COLLIDES,
954 ..Default::default()
955 }
956 }
957
958 pub fn snow() -> Self {
959 Self {
960 lifetime: RangeParam::range(3.0, 8.0),
961 speed: RangeParam::range(0.5, 2.0),
962 size: RangeParam::range(0.5, 1.0),
963 spread: 1.5,
964 drag: 0.3,
965 character: '❄',
966 emission: 0.5,
967 color_over_life: Some(ColorGradient::constant(Vec4::new(0.9, 0.95, 1.0, 0.9))),
968 flags: ParticleFlags::GRAVITY | ParticleFlags::AFFECTED_BY_FIELDS,
969 ..Default::default()
970 }
971 }
972}
973
974pub struct ContinuousEmitter {
978 pub system: ParticleSystem,
979 pub rate: f32, pub shape: EmitterShape,
981 pub template: ParticleTemplate,
982 accumulator: f32,
983 pub active: bool,
984 pub duration: Option<f32>, elapsed: f32,
986 pub bursts: Vec<BurstEvent>,
987}
988
989#[derive(Clone, Debug)]
991pub struct BurstEvent {
992 pub time: f32,
993 pub count: u32,
994 fired: bool,
995}
996
997impl BurstEvent {
998 pub fn new(time: f32, count: u32) -> Self { Self { time, count, fired: false } }
999}
1000
1001impl ContinuousEmitter {
1002 pub fn new(rate: f32, shape: EmitterShape, template: ParticleTemplate) -> Self {
1003 Self {
1004 system: ParticleSystem::new(4096),
1005 rate,
1006 shape,
1007 template,
1008 accumulator: 0.0,
1009 active: true,
1010 duration: None,
1011 elapsed: 0.0,
1012 bursts: Vec::new(),
1013 }
1014 }
1015
1016 pub fn with_duration(mut self, secs: f32) -> Self { self.duration = Some(secs); self }
1017 pub fn with_burst(mut self, b: BurstEvent) -> Self { self.bursts.push(b); self }
1018 pub fn with_capacity(mut self, n: usize) -> Self { self.system.pool = ParticlePool::new(n); self }
1019
1020 pub fn tick(&mut self, dt: f32) {
1021 if !self.active { self.system.tick(dt); return; }
1022
1023 self.elapsed += dt;
1024 if let Some(dur) = self.duration {
1025 if self.elapsed >= dur { self.active = false; }
1026 }
1027
1028 self.accumulator += self.rate * dt;
1030 let count = self.accumulator as u32;
1031 if count > 0 {
1032 self.system.burst(&self.shape, count, &self.template);
1033 self.accumulator -= count as f32;
1034 }
1035
1036 for b in &mut self.bursts {
1038 if !b.fired && self.elapsed >= b.time {
1039 self.system.burst(&self.shape, b.count, &self.template);
1040 b.fired = true;
1041 }
1042 }
1043
1044 self.system.tick(dt);
1045 }
1046
1047 pub fn pool(&self) -> &ParticlePool { &self.system.pool }
1048}
1049
1050#[derive(Debug)]
1054pub struct ParticleGroup {
1055 pub name: String,
1056 pub id: u32,
1057 pub color_mult: Vec4,
1058 pub speed_mult: f32,
1059 pub life_mult: f32,
1060}
1061
1062impl ParticleGroup {
1063 pub fn new(id: u32, name: impl Into<String>) -> Self {
1064 Self { id, name: name.into(), color_mult: Vec4::ONE, speed_mult: 1.0, life_mult: 1.0 }
1065 }
1066}
1067
1068#[derive(Clone, Debug)]
1072pub struct TrailRibbon {
1073 pub positions: Vec<Vec3>,
1074 pub colors: Vec<Vec4>,
1075 pub widths: Vec<f32>,
1076}
1077
1078impl TrailRibbon {
1079 pub fn build(positions: &[Vec3], base_color: Vec4, base_width: f32) -> Self {
1080 let n = positions.len();
1081 let mut colors = Vec::with_capacity(n);
1082 let mut widths = Vec::with_capacity(n);
1083 for i in 0..n {
1084 let t = i as f32 / (n.max(2) - 1) as f32;
1085 let alpha = t; colors.push(Vec4::new(base_color.x, base_color.y, base_color.z, alpha * base_color.w));
1087 widths.push(base_width * alpha);
1088 }
1089 Self { positions: positions.to_vec(), colors, widths }
1090 }
1091}
1092
1093pub struct LodParticleSystem {
1097 pub lods: [ContinuousEmitter; 4],
1099 pub lod_ranges: [f32; 4],
1100 pub position: Vec3,
1101 current_lod: usize,
1102}
1103
1104impl LodParticleSystem {
1105 pub fn new(base_rate: f32, shape: EmitterShape, template: ParticleTemplate) -> Self {
1106 let e0 = ContinuousEmitter::new(base_rate, shape.clone(), template.clone());
1107 let e1 = ContinuousEmitter::new(base_rate * 0.6, shape.clone(), template.clone());
1108 let e2 = ContinuousEmitter::new(base_rate * 0.3, shape.clone(), template.clone());
1109 let e3 = ContinuousEmitter::new(base_rate * 0.1, shape, template);
1110 Self {
1111 lods: [e0, e1, e2, e3],
1112 lod_ranges: [20.0, 50.0, 100.0, 200.0],
1113 position: Vec3::ZERO,
1114 current_lod: 0,
1115 }
1116 }
1117
1118 pub fn tick(&mut self, dt: f32, camera_pos: Vec3) {
1119 let dist = (self.position - camera_pos).length();
1120 self.current_lod = 3;
1121 for (i, &range) in self.lod_ranges.iter().enumerate() {
1122 if dist <= range { self.current_lod = i; break; }
1123 }
1124 self.lods[self.current_lod].tick(dt);
1125 }
1126
1127 pub fn active_pool(&self) -> &ParticlePool { self.lods[self.current_lod].pool() }
1128}
1129
1130#[repr(C)]
1134#[derive(Clone, Copy, Debug, Default)]
1135pub struct GpuParticleInstance {
1136 pub position: [f32; 3],
1137 pub size: f32,
1138 pub color: [f32; 4],
1139 pub velocity: [f32; 3],
1140 pub age_frac: f32,
1141}
1142
1143impl GpuParticleInstance {
1144 pub fn from_particle(p: &MathParticle) -> Self {
1145 let lf = (p.age / p.lifetime).clamp(0.0, 1.0);
1146 Self {
1147 position: p.glyph.position.to_array(),
1148 size: p.scale,
1149 color: p.glyph.color.to_array(),
1150 velocity: p.velocity.to_array(),
1151 age_frac: lf,
1152 }
1153 }
1154}
1155
1156pub fn export_gpu_instances(pool: &ParticlePool) -> Vec<GpuParticleInstance> {
1157 pool.iter().map(GpuParticleInstance::from_particle).collect()
1158}
1159
1160#[derive(Clone, Debug)]
1164pub enum EmitterPreset {
1165 DeathExplosion { color: Vec4 },
1167 LevelUpFountain,
1169 CritBurst,
1171 HitSparks { color: Vec4, count: u8 },
1173 LootSparkle { color: Vec4 },
1175 StatusAmbient { effect_mask: u8 },
1177 StunOrbit,
1179 RoomAmbient { room_type_id: u8 },
1181 BossEntrance { boss_id: u8 },
1183 GravitationalCollapse { color: Vec4, attractor: AttractorType },
1185 SpellStream { element_color: Vec4 },
1187 HealSpiral,
1189 EntropyCascade,
1191 FireBurst { intensity: f32 },
1193 SmokePuff,
1195 ElectricDischarge { color: Vec4 },
1197 BloodSplatter { color: Vec4, count: u8 },
1199 IceShatter,
1201 PoisonCloud,
1203 TeleportFlash { color: Vec4 },
1205 ShieldHit { shield_color: Vec4 },
1207 CoinScatter { count: u8 },
1209 RubbleDebris { count: u8 },
1211 RainShower,
1213 SnowFall,
1215 ConfettiBurst,
1217 Custom { template: ParticleTemplate, count: u32, shape: EmitterShape },
1219}
1220
1221pub fn emit(scene: &mut crate::scene::Scene, preset: EmitterPreset, origin: Vec3) {
1223 emitters::emit_preset(&mut scene.particles, preset, origin);
1224}
1225
1226#[derive(Clone, Debug)]
1230pub struct FastRng {
1231 state: u64,
1232}
1233
1234impl FastRng {
1235 pub fn new(seed: u64) -> Self { Self { state: seed ^ 0x9E3779B97F4A7C15 } }
1236
1237 pub fn next(&mut self) -> u64 {
1238 let mut x = self.state;
1239 x ^= x << 13;
1240 x ^= x >> 7;
1241 x ^= x << 17;
1242 self.state = x;
1243 x
1244 }
1245
1246 pub fn f32(&mut self) -> f32 {
1247 (self.next() & 0x00FF_FFFF) as f32 / 0x00FF_FFFF as f32
1248 }
1249
1250 pub fn range(&mut self, min: f32, max: f32) -> f32 {
1251 min + self.f32() * (max - min)
1252 }
1253
1254 pub fn range_u32(&mut self, min: u32, max: u32) -> u32 {
1255 if min >= max { return min; }
1256 min + (self.next() as u32 % (max - min))
1257 }
1258
1259 pub fn unit_sphere(&mut self) -> (Vec3, f32) {
1261 loop {
1262 let x = self.range(-1.0, 1.0);
1263 let y = self.range(-1.0, 1.0);
1264 let z = self.range(-1.0, 1.0);
1265 let len = (x*x + y*y + z*z).sqrt();
1266 if len > 0.0 && len <= 1.0 {
1267 return (Vec3::new(x/len, y/len, z/len), len);
1268 }
1269 }
1270 }
1271}
1272
1273#[derive(Clone, Debug)]
1277pub struct RangeParam {
1278 pub min: f32,
1279 pub max: f32,
1280}
1281
1282impl RangeParam {
1283 pub fn constant(v: f32) -> Self { Self { min: v, max: v } }
1284 pub fn range(min: f32, max: f32) -> Self { Self { min, max } }
1285 pub fn sample(&self, rng: &mut FastRng) -> f32 { rng.range(self.min, self.max) }
1286}
1287
1288fn jitter_direction(dir: Vec3, spread: f32, rng: &mut FastRng) -> Vec3 {
1292 if spread < 0.001 { return dir.normalize_or_zero(); }
1293 let (perp, _) = rng.unit_sphere();
1294 let jitter = dir + perp * spread;
1295 jitter.normalize_or_zero()
1296}
1297
1298fn pseudo_noise3(p: Vec3, t: f32) -> f32 {
1300 let ix = p.x.floor() as i32;
1301 let iy = p.y.floor() as i32;
1302 let iz = p.z.floor() as i32;
1303 let it = (t * 10.0) as i32;
1304 let h = hash4(ix, iy, iz, it);
1305 let fx = p.x - p.x.floor();
1306 let fy = p.y - p.y.floor();
1307 let fz = p.z - p.z.floor();
1308 let ux = fx * fx * (3.0 - 2.0 * fx);
1310 let uy = fy * fy * (3.0 - 2.0 * fy);
1311 let n = hash4(ix + (ux > 0.5) as i32, iy + (uy > 0.5) as i32, iz, it);
1313 n as f32 / u32::MAX as f32
1314}
1315
1316fn hash4(x: i32, y: i32, z: i32, w: i32) -> u32 {
1317 let mut h = (x as u32).wrapping_mul(1619)
1318 ^ (y as u32).wrapping_mul(31337)
1319 ^ (z as u32).wrapping_mul(1013904223)
1320 ^ (w as u32).wrapping_mul(2654435769);
1321 h ^= h >> 16; h = h.wrapping_mul(0x45d9f3b);
1322 h ^= h >> 16; h
1323}
1324
1325#[derive(Clone, Debug)]
1329pub struct ParticleEffect {
1330 pub name: String,
1331 pub template: ParticleTemplate,
1332 pub shape: EmitterShape,
1333 pub rate: f32,
1334 pub count: u32,
1335 pub forces: Vec<ParticleForce>,
1336 pub duration: Option<f32>,
1337}
1338
1339impl ParticleEffect {
1340 pub fn new(name: impl Into<String>) -> Self {
1341 Self {
1342 name: name.into(),
1343 template: ParticleTemplate::default(),
1344 shape: EmitterShape::Point,
1345 rate: 20.0,
1346 count: 1,
1347 forces: Vec::new(),
1348 duration: None,
1349 }
1350 }
1351
1352 pub fn campfire() -> Self {
1353 Self {
1354 name: "campfire".into(),
1355 template: ParticleTemplate::fire(),
1356 shape: EmitterShape::Disk { radius: 0.3 },
1357 rate: 40.0,
1358 count: 2,
1359 forces: vec![
1360 ParticleForce::Turbulence { strength: 0.5, frequency: 2.0, octaves: 2 },
1361 ParticleForce::Constant { force: Vec3::new(0.0, 1.2, 0.0) },
1362 ],
1363 duration: None,
1364 }
1365 }
1366
1367 pub fn explosion() -> Self {
1368 Self {
1369 name: "explosion".into(),
1370 template: ParticleTemplate {
1371 lifetime: RangeParam::range(0.4, 1.2),
1372 speed: RangeParam::range(5.0, 20.0),
1373 size: RangeParam::range(1.0, 2.5),
1374 spread: 3.14,
1375 drag: 0.08,
1376 character: '█',
1377 emission: 1.5,
1378 color_over_life: Some(ColorGradient::fire()),
1379 flags: ParticleFlags::GRAVITY,
1380 ..Default::default()
1381 },
1382 shape: EmitterShape::Sphere { radius: 0.5 },
1383 rate: 0.0,
1384 count: 80,
1385 forces: vec![ParticleForce::Constant { force: Vec3::new(0.0, -9.81, 0.0) }],
1386 duration: Some(0.05),
1387 }
1388 }
1389
1390 pub fn rain_shower() -> Self {
1391 Self {
1392 name: "rain".into(),
1393 template: ParticleTemplate::rain(),
1394 shape: EmitterShape::Box { half_extents: Vec3::new(20.0, 0.0, 20.0) },
1395 rate: 500.0,
1396 count: 10,
1397 forces: vec![
1398 ParticleForce::Constant { force: Vec3::new(0.3, -15.0, 0.0) },
1399 ParticleForce::KillPlane { y: -1.0 },
1400 ],
1401 duration: None,
1402 }
1403 }
1404}
1405
1406pub struct ParticleLibrary {
1410 effects: HashMap<String, ParticleEffect>,
1411}
1412
1413impl ParticleLibrary {
1414 pub fn new() -> Self {
1415 let mut lib = Self { effects: HashMap::new() };
1416 lib.register(ParticleEffect::campfire());
1417 lib.register(ParticleEffect::explosion());
1418 lib.register(ParticleEffect::rain_shower());
1419 lib
1420 }
1421
1422 pub fn register(&mut self, effect: ParticleEffect) {
1423 self.effects.insert(effect.name.clone(), effect);
1424 }
1425
1426 pub fn get(&self, name: &str) -> Option<&ParticleEffect> {
1427 self.effects.get(name)
1428 }
1429
1430 pub fn names(&self) -> Vec<&str> {
1431 self.effects.keys().map(|s| s.as_str()).collect()
1432 }
1433
1434 pub fn instantiate(&self, name: &str) -> Option<ContinuousEmitter> {
1436 let e = self.effects.get(name)?;
1437 let mut emitter = ContinuousEmitter::new(e.rate, e.shape.clone(), e.template.clone());
1438 for f in &e.forces {
1439 emitter.system.forces.push(f.clone());
1440 }
1441 if let Some(d) = e.duration { emitter = emitter.with_duration(d); }
1442 Some(emitter)
1443 }
1444}
1445
1446impl Default for ParticleLibrary {
1447 fn default() -> Self { Self::new() }
1448}
1449
1450#[derive(Debug, Clone, Default)]
1454pub struct ParticleSystemStats {
1455 pub total_alive: usize,
1456 pub total_spawned: u64,
1457 pub total_expired: u64,
1458 pub total_dropped: u64,
1459 pub emitter_count: usize,
1460}
1461
1462impl ParticleSystemStats {
1463 pub fn from_pool(pool: &ParticlePool) -> Self {
1464 Self {
1465 total_alive: pool.stats.alive,
1466 total_spawned: pool.stats.spawned,
1467 total_expired: pool.stats.expired,
1468 total_dropped: pool.stats.dropped,
1469 emitter_count: 1,
1470 }
1471 }
1472}
1473
1474#[cfg(test)]
1477mod tests {
1478 use super::*;
1479
1480 #[test]
1481 fn float_curve_linear() {
1482 let c = FloatCurve::linear(0.0, 10.0);
1483 assert!((c.evaluate(0.5) - 5.0).abs() < 0.01);
1484 }
1485
1486 #[test]
1487 fn color_gradient_evaluate() {
1488 let g = ColorGradient::fade_out(Vec4::ONE);
1489 let c = g.evaluate(1.0);
1490 assert!(c.w < 0.1);
1491 }
1492
1493 #[test]
1494 fn fast_rng_range() {
1495 let mut rng = FastRng::new(42);
1496 for _ in 0..1000 {
1497 let v = rng.range(0.0, 1.0);
1498 assert!(v >= 0.0 && v <= 1.0);
1499 }
1500 }
1501
1502 #[test]
1503 fn particle_pool_spawn_and_tick() {
1504 let mut pool = ParticlePool::new(16);
1505 let p = MathParticle {
1506 glyph: Glyph { position: Vec3::ZERO, ..Default::default() },
1507 behavior: MathFunction::Sine { amplitude: 1.0, frequency: 1.0, phase: 0.0 },
1508 trail: false, trail_length: 0, trail_decay: 0.0,
1509 interaction: ParticleInteraction::None,
1510 origin: Vec3::ZERO,
1511 age: 0.0, lifetime: 1.0,
1512 velocity: Vec3::new(0.0, 1.0, 0.0),
1513 acceleration: Vec3::ZERO,
1514 drag: 0.0, spin: 0.0, scale: 1.0,
1515 scale_over_life: None, color_over_life: None, size_over_life: None,
1516 group: None, sub_emitter: None,
1517 flags: ParticleFlags::empty(),
1518 user_data: [0.0; 4],
1519 };
1520 assert!(pool.spawn(p));
1521 assert_eq!(pool.count(), 1);
1522 pool.tick(2.0); assert_eq!(pool.count(), 0);
1524 }
1525
1526 #[test]
1527 fn emitter_shape_sample() {
1528 let mut rng = FastRng::new(999);
1529 let shape = EmitterShape::Sphere { radius: 5.0 };
1530 for _ in 0..100 {
1531 let p = shape.sample(&mut rng);
1532 assert!((p.length() - 5.0).abs() < 0.1);
1533 }
1534 }
1535
1536 #[test]
1537 fn emitter_shape_disk() {
1538 let mut rng = FastRng::new(12345);
1539 let shape = EmitterShape::Disk { radius: 3.0 };
1540 for _ in 0..100 {
1541 let p = shape.sample(&mut rng);
1542 assert!(p.y.abs() < 0.001);
1543 assert!(glam::Vec2::new(p.x, p.z).length() <= 3.001);
1544 }
1545 }
1546
1547 #[test]
1548 fn particle_template_defaults() {
1549 let t = ParticleTemplate::default();
1550 assert_eq!(t.character, '·');
1551 }
1552
1553 #[test]
1554 fn scale_curve_evaluate() {
1555 let c = ScaleCurve::uniform(2.0, 0.5);
1556 let v = c.evaluate(0.5);
1557 assert!((v.x - 1.25).abs() < 0.01);
1558 }
1559
1560 #[test]
1561 fn particle_library_campfire() {
1562 let lib = ParticleLibrary::new();
1563 let e = lib.instantiate("campfire");
1564 assert!(e.is_some());
1565 }
1566
1567 #[test]
1568 fn gpu_export_buffer() {
1569 let pool = ParticlePool::new(64);
1570 let buf = pool.export_gpu_buffer();
1571 assert!(buf.is_empty());
1572 }
1573}