feat(camera): orbital CameraController + final demo example

Add resources::CameraController (Etapes 15.C): spherical yaw/pitch/distance/
target with orbit() (mouse drag), zoom() (wheel, clamped), reset(), and
apply_to(&mut Camera). Add Scene::camera_mut() for in-place per-frame edits.

New lib/examples/demo.rs: all six primitives on a textured ground, standard
Phong material, shadow-casting directional + point + spot lights, and a live
orbital view driven by the unified input (drag=orbit, wheel=zoom, R=reset,
1/2/3=front/side/top presets) plus slow primitive rotation.

6 unit tests for CameraController.
This commit is contained in:
Jérôme Bousquié
2026-09-20 08:10:03 +02:00
parent b41f7e259e
commit eeb471f37c
4 changed files with 410 additions and 1 deletions
+176
View File
@@ -98,3 +98,179 @@ impl Camera {
glam::camera::rh::proj::directx::perspective(self.fov, aspect, self.near, self.far)
}
}
/// Vertical pitch clamp (radians) applied by [`CameraController`] so the camera never flips over the
/// poles. Kept a little under ±90°.
pub const PITCH_LIMIT: f32 = 1.45; // ~83°
/// Orbital camera controller (Étape 15, sous-volt 15.C).
///
/// Represents the viewpoint spherically around a `target`: `yaw` (rotation around the world-up axis),
/// `pitch` (elevation above/below the horizontal), `distance` (radius) and the look-at `target`.
/// [`CameraController::apply_to`] writes these into a [`Camera`] each frame, so the controller stays
/// decoupled from `Camera`'s own position/target/up representation.
///
/// ```
/// # use wsg_lib::resources::{Camera, CameraController};
/// # use glam::Vec3;
/// let cam = Camera::new(Vec3::new(3.0, 2.0, 3.0), Vec3::ZERO, Vec3::Y);
/// let mut ctrl = CameraController::from_camera(&cam);
/// ctrl.orbit(0.1, -0.05); // drag: yaw/pitch
/// ctrl.zoom(-1.0); // wheel: distance
/// let mut cam2 = cam;
/// ctrl.apply_to(&mut cam2); // write back into the active camera
/// ```
#[derive(Debug, Clone, Copy)]
pub struct CameraController {
/// Rotation around the world-up (+Y) axis, in radians.
pub yaw: f32,
/// Elevation angle above (+) / below (-) the horizontal, in radians, clamped to ±[`PITCH_LIMIT`].
pub pitch: f32,
/// Distance from the camera position to the `target` (orbit radius).
pub distance: f32,
/// World-space point the camera looks at and orbits around.
pub target: Vec3,
}
impl Default for CameraController {
fn default() -> Self {
Self {
yaw: 0.0,
pitch: 0.0,
distance: 3.0,
target: Vec3::ZERO,
}
}
}
/// Sensitivity of the orbit drag (radians of yaw per pixel of horizontal mouse delta).
pub const DEFAULT_ORBIT_SENSITIVITY: f32 = 0.01;
/// Multiplicative zoom factor applied per unit of vertical scroll.
pub const DEFAULT_ZOOM_FACTOR: f32 = 0.9;
impl CameraController {
/// Builds a controller that reproduces an existing camera's framing by extracting yaw/pitch/
/// distance from `position - target` in spherical coordinates.
pub fn from_camera(camera: &Camera) -> Self {
let offset = camera.position - camera.target;
let distance = offset.length().max(f32::EPSILON);
// Y-up convention: pitch = asin(y / r), yaw measured from +Z toward +X.
let pitch = offset
.y
.atan2((offset.x * offset.x + offset.z * offset.z).sqrt());
let yaw = offset.x.atan2(offset.z);
Self {
yaw,
pitch: pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT),
distance,
target: camera.target,
}
}
/// Computes the world-space eye position from the current yaw/pitch/distance around `target`.
pub fn position(&self) -> Vec3 {
let cp = self.pitch.cos();
let dir = Vec3::new(cp * self.yaw.sin(), self.pitch.sin(), cp * self.yaw.cos());
self.target + dir * self.distance
}
/// Writes the current framing into a [`Camera`]: sets its `position` (spherical away from
/// `target`), its look-at `target`, and forces `up` to world +Y so the horizon stays level.
pub fn apply_to(&self, camera: &mut Camera) {
camera.position = self.position();
camera.target = self.target;
camera.up = Vec3::Y;
}
/// Applies an orbit drag (mouse delta in pixels): `dx` rotates yaw, `dy` rotates pitch
/// (inverted so dragging up tilts the view up). Pitch is clamped to ±[`PITCH_LIMIT`].
pub fn orbit(&mut self, dx: f32, dy: f32) {
self.yaw -= dx * DEFAULT_ORBIT_SENSITIVITY;
self.pitch = (self.pitch + dy * DEFAULT_ORBIT_SENSITIVITY).clamp(-PITCH_LIMIT, PITCH_LIMIT);
}
/// Zooms in/out by an exponential factor on the vertical wheel scroll (`scroll_y`): positive
/// scroll zooms in (distance shrinks). Clamped to a sane `[0.1, 100]` range.
pub fn zoom(&mut self, scroll_y: f32) {
if scroll_y == 0.0 {
return;
}
let factor = DEFAULT_ZOOM_FACTOR.powf(scroll_y);
self.distance = (self.distance * factor).clamp(0.1, 100.0);
}
/// Resets the controller to its default framing (origin target, `distance` 3, level view).
pub fn reset(&mut self) {
*self = Self::default();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_positions_level_front() {
let ctrl = CameraController::default();
let p = ctrl.position();
assert!((p - Vec3::new(0.0, 0.0, 3.0)).length() < 1e-5);
}
#[test]
fn orbit_changes_yaw_and_clamps_pitch() {
let mut ctrl = CameraController::default();
ctrl.orbit(100.0, 0.0); // yaw rotation
let p1 = ctrl.position();
assert!((p1.x.abs()) > 0.1, "yaw should swing around +Y");
assert!(ctrl.pitch == 0.0);
// Pitch clamped to ±PITCH_LIMIT even with a huge drag.
ctrl.orbit(0.0, 1_000.0);
assert!((ctrl.pitch - PITCH_LIMIT).abs() < 1e-5);
ctrl.orbit(0.0, -2_000.0);
assert!((ctrl.pitch + PITCH_LIMIT).abs() < 1e-5);
}
#[test]
fn zoom_inout_clamped() {
let mut ctrl = CameraController::default();
ctrl.zoom(1.0);
assert!(ctrl.distance < 3.0, "positive scroll zooms in");
ctrl.zoom(-10.0);
assert!(ctrl.distance > 3.0);
ctrl.zoom(10_000.0);
assert!(ctrl.distance >= 0.1 - 1e-5);
ctrl.zoom(-10_000.0);
assert!(ctrl.distance <= 100.0 + 1e-5);
}
#[test]
fn roundtrip_from_camera_reproduces_framing() {
let cam = Camera::new(Vec3::new(3.0, 2.0, 3.0), Vec3::new(1.0, 1.0, 0.0), Vec3::Y);
let ctrl = CameraController::from_camera(&cam);
let mut back = cam.clone();
ctrl.apply_to(&mut back);
// Target preserved; position matches up to float error for a non-pole framing.
assert!((back.target - cam.target).length() < 1e-4);
assert!((back.position - cam.position).length() < 1e-2);
}
#[test]
fn reset_restores_defaults() {
let mut ctrl = CameraController::default();
ctrl.orbit(100.0, 50.0);
ctrl.zoom(3.0);
assert!(ctrl.yaw != 0.0);
ctrl.reset();
assert!((ctrl.yaw).abs() < 1e-6);
assert!(ctrl.distance == 3.0);
assert!(ctrl.target == Vec3::ZERO);
}
#[test]
fn apply_to_enforces_world_up() {
let ctrl = CameraController::default();
let mut cam = Camera::new(Vec3::ZERO, Vec3::ZERO, Vec3::X); // odd up
ctrl.apply_to(&mut cam);
assert!(cam.up == Vec3::Y);
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ pub mod uniform;
pub mod vertex;
// Re-exports
pub use camera::Camera;
pub use camera::{Camera, CameraController, PITCH_LIMIT};
pub use lights::Lights;
pub use material::Material;
pub use mesh::Mesh;
+6
View File
@@ -262,6 +262,12 @@ impl Scene {
&self.camera
}
/// Returns a mutable reference to the scene's active camera, for in-place per-frame edits
/// (e.g. [`CameraController::apply_to`](crate::resources::CameraController) during `update`).
pub fn camera_mut(&mut self) -> &mut Camera {
&mut self.camera
}
/// Adds a directional light (direction **from the surface toward the light**, color, intensity).
/// Lights are global to the scene and uploaded into the frame uniforms each frame (Phase 4.2,
/// Étape 12). Returns `Err` if the scene would exceed `MAX_LIGHTS` (capacity is bounded; no