viewport_lib/camera/controllers/
first_person.rs1use glam::Vec3;
23
24use super::look::{build_orientation, orientation_to_aim, yaw_pitch_from_orientation};
25use crate::Camera;
26use crate::interaction::input::action_frame::ActionFrame;
27
28pub struct FirstPersonCameraController {
36 pub yaw: f32,
38 pub pitch: f32,
41 pub sensitivity: f32,
43 pub pitch_clamp: f32,
46}
47
48impl FirstPersonCameraController {
49 pub const DEFAULT_SENSITIVITY: f32 = 0.005;
51 pub const DEFAULT_PITCH_CLAMP: f32 = 1.4;
53
54 pub fn new(sensitivity: f32, pitch_clamp: f32) -> Self {
57 Self {
58 yaw: 0.0,
59 pitch: 0.0,
60 sensitivity,
61 pitch_clamp: pitch_clamp.abs(),
62 }
63 }
64
65 pub fn apply(&mut self, camera: &mut Camera, frame: &ActionFrame, eye_position: Vec3) {
71 let look = frame.navigation.orbit;
72 self.yaw += look.x * self.sensitivity;
75 self.pitch -= look.y * self.sensitivity;
76 self.pitch = self.pitch.clamp(-self.pitch_clamp, self.pitch_clamp);
77
78 let orientation = build_orientation(self.yaw, self.pitch);
79 camera.center = eye_position + orientation_to_aim(orientation);
82 camera.distance = 1.0;
83 camera.orientation = orientation;
84 }
85
86 pub fn sync_from_camera(&mut self, camera: &Camera) {
90 let (yaw, pitch) = yaw_pitch_from_orientation(camera.orientation);
91 self.yaw = yaw;
92 self.pitch = pitch.clamp(-self.pitch_clamp, self.pitch_clamp);
93 }
94
95 pub fn set_look(&mut self, yaw: f32, pitch: f32) {
97 self.yaw = yaw;
98 self.pitch = pitch.clamp(-self.pitch_clamp, self.pitch_clamp);
99 }
100
101 pub fn forward_dir(&self) -> Vec3 {
104 let (s, c) = self.yaw.sin_cos();
105 Vec3::new(s, c, 0.0)
106 }
107
108 pub fn right_dir(&self) -> Vec3 {
111 self.forward_dir().cross(Vec3::Z).normalize_or_zero()
112 }
113
114 pub fn aim_dir(&self) -> Vec3 {
116 orientation_to_aim(build_orientation(self.yaw, self.pitch))
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use glam::Vec2;
124
125 fn cam() -> FirstPersonCameraController {
126 FirstPersonCameraController::new(0.002, 1.4)
127 }
128
129 fn look(x: f32, y: f32) -> ActionFrame {
130 let mut f = ActionFrame::default();
131 f.navigation.orbit = Vec2::new(x, y);
132 f
133 }
134
135 fn approx_eq(a: Vec3, b: Vec3) -> bool {
136 (a - b).length() < 1e-4
137 }
138
139 #[test]
140 fn default_aim_is_forward_along_y() {
141 assert!(approx_eq(cam().aim_dir(), Vec3::Y));
142 }
143
144 #[test]
145 fn right_look_increases_yaw() {
146 let mut c = cam();
147 let mut camera = Camera::default();
148 c.apply(&mut camera, &look(100.0, 0.0), Vec3::ZERO);
149 assert!(c.yaw > 0.0, "look right should increase yaw");
150 }
151
152 #[test]
153 fn down_look_decreases_pitch() {
154 let mut c = cam();
155 let mut camera = Camera::default();
156 c.apply(&mut camera, &look(0.0, 100.0), Vec3::ZERO);
157 assert!(c.pitch < 0.0, "look down should decrease pitch");
158 }
159
160 #[test]
161 fn pitch_is_clamped() {
162 let mut c = FirstPersonCameraController::new(1.0, 1.0);
163 let mut camera = Camera::default();
164 c.apply(&mut camera, &look(0.0, -9999.0), Vec3::ZERO);
165 assert!(c.pitch <= 1.0 && c.pitch >= -1.0, "pitch={}", c.pitch);
166 }
167
168 #[test]
169 fn eye_attaches_to_supplied_position() {
170 let mut c = cam();
171 let mut camera = Camera::default();
172 let eye = Vec3::new(3.0, 4.0, 1.8);
173 c.apply(&mut camera, &look(0.0, 0.0), eye);
174 assert!((camera.eye_position() - eye).length() < 1e-4);
175 }
176
177 #[test]
178 fn forward_dir_is_horizontal_and_rotates() {
179 let mut c = cam();
180 c.set_look(std::f32::consts::FRAC_PI_2, 0.0);
181 let fwd = c.forward_dir();
182 assert!(fwd.z.abs() < 1e-6, "forward must be horizontal");
183 assert!(approx_eq(fwd, Vec3::X), "forward at yaw=PI/2 should be +X");
184 }
185
186 #[test]
187 fn sync_from_camera_round_trips() {
188 let mut c = cam();
189 let mut camera = Camera::default();
190 c.set_look(0.7, 0.3);
191 c.apply(&mut camera, &look(0.0, 0.0), Vec3::ZERO);
192 let mut c2 = cam();
194 c2.sync_from_camera(&camera);
195 assert!((c2.yaw - 0.7).abs() < 1e-4, "yaw={}", c2.yaw);
196 assert!((c2.pitch - 0.3).abs() < 1e-4, "pitch={}", c2.pitch);
197 }
198}