diff --git a/lib/examples/demo.rs b/lib/examples/demo.rs new file mode 100644 index 0000000..eda2c2e --- /dev/null +++ b/lib/examples/demo.rs @@ -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 { + 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 { + 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, + }) +} diff --git a/lib/src/resources/camera.rs b/lib/src/resources/camera.rs index 73a2e2f..95dfe2d 100644 --- a/lib/src/resources/camera.rs +++ b/lib/src/resources/camera.rs @@ -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); + } +} diff --git a/lib/src/resources/mod.rs b/lib/src/resources/mod.rs index a65401a..50fe850 100644 --- a/lib/src/resources/mod.rs +++ b/lib/src/resources/mod.rs @@ -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; diff --git a/lib/src/scene/scene.rs b/lib/src/scene/scene.rs index 464730f..e0fe806 100644 --- a/lib/src/scene/scene.rs +++ b/lib/src/scene/scene.rs @@ -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