1use bevy_ecs::prelude::*;
12use glam::Vec3;
13use bytemuck::{Pod, Zeroable};
14use wgpu::util::DeviceExt;
15
16#[derive(Debug, Clone, Copy)]
29pub struct Particle {
30 pub position: Vec3,
31 pub velocity: Vec3,
32 pub color: [f32; 4],
33 pub size: f32,
34 pub age: f32,
35 pub lifetime: f32,
36}
37
38impl Particle {
39 pub fn new(position: Vec3, velocity: Vec3, lifetime: f32) -> Self {
40 Self {
41 position,
42 velocity,
43 color: [1.0, 1.0, 1.0, 1.0],
44 size: 0.1,
45 age: 0.0,
46 lifetime,
47 }
48 }
49
50 pub fn is_alive(&self) -> bool {
52 self.age < self.lifetime
53 }
54
55 pub fn normalized_age(&self) -> f32 {
57 (self.age / self.lifetime).clamp(0.0, 1.0)
58 }
59
60 pub fn update(&mut self, dt: f32, gravity: Vec3) {
62 self.velocity += gravity * dt;
63 self.position += self.velocity * dt;
64 self.age += dt;
65 self.color[3] = 1.0 - self.normalized_age();
67 }
68}
69
70#[derive(Debug, Clone)]
79pub enum EmitShape {
80 Point,
82 Sphere { radius: f32 },
84 Cone { angle: f32, radius: f32 },
86 Box { half_extents: Vec3 },
88}
89
90impl Default for EmitShape {
91 fn default() -> Self { EmitShape::Point }
92}
93
94#[derive(Debug, Clone, Component)]
114pub struct ParticleEmitter {
115 pub emit_rate: f32,
117 pub lifetime: f32,
119 pub initial_speed: f32,
121 pub speed_variance: f32,
123 pub initial_size: f32,
125 pub size_variance: f32,
127 pub start_color: [f32; 4],
129 pub end_color: [f32; 4],
131 pub gravity: Vec3,
133 pub shape: EmitShape,
135 pub max_particles: usize,
137 pub enabled: bool,
139 pub emit_accumulator: f32,
141}
142
143impl Default for ParticleEmitter {
144 fn default() -> Self {
145 Self {
146 emit_rate: 20.0,
147 lifetime: 1.5,
148 initial_speed: 2.0,
149 speed_variance: 0.5,
150 initial_size: 0.05,
151 size_variance: 0.02,
152 start_color: [1.0, 1.0, 1.0, 1.0],
153 end_color: [1.0, 1.0, 1.0, 0.0],
154 gravity: Vec3::new(0.0, -9.8, 0.0),
155 shape: EmitShape::Point,
156 max_particles: 200,
157 enabled: true,
158 emit_accumulator: 0.0,
159 }
160 }
161}
162
163pub struct ParticleSystem {
175 particles: Vec<Particle>,
176 capacity: usize,
177}
178
179impl ParticleSystem {
180 pub fn new(capacity: usize) -> Self {
181 Self {
182 particles: Vec::with_capacity(capacity),
183 capacity,
184 }
185 }
186
187 pub fn alive_count(&self) -> usize {
189 self.particles.iter().filter(|p| p.is_alive()).count()
190 }
191
192 pub fn capacity(&self) -> usize {
194 self.capacity
195 }
196
197 pub fn emit(&mut self, particle: Particle) {
199 if self.particles.len() < self.capacity {
200 self.particles.push(particle);
201 } else {
202 if let Some(dead) = self.particles.iter_mut().find(|p| !p.is_alive()) {
204 *dead = particle;
205 }
206 }
207 }
208
209 pub fn update(&mut self, dt: f32, gravity: Vec3) {
211 for p in &mut self.particles {
212 if p.is_alive() {
213 p.update(dt, gravity);
214 }
215 }
216 }
217
218 pub fn alive_particles(&self) -> impl Iterator<Item = &Particle> {
220 self.particles.iter().filter(|p| p.is_alive())
221 }
222
223 pub fn clear(&mut self) {
225 self.particles.clear();
226 }
227}
228
229const PARTICLE_SHADER: &str = include_str!("../shaders/particle.wgsl");
234
235#[repr(C)]
237#[derive(Copy, Clone, Debug, Pod, Zeroable)]
238pub struct ParticleVertex {
239 pub position: [f32; 3],
240 pub color: [f32; 4],
241 pub size: f32,
242}
243
244impl ParticleVertex {
245 pub fn layout() -> wgpu::VertexBufferLayout<'static> {
246 const ATTRIBUTES: &[wgpu::VertexAttribute] = &[
247 wgpu::VertexAttribute {
248 offset: 0,
249 shader_location: 0,
250 format: wgpu::VertexFormat::Float32x3,
251 },
252 wgpu::VertexAttribute {
253 offset: 12,
254 shader_location: 1,
255 format: wgpu::VertexFormat::Float32x4,
256 },
257 wgpu::VertexAttribute {
258 offset: 28,
259 shader_location: 2,
260 format: wgpu::VertexFormat::Float32,
261 },
262 ];
263
264 wgpu::VertexBufferLayout {
265 array_stride: std::mem::size_of::<ParticleVertex>() as u64,
266 step_mode: wgpu::VertexStepMode::Instance,
267 attributes: ATTRIBUTES,
268 }
269 }
270}
271
272#[repr(C)]
274#[derive(Copy, Clone, Pod, Zeroable)]
275pub struct ParticleSceneUniform {
276 pub view_proj: [[f32; 4]; 4],
277}
278
279pub struct ParticleRenderer {
281 pub pipeline: wgpu::RenderPipeline,
282 pub scene_buffer: wgpu::Buffer,
283 pub scene_bind_group: wgpu::BindGroup,
284 cached_instance_buf: Option<(wgpu::Buffer, u64)>,
286}
287
288impl ParticleRenderer {
289 pub fn new(device: &super::RenderDevice, format: wgpu::TextureFormat) -> Self {
290 let shader = device.device().create_shader_module(wgpu::ShaderModuleDescriptor {
291 label: Some("Particle Shader"),
292 source: wgpu::ShaderSource::Wgsl(PARTICLE_SHADER.into()),
293 });
294
295 let scene_bgl = device.device().create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
296 label: Some("Particle Scene BGL"),
297 entries: &[wgpu::BindGroupLayoutEntry {
298 binding: 0,
299 visibility: wgpu::ShaderStages::VERTEX,
300 ty: wgpu::BindingType::Buffer {
301 ty: wgpu::BufferBindingType::Uniform,
302 has_dynamic_offset: false,
303 min_binding_size: None,
304 },
305 count: None,
306 }],
307 });
308
309 let pipeline_layout = device.device().create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
310 label: Some("Particle Pipeline Layout"),
311 bind_group_layouts: &[&scene_bgl],
312 push_constant_ranges: &[],
313 });
314
315 let pipeline = device.device().create_render_pipeline(&wgpu::RenderPipelineDescriptor {
316 label: Some("Particle Pipeline"),
317 layout: Some(&pipeline_layout),
318 vertex: wgpu::VertexState {
319 module: &shader,
320 entry_point: "vs_main",
321 buffers: &[ParticleVertex::layout()],
322 },
323 fragment: Some(wgpu::FragmentState {
324 module: &shader,
325 entry_point: "fs_main",
326 targets: &[Some(wgpu::ColorTargetState {
327 format,
328 blend: Some(wgpu::BlendState::ALPHA_BLENDING),
329 write_mask: wgpu::ColorWrites::ALL,
330 })],
331 }),
332 primitive: wgpu::PrimitiveState {
333 topology: wgpu::PrimitiveTopology::TriangleList,
334 ..Default::default()
335 },
336 depth_stencil: None,
337 multisample: wgpu::MultisampleState::default(),
338 multiview: None,
339 });
340
341 let initial = ParticleSceneUniform {
342 view_proj: glam::Mat4::IDENTITY.to_cols_array_2d(),
343 };
344 let scene_buffer = device.device().create_buffer_init(&wgpu::util::BufferInitDescriptor {
345 label: Some("Particle Scene UB"),
346 contents: bytemuck::bytes_of(&initial),
347 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
348 });
349
350 let scene_bg = device.device().create_bind_group(&wgpu::BindGroupDescriptor {
351 label: Some("Particle Scene BG"),
352 layout: &scene_bgl,
353 entries: &[wgpu::BindGroupEntry {
354 binding: 0,
355 resource: scene_buffer.as_entire_binding(),
356 }],
357 });
358
359 Self {
360 pipeline,
361 scene_buffer,
362 scene_bind_group: scene_bg,
363 cached_instance_buf: None,
364 }
365 }
366
367 pub fn render(
369 &mut self,
370 device: &super::RenderDevice,
371 encoder: &mut wgpu::CommandEncoder,
372 target: &wgpu::TextureView,
373 particle_system: &ParticleSystem,
374 view_proj: &glam::Mat4,
375 ) {
376 let vertices: Vec<ParticleVertex> = particle_system
377 .alive_particles()
378 .map(|p| ParticleVertex {
379 position: p.position.into(),
380 color: p.color,
381 size: p.size,
382 })
383 .collect();
384
385 if vertices.is_empty() {
386 return;
387 }
388
389 let uniform = ParticleSceneUniform {
391 view_proj: view_proj.to_cols_array_2d(),
392 };
393 device.queue().write_buffer(&self.scene_buffer, 0, bytemuck::bytes_of(&uniform));
394
395 let data = bytemuck::cast_slice(&vertices);
397 let needed = data.len() as u64;
398 let reuse = self.cached_instance_buf.as_ref().map_or(false, |(_, cap)| *cap >= needed);
399 if !reuse {
400 self.cached_instance_buf = Some((
401 device.device().create_buffer(&wgpu::BufferDescriptor {
402 label: Some("Particle Instance VB (cached)"),
403 size: needed,
404 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
405 mapped_at_creation: false,
406 }),
407 needed,
408 ));
409 }
410 let instance_buffer = &self.cached_instance_buf.as_ref().unwrap().0;
411 device.queue().write_buffer(instance_buffer, 0, data);
412
413 {
414 let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
415 label: Some("Particle Pass"),
416 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
417 view: target,
418 resolve_target: None,
419 ops: wgpu::Operations {
420 load: wgpu::LoadOp::Load,
421 store: wgpu::StoreOp::Store,
422 },
423 })],
424 depth_stencil_attachment: None,
425 timestamp_writes: None,
426 occlusion_query_set: None,
427 });
428
429 rp.set_pipeline(&self.pipeline);
430 rp.set_bind_group(0, &self.scene_bind_group, &[]);
431 rp.set_vertex_buffer(0, instance_buffer.slice(..));
432 rp.draw(0..6, 0..vertices.len() as u32);
434 }
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 #[test]
443 fn test_particle_vertex_size() {
444 assert_eq!(std::mem::size_of::<ParticleVertex>(), 32);
445 }
446
447 #[test]
448 fn test_particle_lifecycle() {
449 let mut p = Particle::new(Vec3::ZERO, Vec3::Y, 1.0);
450 assert!(p.is_alive());
451
452 p.update(0.5, Vec3::ZERO);
453 assert!(p.is_alive());
454 assert!((p.position.y - 0.5).abs() < 0.001);
455
456 p.update(0.6, Vec3::ZERO);
457 assert!(!p.is_alive());
458 }
459
460 #[test]
461 fn test_particle_system() {
462 let mut sys = ParticleSystem::new(10);
463 sys.emit(Particle::new(Vec3::ZERO, Vec3::Y, 1.0));
464 sys.emit(Particle::new(Vec3::ZERO, Vec3::X, 0.5));
465
466 assert_eq!(sys.alive_count(), 2);
467
468 sys.update(0.6, Vec3::ZERO);
469 assert_eq!(sys.alive_count(), 1); sys.update(0.5, Vec3::ZERO);
472 assert_eq!(sys.alive_count(), 0);
473 }
474
475 #[test]
476 fn test_particle_system_recycle() {
477 let mut sys = ParticleSystem::new(2);
478 sys.emit(Particle::new(Vec3::ZERO, Vec3::ZERO, 0.1));
479 sys.emit(Particle::new(Vec3::ZERO, Vec3::ZERO, 0.1));
480
481 sys.update(0.2, Vec3::ZERO);
483 assert_eq!(sys.alive_count(), 0);
484
485 sys.emit(Particle::new(Vec3::ONE, Vec3::ZERO, 1.0));
487 assert_eq!(sys.alive_count(), 1);
488 }
489}