correction orbital camera inputs

This commit is contained in:
Jérôme Bousquié
2026-09-21 11:36:19 +02:00
parent 5ae978da23
commit ef13913464
6 changed files with 138 additions and 37 deletions
+8 -3
View File
@@ -7,7 +7,7 @@
//! * 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 (Step 15.B):
//! moving the mouse orbits (yaw/pitch), the wheel zooms (distance),
//! hold the **left mouse button** and drag to orbit (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,
@@ -19,6 +19,7 @@
//! `cargo run -p wsg-lib --example demo`
use glam::{Quat, Vec3};
use winit::event::MouseButton;
use winit::keyboard::KeyCode;
use wsg_lib::AppHandler;
use wsg_lib::app::AppBuilder;
@@ -177,9 +178,13 @@ impl AppHandler for Demo {
fn update(&mut self, app: &mut wsg_lib::App) {
// ---- Orbital camera from unified input ----
// Moving the mouse orbits (yaw/pitch); the wheel zooms (distance).
// Classic arc-rotate: orbit ONLY while the left button is held (drag); the wheel zooms
// without any button. Sensitivities use the library defaults (0.005 rad/px orbit, 0.9x
// per wheel notch); tune them via `camera.orbit_sensitivity` / `camera.zoom_factor`.
let (dx, dy) = app.input.mouse_delta();
self.camera.orbit(dx, dy);
if app.input.mouse_button_held(MouseButton::Left) {
self.camera.orbit(dx, dy);
}
let (_, sy) = app.input.scroll_delta();
self.camera.zoom(sy);
+55 -22
View File
@@ -53,7 +53,10 @@ pub struct InputState {
mouse_position: (f32, f32),
/// Previous absolute position, to derive the `CursorMoved` delta.
last_mouse_position: Option<(f32, f32)>,
/// Cumulative relative movement during the current frame.
/// Frame accumulator for the relative movement (events between two `begin_frame` calls),
/// rotated into `mouse_delta` at the next `begin_frame` (same pattern as the keyboard).
frame_mouse_delta: (f32, f32),
/// Cumulative relative movement during the current frame (queryable in `update`).
mouse_delta: (f32, f32),
/// Buttons currently held down.
held_buttons: HashSet<MouseButton>,
@@ -66,7 +69,10 @@ pub struct InputState {
frame_released_buttons: HashSet<MouseButton>,
// ---- Wheel ----
/// Cumulative scroll during the current frame (x, y).
/// Frame accumulator for the scroll (x, y) (events between two `begin_frame` calls),
/// rotated into `scroll` at the next `begin_frame`.
frame_scroll: (f32, f32),
/// Cumulative scroll during the current frame (x, y) (queryable in `update`).
scroll: (f32, f32),
// ---- Gamepad (reserved) ----
// (DRAFT D7: optional minimal v1, deferred — the API will extend without breakage.)
@@ -95,7 +101,11 @@ impl InputState {
}
WindowEvent::MouseWheel { delta, .. } => match delta {
MouseScrollDelta::LineDelta(x, y) => self.wheel(*x, *y),
MouseScrollDelta::PixelDelta(p) => self.wheel(p.x as f32, p.y as f32),
// PixelDelta (most Wayland compositors) reports raw pixels — one wheel notch is
// typically ~32 px, so normalize to line (notch) units to keep `scroll_delta()`
// in the same scale as LineDelta backends (X11). Without this, `zoom()` would
// apply `factor^100` per notch and snap to the clamp in a single wheel step.
MouseScrollDelta::PixelDelta(p) => self.wheel(p.x as f32 / 32.0, p.y as f32 / 32.0),
},
_ => {}
}
@@ -130,43 +140,50 @@ impl InputState {
}
}
/// Updates the cursor position and accumulates the relative movement. Called by
/// [`InputState::handle_window_event`].
/// Updates the cursor position and accumulates the relative movement in the frame buffer. Called
/// by [`InputState::handle_window_event`]; the frame buffer is rotated into the queryable
/// `mouse_delta` at the next [`InputState::begin_frame`].
fn cursor_move(&mut self, x: f32, y: f32) {
if let Some((px, py)) = self.last_mouse_position {
self.mouse_delta.0 += x - px;
self.mouse_delta.1 += y - py;
self.frame_mouse_delta.0 += x - px;
self.frame_mouse_delta.1 += y - py;
}
self.last_mouse_position = Some((x, y));
self.mouse_position = (x, y);
}
/// Accumulates the wheel scroll. Called by [`InputState::handle_window_event`].
/// Accumulates the wheel scroll in the frame buffer (in **line/notch units** — `handle_window_event`
/// normalizes `PixelDelta` by /32 before calling this). The frame buffer is rotated into the
/// queryable `scroll` at the next [`InputState::begin_frame`].
fn wheel(&mut self, dx: f32, dy: f32) {
self.scroll.0 += dx;
self.scroll.1 += dy;
self.frame_scroll.0 += dx;
self.frame_scroll.1 += dy;
}
/// Starts a new input frame: **rotates** the event accumulators
/// (accumulated between two `begin_frame` calls) into the queryable sets `pressed`/`released`, and
/// zeroes the mouse delta and the wheel. Call this **before** `AppHandler::update`.
/// Starts a new input frame: **rotates** all the event accumulators (keyboard pressed/released,
/// buttons, mouse delta and wheel — accumulated between two `begin_frame` calls) into the
/// queryable state. Call this **before** `AppHandler::update`.
pub fn begin_frame(&mut self) {
self.pressed = std::mem::take(&mut self.frame_pressed);
self.released = std::mem::take(&mut self.frame_released);
self.pressed_buttons = std::mem::take(&mut self.frame_pressed_buttons);
self.released_buttons = std::mem::take(&mut self.frame_released_buttons);
self.mouse_delta = (0.0, 0.0);
self.scroll = (0.0, 0.0);
self.mouse_delta = self.frame_mouse_delta;
self.frame_mouse_delta = (0.0, 0.0);
self.scroll = self.frame_scroll;
self.frame_scroll = (0.0, 0.0);
}
/// Ends a frame: clears the transient sets `pressed`/`released` (already consumed by
/// `update`). The `held` states and the position are kept. Call this **after**
/// `AppHandler::update` (or `render`).
/// Ends a frame: clears the transient state consumed by `update` (`pressed`/`released`, button
/// sets, queryable mouse delta and wheel). The `held` states and the cursor position are kept.
/// Call this **after** `AppHandler::update` (or `render`).
pub fn end_frame(&mut self) {
self.pressed.clear();
self.released.clear();
self.pressed_buttons.clear();
self.released_buttons.clear();
self.mouse_delta = (0.0, 0.0);
self.scroll = (0.0, 0.0);
}
// ---- Keyboard queries ----
@@ -194,7 +211,9 @@ impl InputState {
pub fn mouse_delta(&self) -> (f32, f32) {
self.mouse_delta
}
/// Cumulative wheel scroll during the current frame (`(dx, dy)`, `dy > 0` = upward).
/// Cumulative wheel scroll during the current frame, in **line (notch) units**
/// (`(dx, dy)`, `dy > 0` = wheel up). `PixelDelta` events are normalized by /32 so the scale
/// is backend-independent (one physical wheel notch ≈ 1.0).
pub fn scroll_delta(&self) -> (f32, f32) {
self.scroll
}
@@ -260,30 +279,44 @@ mod tests {
#[test]
fn mouse_delta_and_position_accumulate() {
// Real winit order: events arrive BETWEEN two frames, then `begin_frame` rotates the
// accumulator into the queryable delta (a `begin_frame` before the events would not lose
// them, the queryable copy is separate from the frame buffer).
let mut input = InputState::new();
input.begin_frame();
input.begin_frame(); // frame 1 starts (empty)
input.cursor_move(10.0, 20.0);
input.cursor_move(30.0, 40.0);
input.end_frame();
// Frame 2 starts: the movement accumulated during frame 1 is rotated into the queryable
// delta and read by `update`.
input.begin_frame();
assert_eq!(input.mouse_delta(), (20.0, 20.0));
assert_eq!(input.mouse_position(), (30.0, 40.0));
input.end_frame();
// New frame: the delta is reset to zero at the start, the position persists.
// Frame 3 without new events: the queryable delta is back to zero, the position persists.
input.begin_frame();
assert_eq!(input.mouse_delta(), (0.0, 0.0));
assert_eq!(input.mouse_position(), (30.0, 40.0));
// Frame 4: a small movement accumulates and is rotated in again.
input.cursor_move(31.0, 42.0);
input.begin_frame();
assert_eq!(input.mouse_delta(), (1.0, 2.0));
}
#[test]
fn scroll_accumulates_per_frame() {
// Real winit order: wheel events accumulate between frames, `begin_frame` rotates them.
let mut input = InputState::new();
input.begin_frame();
input.wheel(1.0, 2.0);
input.wheel(0.5, -1.0);
input.begin_frame();
assert_eq!(input.scroll_delta(), (1.5, 1.0));
input.end_frame();
// Next frame without new scroll: the queryable delta is zero.
input.begin_frame();
assert_eq!(input.scroll_delta(), (0.0, 0.0));
}
+42 -8
View File
@@ -130,6 +130,10 @@ pub struct CameraController {
pub distance: f32,
/// World-space point the camera looks at and orbits around.
pub target: Vec3,
/// Orbit sensitivity (radians of yaw per pixel of mouse delta); see [`DEFAULT_ORBIT_SENSITIVITY`].
pub orbit_sensitivity: f32,
/// Multiplicative zoom factor applied per unit of vertical scroll; see [`DEFAULT_ZOOM_FACTOR`].
pub zoom_factor: f32,
}
impl Default for CameraController {
@@ -139,13 +143,17 @@ impl Default for CameraController {
pitch: 0.0,
distance: 3.0,
target: Vec3::ZERO,
orbit_sensitivity: DEFAULT_ORBIT_SENSITIVITY,
zoom_factor: DEFAULT_ZOOM_FACTOR,
}
}
}
/// 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.
/// 0.005 gives ~110° per full window width — a comfortable default; raise it for smaller viewports.
pub const DEFAULT_ORBIT_SENSITIVITY: f32 = 0.005;
/// Multiplicative zoom factor applied per unit of vertical scroll (one wheel notch ≈ 1 unit after
/// `InputState` normalization). 0.9 → 10% distance change per notch.
pub const DEFAULT_ZOOM_FACTOR: f32 = 0.9;
impl CameraController {
@@ -164,6 +172,8 @@ impl CameraController {
pitch: pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT),
distance,
target: camera.target,
orbit_sensitivity: DEFAULT_ORBIT_SENSITIVITY,
zoom_factor: DEFAULT_ZOOM_FACTOR,
}
}
@@ -183,19 +193,21 @@ impl CameraController {
}
/// 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`].
/// (inverted so dragging up tilts the view up). Pitch is clamped to ±[`PITCH_LIMIT`]. The
/// rotation speed is scaled by `self.orbit_sensitivity`.
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);
self.yaw -= dx * self.orbit_sensitivity;
self.pitch = (self.pitch + dy * self.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.
/// Zooms in/out by an exponential factor on the vertical wheel scroll (`scroll_y`, in wheel
/// notches): positive scroll zooms in (distance shrinks). Clamped to a sane `[0.1, 100]` range.
/// The per-notch factor is `self.zoom_factor`.
pub fn zoom(&mut self, scroll_y: f32) {
if scroll_y == 0.0 {
return;
}
let factor = DEFAULT_ZOOM_FACTOR.powf(scroll_y);
let factor = self.zoom_factor.powf(scroll_y);
self.distance = (self.distance * factor).clamp(0.1, 100.0);
}
@@ -254,6 +266,28 @@ mod tests {
assert!((back.position - cam.position).length() < 1e-2);
}
#[test]
fn sensitivity_is_configurable() {
let mut slow = CameraController::default();
let mut fast = CameraController::default();
slow.orbit_sensitivity = 0.001; // one fifth of the default
fast.orbit_sensitivity = 0.02; // four times the default
slow.orbit(100.0, 0.0);
fast.orbit(100.0, 0.0);
assert!(
(slow.yaw - fast.yaw).abs() > 1.0,
"faster sensitivity must rotate more"
);
// Zoom factor: a gentler factor moves the distance less for the same scroll.
let mut gentle = CameraController::default();
gentle.zoom_factor = 0.99;
let mut aggressive = CameraController::default();
aggressive.zoom_factor = 0.8;
gentle.zoom(3.0);
aggressive.zoom(3.0);
assert!(gentle.distance > aggressive.distance);
}
#[test]
fn reset_restores_defaults() {
let mut ctrl = CameraController::default();