Files
wsg/docs/user/camera-input.md
T
2026-09-21 11:36:19 +02:00

5.7 KiB

Camera & input

Two bricks drive the viewpoint: the scene's active Camera (view/projection matrices built every frame) and the unified InputState (keyboard/mouse, cross-frame semantics). The orbital CameraController bridges the two.

1. The active camera

The scene holds a single camera, read by the engine every frame to write the view/projection matrices into the frame buffer (aspect recomputed from the window size).

use wsg_lib::resources::Camera;
use glam::Vec3;

app.scene.set_camera(Camera::new(
    Vec3::new(3.0, 2.0, 3.0), // eye position
    Vec3::ZERO,               // target point
    Vec3::Y,                  // "up" vector
));
  • Default: position (0, 0, 3), looking at the origin, 45° vertical fov, near 0.1, far 100 — frames a unit cube with no tuning.
  • Camera::with_perspective(fov, near, far) adjusts the projection (fov in radians).
  • Read: app.scene.camera(); direct mutation: app.scene.camera_mut().
  • The up field matters: the orbital camera forces it to +Y (level horizon).

The matrices use the WebGPU convention (NDC depth [0,1]) — do not replace projection_matrix with an OpenGL [-1,1] projection, the near part of the frustum would be clipped.

2. The orbital controller

CameraController represents the viewpoint in spherical coordinates around a target: yaw (azimuth around +Y), pitch (elevation, bounded to ±~83°), distance (radius, bounded to [0.1, 100]), target (target point).

use wsg_lib::resources::CameraController;

let mut ctrl = CameraController::default();   // target at origin, distance 3, front view
ctrl.orbit(dx, dy);      // mouse drag: yaw/pitch (bounded pitch, no poles)
ctrl.zoom(scroll_y);     // wheel: zoom (positive scroll = move closer)
ctrl.reset();            // back to the default framing
ctrl.apply_to(app.scene.camera_mut());  // write the framing into the active camera (do this EVERY frame)

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, update() section.

3. The unified input state

app.input (public field of App) is fed by winit events and rotated automatically every frame (begin_frame/end_frame around your update). Three semantics per control:

Semantics Methods Meaning
pressed key_pressed(code), mouse_button_pressed(btn) true only on the frame the key/button was just pressed
held key_held(code), mouse_button_held(btn) true while the key/button stays down
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, 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 them, add winit = "0.30" to your own dependencies (as the examples do). Input-less applications (like simple/cube) don't need winit: app.input remains usable, only KeyCode comparisons require the import.

use winit::event::MouseButton;
use winit::keyboard::KeyCode;

fn update(&mut self, app: &mut wsg_lib::App) {
    // Orbit (left-drag gated) + zoom driven by the mouse (excerpts from demo):
    let (dx, dy) = app.input.mouse_delta();
    if app.input.mouse_button_held(MouseButton::Left) {
        self.camera.orbit(dx, dy);
    }
    let (_, sy) = app.input.scroll_delta();
    self.camera.zoom(sy);

    // R: reset — key_pressed fires once, not on key-repeat.
    if app.input.key_pressed(KeyCode::KeyR) {
        self.camera.yaw = 0.6;
        self.camera.pitch = 0.35;
        self.camera.distance = 6.5;
    }
    self.camera.apply_to(app.scene.camera_mut());
}

Gamepad: the API is reserved (InputState will pass through DeviceEvents) but not implemented yet — deferred, see ROADMAP.

4. Common recipes

Need Recipe
Standard orbital camera CameraController + mouse_delta/scroll_delta (snippet above)
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)