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
upfield matters: the orbital camera forces it to+Y(level horizon).
The matrices use the WebGPU convention (NDC depth
[0,1]) — do not replaceprojection_matrixwith 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 thedemodoes: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 (
InputStatewill pass throughDeviceEvents) 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) |