Skip to main content

proof_engine/render/
camera.rs

1//! Proof Engine camera system.
2//!
3//! Provides three camera modes usable independently or together:
4//!   1. **Free camera** — spring-driven position + target with trauma shake
5//!   2. **Orbital camera** — rotates around a target at a given distance, with zoom
6//!   3. **Cinematic path** — follows a spline path of control points at a given speed
7//!
8//! All three use `SpringDamper` physics for smooth, organic motion. Shake/trauma
9//! is applied as an additive offset after spring resolution.
10
11use glam::{Mat4, Vec3, Vec4};
12use crate::math::springs::{SpringDamper, Spring3D as SpringDamper3};
13use crate::config::EngineConfig;
14
15// ── CameraState ───────────────────────────────────────────────────────────────
16
17/// A snapshot of camera state for this frame.
18#[derive(Clone, Debug)]
19pub struct CameraState {
20    pub view:        Mat4,
21    pub projection:  Mat4,
22    pub position:    Vec3,
23    pub target:      Vec3,
24    pub fov_degrees: f32,
25    pub aspect:      f32,
26}
27
28impl CameraState {
29    /// Unproject a screen-space point (NDC [-1,1]) to a world-space ray direction.
30    pub fn unproject_ray(&self, ndc_x: f32, ndc_y: f32) -> Vec3 {
31        let inv_proj = self.projection.inverse();
32        let inv_view = self.view.inverse();
33        let clip = Vec4::new(ndc_x, ndc_y, -1.0, 1.0);
34        let view_space = inv_proj * clip;
35        let view_dir   = Vec4::new(view_space.x, view_space.y, -1.0, 0.0);
36        let world_dir  = inv_view * view_dir;
37        Vec3::new(world_dir.x, world_dir.y, world_dir.z).normalize_or_zero()
38    }
39
40    /// World-space position from screen NDC + depth [0, 1].
41    pub fn unproject_point(&self, ndc_x: f32, ndc_y: f32, depth: f32) -> Vec3 {
42        let inv = (self.projection * self.view).inverse();
43        let clip = Vec4::new(ndc_x, ndc_y, depth * 2.0 - 1.0, 1.0);
44        let world = inv * clip;
45        Vec3::new(world.x / world.w, world.y / world.w, world.z / world.w)
46    }
47
48    /// Project a world-space point to NDC [-1, 1].
49    pub fn project(&self, world: Vec3) -> Vec3 {
50        let clip = self.projection * self.view * Vec4::new(world.x, world.y, world.z, 1.0);
51        if clip.w.abs() < 0.0001 { return Vec3::ZERO; }
52        Vec3::new(clip.x / clip.w, clip.y / clip.w, clip.z / clip.w)
53    }
54
55    /// Whether a world point is within the camera frustum (rough test).
56    pub fn is_visible(&self, world: Vec3) -> bool {
57        let ndc = self.project(world);
58        ndc.x >= -1.0 && ndc.x <= 1.0 && ndc.y >= -1.0 && ndc.y <= 1.0 && ndc.z >= 0.0
59    }
60}
61
62// ── Shake ─────────────────────────────────────────────────────────────────────
63
64/// Camera trauma/shake state, driven by a decaying trauma value.
65#[derive(Debug, Clone)]
66pub struct TraumaShake {
67    /// Current trauma [0, 1]. Decays over time.
68    pub trauma:          f32,
69    /// Rate at which trauma decays per second.
70    pub decay_rate:      f32,
71    /// Maximum shake translation at trauma=1 (world units).
72    pub max_translation: f32,
73    /// Maximum shake rotation at trauma=1 (degrees).
74    pub max_rotation:    f32,
75    /// Internal time counter for noise sampling.
76    time:                f32,
77}
78
79impl Default for TraumaShake {
80    fn default() -> Self {
81        Self {
82            trauma:          0.0,
83            decay_rate:      0.8,
84            max_translation: 0.3,
85            max_rotation:    3.0,
86            time:            0.0,
87        }
88    }
89}
90
91impl TraumaShake {
92    pub fn add(&mut self, amount: f32) {
93        self.trauma = (self.trauma + amount).min(1.0);
94    }
95
96    pub fn tick(&mut self, dt: f32) -> (Vec3, f32) {
97        self.trauma = (self.trauma - self.decay_rate * dt).max(0.0);
98        self.time += dt;
99        let shake_sq = self.trauma * self.trauma;  // quadratic for natural falloff
100        let t = self.time;
101        let tx = (t * 47.3).sin() * shake_sq * self.max_translation;
102        let ty = (t * 31.7).cos() * shake_sq * self.max_translation;
103        let rot = (t * 23.1).sin() * shake_sq * self.max_rotation;
104        (Vec3::new(tx, ty, 0.0), rot)
105    }
106
107    pub fn is_idle(&self) -> bool { self.trauma < 0.001 }
108}
109
110// ── Orbital camera ────────────────────────────────────────────────────────────
111
112/// Orbital camera — rotates around a target point at a configurable distance.
113///
114/// Controls:
115///   - `azimuth`   : horizontal rotation angle (radians)
116///   - `elevation` : vertical angle (radians, clamped to avoid gimbal flip)
117///   - `distance`  : how far from the target
118#[derive(Debug, Clone)]
119pub struct OrbitalCamera {
120    pub target:    Vec3,
121    pub azimuth:   f32,   // radians, horizontal
122    pub elevation: f32,   // radians, vertical [-π/2+ε, π/2-ε]
123    pub distance:  f32,
124    /// Spring-damped target following.
125    target_spring: SpringDamper3,
126    /// Spring-damped distance (smooth zoom).
127    dist_spring:   SpringDamper,
128    /// Min/max distance clamp.
129    pub dist_min:  f32,
130    pub dist_max:  f32,
131    /// Min/max elevation clamp.
132    pub elev_min:  f32,
133    pub elev_max:  f32,
134}
135
136impl OrbitalCamera {
137    pub fn new(target: Vec3, distance: f32) -> Self {
138        Self {
139            target,
140            azimuth:   0.0,
141            elevation: 0.4,  // looking slightly down
142            distance,
143            target_spring: SpringDamper3::from_vec3(target, 10.0, 6.0),
144            dist_spring:   SpringDamper::new(distance, 8.0, 5.0),
145            dist_min:  2.0,
146            dist_max:  200.0,
147            elev_min:  -1.4,
148            elev_max:   1.4,
149        }
150    }
151
152    /// Set a new target (spring-animated follow).
153    pub fn set_target(&mut self, pos: Vec3) {
154        self.target_spring.set_target(pos);
155    }
156
157    /// Rotate by delta angles (e.g. from mouse drag).
158    pub fn rotate(&mut self, delta_azimuth: f32, delta_elevation: f32) {
159        self.azimuth   += delta_azimuth;
160        self.elevation  = (self.elevation + delta_elevation)
161            .clamp(self.elev_min, self.elev_max);
162    }
163
164    /// Zoom by changing the target distance.
165    pub fn zoom(&mut self, delta: f32) {
166        let new_dist = (self.distance + delta).clamp(self.dist_min, self.dist_max);
167        self.dist_spring.set_target(new_dist);
168    }
169
170    /// Tick by dt. Returns the camera eye position (for look_at).
171    pub fn tick(&mut self, dt: f32) -> (Vec3, Vec3) {
172        let target = self.target_spring.tick(dt);
173        self.distance = self.dist_spring.tick_get(dt)
174            .clamp(self.dist_min, self.dist_max);
175
176        let eye = target + Vec3::new(
177            self.elevation.cos() * self.azimuth.sin() * self.distance,
178            self.elevation.sin() * self.distance,
179            self.elevation.cos() * self.azimuth.cos() * self.distance,
180        );
181
182        (eye, target)
183    }
184
185    /// Compute view matrix from orbital parameters.
186    pub fn view_matrix(&mut self, dt: f32) -> Mat4 {
187        let (eye, target) = self.tick(dt);
188        Mat4::look_at_rh(eye, target, Vec3::Y)
189    }
190}
191
192// ── Cinematic path ────────────────────────────────────────────────────────────
193
194/// A control point on the cinematic camera path.
195#[derive(Debug, Clone)]
196pub struct PathPoint {
197    pub position: Vec3,
198    pub target:   Vec3,
199    pub fov:      f32,
200    /// Time in seconds to arrive at this point from the previous one.
201    pub duration: f32,
202    /// Easing for this segment.
203    pub ease:     PathEasing,
204}
205
206/// Easing function for a path segment.
207#[derive(Debug, Clone, Copy, PartialEq)]
208pub enum PathEasing {
209    Linear,
210    EaseInOut,
211    EaseIn,
212    EaseOut,
213    Instant,  // immediately jump to this position (cut)
214}
215
216impl PathEasing {
217    pub fn apply(&self, t: f32) -> f32 {
218        let t = t.clamp(0.0, 1.0);
219        match self {
220            PathEasing::Linear    => t,
221            PathEasing::EaseIn    => t * t,
222            PathEasing::EaseOut   => t * (2.0 - t),
223            PathEasing::EaseInOut => t * t * (3.0 - 2.0 * t),
224            PathEasing::Instant   => 1.0,
225        }
226    }
227}
228
229impl PathPoint {
230    pub fn new(position: Vec3, target: Vec3, fov: f32, duration: f32) -> Self {
231        Self { position, target, fov, duration, ease: PathEasing::EaseInOut }
232    }
233
234    pub fn instant(position: Vec3, target: Vec3, fov: f32) -> Self {
235        Self { position, target, fov, duration: 0.0, ease: PathEasing::Instant }
236    }
237}
238
239/// A cinematic camera path — plays through a sequence of `PathPoint`s.
240#[derive(Debug, Clone)]
241pub struct CinematicPath {
242    pub points:   Vec<PathPoint>,
243    pub looping:  bool,
244    elapsed:      f32,
245    done:         bool,
246}
247
248impl CinematicPath {
249    pub fn new(points: Vec<PathPoint>, looping: bool) -> Self {
250        Self { points, looping, elapsed: 0.0, done: false }
251    }
252
253    pub fn is_done(&self) -> bool { self.done }
254    pub fn is_playing(&self) -> bool { !self.points.is_empty() && !self.done }
255
256    pub fn reset(&mut self) {
257        self.elapsed = 0.0;
258        self.done = false;
259    }
260
261    /// Advance the path by dt. Returns (position, target, fov) for this frame.
262    pub fn tick(&mut self, dt: f32) -> (Vec3, Vec3, f32) {
263        if self.points.is_empty() || self.done {
264            return (Vec3::ZERO, Vec3::ZERO, 60.0);
265        }
266
267        self.elapsed += dt;
268
269        // Find which segment we're in
270        let mut t_accum = 0.0f32;
271        for i in 0..self.points.len() {
272            let next_i = (i + 1) % self.points.len();
273            if next_i == 0 && !self.looping { break; }
274            let seg_dur = self.points[next_i].duration.max(f32::EPSILON);
275            let seg_end = t_accum + seg_dur;
276
277            if self.elapsed <= seg_end {
278                let local_t = (self.elapsed - t_accum) / seg_dur;
279                let eased   = self.points[next_i].ease.apply(local_t);
280                let a = &self.points[i];
281                let b = &self.points[next_i];
282                let pos = a.position.lerp(b.position, eased);
283                let tgt = a.target.lerp(b.target, eased);
284                let fov = a.fov + (b.fov - a.fov) * eased;
285                return (pos, tgt, fov);
286            }
287
288            t_accum = seg_end;
289        }
290
291        // Past the end
292        if self.looping {
293            let total = self.total_duration();
294            if total > 0.0 { self.elapsed %= total; }
295            return self.tick(0.0);
296        }
297
298        self.done = true;
299        let last = self.points.last().unwrap();
300        (last.position, last.target, last.fov)
301    }
302
303    pub fn total_duration(&self) -> f32 {
304        self.points.iter().map(|p| p.duration).sum()
305    }
306}
307
308// ── ProofCamera ───────────────────────────────────────────────────────────────
309
310/// The main engine camera. Supports free, orbital, and cinematic modes.
311pub struct ProofCamera {
312    // ── Free camera springs ───────────────────────────────────────────────────
313    pub position:   SpringDamper3,
314    pub target:     SpringDamper3,
315    pub fov:        SpringDamper,
316
317    // ── Shake ─────────────────────────────────────────────────────────────────
318    pub shake:      TraumaShake,
319
320    // ── Orbital ───────────────────────────────────────────────────────────────
321    pub orbital:    Option<OrbitalCamera>,
322
323    // ── Cinematic path ────────────────────────────────────────────────────────
324    pub path:       Option<CinematicPath>,
325
326    // ── Projection ───────────────────────────────────────────────────────────
327    pub aspect:     f32,
328    pub near:       f32,
329    pub far:        f32,
330
331    // ── Accumulated time (for shake noise) ────────────────────────────────────
332    total_time:     f32,
333}
334
335impl ProofCamera {
336    pub fn new(config: &EngineConfig) -> Self {
337        let aspect = config.window_width as f32 / config.window_height.max(1) as f32;
338        Self {
339            position: SpringDamper3::from_vec3(Vec3::new(0.0, 0.0, 10.0), 12.0, 6.0),
340            target:   SpringDamper3::from_vec3(Vec3::ZERO, 14.0, 7.0),
341            fov:      SpringDamper::new(60.0, 8.0, 5.0),
342            shake:    TraumaShake::default(),
343            orbital:  None,
344            path:     None,
345            aspect,
346            near:     0.1,
347            far:      1000.0,
348            total_time: 0.0,
349        }
350    }
351
352    // ── Trauma ────────────────────────────────────────────────────────────────
353
354    pub fn add_trauma(&mut self, amount: f32) {
355        self.shake.add(amount);
356    }
357
358    // ── Free camera controls ──────────────────────────────────────────────────
359
360    /// Set where the camera should move to (spring-animated).
361    pub fn move_to(&mut self, pos: Vec3) {
362        self.position.set_target(pos);
363    }
364
365    /// Set where the camera should look at (spring-animated).
366    pub fn look_at(&mut self, target: Vec3) {
367        self.target.set_target(target);
368    }
369
370    /// Zoom to a specific FOV (spring-animated).
371    pub fn zoom_to(&mut self, fov_degrees: f32) {
372        self.fov.set_target(fov_degrees);
373    }
374
375    /// Teleport the camera instantly (no spring animation).
376    pub fn set_position_instant(&mut self, pos: Vec3) {
377        self.position.x.position = pos.x;
378        self.position.y.position = pos.y;
379        self.position.z.position = pos.z;
380        self.position.set_target(pos);
381    }
382
383    // ── Orbital mode ──────────────────────────────────────────────────────────
384
385    /// Switch to orbital mode around a target.
386    pub fn begin_orbital(&mut self, target: Vec3, distance: f32) {
387        self.orbital = Some(OrbitalCamera::new(target, distance));
388    }
389
390    /// Exit orbital mode (returns to free camera).
391    pub fn end_orbital(&mut self) { self.orbital = None; }
392
393    pub fn is_orbital(&self) -> bool { self.orbital.is_some() }
394
395    /// Rotate the orbital camera by delta angles.
396    pub fn orbital_rotate(&mut self, delta_az: f32, delta_el: f32) {
397        if let Some(ref mut orb) = self.orbital {
398            orb.rotate(delta_az, delta_el);
399        }
400    }
401
402    /// Zoom the orbital camera.
403    pub fn orbital_zoom(&mut self, delta: f32) {
404        if let Some(ref mut orb) = self.orbital {
405            orb.zoom(delta);
406        }
407    }
408
409    // ── Cinematic path ────────────────────────────────────────────────────────
410
411    /// Start a cinematic path sequence.
412    pub fn begin_path(&mut self, points: Vec<PathPoint>, looping: bool) {
413        self.path = Some(CinematicPath::new(points, looping));
414    }
415
416    /// Stop the cinematic path and return to normal camera.
417    pub fn end_path(&mut self) { self.path = None; }
418
419    pub fn is_on_path(&self) -> bool {
420        self.path.as_ref().map(|p| p.is_playing()).unwrap_or(false)
421    }
422
423    // ── Tick ──────────────────────────────────────────────────────────────────
424
425    /// Advance the camera by dt seconds. Returns the current CameraState.
426    pub fn tick(&mut self, dt: f32) -> CameraState {
427        self.total_time += dt;
428        let (shake_offset, _shake_rot) = self.shake.tick(dt);
429
430        let (pos, tgt, fov_deg) = if let Some(ref mut path) = self.path {
431            // Cinematic mode
432            if path.is_playing() {
433                path.tick(dt)
434            } else {
435                self.path = None;
436                (
437                    self.position.tick(dt),
438                    self.target.tick(dt),
439                    self.fov.tick_get(dt),
440                )
441            }
442        } else if let Some(ref mut orb) = self.orbital {
443            // Orbital mode
444            let (eye, target) = orb.tick(dt);
445            let fov = self.fov.tick_get(dt);
446            (eye, target, fov)
447        } else {
448            // Free camera
449            (
450                self.position.tick(dt),
451                self.target.tick(dt),
452                self.fov.tick_get(dt),
453            )
454        };
455
456        let final_pos = pos + shake_offset;
457        let view       = Mat4::look_at_rh(final_pos, tgt, Vec3::Y);
458        let projection = Mat4::perspective_rh(
459            fov_deg.to_radians(), self.aspect, self.near, self.far,
460        );
461
462        CameraState { view, projection, position: final_pos, target: tgt,
463                      fov_degrees: fov_deg, aspect: self.aspect }
464    }
465
466    pub fn on_resize(&mut self, width: u32, height: u32) {
467        self.aspect = width as f32 / height.max(1) as f32;
468    }
469}
470
471impl Default for ProofCamera {
472    fn default() -> Self { Self::new(&EngineConfig::default()) }
473}
474
475// ── Tests ─────────────────────────────────────────────────────────────────────
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use crate::config::EngineConfig;
481
482    #[test]
483    fn camera_tick_produces_finite_matrices() {
484        let config = EngineConfig::default();
485        let mut cam = ProofCamera::new(&config);
486        let state = cam.tick(0.016);
487        assert!(state.view.is_finite());
488        assert!(state.projection.is_finite());
489    }
490
491    #[test]
492    fn shake_decays_to_zero() {
493        let mut shake = TraumaShake::default();
494        shake.add(1.0);
495        for _ in 0..100 { shake.tick(0.016); }
496        assert!(shake.trauma < 0.01);
497    }
498
499    #[test]
500    fn orbital_camera_moves() {
501        let mut orb = OrbitalCamera::new(Vec3::ZERO, 10.0);
502        orb.rotate(0.5, 0.2);
503        let (eye, _) = orb.tick(0.016);
504        assert!(eye.length() > 5.0);
505    }
506
507    #[test]
508    fn cinematic_path_reaches_end() {
509        let points = vec![
510            PathPoint::new(Vec3::ZERO, Vec3::Z, 60.0, 1.0),
511            PathPoint::new(Vec3::X * 5.0, Vec3::Z, 60.0, 1.0),
512        ];
513        let mut path = CinematicPath::new(points, false);
514        let (start, _, _) = path.tick(0.01);
515        assert!(start.x < 0.5);
516        let (end, _, _) = path.tick(2.0);
517        assert!((end.x - 5.0).abs() < 0.1, "Expected near 5.0, got {}", end.x);
518    }
519
520    #[test]
521    fn unproject_ray_is_normalized() {
522        let config = EngineConfig::default();
523        let mut cam = ProofCamera::new(&config);
524        let state = cam.tick(0.016);
525        let ray = state.unproject_ray(0.0, 0.0);
526        assert!((ray.length() - 1.0).abs() < 0.001);
527    }
528
529    #[test]
530    fn on_resize_updates_aspect() {
531        let mut cam = ProofCamera::default();
532        cam.on_resize(1920, 1080);
533        let expected = 1920.0 / 1080.0;
534        assert!((cam.aspect - expected).abs() < 0.001);
535    }
536}