Skip to main content

blinc_gpu/
particles.rs

1//! GPU Particle System
2//!
3//! Provides fully GPU-accelerated particle simulation and rendering.
4//! Particles are simulated using compute shaders and rendered using
5//! instanced billboards.
6//!
7//! # Architecture
8//!
9//! ```text
10//! ParticleSystemGpu
11//!        │
12//!        ├── Compute Pass (Simulation)
13//!        │   └── Updates particle positions, velocities, lifetimes
14//!        │
15//!        └── Render Pass (Drawing)
16//!            └── Draws particle billboards as instanced quads
17//! ```
18
19use bytemuck::{Pod, Zeroable};
20use std::collections::HashMap;
21use wgpu::util::DeviceExt;
22
23/// Maximum particles per system for buffer allocation
24pub const MAX_PARTICLES_PER_SYSTEM: u32 = 100_000;
25
26/// GPU particle data structure
27/// Must match the WGSL struct layout exactly
28#[repr(C)]
29#[derive(Clone, Copy, Debug, Pod, Zeroable)]
30pub struct GpuParticle {
31    /// Position (xyz) and life remaining (w)
32    pub position_life: [f32; 4],
33    /// Velocity (xyz) and max lifetime (w)
34    pub velocity_max_life: [f32; 4],
35    /// Color (rgba)
36    pub color: [f32; 4],
37    /// Size (current, start, end, rotation)
38    pub size_rotation: [f32; 4],
39}
40
41impl Default for GpuParticle {
42    fn default() -> Self {
43        Self {
44            position_life: [0.0, 0.0, 0.0, 0.0], // life=0 means inactive
45            velocity_max_life: [0.0, 0.0, 0.0, 1.0],
46            color: [1.0, 1.0, 1.0, 1.0],
47            size_rotation: [0.1, 0.1, 0.0, 0.0],
48        }
49    }
50}
51
52/// Emitter configuration for GPU
53#[repr(C)]
54#[derive(Clone, Copy, Debug, Pod, Zeroable)]
55pub struct GpuEmitter {
56    /// Emitter position (xyz) and shape type (w as u32 bits)
57    pub position_shape: [f32; 4],
58    /// Shape parameters (radius, angle, half_extents, etc.)
59    pub shape_params: [f32; 4],
60    /// Direction (xyz) and randomness (w)
61    pub direction_randomness: [f32; 4],
62    /// Emission rate, burst count, spawn accumulated, gravity scale
63    pub emission_config: [f32; 4],
64    /// Lifetime (min, max), speed (min, max)
65    pub lifetime_speed: [f32; 4],
66    /// Start size (min, max), end size (min, max)
67    pub size_config: [f32; 4],
68    /// Start color (rgba) - base of fire (yellow)
69    pub start_color: [f32; 4],
70    /// Mid color (rgba) - middle of fire (red)
71    pub mid_color: [f32; 4],
72    /// End color (rgba) - tip of fire (dark/burnt)
73    pub end_color: [f32; 4],
74}
75
76impl Default for GpuEmitter {
77    fn default() -> Self {
78        Self {
79            position_shape: [0.0, 0.0, 0.0, 0.0], // Point emitter
80            shape_params: [0.0; 4],
81            direction_randomness: [0.0, 1.0, 0.0, 0.0], // Up, no randomness
82            emission_config: [100.0, 0.0, 0.0, 1.0],    // 100/s, no burst, gravity=1
83            lifetime_speed: [1.0, 2.0, 1.0, 2.0],
84            size_config: [0.1, 0.2, 0.0, 0.1],
85            start_color: [1.0, 1.0, 1.0, 1.0],
86            mid_color: [1.0, 1.0, 1.0, 0.5],
87            end_color: [1.0, 1.0, 1.0, 0.0],
88        }
89    }
90}
91
92/// Force affector for GPU
93#[repr(C)]
94#[derive(Clone, Copy, Debug, Pod, Zeroable)]
95pub struct GpuForce {
96    /// Force type (0=gravity, 1=wind, 2=vortex, 3=drag, 4=turbulence, 5=attractor)
97    /// and strength packed as (type, strength, 0, 0)
98    pub type_strength: [f32; 4],
99    /// Direction/position (xyz) and extra param (w)
100    pub direction_params: [f32; 4],
101}
102
103impl Default for GpuForce {
104    fn default() -> Self {
105        Self {
106            type_strength: [0.0, 0.0, 0.0, 0.0],
107            direction_params: [0.0, -9.8, 0.0, 0.0], // Default gravity
108        }
109    }
110}
111
112/// Simulation uniforms for compute shader
113#[repr(C)]
114#[derive(Clone, Copy, Debug, Pod, Zeroable)]
115pub struct GpuSimulationUniforms {
116    /// Delta time, total time, max particles, active particles
117    pub time_config: [f32; 4],
118    /// Random seed (4 values for better distribution)
119    pub random_seed: [f32; 4],
120    /// Number of forces, padding
121    pub force_config: [f32; 4],
122}
123
124impl Default for GpuSimulationUniforms {
125    fn default() -> Self {
126        Self {
127            time_config: [0.016, 0.0, MAX_PARTICLES_PER_SYSTEM as f32, 0.0],
128            random_seed: [0.0; 4],
129            force_config: [0.0; 4],
130        }
131    }
132}
133
134/// Render uniforms for vertex/fragment shaders
135#[repr(C)]
136#[derive(Clone, Copy, Debug, Pod, Zeroable)]
137pub struct GpuRenderUniforms {
138    /// View-projection matrix (column-major)
139    pub view_proj: [[f32; 4]; 4],
140    /// Camera position (xyz) and FOV (w)
141    pub camera_pos_fov: [f32; 4],
142    /// Camera right vector (xyz) and aspect ratio (w)
143    pub camera_right_aspect: [f32; 4],
144    /// Camera up vector (xyz) and padding (w)
145    pub camera_up: [f32; 4],
146    /// Viewport size (width, height) and render mode, blend mode
147    pub viewport_config: [f32; 4],
148}
149
150impl Default for GpuRenderUniforms {
151    fn default() -> Self {
152        Self {
153            view_proj: [
154                [1.0, 0.0, 0.0, 0.0],
155                [0.0, 1.0, 0.0, 0.0],
156                [0.0, 0.0, 1.0, 0.0],
157                [0.0, 0.0, 0.0, 1.0],
158            ],
159            camera_pos_fov: [0.0, 0.0, 5.0, 0.8],
160            camera_right_aspect: [1.0, 0.0, 0.0, 1.0],
161            camera_up: [0.0, 1.0, 0.0, 0.0],
162            viewport_config: [800.0, 600.0, 0.0, 0.0],
163        }
164    }
165}
166
167/// Particle viewport data for rendering
168#[derive(Clone, Debug)]
169pub struct ParticleViewport {
170    /// Emitter configuration
171    pub emitter: GpuEmitter,
172    /// Force affectors (up to 8)
173    pub forces: Vec<GpuForce>,
174    /// Maximum particles
175    pub max_particles: u32,
176    /// Camera position
177    pub camera_pos: [f32; 3],
178    /// Camera target
179    pub camera_target: [f32; 3],
180    /// Camera up vector
181    pub camera_up: [f32; 3],
182    /// Field of view
183    pub fov: f32,
184    /// Current time
185    pub time: f32,
186    /// Delta time
187    pub delta_time: f32,
188    /// Viewport bounds (x, y, width, height)
189    pub bounds: [f32; 4],
190    /// Blend mode (0=alpha, 1=additive)
191    pub blend_mode: u32,
192    /// Whether system is playing
193    pub playing: bool,
194}
195
196impl Default for ParticleViewport {
197    fn default() -> Self {
198        Self {
199            emitter: GpuEmitter::default(),
200            forces: Vec::new(),
201            max_particles: 10000,
202            camera_pos: [0.0, 0.0, 5.0],
203            camera_target: [0.0, 0.0, 0.0],
204            camera_up: [0.0, 1.0, 0.0],
205            fov: 0.8,
206            time: 0.0,
207            delta_time: 0.016,
208            bounds: [0.0, 0.0, 800.0, 600.0],
209            blend_mode: 0,
210            playing: true,
211        }
212    }
213}
214
215/// GPU particle compute shader
216pub const PARTICLE_COMPUTE_SHADER: &str = r#"
217// ============================================================================
218// Blinc GPU Particle Compute Shader
219// ============================================================================
220
221struct Particle {
222    position_life: vec4<f32>,      // xyz=position, w=life remaining
223    velocity_max_life: vec4<f32>,  // xyz=velocity, w=max lifetime
224    color: vec4<f32>,              // rgba
225    size_rotation: vec4<f32>,      // current, start, end, rotation
226}
227
228struct Emitter {
229    position_shape: vec4<f32>,       // xyz=position, w=shape type
230    shape_params: vec4<f32>,         // shape-specific params
231    direction_randomness: vec4<f32>, // xyz=direction, w=randomness
232    emission_config: vec4<f32>,      // rate, burst, spawn_acc, gravity_scale
233    lifetime_speed: vec4<f32>,       // min_life, max_life, min_speed, max_speed
234    size_config: vec4<f32>,          // start_min, start_max, end_min, end_max
235    start_color: vec4<f32>,          // rgba - base (young particles)
236    mid_color: vec4<f32>,            // rgba - middle age
237    end_color: vec4<f32>,            // rgba - tip (old particles)
238}
239
240struct Force {
241    type_strength: vec4<f32>,    // type, strength, 0, 0
242    direction_params: vec4<f32>, // xyz=dir/pos, w=extra
243}
244
245struct SimUniforms {
246    time_config: vec4<f32>,   // dt, time, max_particles, active
247    random_seed: vec4<f32>,   // 4 random seeds
248    force_config: vec4<f32>,  // num_forces, 0, 0, 0
249}
250
251@group(0) @binding(0) var<storage, read_write> particles: array<Particle>;
252@group(0) @binding(1) var<uniform> emitter: Emitter;
253@group(0) @binding(2) var<uniform> uniforms: SimUniforms;
254@group(0) @binding(3) var<storage, read> forces: array<Force>;
255
256// Constants for emitter shapes
257const SHAPE_POINT: u32 = 0u;
258const SHAPE_SPHERE: u32 = 1u;
259const SHAPE_HEMISPHERE: u32 = 2u;
260const SHAPE_CONE: u32 = 3u;
261const SHAPE_BOX: u32 = 4u;
262const SHAPE_CIRCLE: u32 = 5u;
263
264// Constants for force types
265const FORCE_GRAVITY: u32 = 0u;
266const FORCE_WIND: u32 = 1u;
267const FORCE_VORTEX: u32 = 2u;
268const FORCE_DRAG: u32 = 3u;
269const FORCE_TURBULENCE: u32 = 4u;
270const FORCE_ATTRACTOR: u32 = 5u;
271const FORCE_RADIAL: u32 = 6u;
272
273// PCG random number generator
274fn pcg_hash(input: u32) -> u32 {
275    let state = input * 747796405u + 2891336453u;
276    let word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
277    return (word >> 22u) ^ word;
278}
279
280fn random_float(seed: ptr<function, u32>) -> f32 {
281    *seed = pcg_hash(*seed);
282    return f32(*seed) / 4294967295.0;
283}
284
285fn random_range(seed: ptr<function, u32>, min_val: f32, max_val: f32) -> f32 {
286    return min_val + random_float(seed) * (max_val - min_val);
287}
288
289fn random_unit_vector(seed: ptr<function, u32>) -> vec3<f32> {
290    let theta = random_float(seed) * 6.283185;
291    let z = random_range(seed, -1.0, 1.0);
292    let r = sqrt(1.0 - z * z);
293    return vec3<f32>(r * cos(theta), r * sin(theta), z);
294}
295
296// Get spawn position based on emitter shape
297fn get_spawn_position(seed: ptr<function, u32>) -> vec3<f32> {
298    let shape = u32(emitter.position_shape.w);
299    let base_pos = emitter.position_shape.xyz;
300
301    switch (shape) {
302        case SHAPE_POINT: {
303            return base_pos;
304        }
305        case SHAPE_SPHERE: {
306            let radius = emitter.shape_params.x;
307            let dir = random_unit_vector(seed);
308            let r = radius * pow(random_float(seed), 1.0/3.0);
309            return base_pos + dir * r;
310        }
311        case SHAPE_HEMISPHERE: {
312            let radius = emitter.shape_params.x;
313            var dir = random_unit_vector(seed);
314            dir.y = abs(dir.y); // Upper hemisphere
315            let r = radius * pow(random_float(seed), 1.0/3.0);
316            return base_pos + dir * r;
317        }
318        case SHAPE_CONE: {
319            let angle = emitter.shape_params.x;
320            let radius = emitter.shape_params.y;
321            let theta = random_float(seed) * 6.283185;
322            let r = radius * sqrt(random_float(seed));
323            return base_pos + vec3<f32>(r * cos(theta), 0.0, r * sin(theta));
324        }
325        case SHAPE_BOX: {
326            let half = emitter.shape_params.xyz;
327            return base_pos + vec3<f32>(
328                random_range(seed, -half.x, half.x),
329                random_range(seed, -half.y, half.y),
330                random_range(seed, -half.z, half.z)
331            );
332        }
333        case SHAPE_CIRCLE: {
334            let radius = emitter.shape_params.x;
335            let theta = random_float(seed) * 6.283185;
336            let r = radius * sqrt(random_float(seed));
337            return base_pos + vec3<f32>(r * cos(theta), 0.0, r * sin(theta));
338        }
339        default: {
340            return base_pos;
341        }
342    }
343}
344
345// Get spawn velocity based on direction and randomness
346fn get_spawn_velocity(seed: ptr<function, u32>) -> vec3<f32> {
347    let base_dir = normalize(emitter.direction_randomness.xyz);
348    let randomness = emitter.direction_randomness.w;
349
350    // Mix base direction with random direction
351    let random_dir = random_unit_vector(seed);
352    let dir = normalize(mix(base_dir, random_dir, randomness));
353
354    let speed = random_range(seed, emitter.lifetime_speed.z, emitter.lifetime_speed.w);
355    return dir * speed;
356}
357
358// Apply force to velocity
359fn apply_force(force: Force, pos: vec3<f32>, vel: vec3<f32>, dt: f32) -> vec3<f32> {
360    let force_type = u32(force.type_strength.x);
361    let strength = force.type_strength.y;
362    var new_vel = vel;
363
364    switch (force_type) {
365        case FORCE_GRAVITY: {
366            new_vel = vel + force.direction_params.xyz * dt;
367        }
368        case FORCE_WIND: {
369            let turbulence = force.direction_params.w;
370            new_vel = vel + force.direction_params.xyz * strength * dt;
371        }
372        case FORCE_VORTEX: {
373            let axis = normalize(force.direction_params.xyz);
374            let to_particle = pos;
375            let tangent = cross(axis, to_particle);
376            new_vel = vel + normalize(tangent) * strength * dt;
377        }
378        case FORCE_DRAG: {
379            new_vel = vel * (1.0 - strength * dt);
380        }
381        case FORCE_TURBULENCE: {
382            // 3D turbulence with per-particle variation
383            let freq = force.direction_params.w;
384            let t = uniforms.time_config.y;
385
386            // Use velocity magnitude as per-particle seed for variation
387            // This makes each particle get unique turbulence even at same position
388            let vel_seed = dot(vel, vec3<f32>(12.9898, 78.233, 37.719));
389            let particle_offset = fract(sin(vel_seed) * 43758.5453) * 6.28318; // 0 to 2*PI
390
391            // Position-based noise with per-particle phase offset
392            let p1 = pos * freq + vec3<f32>(t * 1.3 + particle_offset, t * 0.7, t * 1.1);
393            let p2 = pos * freq * 1.7 + vec3<f32>(t * 0.9, t * 1.4 + particle_offset, t * 0.6);
394
395            // Multi-octave noise
396            let n1 = sin(p1.x + particle_offset) * cos(p1.y) * sin(p1.z + t);
397            let n2 = cos(p1.y * 1.3 + particle_offset) * sin(p1.z * 0.8) * cos(p1.x + t * 0.7);
398            let n3 = sin(p1.z * 1.1) * cos(p1.x * 0.9 + particle_offset) * sin(p1.y + t * 1.2);
399
400            // Second octave
401            let m1 = sin(p2.x + particle_offset * 0.7) * cos(p2.z) * 0.5;
402            let m2 = cos(p2.y) * sin(p2.x + particle_offset * 1.3) * 0.5;
403            let m3 = sin(p2.z + particle_offset * 0.5) * cos(p2.y) * 0.5;
404
405            // Combine - allow more vertical variation for dancing flames
406            let turb = vec3<f32>(
407                n1 + m1,
408                (n2 + m2) * 0.5,
409                n3 + m3
410            );
411
412            new_vel = vel + turb * strength * dt;
413        }
414        case FORCE_ATTRACTOR: {
415            let attractor_pos = force.direction_params.xyz;
416            let to_attractor = attractor_pos - pos;
417            let dist = max(length(to_attractor), 0.1);
418            new_vel = vel + normalize(to_attractor) * strength / (dist * dist) * dt;
419        }
420        case FORCE_RADIAL: {
421            // Centering force - pulls particles toward the Y-axis (center line)
422            // Negative strength = pull toward center, positive = push away
423            let center = force.direction_params.xyz;
424            let to_center = vec3<f32>(center.x - pos.x, 0.0, center.z - pos.z);
425            let dist = max(length(to_center), 0.01);
426            // Linear falloff - stronger when further from center
427            new_vel = vel + to_center * strength * dt;
428        }
429        default: {}
430    }
431
432    return new_vel;
433}
434
435@compute @workgroup_size(64)
436fn cs_main(@builtin(global_invocation_id) global_id: vec3<u32>) {
437    let idx = global_id.x;
438    let max_particles = u32(uniforms.time_config.z);
439
440    if (idx >= max_particles) {
441        return;
442    }
443
444    var p = particles[idx];
445    let dt = uniforms.time_config.x;
446    let time = uniforms.time_config.y;
447
448    // Initialize random seed based on particle index and time
449    var seed = idx + u32(time * 1000.0);
450    seed = pcg_hash(seed + u32(uniforms.random_seed.x * 1000000.0));
451
452    // Check if particle is alive
453    if (p.position_life.w > 0.0) {
454        // Update particle
455        p.position_life.w -= dt;
456
457        if (p.position_life.w <= 0.0) {
458            // Particle died
459            p.position_life.w = 0.0;
460        } else {
461            // Apply forces
462            var vel = p.velocity_max_life.xyz;
463            let num_forces = u32(uniforms.force_config.x);
464            let gravity_scale = emitter.emission_config.w;
465
466            // Apply forces from the forces array
467            // Note: Gravity is handled via Force::Gravity in the forces array, not hardcoded
468            for (var i = 0u; i < num_forces; i++) {
469                let force = forces[i];
470                // For gravity forces (type 0), apply gravity_scale
471                if (u32(force.type_strength.x) == FORCE_GRAVITY) {
472                    let gravity_dir = force.direction_params.xyz * gravity_scale;
473                    vel = vel + gravity_dir * dt;
474                } else {
475                    vel = apply_force(force, p.position_life.xyz, vel, dt);
476                }
477            }
478
479            p.velocity_max_life = vec4<f32>(vel, p.velocity_max_life.w);
480
481            // Update position
482            p.position_life = vec4<f32>(
483                p.position_life.xyz + vel * dt,
484                p.position_life.w
485            );
486
487            // Update color based on lifetime (3-color gradient)
488            // life_ratio: 1.0 = just born (start), 0.5 = mid-life (mid), 0.0 = dying (end)
489            let life_ratio = p.position_life.w / p.velocity_max_life.w;
490            if (life_ratio > 0.5) {
491                // Young particle: interpolate from start to mid
492                let t = (life_ratio - 0.5) * 2.0; // 0.5->1.0 maps to 0->1
493                p.color = mix(emitter.mid_color, emitter.start_color, t);
494            } else {
495                // Older particle: interpolate from mid to end
496                let t = life_ratio * 2.0; // 0->0.5 maps to 0->1
497                p.color = mix(emitter.end_color, emitter.mid_color, t);
498            }
499
500            // Update size based on lifetime
501            let start_size = p.size_rotation.y;
502            let end_size = p.size_rotation.z;
503            p.size_rotation.x = mix(end_size, start_size, life_ratio);
504        }
505    } else {
506        // Try to spawn new particle
507        let emission_rate = emitter.emission_config.x;
508        let burst_count = emitter.emission_config.y;
509
510        // Calculate spawn chance: combines continuous emission with burst
511        var spawn_chance = 0.0;
512        if (emission_rate > 0.0) {
513            spawn_chance = emission_rate * dt / f32(max_particles);
514        }
515        // For burst effects, use burst_count to determine spawn probability
516        if (burst_count > 0.0) {
517            spawn_chance = spawn_chance + burst_count / f32(max_particles);
518        }
519
520        if (random_float(&seed) < spawn_chance) {
521            // Spawn new particle
522            let pos = get_spawn_position(&seed);
523            let vel = get_spawn_velocity(&seed);
524            let lifetime = random_range(&seed, emitter.lifetime_speed.x, emitter.lifetime_speed.y);
525            let size = random_range(&seed, emitter.size_config.x, emitter.size_config.y);
526            let end_size = random_range(&seed, emitter.size_config.z, emitter.size_config.w);
527
528            p.position_life = vec4<f32>(pos, lifetime);
529            p.velocity_max_life = vec4<f32>(vel, lifetime);
530            p.color = emitter.start_color;
531            p.size_rotation = vec4<f32>(size, size, end_size, 0.0);
532        }
533    }
534
535    particles[idx] = p;
536}
537"#;
538
539/// Data-texture fallback: Particle rendering (no storage buffers — WebGL2)
540pub const PARTICLE_RENDER_DT_SHADER: &str = include_str!("shaders/particle_render_dt.wgsl");
541
542/// GPU particle render shader (billboard quads)
543pub const PARTICLE_RENDER_SHADER: &str = r#"
544// ============================================================================
545// Blinc GPU Particle Render Shader
546// ============================================================================
547
548struct Particle {
549    position_life: vec4<f32>,
550    velocity_max_life: vec4<f32>,
551    color: vec4<f32>,
552    size_rotation: vec4<f32>,
553}
554
555struct RenderUniforms {
556    view_proj: mat4x4<f32>,
557    camera_pos_fov: vec4<f32>,
558    camera_right_aspect: vec4<f32>,
559    camera_up: vec4<f32>,
560    viewport_config: vec4<f32>,
561}
562
563struct VertexOutput {
564    @builtin(position) position: vec4<f32>,
565    @location(0) uv: vec2<f32>,
566    @location(1) color: vec4<f32>,
567}
568
569@group(0) @binding(0) var<storage, read> particles: array<Particle>;
570@group(0) @binding(1) var<uniform> uniforms: RenderUniforms;
571
572@vertex
573fn vs_main(
574    @builtin(vertex_index) vertex_index: u32,
575    @builtin(instance_index) instance_index: u32,
576) -> VertexOutput {
577    var out: VertexOutput;
578
579    let p = particles[instance_index];
580
581    // Skip dead particles (move to clip space far away)
582    if (p.position_life.w <= 0.0) {
583        out.position = vec4<f32>(0.0, 0.0, 1000.0, 1.0);
584        out.uv = vec2<f32>(0.0);
585        out.color = vec4<f32>(0.0);
586        return out;
587    }
588
589    // Billboard quad vertices
590    let quad_verts = array<vec2<f32>, 6>(
591        vec2<f32>(-1.0, -1.0),
592        vec2<f32>( 1.0, -1.0),
593        vec2<f32>(-1.0,  1.0),
594        vec2<f32>( 1.0, -1.0),
595        vec2<f32>( 1.0,  1.0),
596        vec2<f32>(-1.0,  1.0),
597    );
598
599    let quad_uvs = array<vec2<f32>, 6>(
600        vec2<f32>(0.0, 1.0),
601        vec2<f32>(1.0, 1.0),
602        vec2<f32>(0.0, 0.0),
603        vec2<f32>(1.0, 1.0),
604        vec2<f32>(1.0, 0.0),
605        vec2<f32>(0.0, 0.0),
606    );
607
608    let local_pos = quad_verts[vertex_index];
609    let size = p.size_rotation.x;
610
611    // Calculate billboard orientation
612    let camera_right = uniforms.camera_right_aspect.xyz;
613    let camera_up = uniforms.camera_up.xyz;
614
615    // World position with billboard offset
616    let world_pos = p.position_life.xyz +
617                    camera_right * local_pos.x * size +
618                    camera_up * local_pos.y * size;
619
620    // Project to clip space
621    out.position = uniforms.view_proj * vec4<f32>(world_pos, 1.0);
622    out.uv = quad_uvs[vertex_index];
623    out.color = p.color;
624
625    return out;
626}
627
628@fragment
629fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
630    // Circular particle with soft edges
631    let center = vec2<f32>(0.5);
632    let dist = length(in.uv - center) * 2.0;
633
634    // Soft circle falloff
635    let alpha = 1.0 - smoothstep(0.8, 1.0, dist);
636
637    // Discard if too far from center
638    if (alpha < 0.01) {
639        discard;
640    }
641
642    return vec4<f32>(in.color.rgb, in.color.a * alpha);
643}
644"#;
645
646/// Handle to a GPU particle system
647#[derive(Debug)]
648pub struct ParticleSystemGpu {
649    /// Particle buffer (read/write for compute)
650    particle_buffer: wgpu::Buffer,
651    /// Emitter uniform buffer
652    emitter_buffer: wgpu::Buffer,
653    /// Simulation uniforms buffer
654    sim_uniform_buffer: wgpu::Buffer,
655    /// Render uniforms buffer
656    render_uniform_buffer: wgpu::Buffer,
657    /// Forces buffer
658    forces_buffer: wgpu::Buffer,
659    /// Compute pipeline
660    compute_pipeline: wgpu::ComputePipeline,
661    /// Render pipeline
662    render_pipeline: wgpu::RenderPipeline,
663    /// Compute bind group
664    compute_bind_group: wgpu::BindGroup,
665    /// Render bind group
666    render_bind_group: wgpu::BindGroup,
667    /// Max particles
668    max_particles: u32,
669    /// Current time
670    time: f32,
671}
672
673impl ParticleSystemGpu {
674    /// Create a new GPU particle system
675    pub fn new(
676        device: &wgpu::Device,
677        surface_format: wgpu::TextureFormat,
678        max_particles: u32,
679    ) -> Self {
680        let max_particles = max_particles.min(MAX_PARTICLES_PER_SYSTEM);
681
682        // Create particle buffer
683        let particle_buffer = device.create_buffer(&wgpu::BufferDescriptor {
684            label: Some("Particle Buffer"),
685            size: (std::mem::size_of::<GpuParticle>() * max_particles as usize) as u64,
686            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
687            mapped_at_creation: false,
688        });
689
690        // Create emitter buffer
691        let emitter_buffer = device.create_buffer(&wgpu::BufferDescriptor {
692            label: Some("Emitter Buffer"),
693            size: std::mem::size_of::<GpuEmitter>() as u64,
694            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
695            mapped_at_creation: false,
696        });
697
698        // Create simulation uniforms buffer
699        let sim_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
700            label: Some("Simulation Uniforms"),
701            size: std::mem::size_of::<GpuSimulationUniforms>() as u64,
702            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
703            mapped_at_creation: false,
704        });
705
706        // Create render uniforms buffer
707        let render_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
708            label: Some("Render Uniforms"),
709            size: std::mem::size_of::<GpuRenderUniforms>() as u64,
710            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
711            mapped_at_creation: false,
712        });
713
714        // Create forces buffer (support up to 8 forces)
715        let forces_buffer = device.create_buffer(&wgpu::BufferDescriptor {
716            label: Some("Forces Buffer"),
717            size: (std::mem::size_of::<GpuForce>() * 8) as u64,
718            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
719            mapped_at_creation: false,
720        });
721
722        // Create compute shader module
723        let compute_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
724            label: Some("Particle Compute Shader"),
725            source: wgpu::ShaderSource::Wgsl(PARTICLE_COMPUTE_SHADER.into()),
726        });
727
728        // Create render shader module
729        let render_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
730            label: Some("Particle Render Shader"),
731            source: wgpu::ShaderSource::Wgsl(PARTICLE_RENDER_SHADER.into()),
732        });
733
734        // Create compute bind group layout
735        let compute_bind_group_layout =
736            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
737                label: Some("Particle Compute Bind Group Layout"),
738                entries: &[
739                    // Particles (storage, read_write)
740                    wgpu::BindGroupLayoutEntry {
741                        binding: 0,
742                        visibility: wgpu::ShaderStages::COMPUTE,
743                        ty: wgpu::BindingType::Buffer {
744                            ty: wgpu::BufferBindingType::Storage { read_only: false },
745                            has_dynamic_offset: false,
746                            min_binding_size: None,
747                        },
748                        count: None,
749                    },
750                    // Emitter (uniform)
751                    wgpu::BindGroupLayoutEntry {
752                        binding: 1,
753                        visibility: wgpu::ShaderStages::COMPUTE,
754                        ty: wgpu::BindingType::Buffer {
755                            ty: wgpu::BufferBindingType::Uniform,
756                            has_dynamic_offset: false,
757                            min_binding_size: None,
758                        },
759                        count: None,
760                    },
761                    // Simulation uniforms
762                    wgpu::BindGroupLayoutEntry {
763                        binding: 2,
764                        visibility: wgpu::ShaderStages::COMPUTE,
765                        ty: wgpu::BindingType::Buffer {
766                            ty: wgpu::BufferBindingType::Uniform,
767                            has_dynamic_offset: false,
768                            min_binding_size: None,
769                        },
770                        count: None,
771                    },
772                    // Forces (storage, read)
773                    wgpu::BindGroupLayoutEntry {
774                        binding: 3,
775                        visibility: wgpu::ShaderStages::COMPUTE,
776                        ty: wgpu::BindingType::Buffer {
777                            ty: wgpu::BufferBindingType::Storage { read_only: true },
778                            has_dynamic_offset: false,
779                            min_binding_size: None,
780                        },
781                        count: None,
782                    },
783                ],
784            });
785
786        // Create render bind group layout
787        let render_bind_group_layout =
788            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
789                label: Some("Particle Render Bind Group Layout"),
790                entries: &[
791                    // Particles (storage, read)
792                    wgpu::BindGroupLayoutEntry {
793                        binding: 0,
794                        visibility: wgpu::ShaderStages::VERTEX,
795                        ty: wgpu::BindingType::Buffer {
796                            ty: wgpu::BufferBindingType::Storage { read_only: true },
797                            has_dynamic_offset: false,
798                            min_binding_size: None,
799                        },
800                        count: None,
801                    },
802                    // Render uniforms
803                    wgpu::BindGroupLayoutEntry {
804                        binding: 1,
805                        visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
806                        ty: wgpu::BindingType::Buffer {
807                            ty: wgpu::BufferBindingType::Uniform,
808                            has_dynamic_offset: false,
809                            min_binding_size: None,
810                        },
811                        count: None,
812                    },
813                ],
814            });
815
816        // Create compute pipeline
817        let compute_pipeline_layout =
818            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
819                label: Some("Particle Compute Pipeline Layout"),
820                bind_group_layouts: &[&compute_bind_group_layout],
821                push_constant_ranges: &[],
822            });
823
824        let compute_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
825            label: Some("Particle Compute Pipeline"),
826            layout: Some(&compute_pipeline_layout),
827            module: &compute_shader,
828            entry_point: Some("cs_main"),
829            compilation_options: Default::default(),
830            cache: None,
831        });
832
833        // Create render pipeline
834        let render_pipeline_layout =
835            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
836                label: Some("Particle Render Pipeline Layout"),
837                bind_group_layouts: &[&render_bind_group_layout],
838                push_constant_ranges: &[],
839            });
840
841        let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
842            label: Some("Particle Render Pipeline"),
843            layout: Some(&render_pipeline_layout),
844            vertex: wgpu::VertexState {
845                module: &render_shader,
846                entry_point: Some("vs_main"),
847                buffers: &[],
848                compilation_options: Default::default(),
849            },
850            fragment: Some(wgpu::FragmentState {
851                module: &render_shader,
852                entry_point: Some("fs_main"),
853                targets: &[Some(wgpu::ColorTargetState {
854                    format: surface_format,
855                    blend: Some(wgpu::BlendState {
856                        color: wgpu::BlendComponent {
857                            src_factor: wgpu::BlendFactor::SrcAlpha,
858                            dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
859                            operation: wgpu::BlendOperation::Add,
860                        },
861                        alpha: wgpu::BlendComponent {
862                            src_factor: wgpu::BlendFactor::One,
863                            dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
864                            operation: wgpu::BlendOperation::Add,
865                        },
866                    }),
867                    write_mask: wgpu::ColorWrites::ALL,
868                })],
869                compilation_options: Default::default(),
870            }),
871            primitive: wgpu::PrimitiveState {
872                topology: wgpu::PrimitiveTopology::TriangleList,
873                strip_index_format: None,
874                front_face: wgpu::FrontFace::Ccw,
875                cull_mode: None, // Billboards face camera
876                unclipped_depth: false,
877                polygon_mode: wgpu::PolygonMode::Fill,
878                conservative: false,
879            },
880            depth_stencil: None,
881            multisample: wgpu::MultisampleState::default(),
882            multiview: None,
883            cache: None,
884        });
885
886        // Create bind groups
887        let compute_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
888            label: Some("Particle Compute Bind Group"),
889            layout: &compute_bind_group_layout,
890            entries: &[
891                wgpu::BindGroupEntry {
892                    binding: 0,
893                    resource: particle_buffer.as_entire_binding(),
894                },
895                wgpu::BindGroupEntry {
896                    binding: 1,
897                    resource: emitter_buffer.as_entire_binding(),
898                },
899                wgpu::BindGroupEntry {
900                    binding: 2,
901                    resource: sim_uniform_buffer.as_entire_binding(),
902                },
903                wgpu::BindGroupEntry {
904                    binding: 3,
905                    resource: forces_buffer.as_entire_binding(),
906                },
907            ],
908        });
909
910        let render_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
911            label: Some("Particle Render Bind Group"),
912            layout: &render_bind_group_layout,
913            entries: &[
914                wgpu::BindGroupEntry {
915                    binding: 0,
916                    resource: particle_buffer.as_entire_binding(),
917                },
918                wgpu::BindGroupEntry {
919                    binding: 1,
920                    resource: render_uniform_buffer.as_entire_binding(),
921                },
922            ],
923        });
924
925        Self {
926            particle_buffer,
927            emitter_buffer,
928            sim_uniform_buffer,
929            render_uniform_buffer,
930            forces_buffer,
931            compute_pipeline,
932            render_pipeline,
933            compute_bind_group,
934            render_bind_group,
935            max_particles,
936            time: 0.0,
937        }
938    }
939
940    /// Update the particle system for one frame
941    pub fn update(
942        &mut self,
943        queue: &wgpu::Queue,
944        encoder: &mut wgpu::CommandEncoder,
945        viewport: &ParticleViewport,
946    ) {
947        if !viewport.playing {
948            return;
949        }
950
951        self.time = viewport.time;
952
953        // Update emitter buffer
954        queue.write_buffer(
955            &self.emitter_buffer,
956            0,
957            bytemuck::bytes_of(&viewport.emitter),
958        );
959
960        // Update simulation uniforms
961        let sim_uniforms = GpuSimulationUniforms {
962            time_config: [
963                viewport.delta_time,
964                viewport.time,
965                self.max_particles as f32,
966                0.0,
967            ],
968            random_seed: [
969                viewport.time * 12_345.679,
970                viewport.time * 98_765.43,
971                (viewport.time * 11_111.111).fract(),
972                (viewport.time * 22_222.223).fract(),
973            ],
974            force_config: [viewport.forces.len() as f32, 0.0, 0.0, 0.0],
975        };
976        queue.write_buffer(
977            &self.sim_uniform_buffer,
978            0,
979            bytemuck::bytes_of(&sim_uniforms),
980        );
981
982        // Update forces buffer
983        let mut forces = [GpuForce::default(); 8];
984        for (i, force) in viewport.forces.iter().take(8).enumerate() {
985            forces[i] = *force;
986        }
987        queue.write_buffer(&self.forces_buffer, 0, bytemuck::cast_slice(&forces));
988
989        // Dispatch compute shader
990        let workgroups = self.max_particles.div_ceil(64);
991        let mut compute_pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
992            label: Some("Particle Compute Pass"),
993            timestamp_writes: None,
994        });
995        compute_pass.set_pipeline(&self.compute_pipeline);
996        compute_pass.set_bind_group(0, &self.compute_bind_group, &[]);
997        compute_pass.dispatch_workgroups(workgroups, 1, 1);
998    }
999
1000    /// Render the particles
1001    pub fn render<'a>(
1002        &'a self,
1003        queue: &wgpu::Queue,
1004        render_pass: &mut wgpu::RenderPass<'a>,
1005        viewport: &ParticleViewport,
1006    ) {
1007        // Calculate view-projection matrix
1008        let view = Self::look_at(
1009            viewport.camera_pos,
1010            viewport.camera_target,
1011            viewport.camera_up,
1012        );
1013        let aspect = viewport.bounds[2] / viewport.bounds[3];
1014        let proj = Self::perspective(viewport.fov, aspect, 0.1, 100.0);
1015        let view_proj = Self::mat4_mul(&proj, &view);
1016
1017        // Calculate camera vectors
1018        let forward = [
1019            viewport.camera_target[0] - viewport.camera_pos[0],
1020            viewport.camera_target[1] - viewport.camera_pos[1],
1021            viewport.camera_target[2] - viewport.camera_pos[2],
1022        ];
1023        let forward = Self::normalize(forward);
1024        let right = Self::cross(forward, viewport.camera_up);
1025        let right = Self::normalize(right);
1026        let up = Self::cross(right, forward);
1027
1028        // Update render uniforms
1029        let render_uniforms = GpuRenderUniforms {
1030            view_proj,
1031            camera_pos_fov: [
1032                viewport.camera_pos[0],
1033                viewport.camera_pos[1],
1034                viewport.camera_pos[2],
1035                viewport.fov,
1036            ],
1037            camera_right_aspect: [right[0], right[1], right[2], aspect],
1038            camera_up: [up[0], up[1], up[2], 0.0],
1039            viewport_config: [
1040                viewport.bounds[2],
1041                viewport.bounds[3],
1042                0.0, // render mode
1043                viewport.blend_mode as f32,
1044            ],
1045        };
1046        queue.write_buffer(
1047            &self.render_uniform_buffer,
1048            0,
1049            bytemuck::bytes_of(&render_uniforms),
1050        );
1051
1052        // Draw particles
1053        render_pass.set_pipeline(&self.render_pipeline);
1054        render_pass.set_bind_group(0, &self.render_bind_group, &[]);
1055        render_pass.draw(0..6, 0..self.max_particles); // 6 vertices per quad (2 triangles)
1056    }
1057
1058    // Helper math functions
1059    fn normalize(v: [f32; 3]) -> [f32; 3] {
1060        let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
1061        if len > 0.0001 {
1062            [v[0] / len, v[1] / len, v[2] / len]
1063        } else {
1064            [0.0, 1.0, 0.0]
1065        }
1066    }
1067
1068    fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
1069        [
1070            a[1] * b[2] - a[2] * b[1],
1071            a[2] * b[0] - a[0] * b[2],
1072            a[0] * b[1] - a[1] * b[0],
1073        ]
1074    }
1075
1076    fn look_at(eye: [f32; 3], target: [f32; 3], up: [f32; 3]) -> [[f32; 4]; 4] {
1077        let f = Self::normalize([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]);
1078        let r = Self::normalize(Self::cross(f, up));
1079        let u = Self::cross(r, f);
1080
1081        [
1082            [r[0], u[0], -f[0], 0.0],
1083            [r[1], u[1], -f[1], 0.0],
1084            [r[2], u[2], -f[2], 0.0],
1085            [
1086                -(r[0] * eye[0] + r[1] * eye[1] + r[2] * eye[2]),
1087                -(u[0] * eye[0] + u[1] * eye[1] + u[2] * eye[2]),
1088                f[0] * eye[0] + f[1] * eye[1] + f[2] * eye[2],
1089                1.0,
1090            ],
1091        ]
1092    }
1093
1094    fn perspective(fov: f32, aspect: f32, near: f32, far: f32) -> [[f32; 4]; 4] {
1095        let f = 1.0 / (fov * 0.5).tan();
1096        let nf = 1.0 / (near - far);
1097
1098        [
1099            [f / aspect, 0.0, 0.0, 0.0],
1100            [0.0, f, 0.0, 0.0],
1101            [0.0, 0.0, (far + near) * nf, -1.0],
1102            [0.0, 0.0, 2.0 * far * near * nf, 0.0],
1103        ]
1104    }
1105
1106    fn mat4_mul(a: &[[f32; 4]; 4], b: &[[f32; 4]; 4]) -> [[f32; 4]; 4] {
1107        let mut result = [[0.0f32; 4]; 4];
1108        for i in 0..4 {
1109            for j in 0..4 {
1110                result[i][j] =
1111                    a[0][j] * b[i][0] + a[1][j] * b[i][1] + a[2][j] * b[i][2] + a[3][j] * b[i][3];
1112            }
1113        }
1114        result
1115    }
1116}
1117
1118/// Manager for multiple particle systems
1119pub struct ParticleManager {
1120    systems: HashMap<u64, ParticleSystemGpu>,
1121    next_id: u64,
1122}
1123
1124impl ParticleManager {
1125    pub fn new() -> Self {
1126        Self {
1127            systems: HashMap::new(),
1128            next_id: 0,
1129        }
1130    }
1131
1132    pub fn create_system(
1133        &mut self,
1134        device: &wgpu::Device,
1135        surface_format: wgpu::TextureFormat,
1136        max_particles: u32,
1137    ) -> u64 {
1138        let id = self.next_id;
1139        self.next_id += 1;
1140        let system = ParticleSystemGpu::new(device, surface_format, max_particles);
1141        self.systems.insert(id, system);
1142        id
1143    }
1144
1145    pub fn get_system(&self, id: u64) -> Option<&ParticleSystemGpu> {
1146        self.systems.get(&id)
1147    }
1148
1149    pub fn get_system_mut(&mut self, id: u64) -> Option<&mut ParticleSystemGpu> {
1150        self.systems.get_mut(&id)
1151    }
1152
1153    pub fn remove_system(&mut self, id: u64) {
1154        self.systems.remove(&id);
1155    }
1156}
1157
1158impl Default for ParticleManager {
1159    fn default() -> Self {
1160        Self::new()
1161    }
1162}