1use crate::components::*;
18use crate::mesh::{MeshData, Vertex};
19use crate::procedural::Rng;
20use crate::reflect::{AssetKind, Field};
21use glam::{Mat4, Vec3};
22use kvas_ecs::World;
23use serde::{Deserialize, Serialize};
24use std::sync::Arc;
25
26crate::choice_enum!(
27 EmitterShape { Point, Sphere, Box, Cone, Disc } default Point
29);
30
31#[derive(Clone, Serialize, Deserialize)]
33#[serde(default)]
34pub struct ParticleEmitter {
35 pub rate: f32,
37 pub burst: i32,
39 pub lifetime: f32,
41 pub lifetime_variation: f32,
43 pub speed: f32,
45 pub speed_variation: f32,
47 pub spread: f32,
49 pub gravity: f32,
51 pub drag: f32,
53 pub size_start: f32,
55 pub size_end: f32,
57 pub color_start: [f32; 4],
59 pub color_end: [f32; 4],
61 pub glow: f32,
63 pub shape: EmitterShape,
65 pub shape_size: f32,
67 pub texture: String,
69 pub looping: bool,
71 pub duration: f32,
73 pub max_particles: i32,
75 pub seed: i32,
77 pub stretch: f32,
79 pub collide_floor: bool,
81 pub floor_height: f32,
83 pub splash: i32,
85 pub follow_camera: bool,
87 pub soft: f32,
89 #[serde(skip)]
90 #[doc(hidden)]
91 pub state: Option<ParticleState>,
92}
93
94impl Default for ParticleEmitter {
95 fn default() -> Self {
96 Self {
97 rate: 30.0,
98 burst: 0,
99 lifetime: 2.0,
100 lifetime_variation: 0.3,
101 speed: 2.0,
102 speed_variation: 0.3,
103 spread: 25.0,
104 gravity: 0.0,
105 drag: 0.2,
106 size_start: 0.3,
107 size_end: 0.05,
108 color_start: [1.0, 0.8, 0.4, 1.0],
109 color_end: [1.0, 0.2, 0.05, 0.0],
110 glow: 1.5,
111 shape: EmitterShape::Point,
112 shape_size: 0.2,
113 texture: String::new(),
114 looping: true,
115 duration: 5.0,
116 max_particles: 600,
117 seed: 7,
118 stretch: 0.0,
119 collide_floor: false,
120 floor_height: 0.0,
121 splash: 0,
122 follow_camera: false,
123 soft: 0.0,
124 state: None,
125 }
126 }
127}
128
129impl Component for ParticleEmitter {
130 fn fields(&mut self) -> Vec<Field<'_>> {
131 vec![
132 Field::f32("rate", &mut self.rate),
133 Field::i32("burst", &mut self.burst),
134 Field::f32("lifetime", &mut self.lifetime),
135 Field::f32("lifetime_variation", &mut self.lifetime_variation),
136 Field::f32("speed", &mut self.speed),
137 Field::f32("speed_variation", &mut self.speed_variation),
138 Field::f32("spread", &mut self.spread),
139 Field::f32("gravity", &mut self.gravity),
140 Field::f32("drag", &mut self.drag),
141 Field::f32("size_start", &mut self.size_start),
142 Field::f32("size_end", &mut self.size_end),
143 Field::color("color_start", &mut self.color_start),
144 Field::color("color_end", &mut self.color_end),
145 Field::f32("glow", &mut self.glow),
146 Field::choice("shape", &mut self.shape),
147 Field::f32("shape_size", &mut self.shape_size),
148 Field::asset("texture", &mut self.texture, AssetKind::Texture),
149 Field::bool("looping", &mut self.looping),
150 Field::f32("duration", &mut self.duration),
151 Field::i32("max_particles", &mut self.max_particles),
152 Field::i32("seed", &mut self.seed),
153 Field::f32("stretch", &mut self.stretch),
154 Field::bool("collide_floor", &mut self.collide_floor),
155 Field::f32("floor_height", &mut self.floor_height),
156 Field::i32("splash", &mut self.splash),
157 Field::bool("follow_camera", &mut self.follow_camera),
158 Field::f32("soft", &mut self.soft),
159 ]
160 }
161}
162
163impl ParticleEmitter {
164 pub fn preset(name: &str) -> Self {
167 let base = Self::default();
168 match name {
169 "fire" => Self { rate: 60.0, lifetime: 1.1, speed: 1.4, spread: 12.0, gravity: 2.5, drag: 0.4, size_start: 0.35, size_end: 0.05, shape: EmitterShape::Disc, shape_size: 0.25, color_start: [1.0, 0.75, 0.25, 0.9], color_end: [0.9, 0.1, 0.02, 0.0], glow: 3.0, ..base },
170 "smoke" => Self { rate: 14.0, lifetime: 4.5, speed: 0.9, spread: 18.0, gravity: 0.6, drag: 0.5, size_start: 0.4, size_end: 2.2, shape: EmitterShape::Disc, shape_size: 0.2, color_start: [0.35, 0.35, 0.38, 0.55], color_end: [0.6, 0.6, 0.65, 0.0], glow: 0.4, ..base },
171 "sparks" => Self { rate: 40.0, lifetime: 0.9, speed: 4.5, spread: 55.0, gravity: -9.0, drag: 0.1, size_start: 0.06, size_end: 0.02, color_start: [1.0, 0.85, 0.4, 1.0], color_end: [1.0, 0.3, 0.05, 0.0], glow: 4.0, ..base },
172 "snow" => Self { rate: 120.0, lifetime: 7.0, speed: 0.2, spread: 180.0, gravity: -1.1, drag: 0.8, size_start: 0.07, size_end: 0.07, shape: EmitterShape::Box, shape_size: 10.0, color_start: [1.0, 1.0, 1.0, 0.9], color_end: [1.0, 1.0, 1.0, 0.0], glow: 1.2, max_particles: 1500, ..base },
173 "rain" => Self { rate: 400.0, lifetime: 1.2, speed: 0.0, spread: 0.0, gravity: -18.0, drag: 0.0, size_start: 0.03, size_end: 0.03, shape: EmitterShape::Box, shape_size: 10.0, color_start: [0.7, 0.8, 1.0, 0.5], color_end: [0.7, 0.8, 1.0, 0.1], glow: 1.0, max_particles: 2500, ..base },
174 "heavy_rain" => Self { rate: 2600.0, lifetime: 1.0, lifetime_variation: 0.1, speed: 9.0, speed_variation: 0.15, spread: 4.0, gravity: -14.0, drag: 0.0, size_start: 0.012, size_end: 0.012, shape: EmitterShape::Box, shape_size: 14.0, color_start: [0.75, 0.82, 0.95, 0.35], color_end: [0.75, 0.82, 0.95, 0.25], glow: 1.6, max_particles: 6000, stretch: 0.045, collide_floor: true, splash: 2, follow_camera: true, ..base },
175 "drizzle" => Self { rate: 700.0, lifetime: 1.6, speed: 5.0, spread: 6.0, gravity: -8.0, drag: 0.0, size_start: 0.008, size_end: 0.008, shape: EmitterShape::Box, shape_size: 12.0, color_start: [0.8, 0.85, 0.95, 0.25], color_end: [0.8, 0.85, 0.95, 0.15], glow: 1.4, max_particles: 2400, stretch: 0.03, collide_floor: true, follow_camera: true, ..base },
176 "magic" => Self { rate: 35.0, lifetime: 2.2, speed: 0.7, spread: 180.0, gravity: 0.4, drag: 0.7, size_start: 0.12, size_end: 0.0, shape: EmitterShape::Sphere, shape_size: 0.6, color_start: [0.5, 0.8, 1.0, 1.0], color_end: [0.8, 0.3, 1.0, 0.0], glow: 4.0, ..base },
177 "dust" => Self { rate: 25.0, lifetime: 6.0, speed: 0.15, spread: 180.0, gravity: 0.0, drag: 0.9, size_start: 0.04, size_end: 0.04, shape: EmitterShape::Box, shape_size: 4.0, color_start: [1.0, 0.95, 0.8, 0.5], color_end: [1.0, 0.95, 0.8, 0.0], glow: 1.5, ..base },
178 "fog" => Self { rate: 6.0, lifetime: 14.0, lifetime_variation: 0.4, speed: 0.12, spread: 180.0, gravity: 0.0, drag: 0.3, size_start: 4.0, size_end: 7.0, shape: EmitterShape::Box, shape_size: 9.0, color_start: [0.78, 0.82, 0.88, 0.0], color_end: [0.78, 0.82, 0.88, 0.0], glow: 0.9, max_particles: 160, ..base },
179 "mist" => Self { rate: 10.0, lifetime: 10.0, speed: 0.2, spread: 90.0, gravity: 0.0, drag: 0.4, size_start: 2.5, size_end: 4.5, shape: EmitterShape::Disc, shape_size: 7.0, color_start: [0.85, 0.9, 0.95, 0.18], color_end: [0.85, 0.9, 0.95, 0.0], glow: 1.0, max_particles: 220, ..base },
180 "steam" => Self { rate: 22.0, lifetime: 2.4, speed: 1.2, spread: 14.0, gravity: 1.2, drag: 0.6, size_start: 0.2, size_end: 1.1, shape: EmitterShape::Disc, shape_size: 0.12, color_start: [0.95, 0.95, 0.97, 0.45], color_end: [0.95, 0.95, 0.97, 0.0], glow: 1.1, ..base },
181 "embers" => Self { rate: 18.0, lifetime: 3.4, speed: 1.1, spread: 40.0, gravity: 1.4, drag: 0.35, size_start: 0.05, size_end: 0.01, shape: EmitterShape::Disc, shape_size: 0.5, color_start: [1.0, 0.55, 0.12, 1.0], color_end: [1.0, 0.1, 0.0, 0.0], glow: 5.0, ..base },
182 "fireflies" => Self { rate: 6.0, lifetime: 7.0, speed: 0.25, spread: 180.0, gravity: 0.0, drag: 0.2, size_start: 0.07, size_end: 0.07, shape: EmitterShape::Box, shape_size: 6.0, color_start: [0.8, 1.0, 0.4, 0.9], color_end: [0.8, 1.0, 0.4, 0.0], glow: 6.0, max_particles: 80, ..base },
183 "leaves" => Self { rate: 7.0, lifetime: 8.0, speed: 0.5, spread: 70.0, gravity: -0.8, drag: 1.2, size_start: 0.14, size_end: 0.14, shape: EmitterShape::Box, shape_size: 6.0, color_start: [0.8, 0.55, 0.15, 0.95], color_end: [0.6, 0.35, 0.1, 0.0], glow: 1.0, max_particles: 160, ..base },
184 "bubbles" => Self { rate: 8.0, lifetime: 4.0, speed: 0.7, spread: 18.0, gravity: 0.5, drag: 0.3, size_start: 0.08, size_end: 0.16, shape: EmitterShape::Disc, shape_size: 0.4, color_start: [0.8, 0.95, 1.0, 0.5], color_end: [0.8, 0.95, 1.0, 0.0], glow: 1.4, ..base },
185 "campfire_smoke" => Self { rate: 18.0, lifetime: 6.0, speed: 1.1, spread: 12.0, gravity: 0.7, drag: 0.35, size_start: 0.35, size_end: 2.6, shape: EmitterShape::Disc, shape_size: 0.25, color_start: [0.22, 0.22, 0.24, 0.5], color_end: [0.5, 0.5, 0.54, 0.0], glow: 0.6, ..base },
186 "dust_storm" => Self { rate: 60.0, lifetime: 5.0, speed: 6.0, spread: 20.0, gravity: 0.0, drag: 0.2, size_start: 1.2, size_end: 3.0, shape: EmitterShape::Box, shape_size: 8.0, color_start: [0.85, 0.7, 0.45, 0.28], color_end: [0.85, 0.7, 0.45, 0.0], glow: 1.0, max_particles: 600, ..base },
187 "explosion" => Self { rate: 0.0, burst: 140, lifetime: 1.4, speed: 7.0, spread: 180.0, gravity: -2.0, drag: 1.6, size_start: 0.6, size_end: 0.1, shape: EmitterShape::Sphere, shape_size: 0.2, color_start: [1.0, 0.7, 0.2, 1.0], color_end: [0.3, 0.1, 0.05, 0.0], glow: 3.5, looping: false, duration: 0.1, ..base },
188 _ => base,
189 }
190 }
191
192 pub fn alive(&self) -> usize {
194 self.state.as_ref().map_or(0, |s| s.particles.len())
195 }
196
197 pub fn restart(&mut self) {
199 self.state = None;
200 }
201}
202
203#[derive(Clone)]
204struct Particle {
205 pos: Vec3,
206 vel: Vec3,
207 age: f32,
208 life: f32,
209 splash: bool,
211}
212
213#[doc(hidden)]
215#[derive(Clone)]
216pub struct ParticleState {
217 particles: Vec<Particle>,
218 rng: Rng,
219 carry: f32,
220 elapsed: f32,
221 last_time: f32,
222 version: u64,
223}
224
225impl ParticleState {
226 fn new(seed: i32) -> Self {
227 Self { particles: Vec::new(), rng: Rng::new(seed as u64), carry: 0.0, elapsed: 0.0, last_time: f32::NAN, version: 0 }
228 }
229}
230
231fn random_in_cone(rng: &mut Rng, axis: Vec3, spread_deg: f32) -> Vec3 {
232 let spread = spread_deg.clamp(0.0, 180.0).to_radians();
233 let cos_t = 1.0 - rng.f32() * (1.0 - spread.cos());
234 let sin_t = (1.0 - cos_t * cos_t).max(0.0).sqrt();
235 let phi = rng.f32() * std::f32::consts::TAU;
236 let u = axis.any_orthonormal_vector();
237 let v = axis.cross(u);
238 (axis * cos_t + (u * phi.cos() + v * phi.sin()) * sin_t).normalize_or_zero()
239}
240
241fn spawn(e: &ParticleEmitter, st: &mut ParticleState, world: &Mat4) {
242 if st.particles.len() >= e.max_particles.clamp(1, 20_000) as usize {
243 return;
244 }
245 let rng = &mut st.rng;
246 let size = e.shape_size.max(0.0);
247 let local = match e.shape {
248 EmitterShape::Point => Vec3::ZERO,
249 EmitterShape::Sphere => random_in_cone(rng, Vec3::Y, 180.0) * size * rng.f32().cbrt(),
250 EmitterShape::Box => Vec3::new(rng.f32() * 2.0 - 1.0, rng.f32() * 2.0 - 1.0, rng.f32() * 2.0 - 1.0) * size,
251 EmitterShape::Cone => {
252 let a = rng.f32() * std::f32::consts::TAU;
253 let r = rng.f32().sqrt() * size;
254 Vec3::new(a.cos() * r, 0.0, a.sin() * r)
255 }
256 EmitterShape::Disc => {
257 let a = rng.f32() * std::f32::consts::TAU;
258 let r = rng.f32().sqrt() * size;
259 Vec3::new(a.cos() * r, 0.0, a.sin() * r)
260 }
261 };
262 let dir_local = random_in_cone(rng, Vec3::Y, e.spread);
263 let speed = e.speed * (1.0 + (rng.f32() * 2.0 - 1.0) * e.speed_variation.clamp(0.0, 1.0));
264 let life = (e.lifetime * (1.0 + (rng.f32() * 2.0 - 1.0) * e.lifetime_variation.clamp(0.0, 1.0))).max(0.05);
265 st.particles.push(Particle { pos: world.transform_point3(local), vel: world.transform_vector3(dir_local).normalize_or_zero() * speed, age: 0.0, life, splash: false });
266}
267
268fn step(e: &ParticleEmitter, st: &mut ParticleState, world: &Mat4, dt: f32) {
269 st.elapsed += dt;
270 let drag = (1.0 - e.drag.max(0.0) * dt).max(0.0);
271 let mut hits = Vec::new();
272 for p in &mut st.particles {
273 p.age += dt;
274 p.vel.y += if p.splash { -9.0 } else { e.gravity } * dt;
275 p.vel *= drag;
276 p.pos += p.vel * dt;
277 if e.collide_floor && p.pos.y < e.floor_height && p.age < p.life {
278 if !p.splash {
279 hits.push(Vec3::new(p.pos.x, e.floor_height + 0.01, p.pos.z));
280 }
281 p.age = p.life;
282 }
283 }
284 st.particles.retain(|p| p.age < p.life);
285 let budget = e.max_particles.clamp(1, 20_000) as usize;
287 for at in hits {
288 for _ in 0..e.splash.clamp(0, 8) {
289 if st.particles.len() >= budget {
290 break;
291 }
292 let a = st.rng.f32() * std::f32::consts::TAU;
293 let up = 0.8 + st.rng.f32() * 1.2;
294 let out = 0.3 + st.rng.f32() * 0.6;
295 st.particles.push(Particle { pos: at, vel: Vec3::new(a.cos() * out, up, a.sin() * out), age: 0.0, life: 0.18 + st.rng.f32() * 0.12, splash: true });
296 }
297 }
298 let emitting = e.looping || st.elapsed <= e.duration;
299 if emitting && e.rate > 0.0 {
300 st.carry += e.rate * dt;
301 while st.carry >= 1.0 {
302 st.carry -= 1.0;
303 spawn(e, st, world);
304 }
305 }
306}
307
308fn camera_position(world: &World) -> Option<Vec3> {
309 let entity = crate::components::active_camera(world)?;
310 Some(world.get::<GlobalTransform>(entity)?.0.w_axis.truncate())
311}
312
313fn build_mesh(e: &ParticleEmitter, st: &ParticleState, to_local: &Mat4) -> MeshData {
319 let mut m = MeshData::default();
320 m.vertices.reserve(st.particles.len() * 4);
321 m.indices.reserve(st.particles.len() * 6);
322 for p in &st.particles {
323 let t = (p.age / p.life).clamp(0.0, 1.0);
324 let size = if p.splash { e.size_start.max(0.006) * 0.8 } else { e.size_start + (e.size_end - e.size_start) * t };
325 let center = to_local.transform_point3(p.pos);
326 let streak = (e.stretch > 0.0 && !p.splash && p.vel.length_squared() > 1e-4)
328 .then(|| to_local.transform_vector3(p.vel.normalize()) * (p.vel.length() * e.stretch).max(size));
329 let base = m.vertices.len() as u32;
330 for (qx, qy) in [(-1.0f32, -1.0f32), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)] {
331 let (pos, mode) = match streak {
332 Some(along) => (center + along * qy, -2.0),
333 None => (center, -1.0),
334 };
335 m.vertices.push(Vertex { pos: pos.into(), normal: [qx, qy, t], uv: [size, mode] });
336 }
337 m.indices.extend([base, base + 1, base + 2, base, base + 2, base + 3]);
338 }
339 m
340}
341
342pub fn update(world: &mut World, time: f32, dt: f32, simulate: bool) {
345 for entity in world.entities_with::<ParticleEmitter>() {
346 let Some(mut emitter) = world.get::<ParticleEmitter>(entity).cloned() else { continue };
347 let mut global = world.get::<GlobalTransform>(entity).map_or(Mat4::IDENTITY, |g| g.0);
348 let eye = camera_position(world);
349 if emitter.follow_camera {
350 if let Some(c) = eye {
351 let (s, r, t) = global.to_scale_rotation_translation();
353 global = Mat4::from_scale_rotation_translation(s, r, Vec3::new(c.x, t.y.max(c.y + 4.0), c.z));
354 }
355 }
356 let mut st = emitter.state.take().unwrap_or_else(|| ParticleState::new(emitter.seed));
357 let first = st.last_time.is_nan();
358 let delta = if simulate { dt } else if first { 0.0 } else { (time - st.last_time).clamp(0.0, 0.1) };
359 st.last_time = time;
360 if first {
361 for _ in 0..emitter.burst.clamp(0, 5000) {
362 spawn(&emitter, &mut st, &global);
363 }
364 }
365 let steps = ((delta / 0.033).ceil() as usize).max(1);
367 for _ in 0..steps {
368 step(&emitter, &mut st, &global, delta / steps as f32);
369 }
370 st.version += 1;
371 let inverse = if global.determinant().abs() > 1e-9 { global.inverse() } else { Mat4::IDENTITY };
372 let real = world.get::<GlobalTransform>(entity).map_or(Mat4::IDENTITY, |g| g.0);
373 let inverse = if emitter.follow_camera && real.determinant().abs() > 1e-9 { real.inverse() } else { inverse };
374 let mesh = build_mesh(&emitter, &st, &inverse);
375 let version = st.version;
376 emitter.state = Some(st);
377 if let Some(slot) = world.get_mut::<ParticleEmitter>(entity) {
378 *slot = emitter.clone();
379 }
380 world.insert(entity, DynamicMesh { data: Arc::new(mesh), version });
381 if !world.has::<MeshRenderer>(entity) {
382 world.insert(entity, MeshRenderer::default());
383 }
384 let m = world.get_mut::<MeshRenderer>(entity).unwrap();
385 m.shader = "builtin:particle".into();
386 m.cast_shadows = false;
387 m.color = [1.0, 1.0, 1.0, 0.99];
388 m.texture = emitter.texture.clone();
389 m.emissive = emitter.glow;
390 m.roughness = emitter.soft;
391 m.user = [
392 emitter.color_start[0], emitter.color_start[1], emitter.color_start[2], emitter.color_start[3],
393 emitter.color_end[0], emitter.color_end[1], emitter.color_end[2], emitter.color_end[3],
394 ];
395 }
396}