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