Skip to main content

dreamwell_engine/input/
particle.rs

1// Particle input domain — procedurally driven particle cloud character controller.
2//
3// The particle controller is a first-class input domain alongside humanoid.
4// It requires no FBX animations — the cloud shape is driven by velocity,
5// acceleration, and locomotion state via pure math.
6//
7// Clean Compute: all state is inline [f32] arrays. Zero allocation per frame.
8
9/// Particle locomotion state — drives the cloud shape procedurally.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum ParticleLocomotion {
12    /// Tight sphere, particles close together. Player is stationary.
13    #[default]
14    Idle,
15    /// Stretched ellipsoid in movement direction. Player is walking/running.
16    Moving,
17    /// Expanded sphere, particles spread out. Player is jumping/ascending.
18    Jumping,
19    /// Compressed disc, particles flatten. Player just landed.
20    Landing,
21    /// Elongated stream, particles trail behind. Player is sprinting.
22    Sprinting,
23}
24
25impl ParticleLocomotion {
26    /// Shape scale multipliers for the cloud [x_stretch, y_stretch, z_stretch, radius_scale].
27    /// Applied to the base sphere distribution to produce the locomotion-appropriate shape.
28    pub fn shape_params(self) -> ParticleShapeParams {
29        match self {
30            Self::Idle => ParticleShapeParams {
31                stretch: [1.0, 1.0, 1.0],
32                radius_scale: 1.0,
33                particle_scale: 0.08,
34                noise_amplitude: 0.06,
35            },
36            Self::Moving => ParticleShapeParams {
37                stretch: [1.0, 0.9, 1.3],
38                radius_scale: 1.2,
39                particle_scale: 0.07,
40                noise_amplitude: 0.10,
41            },
42            Self::Jumping => ParticleShapeParams {
43                stretch: [1.2, 1.5, 1.2],
44                radius_scale: 1.5,
45                particle_scale: 0.06,
46                noise_amplitude: 0.14,
47            },
48            Self::Landing => ParticleShapeParams {
49                stretch: [1.5, 0.5, 1.5],
50                radius_scale: 1.3,
51                particle_scale: 0.09,
52                noise_amplitude: 0.08,
53            },
54            Self::Sprinting => ParticleShapeParams {
55                stretch: [0.8, 0.85, 1.8],
56                radius_scale: 1.4,
57                particle_scale: 0.06,
58                noise_amplitude: 0.12,
59            },
60        }
61    }
62
63    pub fn label(self) -> &'static str {
64        match self {
65            Self::Idle => "Idle",
66            Self::Moving => "Moving",
67            Self::Jumping => "Jumping",
68            Self::Landing => "Landing",
69            Self::Sprinting => "Sprinting",
70        }
71    }
72}
73
74/// Shape parameters for the particle cloud at a given locomotion state.
75#[derive(Debug, Clone, Copy)]
76pub struct ParticleShapeParams {
77    /// Axis stretch multipliers [x, y, z] applied to sphere offsets.
78    pub stretch: [f32; 3],
79    /// Overall radius multiplier (1.0 = base radius).
80    pub radius_scale: f32,
81    /// Per-particle visual scale.
82    pub particle_scale: f32,
83    /// Noise amplitude for organic movement.
84    pub noise_amplitude: f32,
85}
86
87// ═══════════════════════════════════════════════════════════════════
88// Wave Formation System — real wave mathematics for particle positions.
89//
90// Moving: particles form a traveling wave surface (fabric in wind).
91//   y(x, z, t) = A · sin(k·x - ω·t) · cos(k·z)
92//   where k = 2π/λ (wave number), ω = 2π·f (angular frequency)
93//
94// Idle: particles decohere from wave into Fibonacci phyllotaxis disc.
95//   Uses golden angle θ = 2π/φ² placement with exponential radius growth.
96//   Transition from wave → disc uses Lindblad kernel: blend = exp(-Γ·t_idle).
97// ═══════════════════════════════════════════════════════════════════
98
99/// Per-particle wave formation state. Pre-allocated, updated every frame.
100/// Drives the visual shape of the particle using real wave equation math.
101#[derive(Clone)]
102pub struct WaveFormationState {
103    /// Cumulative phase time (seconds). Drives ω·t in the wave equation.
104    pub phase_time: f32,
105    /// Time spent in current idle (seconds). Drives idle→Fibonacci decoherence.
106    pub idle_time: f32,
107    /// Blend from wave(0.0) to Fibonacci(1.0). Uses exp(-Γ·t_idle).
108    pub fibonacci_blend: f32,
109    /// Wave amplitude A. Scales with velocity for weighty feel.
110    pub amplitude: f32,
111    /// Wave frequency f (Hz). Higher = faster ripple.
112    pub frequency: f32,
113    /// Wavelength λ. Controls spatial density of ripples.
114    pub wavelength: f32,
115    /// Heading direction [x, z] normalized. Wave propagates along this axis.
116    pub heading: [f32; 2],
117    /// Pre-computed particle offsets for current frame. Reused by GPU upload.
118    pub offsets: Vec<[f32; 3]>,
119}
120
121impl WaveFormationState {
122    pub fn new(particle_count: u32) -> Self {
123        Self {
124            phase_time: 0.0,
125            idle_time: 0.0,
126            fibonacci_blend: 0.0,
127            amplitude: 0.3,
128            frequency: 2.0,
129            wavelength: 1.5,
130            heading: [0.0, 1.0],
131            offsets: vec![[0.0; 3]; particle_count as usize],
132        }
133    }
134
135    /// Update wave formation from controller state.
136    /// `speed`: current movement speed (0 = idle).
137    /// `heading`: normalized [x, z] movement direction.
138    /// `dt`: frame delta time.
139    pub fn update(&mut self, speed: f32, heading: [f32; 2], yaw: f32, dt: f32, particle_count: u32, radius: f32) {
140        self.phase_time += dt;
141        let moving = speed > 0.5;
142
143        if moving {
144            // Reset idle decoherence when moving.
145            self.idle_time = 0.0;
146            // Wave amplitude scales with speed for weighty feel.
147            // Clamp to prevent extreme distortion.
148            self.amplitude = (speed * 0.06).clamp(0.1, 0.5);
149            self.heading = if heading[0].abs() + heading[1].abs() > 0.01 {
150                heading
151            } else {
152                [yaw.sin(), yaw.cos()]
153            };
154            // Fibonacci blend decays toward 0 (wave form) using exponential.
155            self.fibonacci_blend *= (-6.0 * dt).exp(); // fast re-wave
156        } else {
157            // Idle: decohere into Fibonacci disc.
158            self.idle_time += dt;
159            // Lindblad kernel: fibonacci_blend approaches 1.0 exponentially.
160            // Rate Γ = 2.0: gentle formation over ~1.5 seconds.
161            self.fibonacci_blend = 1.0 - (-2.0 * self.idle_time).exp();
162            // Dampen wave amplitude toward 0 for smooth settling.
163            self.amplitude *= (-3.0 * dt).exp();
164        }
165
166        // Generate per-particle positions.
167        let n = particle_count as usize;
168        if self.offsets.len() != n {
169            self.offsets.resize(n, [0.0; 3]);
170        }
171
172        let k = std::f32::consts::TAU / self.wavelength; // wave number
173        let omega = std::f32::consts::TAU * self.frequency; // angular frequency
174        let t = self.phase_time;
175        let a = self.amplitude;
176        let golden_angle = std::f32::consts::TAU / (((1.0 + 5.0f32.sqrt()) / 2.0) * ((1.0 + 5.0f32.sqrt()) / 2.0));
177        let fib_blend = self.fibonacci_blend.clamp(0.0, 1.0);
178
179        for i in 0..n {
180            let frac = (i as f32 + 0.5) / n as f32;
181
182            // ── Traveling Wave Formation ──
183            // Particles on a rectangular grid, displaced by wave equation.
184            // Grid: sqrt(N) × sqrt(N) in the heading-perpendicular plane.
185            let grid_side = (n as f32).sqrt().ceil() as usize;
186            let gx = (i % grid_side) as f32 / grid_side as f32 - 0.5;
187            let gz = (i / grid_side) as f32 / grid_side as f32 - 0.5;
188
189            // Rotate grid to align with heading direction.
190            let hx = self.heading[0];
191            let hz = self.heading[1];
192            let world_x = gx * hz - gz * hx; // perpendicular to heading
193            let world_z = gx * hx + gz * hz; // along heading
194
195            // Wave equation: y = A · sin(k·along - ω·t) · cos(k·perp × 0.5)
196            // The cos term creates a natural tapering at the edges.
197            let wave_y = a * (k * world_z * radius * 2.0 - omega * t).sin() * (k * world_x * radius * 0.5).cos();
198
199            let wave_pos = [world_x * radius * 2.0, wave_y, world_z * radius * 2.0];
200
201            // ── Fibonacci Phyllotaxis Disc ──
202            // Golden angle spiral on a flat disc. Radius grows as sqrt(i).
203            let fib_r = frac.sqrt() * radius * 1.2;
204            let fib_theta = i as f32 * golden_angle + t * 0.3; // slow rotation
205            let fib_pos = [
206                fib_r * fib_theta.cos(),
207                a * 0.1 * (fib_theta * 3.0 + t).sin(), // gentle vertical bob
208                fib_r * fib_theta.sin(),
209            ];
210
211            // ── Blend: wave → fibonacci using decoherence kernel ──
212            self.offsets[i] = [
213                wave_pos[0] * (1.0 - fib_blend) + fib_pos[0] * fib_blend,
214                wave_pos[1] * (1.0 - fib_blend) + fib_pos[1] * fib_blend,
215                wave_pos[2] * (1.0 - fib_blend) + fib_pos[2] * fib_blend,
216            ];
217        }
218    }
219}
220
221/// Particle controller state — owns position, velocity, and locomotion.
222/// Updated per frame from input, drives the particle cloud shape.
223pub struct ParticleController {
224    /// World-space center of the particle.
225    pub position: [f32; 3],
226    /// Previous frame position (GPU derives anchor velocity).
227    pub previous_position: [f32; 3],
228    /// Current velocity (m/s).
229    pub velocity: [f32; 3],
230    /// Movement speed (m/s).
231    pub move_speed: f32,
232    /// Sprint speed multiplier.
233    pub sprint_multiplier: f32,
234    /// Current locomotion state.
235    pub locomotion: ParticleLocomotion,
236    /// Gravity (m/s²).
237    pub gravity: f32,
238    /// Whether the particle is on the ground.
239    pub grounded: bool,
240    /// Time since last landing (seconds) — used for landing squash duration.
241    pub landing_timer: f32,
242    /// Facing direction (yaw in radians, 0 = +Z).
243    pub facing_yaw: f32,
244    /// Cloud base radius (meters).
245    pub cloud_radius: f32,
246    /// Number of particles in the particle.
247    pub particle_count: u32,
248    /// Base particle color [r, g, b, a].
249    pub base_color: [f32; 4],
250    /// Normalized world-space movement direction (from WASD+yaw).
251    pub movement_force_direction: [f32; 3],
252    /// Movement force magnitude: 0=idle, move_speed=walk, sprint=sprint.
253    pub movement_force_magnitude: f32,
254    /// Current rheology blend (smooth interpolation toward target, 8/sec exponential).
255    pub rheology_blend: f32,
256    /// Rheology target per locomotion state.
257    pub rheology_target: f32,
258    /// Wave formation state — real wave math for particle positions.
259    pub wave_formation: WaveFormationState,
260}
261
262impl Default for ParticleController {
263    fn default() -> Self {
264        Self {
265            position: [0.0, 1.0, 0.0],
266            previous_position: [0.0, 1.0, 0.0],
267            velocity: [0.0; 3],
268            move_speed: 5.0,
269            sprint_multiplier: 2.0,
270            locomotion: ParticleLocomotion::Idle,
271            gravity: 1.2,
272            grounded: true,
273            landing_timer: 0.0,
274            facing_yaw: 0.0,
275            cloud_radius: 0.8,
276            particle_count: 256,
277            base_color: [0.4, 0.6, 1.0, 0.85],
278            movement_force_direction: [0.0; 3],
279            movement_force_magnitude: 0.0,
280            rheology_blend: 0.15,
281            rheology_target: 0.15,
282            wave_formation: WaveFormationState::new(256),
283        }
284    }
285}
286
287impl ParticleController {
288    pub fn new(position: [f32; 3], particle_count: u32) -> Self {
289        Self {
290            position,
291            particle_count,
292            wave_formation: WaveFormationState::new(particle_count),
293            ..Default::default()
294        }
295    }
296
297    /// Update the particle from WASD input relative to camera yaw.
298    /// Returns true if the particle moved this frame.
299    pub fn update(
300        &mut self,
301        forward: bool,
302        back: bool,
303        left: bool,
304        right: bool,
305        jump: bool,
306        sprint: bool,
307        camera_yaw: f32,
308        dt: f32,
309    ) -> bool {
310        // Store previous position before update (GPU derives anchor velocity).
311        self.previous_position = self.position;
312
313        // Compute movement direction from input in view-local space.
314        let mut dir_fwd = 0.0f32;
315        let mut dir_right = 0.0f32;
316        if forward {
317            dir_fwd += 1.0;
318        }
319        if back {
320            dir_fwd -= 1.0;
321        }
322        if left {
323            dir_right -= 1.0;
324        }
325        if right {
326            dir_right += 1.0;
327        }
328
329        let len = (dir_fwd * dir_fwd + dir_right * dir_right).sqrt();
330        let moving = len > 0.001;
331
332        if moving {
333            dir_fwd /= len;
334            dir_right /= len;
335        }
336
337        // Rotate view-local input to world space using camera yaw.
338        let cos_yaw = camera_yaw.cos();
339        let sin_yaw = camera_yaw.sin();
340        let world_x = dir_fwd * (-cos_yaw) + dir_right * sin_yaw;
341        let world_z = dir_fwd * (-sin_yaw) + dir_right * (-cos_yaw);
342
343        // Apply speed
344        let speed = if sprint {
345            self.move_speed * self.sprint_multiplier
346        } else {
347            self.move_speed
348        };
349        self.velocity[0] = if moving { world_x * speed } else { 0.0 };
350        self.velocity[2] = if moving { world_z * speed } else { 0.0 };
351
352        // Gravity + jump
353        if !self.grounded {
354            self.velocity[1] -= self.gravity * dt;
355        } else if jump {
356            self.velocity[1] = 3.5;
357            self.grounded = false;
358        }
359
360        // Apply velocity
361        self.position[0] += self.velocity[0] * dt;
362        self.position[1] += self.velocity[1] * dt;
363        self.position[2] += self.velocity[2] * dt;
364
365        // Ground collision at Y=0
366        let was_airborne = !self.grounded;
367        if self.position[1] <= 0.5 {
368            self.position[1] = 0.5;
369            self.velocity[1] = 0.0;
370            self.grounded = true;
371            if was_airborne {
372                self.landing_timer = 0.3;
373            }
374        }
375
376        // Update facing
377        if moving {
378            self.facing_yaw = world_z.atan2(world_x);
379        }
380
381        // Update locomotion state
382        self.landing_timer = (self.landing_timer - dt).max(0.0);
383        self.locomotion = if self.landing_timer > 0.0 {
384            ParticleLocomotion::Landing
385        } else if !self.grounded {
386            ParticleLocomotion::Jumping
387        } else if sprint && moving {
388            ParticleLocomotion::Sprinting
389        } else if moving {
390            ParticleLocomotion::Moving
391        } else {
392            ParticleLocomotion::Idle
393        };
394
395        // Update movement force direction and magnitude for GPU force field.
396        if moving {
397            self.movement_force_direction = [world_x, 0.0, world_z];
398            self.movement_force_magnitude = speed;
399        } else {
400            self.movement_force_direction = [0.0; 3];
401            self.movement_force_magnitude = 0.0;
402        }
403
404        // Update rheology: smooth exponential interpolation toward target (8/sec).
405        self.rheology_target = match self.locomotion {
406            ParticleLocomotion::Idle => 0.15,
407            ParticleLocomotion::Moving => 0.5,
408            ParticleLocomotion::Sprinting => 0.7,
409            ParticleLocomotion::Jumping => 0.1,
410            ParticleLocomotion::Landing => 0.6,
411        };
412        let blend_rate = 1.0 - (-8.0 * dt).exp();
413        self.rheology_blend += (self.rheology_target - self.rheology_blend) * blend_rate;
414
415        // Update wave formation: traveling wave when moving, Fibonacci disc when idle.
416        let speed_mag = (self.velocity[0] * self.velocity[0] + self.velocity[2] * self.velocity[2]).sqrt();
417        let heading = if speed_mag > 0.01 {
418            [self.velocity[0] / speed_mag, self.velocity[2] / speed_mag]
419        } else {
420            [0.0, 0.0]
421        };
422        self.wave_formation.update(
423            speed_mag,
424            heading,
425            self.facing_yaw,
426            dt,
427            self.particle_count,
428            self.cloud_radius,
429        );
430
431        moving
432    }
433
434    /// Get wave formation particle offsets (relative to particle center).
435    /// These are the real wave-equation-driven positions for GPU upload.
436    pub fn wave_offsets(&self) -> &[[f32; 3]] {
437        &self.wave_formation.offsets
438    }
439
440    /// Get the current shape parameters for the particle cloud.
441    pub fn shape_params(&self) -> ParticleShapeParams {
442        self.locomotion.shape_params()
443    }
444}
445
446/// Generate sphere-distributed offsets using the golden ratio spiral.
447/// Deterministic — same input always produces the same output.
448/// Returns `count` unit-sphere offsets that are then scaled by radius + shape params.
449pub fn particle_sphere_offsets(count: u32) -> Vec<[f32; 3]> {
450    let golden_ratio = (1.0 + 5.0f32.sqrt()) / 2.0;
451    let angle_increment = std::f32::consts::TAU * golden_ratio;
452    let mut offsets = Vec::with_capacity(count as usize);
453
454    for i in 0..count {
455        let t = (i as f32 + 0.5) / count as f32;
456        let phi = (1.0 - 2.0 * t).acos();
457        let theta = angle_increment * i as f32;
458
459        let x = phi.sin() * theta.cos();
460        let y = phi.cos();
461        let z = phi.sin() * theta.sin();
462        offsets.push([x, y, z]);
463    }
464
465    offsets
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn golden_spiral_produces_correct_count() {
474        let pts = particle_sphere_offsets(128);
475        assert_eq!(pts.len(), 128);
476        // All points should be on the unit sphere (length ≈ 1.0)
477        for p in &pts {
478            let len = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
479            assert!((len - 1.0).abs() < 0.01, "point not on unit sphere: len={len}");
480        }
481    }
482
483    #[test]
484    fn particle_locomotion_transitions() {
485        let mut ctrl = ParticleController::default();
486        assert_eq!(ctrl.locomotion, ParticleLocomotion::Idle);
487
488        // Move forward
489        ctrl.update(true, false, false, false, false, false, 0.0, 0.016);
490        assert_eq!(ctrl.locomotion, ParticleLocomotion::Moving);
491
492        // Sprint
493        ctrl.update(true, false, false, false, false, true, 0.0, 0.016);
494        assert_eq!(ctrl.locomotion, ParticleLocomotion::Sprinting);
495
496        // Stop
497        ctrl.update(false, false, false, false, false, false, 0.0, 0.016);
498        assert_eq!(ctrl.locomotion, ParticleLocomotion::Idle);
499    }
500
501    #[test]
502    fn particle_jump_and_land() {
503        let mut ctrl = ParticleController::default();
504        ctrl.position = [0.0, 0.5, 0.0];
505
506        // Jump
507        ctrl.update(false, false, false, false, true, false, 0.0, 0.016);
508        assert_eq!(ctrl.locomotion, ParticleLocomotion::Jumping);
509        assert!(!ctrl.grounded);
510
511        // Fall back to ground (simulate many frames — needs more with low gravity)
512        for _ in 0..400 {
513            ctrl.update(false, false, false, false, false, false, 0.0, 0.016);
514        }
515        assert!(ctrl.grounded);
516        // Should be landing or idle after settling
517        assert!(ctrl.locomotion == ParticleLocomotion::Idle || ctrl.locomotion == ParticleLocomotion::Landing);
518    }
519
520    #[test]
521    fn shape_params_vary_by_state() {
522        let idle = ParticleLocomotion::Idle.shape_params();
523        let moving = ParticleLocomotion::Moving.shape_params();
524        assert!(idle.radius_scale < moving.radius_scale);
525        assert!(idle.noise_amplitude < moving.noise_amplitude);
526    }
527
528    // ── ParticleController state tracking tests ────────────────────────────
529
530    #[test]
531    fn particle_controller_previous_position_stored() {
532        let mut ctrl = ParticleController::new([3.0, 1.0, -2.0], 128);
533        let pos_before = ctrl.position;
534
535        // Move forward — position changes, previous_position should be the old value.
536        ctrl.update(true, false, false, false, false, false, 0.0, 0.016);
537        assert_eq!(
538            ctrl.previous_position, pos_before,
539            "previous_position should equal position before update"
540        );
541        // Position itself should have changed (moving forward with nonzero speed).
542        assert_ne!(ctrl.position, pos_before, "position should change when moving forward");
543    }
544
545    #[test]
546    fn particle_controller_force_direction_when_moving() {
547        let mut ctrl = ParticleController::default();
548        ctrl.update(true, false, false, false, false, false, 0.0, 0.016);
549
550        let dir = ctrl.movement_force_direction;
551        let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
552        assert!(
553            len > 0.001,
554            "movement_force_direction should be nonzero when moving, got length {}",
555            len
556        );
557    }
558
559    #[test]
560    fn particle_controller_force_direction_when_idle() {
561        let mut ctrl = ParticleController::default();
562        // No movement input.
563        ctrl.update(false, false, false, false, false, false, 0.0, 0.016);
564
565        assert_eq!(
566            ctrl.movement_force_magnitude, 0.0,
567            "movement_force_magnitude should be 0 when idle"
568        );
569        assert_eq!(
570            ctrl.movement_force_direction, [0.0; 3],
571            "movement_force_direction should be zero when idle"
572        );
573    }
574
575    #[test]
576    fn particle_controller_rheology_idle_target() {
577        let mut ctrl = ParticleController::default();
578        ctrl.update(false, false, false, false, false, false, 0.0, 0.016);
579        assert_eq!(ctrl.locomotion, ParticleLocomotion::Idle);
580        assert!(
581            (ctrl.rheology_target - 0.15).abs() < 1e-5,
582            "idle rheology_target should be 0.15, got {}",
583            ctrl.rheology_target
584        );
585    }
586
587    #[test]
588    fn particle_controller_rheology_sprinting_target() {
589        let mut ctrl = ParticleController::default();
590        ctrl.update(true, false, false, false, false, true, 0.0, 0.016);
591        assert_eq!(ctrl.locomotion, ParticleLocomotion::Sprinting);
592        assert!(
593            (ctrl.rheology_target - 0.7).abs() < 1e-5,
594            "sprinting rheology_target should be 0.7, got {}",
595            ctrl.rheology_target
596        );
597    }
598
599    #[test]
600    fn particle_controller_rheology_blend_approaches_target() {
601        let mut ctrl = ParticleController::default();
602        // Start at idle default (0.15), switch to sprinting (target 0.7).
603        for _ in 0..600 {
604            ctrl.update(true, false, false, false, false, true, 0.0, 0.016);
605        }
606        // After many frames at 8/sec exponential, blend should be close to 0.7.
607        assert!(
608            (ctrl.rheology_blend - ctrl.rheology_target).abs() < 0.01,
609            "after 600 frames, rheology_blend ({}) should approach target ({})",
610            ctrl.rheology_blend,
611            ctrl.rheology_target
612        );
613    }
614
615    #[test]
616    fn particle_controller_default_particle_count_256() {
617        let ctrl = ParticleController::default();
618        assert_eq!(
619            ctrl.particle_count, 256,
620            "default particle_count should be 256, got {}",
621            ctrl.particle_count
622        );
623    }
624
625    #[test]
626    fn particle_controller_default_cloud_radius_08() {
627        let ctrl = ParticleController::default();
628        assert!(
629            (ctrl.cloud_radius - 0.8).abs() < 1e-5,
630            "default cloud_radius should be 0.8, got {}",
631            ctrl.cloud_radius
632        );
633    }
634}