1
2use glam::{Vec2, Vec3, Vec4};
5use std::collections::HashMap;
6
7#[derive(Debug, Clone)]
12pub enum MinMaxCurve {
13 Constant(f32),
14 Random(f32, f32), Curve(Vec<Vec2>), TwoCurves(Vec<Vec2>, Vec<Vec2>),
17}
18
19impl MinMaxCurve {
20 pub fn evaluate(&self, t: f32, rand01: f32) -> f32 {
21 let t = t.clamp(0.0, 1.0);
22 match self {
23 MinMaxCurve::Constant(v) => *v,
24 MinMaxCurve::Random(lo, hi) => lo + (hi - lo) * rand01,
25 MinMaxCurve::Curve(pts) => {
26 if pts.is_empty() { return 0.0; }
27 if pts.len() == 1 { return pts[0].y; }
28 let i = pts.partition_point(|p| p.x <= t);
29 if i == 0 { return pts[0].y; }
30 if i >= pts.len() { return pts[pts.len()-1].y; }
31 let a = pts[i-1];
32 let b = pts[i];
33 let u = (t - a.x) / (b.x - a.x).max(1e-6);
34 a.y + (b.y - a.y) * u
35 }
36 MinMaxCurve::TwoCurves(lo_pts, hi_pts) => {
37 let lo = MinMaxCurve::Curve(lo_pts.clone()).evaluate(t, 0.0);
38 let hi = MinMaxCurve::Curve(hi_pts.clone()).evaluate(t, 0.0);
39 lo + (hi - lo) * rand01
40 }
41 }
42 }
43
44 pub fn constant(v: f32) -> Self { MinMaxCurve::Constant(v) }
45 pub fn random_range(lo: f32, hi: f32) -> Self { MinMaxCurve::Random(lo, hi) }
46}
47
48#[derive(Debug, Clone)]
49pub struct MinMaxGradient {
50 pub stops: Vec<(f32, Vec4)>, }
52
53impl MinMaxGradient {
54 pub fn solid(color: Vec4) -> Self {
55 Self { stops: vec![(0.0, color), (1.0, color)] }
56 }
57
58 pub fn two_color(from: Vec4, to: Vec4) -> Self {
59 Self { stops: vec![(0.0, from), (1.0, to)] }
60 }
61
62 pub fn evaluate(&self, t: f32) -> Vec4 {
63 let t = t.clamp(0.0, 1.0);
64 if self.stops.is_empty() { return Vec4::ONE; }
65 if self.stops.len() == 1 { return self.stops[0].1; }
66 let i = self.stops.partition_point(|s| s.0 <= t);
67 if i == 0 { return self.stops[0].1; }
68 if i >= self.stops.len() { return self.stops[self.stops.len()-1].1; }
69 let (ta, ca) = self.stops[i-1];
70 let (tb, cb) = self.stops[i];
71 let u = (t - ta) / (tb - ta).max(1e-6);
72 ca.lerp(cb, u)
73 }
74
75 pub fn add_stop(&mut self, t: f32, color: Vec4) {
76 let i = self.stops.partition_point(|s| s.0 <= t);
77 self.stops.insert(i, (t, color));
78 }
79}
80
81#[derive(Debug, Clone, PartialEq)]
86pub enum EmitterShape {
87 Point,
88 Sphere { radius: f32, thickness: f32 },
89 Hemisphere { radius: f32, thickness: f32 },
90 Cone { angle: f32, radius: f32, length: f32, emit_from_volume: bool },
91 Box { half_extents: Vec3, emit_from_surface: bool },
92 Circle { radius: f32, arc: f32, thickness: f32 },
93 Edge { length: f32 },
94 Mesh { mesh_id: u64 },
95 Trail,
96 Donut { radius: f32, donut_radius: f32 },
97 Rectangle { half_extents: Vec2, emit_from_edge: bool },
98 Line { length: f32, spread: f32 },
99}
100
101impl EmitterShape {
102 pub fn label(&self) -> &'static str {
103 match self {
104 EmitterShape::Point => "Point",
105 EmitterShape::Sphere { .. } => "Sphere",
106 EmitterShape::Hemisphere { .. } => "Hemisphere",
107 EmitterShape::Cone { .. } => "Cone",
108 EmitterShape::Box { .. } => "Box",
109 EmitterShape::Circle { .. } => "Circle",
110 EmitterShape::Edge { .. } => "Edge",
111 EmitterShape::Mesh { .. } => "Mesh",
112 EmitterShape::Trail => "Trail",
113 EmitterShape::Donut { .. } => "Donut",
114 EmitterShape::Rectangle { .. } => "Rectangle",
115 EmitterShape::Line { .. } => "Line",
116 }
117 }
118
119 pub fn sample_position(&self, rand: [f32; 4]) -> (Vec3, Vec3) {
121 match self {
122 EmitterShape::Point => (Vec3::ZERO, Vec3::Y),
123 EmitterShape::Sphere { radius, thickness } => {
124 let theta = rand[0] * std::f32::consts::TAU;
125 let phi = (rand[1] * 2.0 - 1.0).acos();
126 let r = radius * (1.0 - thickness * rand[2]);
127 let pos = Vec3::new(phi.sin() * theta.cos(), phi.cos(), phi.sin() * theta.sin()) * r;
128 (pos, pos.normalize())
129 }
130 EmitterShape::Cone { angle, radius, length, .. } => {
131 let t = rand[0] * length;
132 let local_radius = (angle * std::f32::consts::PI / 180.0).tan() * t + radius;
133 let theta = rand[1] * std::f32::consts::TAU;
134 let r = (rand[2]).sqrt() * local_radius;
135 let pos = Vec3::new(r * theta.cos(), t, r * theta.sin());
136 let dir = Vec3::new(0.0, 1.0, 0.0).normalize();
137 (pos, dir)
138 }
139 EmitterShape::Box { half_extents, emit_from_surface } => {
140 if *emit_from_surface {
141 let pos = Vec3::new(
142 (rand[0] * 2.0 - 1.0) * half_extents.x,
143 half_extents.y,
144 (rand[1] * 2.0 - 1.0) * half_extents.z,
145 );
146 (pos, Vec3::Y)
147 } else {
148 let pos = Vec3::new(
149 (rand[0] * 2.0 - 1.0) * half_extents.x,
150 (rand[1] * 2.0 - 1.0) * half_extents.y,
151 (rand[2] * 2.0 - 1.0) * half_extents.z,
152 );
153 (pos, Vec3::Y)
154 }
155 }
156 EmitterShape::Circle { radius, arc, thickness } => {
157 let theta = rand[0] * arc * std::f32::consts::PI / 180.0;
158 let r = radius * (1.0 - thickness * rand[1]);
159 (Vec3::new(r * theta.cos(), 0.0, r * theta.sin()), Vec3::Y)
160 }
161 _ => (Vec3::ZERO, Vec3::Y),
162 }
163 }
164}
165
166#[derive(Debug, Clone)]
171pub struct MainModule {
172 pub duration: f32,
173 pub looping: bool,
174 pub prewarm: bool,
175 pub start_delay: MinMaxCurve,
176 pub start_lifetime: MinMaxCurve,
177 pub start_speed: MinMaxCurve,
178 pub start_size_3d: bool,
179 pub start_size: MinMaxCurve,
180 pub start_size_x: MinMaxCurve,
181 pub start_size_y: MinMaxCurve,
182 pub start_size_z: MinMaxCurve,
183 pub start_rotation_3d: bool,
184 pub start_rotation: MinMaxCurve,
185 pub flip_rotation: f32,
186 pub start_color: MinMaxGradient,
187 pub gravity_modifier: MinMaxCurve,
188 pub simulation_space: SimulationSpace,
189 pub simulation_speed: f32,
190 pub stop_action: StopAction,
191 pub culling_mode: CullingMode,
192 pub max_particles: u32,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq)]
196pub enum SimulationSpace { Local, World, Custom }
197#[derive(Debug, Clone, Copy, PartialEq)]
198pub enum StopAction { None, Disable, Destroy, Callback }
199#[derive(Debug, Clone, Copy, PartialEq)]
200pub enum CullingMode { Automatic, AlwaysSimulate, PauseAndCatchup, Pause }
201
202impl Default for MainModule {
203 fn default() -> Self {
204 Self {
205 duration: 5.0,
206 looping: true,
207 prewarm: false,
208 start_delay: MinMaxCurve::constant(0.0),
209 start_lifetime: MinMaxCurve::random_range(1.0, 3.0),
210 start_speed: MinMaxCurve::random_range(1.0, 5.0),
211 start_size_3d: false,
212 start_size: MinMaxCurve::random_range(0.05, 0.2),
213 start_size_x: MinMaxCurve::constant(1.0),
214 start_size_y: MinMaxCurve::constant(1.0),
215 start_size_z: MinMaxCurve::constant(1.0),
216 start_rotation_3d: false,
217 start_rotation: MinMaxCurve::random_range(0.0, 360.0),
218 flip_rotation: 0.0,
219 start_color: MinMaxGradient::solid(Vec4::ONE),
220 gravity_modifier: MinMaxCurve::constant(0.0),
221 simulation_space: SimulationSpace::World,
222 simulation_speed: 1.0,
223 stop_action: StopAction::None,
224 culling_mode: CullingMode::Automatic,
225 max_particles: 1000,
226 }
227 }
228}
229
230#[derive(Debug, Clone)]
231pub struct EmissionModule {
232 pub enabled: bool,
233 pub rate_over_time: MinMaxCurve,
234 pub rate_over_distance: MinMaxCurve,
235 pub bursts: Vec<EmissionBurst>,
236}
237
238#[derive(Debug, Clone)]
239pub struct EmissionBurst {
240 pub time: f32,
241 pub count: MinMaxCurve,
242 pub cycles: u32,
243 pub interval: f32,
244 pub probability: f32,
245}
246
247impl Default for EmissionModule {
248 fn default() -> Self {
249 Self {
250 enabled: true,
251 rate_over_time: MinMaxCurve::constant(10.0),
252 rate_over_distance: MinMaxCurve::constant(0.0),
253 bursts: Vec::new(),
254 }
255 }
256}
257
258#[derive(Debug, Clone)]
259pub struct VelocityOverLifetimeModule {
260 pub enabled: bool,
261 pub x: MinMaxCurve,
262 pub y: MinMaxCurve,
263 pub z: MinMaxCurve,
264 pub space: SimulationSpace,
265 pub orbital_x: MinMaxCurve,
266 pub orbital_y: MinMaxCurve,
267 pub orbital_z: MinMaxCurve,
268 pub orbital_offset_x: MinMaxCurve,
269 pub orbital_offset_y: MinMaxCurve,
270 pub orbital_offset_z: MinMaxCurve,
271 pub radial: MinMaxCurve,
272 pub speed_modifier: MinMaxCurve,
273}
274
275impl Default for VelocityOverLifetimeModule {
276 fn default() -> Self {
277 Self {
278 enabled: false,
279 x: MinMaxCurve::constant(0.0),
280 y: MinMaxCurve::constant(0.0),
281 z: MinMaxCurve::constant(0.0),
282 space: SimulationSpace::Local,
283 orbital_x: MinMaxCurve::constant(0.0),
284 orbital_y: MinMaxCurve::constant(0.0),
285 orbital_z: MinMaxCurve::constant(0.0),
286 orbital_offset_x: MinMaxCurve::constant(0.0),
287 orbital_offset_y: MinMaxCurve::constant(0.0),
288 orbital_offset_z: MinMaxCurve::constant(0.0),
289 radial: MinMaxCurve::constant(0.0),
290 speed_modifier: MinMaxCurve::constant(1.0),
291 }
292 }
293}
294
295#[derive(Debug, Clone)]
296pub struct ColorOverLifetimeModule {
297 pub enabled: bool,
298 pub color: MinMaxGradient,
299}
300
301impl Default for ColorOverLifetimeModule {
302 fn default() -> Self {
303 Self {
304 enabled: true,
305 color: MinMaxGradient::two_color(Vec4::ONE, Vec4::new(1.0, 1.0, 1.0, 0.0)),
306 }
307 }
308}
309
310#[derive(Debug, Clone)]
311pub struct SizeOverLifetimeModule {
312 pub enabled: bool,
313 pub size: MinMaxCurve,
314 pub size_3d: bool,
315 pub x: MinMaxCurve,
316 pub y: MinMaxCurve,
317 pub z: MinMaxCurve,
318}
319
320impl Default for SizeOverLifetimeModule {
321 fn default() -> Self {
322 Self {
323 enabled: true,
324 size: MinMaxCurve::Curve(vec![
325 Vec2::new(0.0, 0.0),
326 Vec2::new(0.2, 1.0),
327 Vec2::new(0.8, 1.0),
328 Vec2::new(1.0, 0.0),
329 ]),
330 size_3d: false,
331 x: MinMaxCurve::constant(1.0),
332 y: MinMaxCurve::constant(1.0),
333 z: MinMaxCurve::constant(1.0),
334 }
335 }
336}
337
338#[derive(Debug, Clone)]
339pub struct RotationOverLifetimeModule {
340 pub enabled: bool,
341 pub angular_velocity: MinMaxCurve,
342 pub rotation_3d: bool,
343 pub x: MinMaxCurve,
344 pub y: MinMaxCurve,
345 pub z: MinMaxCurve,
346}
347
348impl Default for RotationOverLifetimeModule {
349 fn default() -> Self {
350 Self {
351 enabled: false,
352 angular_velocity: MinMaxCurve::random_range(-90.0, 90.0),
353 rotation_3d: false,
354 x: MinMaxCurve::constant(0.0),
355 y: MinMaxCurve::constant(0.0),
356 z: MinMaxCurve::constant(0.0),
357 }
358 }
359}
360
361#[derive(Debug, Clone, Copy, PartialEq)]
362pub enum ForceMode { Force, Acceleration, Impulse, VelocityChange }
363
364#[derive(Debug, Clone)]
365pub struct ForceOverLifetimeModule {
366 pub enabled: bool,
367 pub x: MinMaxCurve,
368 pub y: MinMaxCurve,
369 pub z: MinMaxCurve,
370 pub space: SimulationSpace,
371 pub randomize_per_frame: bool,
372}
373
374impl Default for ForceOverLifetimeModule {
375 fn default() -> Self {
376 Self {
377 enabled: false,
378 x: MinMaxCurve::constant(0.0),
379 y: MinMaxCurve::constant(-9.8),
380 z: MinMaxCurve::constant(0.0),
381 space: SimulationSpace::World,
382 randomize_per_frame: false,
383 }
384 }
385}
386
387#[derive(Debug, Clone, Copy, PartialEq)]
388pub enum CollisionType { Planes, World }
389#[derive(Debug, Clone, Copy, PartialEq)]
390pub enum CollisionMode { Collision2D, Collision3D }
391
392#[derive(Debug, Clone)]
393pub struct CollisionModule {
394 pub enabled: bool,
395 pub collision_type: CollisionType,
396 pub mode: CollisionMode,
397 pub dampen: MinMaxCurve,
398 pub bounce: MinMaxCurve,
399 pub lifetime_loss: MinMaxCurve,
400 pub min_kill_speed: f32,
401 pub max_kill_speed: f32,
402 pub radius_scale: f32,
403 pub send_collision_messages: bool,
404}
405
406impl Default for CollisionModule {
407 fn default() -> Self {
408 Self {
409 enabled: false,
410 collision_type: CollisionType::World,
411 mode: CollisionMode::Collision3D,
412 dampen: MinMaxCurve::constant(0.1),
413 bounce: MinMaxCurve::constant(0.6),
414 lifetime_loss: MinMaxCurve::constant(0.0),
415 min_kill_speed: 0.0,
416 max_kill_speed: 10000.0,
417 radius_scale: 1.0,
418 send_collision_messages: false,
419 }
420 }
421}
422
423#[derive(Debug, Clone)]
424pub struct TrailModule {
425 pub enabled: bool,
426 pub mode: TrailMode,
427 pub ratio: f32,
428 pub lifetime: MinMaxCurve,
429 pub min_vertex_distance: f32,
430 pub world_space: bool,
431 pub die_with_particles: bool,
432 pub texture_mode: TrailTextureMode,
433 pub size_affects_width: bool,
434 pub size_affects_lifetime: bool,
435 pub inherit_particle_color: bool,
436 pub color_over_lifetime: MinMaxGradient,
437 pub width_over_trail: MinMaxCurve,
438 pub color_over_trail: MinMaxGradient,
439 pub generate_lighting_data: bool,
440 pub shadow_bias: f32,
441}
442
443#[derive(Debug, Clone, Copy, PartialEq)]
444pub enum TrailMode { PerParticle, Ribbon }
445#[derive(Debug, Clone, Copy, PartialEq)]
446pub enum TrailTextureMode { Stretch, Tile, DistributedPerSegment, RepeatPerSegment }
447
448impl Default for TrailModule {
449 fn default() -> Self {
450 Self {
451 enabled: false,
452 mode: TrailMode::PerParticle,
453 ratio: 1.0,
454 lifetime: MinMaxCurve::constant(1.0),
455 min_vertex_distance: 0.1,
456 world_space: false,
457 die_with_particles: true,
458 texture_mode: TrailTextureMode::Stretch,
459 size_affects_width: true,
460 size_affects_lifetime: false,
461 inherit_particle_color: true,
462 color_over_lifetime: MinMaxGradient::solid(Vec4::ONE),
463 width_over_trail: MinMaxCurve::constant(1.0),
464 color_over_trail: MinMaxGradient::solid(Vec4::ONE),
465 generate_lighting_data: false,
466 shadow_bias: 0.5,
467 }
468 }
469}
470
471#[derive(Debug, Clone, Copy, PartialEq)]
476pub enum RenderMode { Billboard, StretchedBillboard, HorizontalBillboard, VerticalBillboard, Mesh, None }
477#[derive(Debug, Clone, Copy, PartialEq)]
478pub enum BillboardAlignment { View, World, Local, Facing, Velocity }
479#[derive(Debug, Clone, Copy, PartialEq)]
480pub enum SortMode { None, ByDistance, OldestInFront, YoungestInFront }
481
482#[derive(Debug, Clone)]
483pub struct RendererModule {
484 pub render_mode: RenderMode,
485 pub material_id: Option<u64>,
486 pub mesh_id: Option<u64>,
487 pub sort_mode: SortMode,
488 pub sorting_fudge: f32,
489 pub min_particle_size: f32,
490 pub max_particle_size: f32,
491 pub billboard_alignment: BillboardAlignment,
492 pub flip_u: f32,
493 pub flip_v: f32,
494 pub enable_gpu_instancing: bool,
495 pub shadow_casting: bool,
496 pub receive_shadows: bool,
497 pub motion_vectors: bool,
498 pub allow_roll: bool,
499 pub pivot: Vec3,
500 pub masking_layer: u32,
501 pub apply_active_color_space: bool,
502}
503
504impl Default for RendererModule {
505 fn default() -> Self {
506 Self {
507 render_mode: RenderMode::Billboard,
508 material_id: None,
509 mesh_id: None,
510 sort_mode: SortMode::None,
511 sorting_fudge: 0.0,
512 min_particle_size: 0.0,
513 max_particle_size: 0.5,
514 billboard_alignment: BillboardAlignment::View,
515 flip_u: 0.0,
516 flip_v: 0.0,
517 enable_gpu_instancing: true,
518 shadow_casting: false,
519 receive_shadows: false,
520 motion_vectors: false,
521 allow_roll: true,
522 pivot: Vec3::ZERO,
523 masking_layer: 0,
524 apply_active_color_space: true,
525 }
526 }
527}
528
529#[derive(Debug, Clone)]
534pub struct ParticleSystem {
535 pub id: u64,
536 pub name: String,
537 pub main: MainModule,
538 pub emission: EmissionModule,
539 pub shape: EmitterShape,
540 pub velocity_over_lifetime: VelocityOverLifetimeModule,
541 pub force_over_lifetime: ForceOverLifetimeModule,
542 pub color_over_lifetime: ColorOverLifetimeModule,
543 pub size_over_lifetime: SizeOverLifetimeModule,
544 pub rotation_over_lifetime: RotationOverLifetimeModule,
545 pub collision: CollisionModule,
546 pub trails: TrailModule,
547 pub renderer: RendererModule,
548 pub position: Vec3,
549 pub enabled: bool,
550 pub sub_systems: Vec<u64>,
551 pub tags: Vec<String>,
552}
553
554impl ParticleSystem {
555 pub fn new(id: u64, name: impl Into<String>) -> Self {
556 Self {
557 id,
558 name: name.into(),
559 main: MainModule::default(),
560 emission: EmissionModule::default(),
561 shape: EmitterShape::Cone { angle: 25.0, radius: 0.1, length: 0.0, emit_from_volume: false },
562 velocity_over_lifetime: VelocityOverLifetimeModule::default(),
563 force_over_lifetime: ForceOverLifetimeModule::default(),
564 color_over_lifetime: ColorOverLifetimeModule::default(),
565 size_over_lifetime: SizeOverLifetimeModule::default(),
566 rotation_over_lifetime: RotationOverLifetimeModule::default(),
567 collision: CollisionModule::default(),
568 trails: TrailModule::default(),
569 renderer: RendererModule::default(),
570 position: Vec3::ZERO,
571 enabled: true,
572 sub_systems: Vec::new(),
573 tags: Vec::new(),
574 }
575 }
576
577 pub fn fire() -> Self {
578 let mut ps = Self::new(1, "Fire");
579 ps.main.start_lifetime = MinMaxCurve::random_range(0.5, 1.5);
580 ps.main.start_speed = MinMaxCurve::random_range(0.5, 2.0);
581 ps.main.start_size = MinMaxCurve::random_range(0.1, 0.5);
582 ps.main.start_color = MinMaxGradient::two_color(
583 Vec4::new(1.0, 0.5, 0.0, 1.0),
584 Vec4::new(1.0, 0.8, 0.0, 1.0),
585 );
586 ps.main.gravity_modifier = MinMaxCurve::constant(-0.2);
587 ps.emission.rate_over_time = MinMaxCurve::constant(30.0);
588 ps.shape = EmitterShape::Cone { angle: 15.0, radius: 0.2, length: 0.0, emit_from_volume: false };
589 ps.color_over_lifetime.color = MinMaxGradient::two_color(
590 Vec4::new(1.0, 0.5, 0.0, 1.0),
591 Vec4::new(0.2, 0.2, 0.2, 0.0),
592 );
593 ps
594 }
595
596 pub fn smoke() -> Self {
597 let mut ps = Self::new(2, "Smoke");
598 ps.main.start_lifetime = MinMaxCurve::random_range(2.0, 5.0);
599 ps.main.start_speed = MinMaxCurve::random_range(0.2, 0.8);
600 ps.main.start_size = MinMaxCurve::random_range(0.3, 1.0);
601 ps.main.start_color = MinMaxGradient::solid(Vec4::new(0.4, 0.4, 0.4, 0.5));
602 ps.emission.rate_over_time = MinMaxCurve::constant(5.0);
603 ps.size_over_lifetime.size = MinMaxCurve::Curve(vec![
604 Vec2::new(0.0, 0.3), Vec2::new(0.5, 0.8), Vec2::new(1.0, 2.0),
605 ]);
606 ps.rotation_over_lifetime.enabled = true;
607 ps
608 }
609
610 pub fn sparks() -> Self {
611 let mut ps = Self::new(3, "Sparks");
612 ps.main.start_lifetime = MinMaxCurve::random_range(0.5, 1.5);
613 ps.main.start_speed = MinMaxCurve::random_range(3.0, 8.0);
614 ps.main.start_size = MinMaxCurve::random_range(0.02, 0.06);
615 ps.main.start_color = MinMaxGradient::two_color(
616 Vec4::new(1.0, 1.0, 0.5, 1.0),
617 Vec4::new(1.0, 0.3, 0.0, 1.0),
618 );
619 ps.main.gravity_modifier = MinMaxCurve::constant(1.0);
620 ps.emission.rate_over_time = MinMaxCurve::constant(0.0);
621 ps.emission.bursts.push(EmissionBurst {
622 time: 0.0,
623 count: MinMaxCurve::random_range(50.0, 100.0),
624 cycles: 1,
625 interval: 0.0,
626 probability: 1.0,
627 });
628 ps.shape = EmitterShape::Sphere { radius: 0.1, thickness: 0.0 };
629 ps.collision.enabled = true;
630 ps
631 }
632
633 pub fn rain() -> Self {
634 let mut ps = Self::new(4, "Rain");
635 ps.main.start_lifetime = MinMaxCurve::constant(2.0);
636 ps.main.start_speed = MinMaxCurve::random_range(5.0, 10.0);
637 ps.main.start_size = MinMaxCurve::random_range(0.01, 0.03);
638 ps.main.start_color = MinMaxGradient::solid(Vec4::new(0.6, 0.7, 1.0, 0.6));
639 ps.main.gravity_modifier = MinMaxCurve::constant(1.0);
640 ps.main.max_particles = 5000;
641 ps.emission.rate_over_time = MinMaxCurve::constant(200.0);
642 ps.shape = EmitterShape::Box { half_extents: Vec3::new(20.0, 0.1, 20.0), emit_from_surface: true };
643 ps.renderer.render_mode = RenderMode::StretchedBillboard;
644 ps.collision.enabled = true;
645 ps
646 }
647
648 pub fn estimated_particle_count(&self) -> f32 {
649 let rate = self.emission.rate_over_time.evaluate(0.0, 0.5);
650 let lifetime_avg = (self.main.start_lifetime.evaluate(0.0, 0.0) +
651 self.main.start_lifetime.evaluate(0.0, 1.0)) * 0.5;
652 (rate * lifetime_avg).min(self.main.max_particles as f32)
653 }
654}
655
656#[derive(Debug, Clone, Copy, PartialEq)]
661pub enum ParticleEditorTab {
662 Emitter,
663 Modules,
664 Preview,
665 Timeline,
666 Curves,
667 Gradient,
668}
669
670#[derive(Debug, Clone, Copy, PartialEq)]
671pub enum ParticlePreviewMode {
672 Solid,
673 Wireframe,
674 Velocity,
675 Age,
676 Count,
677}
678
679#[derive(Debug, Clone)]
680pub struct ParticleEditor {
681 pub systems: Vec<ParticleSystem>,
682 pub selected_system: Option<usize>,
683 pub active_tab: ParticleEditorTab,
684 pub preview_mode: ParticlePreviewMode,
685 pub preview_time: f32,
686 pub preview_playing: bool,
687 pub preview_loop: bool,
688 pub preview_speed: f32,
689 pub show_bounds: bool,
690 pub show_emitter_shape: bool,
691 pub camera_distance: f32,
692 pub search_query: String,
693 pub next_id: u64,
694}
695
696impl ParticleEditor {
697 pub fn new() -> Self {
698 let mut ed = Self {
699 systems: Vec::new(),
700 selected_system: None,
701 active_tab: ParticleEditorTab::Emitter,
702 preview_mode: ParticlePreviewMode::Solid,
703 preview_time: 0.0,
704 preview_playing: true,
705 preview_loop: true,
706 preview_speed: 1.0,
707 show_bounds: false,
708 show_emitter_shape: true,
709 camera_distance: 10.0,
710 search_query: String::new(),
711 next_id: 5,
712 };
713 ed.systems.push(ParticleSystem::fire());
714 ed.systems.push(ParticleSystem::smoke());
715 ed.systems.push(ParticleSystem::sparks());
716 ed.systems.push(ParticleSystem::rain());
717 ed.selected_system = Some(0);
718 ed
719 }
720
721 pub fn add_system(&mut self, name: impl Into<String>) -> usize {
722 let id = self.next_id;
723 self.next_id += 1;
724 let ps = ParticleSystem::new(id, name);
725 let idx = self.systems.len();
726 self.systems.push(ps);
727 idx
728 }
729
730 pub fn remove_system(&mut self, idx: usize) {
731 if idx < self.systems.len() {
732 self.systems.remove(idx);
733 if self.selected_system == Some(idx) {
734 self.selected_system = if self.systems.is_empty() { None } else { Some(0) };
735 }
736 }
737 }
738
739 pub fn duplicate_system(&mut self, idx: usize) -> Option<usize> {
740 if idx >= self.systems.len() { return None; }
741 let mut copy = self.systems[idx].clone();
742 copy.id = self.next_id;
743 self.next_id += 1;
744 copy.name = format!("{}_copy", copy.name);
745 let new_idx = self.systems.len();
746 self.systems.push(copy);
747 Some(new_idx)
748 }
749
750 pub fn update(&mut self, dt: f32) {
751 if self.preview_playing {
752 self.preview_time += dt * self.preview_speed;
753 if let Some(idx) = self.selected_system {
754 let duration = self.systems[idx].main.duration;
755 if self.preview_time >= duration {
756 if self.preview_loop {
757 self.preview_time = self.preview_time % duration;
758 } else {
759 self.preview_time = duration;
760 self.preview_playing = false;
761 }
762 }
763 }
764 }
765 }
766
767 pub fn selected_system(&self) -> Option<&ParticleSystem> {
768 self.selected_system.and_then(|i| self.systems.get(i))
769 }
770
771 pub fn selected_system_mut(&mut self) -> Option<&mut ParticleSystem> {
772 self.selected_system.and_then(|i| self.systems.get_mut(i))
773 }
774
775 pub fn search_systems(&self, query: &str) -> Vec<usize> {
776 let q = query.to_lowercase();
777 self.systems.iter().enumerate()
778 .filter(|(_, s)| s.name.to_lowercase().contains(&q) || s.tags.iter().any(|t| t.to_lowercase().contains(&q)))
779 .map(|(i, _)| i)
780 .collect()
781 }
782
783 pub fn total_estimated_particles(&self) -> f32 {
784 self.systems.iter().filter(|s| s.enabled).map(|s| s.estimated_particle_count()).sum()
785 }
786}
787
788#[cfg(test)]
792mod tests {
793 use super::*;
794
795 #[test]
796 fn test_min_max_curve() {
797 let c = MinMaxCurve::random_range(1.0, 3.0);
798 let v = c.evaluate(0.5, 0.5);
799 assert!((v - 2.0).abs() < 1e-5);
800 }
801
802 #[test]
803 fn test_gradient() {
804 let g = MinMaxGradient::two_color(Vec4::ZERO, Vec4::ONE);
805 let mid = g.evaluate(0.5);
806 assert!((mid.x - 0.5).abs() < 1e-5);
807 }
808
809 #[test]
810 fn test_emitter_shape_sample() {
811 let shape = EmitterShape::Sphere { radius: 1.0, thickness: 0.0 };
812 let (pos, dir) = shape.sample_position([0.3, 0.6, 0.9, 0.1]);
813 assert!((pos.length() - 1.0).abs() < 0.01);
814 assert!((dir.length() - 1.0).abs() < 0.01);
815 }
816
817 #[test]
818 fn test_particle_system_presets() {
819 let fire = ParticleSystem::fire();
820 assert!(fire.estimated_particle_count() > 0.0);
821 let sparks = ParticleSystem::sparks();
822 assert!(!sparks.emission.bursts.is_empty());
823 }
824
825 #[test]
826 fn test_editor() {
827 let mut ed = ParticleEditor::new();
828 assert_eq!(ed.systems.len(), 4);
829 ed.update(0.016);
830 let idx = ed.add_system("TestPS");
831 assert!(ed.systems[idx].name == "TestPS");
832 }
833}