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
+12
View File
@@ -2,6 +2,18 @@
> Étape 16 (Phase 5 — Documentation & Polish) **terminée** le 2026-07-19.
>
> **Tuning caméra 2026-07-19** — sur rétroaction utilisateur (caméra trop sensible, orbit permanent) :
> (1) `CameraController` a gagné deux champs configurables `orbit_sensitivity`/`zoom_factor` (defaults : 0.01→0.005
> rad/px, 0.9/tic) ; (2) `InputState` normalise `PixelDelta`/32 en unités « notch » (le Wayland renvoyait ~100 px/tic,
> donc `0.9^100` → zoom au clamping en un geste) ; (3) le `demo` orbite désormais **sur clic gauche enfoncé** (arc-rotate
> classique). Docs `camera-input.md`/`examples.md` mises à jour. 51 tests OK.
>
> **Bug fix 2026-07-19** — `InputState::begin_frame()` remettait à zéro `mouse_delta`/`scroll` AVANT que
> `update()` ne les lise, alors que les événements winit (CursorMoved/MouseWheel) s'accumulent ENTRE deux
> frames. Résultat : la caméra orbitale du `demo` ne bougeait jamais (souris/molette toujours (0,0) dans
> `update`). Corrigé par rotation des accumulateurs (`frame_mouse_delta`/`frame_scroll` → queryables à
> `begin_frame`), aligné sur le pattern clavier/boutons. Tests mis à jour (ordre réel winit) + 45/50 tests OK.
>
> Traduction anglaise de toute la documentation (hors `docs/tech/`, DRAFT/PLAN/ROADMAP) **terminée** le 2026-07-19 :
> `docs/user/*`, `README.md`, READMEs de modules, doc/rustdoc de tous les `.rs` (src + examples + tests),
> `Étape`→`Step` global. Vérifications : 50 tests OK, `cargo fmt` clean, aucun lien cassé, 0 accent restant hors zone franche.
+20 -3
View File
@@ -49,6 +49,13 @@ ctrl.apply_to(app.scene.camera_mut()); // write the framing into the active cam
`CameraController::from_camera(&cam)` rebuilds a controller from an existing camera
(useful to start the orbit from a manual framing).
Two public fields tune the feel of the camera (defaults in parentheses):
| Field | Meaning | Default |
|-------|---------|---------|
| `orbit_sensitivity` | radians of yaw per pixel of mouse delta | `0.005` (~110° per full window width) |
| `zoom_factor` | multiplicative distance change per wheel notch (`distance *= factor^scroll`) | `0.9` (10% per notch) |
The exact wiring snippet (orbit + zoom + reset + `1`/`2`/`3` presets, driven from
`app.input`) is in [`demo.rs`](../../lib/examples/demo.rs), `update()` section.
@@ -64,7 +71,13 @@ every frame (`begin_frame`/`end_frame` around your `update`). Three semantics pe
| **released** | `key_released(code)`, `mouse_button_released(btn)` | true **only** on the release frame |
Plus: `mouse_position() -> (f32, f32)`, `mouse_delta() -> (f32, f32)` (accumulated over the
frame, reset between frames), `scroll_delta() -> (f32, f32)` (wheel).
frame, reset between frames), `scroll_delta() -> (f32, f32)` (wheel, in **line/notch units** —
`PixelDelta` events are normalized by /32 so one physical wheel notch ≈ 1.0 on every backend).
> **Button-gated orbit**: `mouse_delta()` returns movement *whenever* the mouse moves. For a
> classic arc-rotate camera, apply it only while a button is held — that is what the `demo` does:
> `if app.input.mouse_button_held(MouseButton::Left) { self.camera.orbit(dx, dy); }`.
> Free-movement orbit (no button) is also possible, just drop the condition.
`KeyCode` values are winit's physical codes (`winit::keyboard::KeyCode`); mouse buttons are
`winit::event::MouseButton`. The library does not re-export them: if your code mentions
@@ -73,12 +86,15 @@ applications (like `simple`/`cube`) don't need winit: `app.input` remains usable
`KeyCode` comparisons require the import.
```rust
use winit::event::MouseButton;
use winit::keyboard::KeyCode;
fn update(&mut self, app: &mut wsg_lib::App) {
// Orbit + zoom driven by the mouse (excerpts from demo):
// Orbit (left-drag gated) + zoom driven by the mouse (excerpts from demo):
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);
@@ -103,6 +119,7 @@ fn update(&mut self, app: &mut wsg_lib::App) {
| FPS camera (WASD) | `key_held(KeyCode::KeyW)` in `update` → move `camera.position`/`target`; override `render()` if needed |
| Changing the orbit target | `ctrl.target = subject_position;` (following an object) |
| View presets | `key_pressed(Digit1/2/3)` → write yaw/pitch/distance (from the `demo`) |
| Tuning the camera speed | `ctrl.orbit_sensitivity = 0.003;` (slower orbit), `ctrl.zoom_factor = 0.95;` (gentler zoom) |
## Links
+1 -1
View File
@@ -8,7 +8,7 @@ Seven examples live in [`lib/examples/`](../../lib/examples/) and all launch wit
|---------|----------|---------------|--------------------|
| `simple` | `cargo run -p wsg-lib --example simple` | The minimal declarative workflow: a two-tone 2D quad, **unlit**, rendered automatically. The "15 lines, no wgpu" model | [Quickstart](quickstart.md), [Materials](materials.md) (§ unlit) |
| `cube` | `cargo run -p wsg-lib --example cube` | The 3D MVP: a textured (checkerboard) cube, lit (directional + point + spot), spinning | [Meshes](meshes.md), [Materials](materials.md), [Lights](lights.md) |
| `demo` | `cargo run -p wsg-lib --example demo` | The full showcase: ground + 6 primitives, textures, 3 lights, **shadows**, **orbital camera** on keyboard/mouse (drag = orbit, wheel = zoom, `R` = reset, `1`/`2`/`3` = presets) | [All pages](README.md) |
| `demo` | `cargo run -p wsg-lib --example demo` | The full showcase: ground + 6 primitives, textures, 3 lights, **shadows**, **orbital camera** on keyboard/mouse (left-drag = orbit, wheel = zoom, `R` = reset, `1`/`2`/`3` = presets) | [All pages](README.md) |
| `shadow_test` | `cargo run -p wsg-lib --example shadow_test` | Isolated shadow mapping: a cube casts a PCF-softened shadow on the ground (`clear_lights` technique → caster at index 0) | [Shadows](shadows.md) |
| `spot_test` | `cargo run -p wsg-lib --example spot_test` | Isolated spot (ambient nearly zero): the directed beam, the penumbra, the attenuation | [Lights](lights.md) |
| `manual` | `cargo run -p wsg-lib --example manual` | The **advanced** workflow: `Context`/`Renderer`/`PipelineCache` driven by hand, without the `App` facade (winit 0.30 `ApplicationHandler`) | below |
+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();