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
+227
View File
@@ -0,0 +1,227 @@
//! **WSG `demo`** — the final showcase example (Étape 15, sous-volt 15.C).
//!
//! Combines everything built throughout the library into one declarative scene:
//!
//! * a **ground plane** plus one of each procedural primitive from `math::primitives`
//! (`cube`, `uv_sphere`, `icosphere`, `cylinder`, `cone`, `torus`) placed around it,
//! * a **procedural texture** per mesh (checker / stripe grids, no assets on disk),
//! * the **standard** Phong material wired to those textures,
//! * an **orbital camera** driven live by the unified input state (Étape 15.B):
//! moving the mouse orbits (yaw/pitch), the wheel zooms (distance),
//! * `R` resets the view, keys `1`/`2`/`3` jump to front / side / top presets,
//! * a **directional** light (the shadow caster) + a **point** light + a **spot** light,
//! so the shadow of the cube and the colored light halos are all visible,
//! * the primitives slowly rotate in `update`, so depth, lighting and shadows read clearly.
//!
//! Doc (this header) follows the English convention used for examples; internal comments stay
//! concise and French where helpful. Run with:
//!
//! `cargo run -p wsg-lib --example demo`
use glam::{Quat, Vec3};
use winit::keyboard::KeyCode;
use wsg_lib::AppHandler;
use wsg_lib::app::AppBuilder;
use wsg_lib::math::{Transform, cone, cube, cylinder, icosphere, plane, torus, uv_sphere};
use wsg_lib::resources::{CameraController, Texture};
use wsg_lib::utils::WsgError;
/// Generates an 8×8 RGBA checkerboard (white / brick) as raw bytes for `Texture::from_rgba8`.
fn checkerboard_rgba() -> Vec<u8> {
const SIZE: u32 = 8;
let mut rgba = Vec::with_capacity((SIZE * SIZE * 4) as usize);
for y in 0..SIZE {
for x in 0..SIZE {
let even = (x + y) % 2 == 0;
let (r, g, b) = if even { (235, 235, 228) } else { (150, 90, 70) };
rgba.extend_from_slice(&[r, g, b, 255]);
}
}
rgba
}
/// Generates a vertical stripe texture (blue / cyan), useful to make rotation visible on rounded
/// bodies (sphere / cylinder) via the UV seams.
fn stripes_rgba() -> Vec<u8> {
const W: u32 = 32;
const H: u32 = 16;
let mut rgba = Vec::with_capacity((W * H * 4) as usize);
for _y in 0..H {
for x in 0..W {
let band = (x / 4) % 2 == 0;
let (r, g, b) = if band { (40, 90, 190) } else { (120, 210, 235) };
rgba.extend_from_slice(&[r, g, b, 255]);
}
}
rgba
}
/// Demo handler: holds the orbital controller plus a slow rotation angle.
struct Demo {
camera: CameraController,
angle: f32,
}
/// Horizontal radius at which the primitives sit around the origin.
const ORBIT_RADIUS: f32 = 1.7;
/// Vertical offset so the meshes stand on the ground plane (y = 0).
const STAND_HEIGHT: f32 = 0.5;
/// Lays out one primitive (already scaled/positioned) at an angle around the origin.
fn place(label: &str, mesh: &str, app: &mut wsg_lib::App, index: usize) {
let a = index as f32 / 6.0 * std::f32::consts::TAU;
let mut tf = Transform::identity();
tf.translation = Vec3::new(a.cos() * ORBIT_RADIUS, STAND_HEIGHT, a.sin() * ORBIT_RADIUS);
tf.rotation = Quat::from_rotation_y(a); // face the center
app.scene
.add_entity_with_transform(label, mesh, tf)
.unwrap();
}
impl AppHandler for Demo {
fn setup(&mut self, app: &mut wsg_lib::App) {
// 1. Shader + material base.
app.scene
.register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH)
.unwrap();
let (device, queue) = {
let ctx = app.context();
(ctx.device.clone(), ctx.queue.clone())
};
// 2. Procedural textures, one material per pattern.
let checker =
Texture::from_rgba8(&device, &queue, 8, 8, &checkerboard_rgba(), "checker").unwrap();
app.scene.add_texture("checker_texture", checker).unwrap();
app.scene
.add_material_texture("ground_mat", "standard", "checker_texture")
.unwrap();
app.scene
.add_material_texture("solid_mat", "standard", "checker_texture")
.unwrap();
let stripes =
Texture::from_rgba8(&device, &queue, 32, 16, &stripes_rgba(), "stripes").unwrap();
app.scene.add_texture("stripes_texture", stripes).unwrap();
app.scene
.add_material_texture("stripes_mat", "standard", "stripes_texture")
.unwrap();
// 3. Ground plane (large, thin, textured).
app.scene
.create_mesh("ground_mesh", plane(9.0, 9.0, 1, 1), Some("ground_mat"))
.unwrap();
app.scene.add_entity("ground", "ground_mesh").unwrap();
// 4. One mesh per primitive, each assigned to a textured (or stripe) material.
app.scene
.create_mesh("cube_mesh", cube(0.8), Some("solid_mat"))
.unwrap();
app.scene
.create_mesh("sphere_mesh", uv_sphere(0.55, 32, 20), Some("stripes_mat"))
.unwrap();
app.scene
.create_mesh("ico_mesh", icosphere(0.5, 2), Some("solid_mat"))
.unwrap();
app.scene
.create_mesh("cyl_mesh", cylinder(0.4, 0.9, 32), Some("stripes_mat"))
.unwrap();
app.scene
.create_mesh("cone_mesh", cone(0.45, 0.9, 32), Some("solid_mat"))
.unwrap();
app.scene
.create_mesh("torus_mesh", torus(0.42, 0.16, 24, 16), Some("solid_mat"))
.unwrap();
place("cube_e", "cube_mesh", app, 0);
place("sphere_e", "sphere_mesh", app, 1);
place("ico_e", "ico_mesh", app, 2);
place("cyl_e", "cyl_mesh", app, 3);
place("cone_e", "cone_mesh", app, 4);
place("torus_e", "torus_mesh", app, 5);
// 5. Lights: a shadow-casting directional + a warm point + a green spot.
// Start from the default list (directional +Z) so we keep it and add the rest.
let toward_light = Vec3::new(1.0, 1.2, 1.0).normalize();
app.scene
.add_directional_light(toward_light, [1.0, 0.98, 0.92], 1.5)
.unwrap();
app.scene
.add_point_light(Vec3::new(0.5, 1.6, 1.8), [1.0, 0.7, 0.3], 1.2, 6.0)
.unwrap();
app.scene
.add_spot_light(
Vec3::new(-2.5, 2.2, 1.0),
Vec3::new(2.5, -2.2, -1.0).normalize(),
[0.3, 1.0, 0.5],
1.4,
8.0,
0.45,
)
.unwrap();
// The first directional light (packed index 0) casts shadows.
app.scene.set_shadow_caster(Some(0));
app.scene.set_ambient([0.14, 0.14, 0.16]);
// 6. Active camera, driven by the orbital controller (position, distance, preset target).
self.camera.yaw = 0.6;
self.camera.pitch = 0.35;
self.camera.distance = 6.5;
self.camera.target = Vec3::ZERO;
self.camera.apply_to(app.scene.camera_mut());
}
fn update(&mut self, app: &mut wsg_lib::App) {
// ---- Orbital camera from unified input ----
// Moving the mouse orbits (yaw/pitch); the wheel zooms (distance).
let (dx, dy) = app.input.mouse_delta();
self.camera.orbit(dx, dy);
let (_, sy) = app.input.scroll_delta();
self.camera.zoom(sy);
// R: reset the view. Keys 1/2/3: front / side / top presets.
if app.input.key_pressed(KeyCode::KeyR) {
// Keep the target but restore a pleasing default framing.
self.camera.yaw = 0.6;
self.camera.pitch = 0.35;
self.camera.distance = 6.5;
}
if app.input.key_pressed(KeyCode::Digit1) {
self.camera.yaw = 0.0;
self.camera.pitch = 0.25;
self.camera.distance = 6.5;
}
if app.input.key_pressed(KeyCode::Digit2) {
self.camera.yaw = std::f32::consts::FRAC_PI_2;
self.camera.pitch = 0.15;
self.camera.distance = 6.5;
}
if app.input.key_pressed(KeyCode::Digit3) {
self.camera.yaw = 0.0;
self.camera.pitch = 1.25;
self.camera.distance = 8.0;
}
self.camera.apply_to(app.scene.camera_mut());
// ---- Slow rotation of the primitives so lighting/shadow read clearly ----
self.angle += 0.008;
let base = *app
.scene
.entity_transform("cube_e")
.expect("cube entity present");
let mut tf = base;
tf.rotation = Quat::from_rotation_y(self.angle) * Quat::from_rotation_x(self.angle * 0.4);
app.scene.set_entity_transform("cube_e", tf);
}
}
#[pollster::main]
async fn main() -> Result<(), WsgError> {
let app = AppBuilder::new().title("WSG Demo").build().await?;
app.run(Demo {
camera: CameraController::default(),
angle: 0.0,
})
}
+176
View File
@@ -98,3 +98,179 @@ impl Camera {
glam::camera::rh::proj::directx::perspective(self.fov, aspect, self.near, self.far) 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; pub mod vertex;
// Re-exports // Re-exports
pub use camera::Camera; pub use camera::{Camera, CameraController, PITCH_LIMIT};
pub use lights::Lights; pub use lights::Lights;
pub use material::Material; pub use material::Material;
pub use mesh::Mesh; pub use mesh::Mesh;
+6
View File
@@ -262,6 +262,12 @@ impl Scene {
&self.camera &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). /// 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, /// 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 /// Étape 12). Returns `Err` if the scene would exceed `MAX_LIGHTS` (capacity is bounded; no