feat(client): add free-fly camera with wasd and mouse-look

This commit is contained in:
Serkyo 2026-07-06 00:20:57 +02:00
parent eb7a297f3e
commit ea6fbf1624
5 changed files with 223 additions and 20 deletions

1
Cargo.lock generated
View file

@ -236,6 +236,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"ash-window",
"glam 0.29.3",
"raw-window-handle",
"renderer",
"serde_json",

View file

@ -14,6 +14,7 @@ tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
winit = "0.30.13"
renderer = { path = "../renderer" }
glam = "0.29"
raw-window-handle = "0.6.2"
ash-window = "0.13.0"
serde_json = "1.0.149"

101
crates/client/src/camera.rs Normal file
View file

@ -0,0 +1,101 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Free-fly camera used to observe the world.
//!
//! The camera stores a world-space position and an orientation expressed as yaw and pitch
//! angles. A view matrix is derived on demand from these values, and the orientation and
//! position are advanced each frame from accumulated keyboard and mouse input.
use glam::{Mat4, Vec3};
use crate::InputState;
/// A free-flying camera driven by keyboard and mouse input.
pub struct Camera {
/// World-space position of the camera eye, measured in blocks.
pub position: Vec3,
/// Rotation about the world up axis (+Y), in radians. Controls left/right look.
pub yaw: f32,
/// Rotation above or below the horizon, in radians. Controls up/down look.
pub pitch: f32,
/// Translation speed applied to movement input, in blocks per second.
pub speed: f32,
/// Factor converting a unit of raw mouse motion into radians of rotation.
pub sensitivity: f32,
}
impl Camera {
/// Maximum absolute pitch, held just under vertical to avoid the view flipping over.
const PITCH_LIMIT: f32 = 1.553; // ~89 degrees expressed in radians.
/// Creates a camera at `position` facing the direction given by `yaw` and `pitch`.
#[must_use]
pub fn new(position: Vec3, yaw: f32, pitch: f32) -> Self {
Self {
position,
yaw,
pitch,
speed: 20.0,
sensitivity: 0.0025,
}
}
/// Returns the normalised world-space direction the camera currently faces.
#[must_use]
pub fn forward(&self) -> Vec3 {
// Spherical-to-Cartesian conversion: yaw sweeps around +Y, pitch tilts up and down.
Vec3::new(
self.yaw.cos() * self.pitch.cos(),
self.pitch.sin(),
self.yaw.sin() * self.pitch.cos(),
)
.normalize()
}
/// Builds the right-handed view matrix for the current position and orientation.
#[must_use]
pub fn view_matrix(&self) -> Mat4 {
Mat4::look_at_rh(self.position, self.position + self.forward(), Vec3::Y)
}
/// Advances the camera by a single frame, applying `input` accumulated over `dt` seconds.
pub fn update(&mut self, input: &InputState, dt: f32) {
// Apply accumulated mouse motion to the orientation. A downward mouse delta (positive y) lowers the pitch, so the vertical term is subtracted.
#[allow(clippy::cast_possible_truncation)]
{
self.yaw += input.mouse_delta.0 as f32 * self.sensitivity;
self.pitch -= input.mouse_delta.1 as f32 * self.sensitivity;
}
self.pitch = self.pitch.clamp(-Self::PITCH_LIMIT, Self::PITCH_LIMIT);
// Derive the movement basis from the current facing. The right vector is horizontal because it is the cross product of the facing direction with the world up axis.
let forward = self.forward();
let right = forward.cross(Vec3::Y).normalize();
// Accumulate a movement direction from the currently held keys.
let mut direction = Vec3::ZERO;
if input.forward {
direction += forward;
}
if input.backward {
direction -= forward;
}
if input.right {
direction += right;
}
if input.left {
direction -= right;
}
if input.up {
direction += Vec3::Y;
}
if input.down {
direction -= Vec3::Y;
}
// Normalising keeps diagonal movement the same speed as axis-aligned movement. The guard avoids normalising a zero vector, which would produce NaN when idle.
if direction.length_squared() > 0.0 {
self.position += direction.normalize() * self.speed * dt;
}
}
}

View file

@ -4,20 +4,75 @@
//!
//! This crate handles window creation, input processing, and drives the
//! renderer to display the game world.
mod camera;
mod meshing;
use anyhow::{Context, Result};
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
use tracing::{error, info};
use winit::application::ApplicationHandler;
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::window::{Window, WindowId};
use std::time::Instant;
use anyhow::{Context, Result};
use camera::Camera;
use glam::Vec3;
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
use tracing::{error, info, warn};
use winit::application::ApplicationHandler;
use winit::event::{DeviceEvent, DeviceId, ElementState, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::keyboard::{KeyCode, PhysicalKey};
use winit::window::{CursorGrabMode, Window, WindowId};
/// Transient per-frame input state sampled from window and device events.
///
/// Keyboard fields hold whether a movement key is currently pressed. `mouse_delta` accumulates
/// raw pointer motion between frames and is consumed (reset to zero) once applied to the camera.
// The bools are independent per-key held states, for which a flat struct is the clearest form.
#[allow(clippy::struct_excessive_bools)]
#[derive(Default)]
struct InputState {
/// Whether the "move forward" key (W) is held.
forward: bool,
/// Whether the "move backward" key (S) is held.
backward: bool,
/// Whether the "strafe left" key (A) is held.
left: bool,
/// Whether the "strafe right" key (D) is held.
right: bool,
/// Whether the "move up" key (Space) is held.
up: bool,
/// Whether the "move down" key (Left Shift) is held.
down: bool,
/// Accumulated raw mouse motion (x, y) since the last frame, in device units.
mouse_delta: (f64, f64),
}
/// Top-level application state driving the window, renderer, and camera.
struct App {
/// The Vulkan renderer, initialised once the window exists.
renderer: Option<renderer::Renderer>,
/// The application window, created on resume.
window: Option<Window>,
/// The free-fly camera supplying the view matrix each frame.
camera: Camera,
/// The current keyboard and mouse input state.
input: InputState,
/// Timestamp of the previous frame, used to derive delta-time. `None` before the first frame.
last_frame: Option<Instant>,
}
impl Default for App {
fn default() -> Self {
Self {
renderer: None,
window: None,
// Start above and behind the origin chunk, looking toward -Z and angled downward.
camera: Camera::new(
Vec3::new(16.0, 40.0, 60.0),
-std::f32::consts::FRAC_PI_2,
-0.5,
),
input: InputState::default(),
last_frame: None,
}
}
}
impl ApplicationHandler for App {
@ -76,6 +131,15 @@ impl ApplicationHandler for App {
}
};
// Confine and hide the pointer so mouse motion drives the camera rather than moving a visible cursor. `Locked` is preferred; some platforms only support `Confined`.
if let Err(e) = window
.set_cursor_grab(CursorGrabMode::Locked)
.or_else(|_| window.set_cursor_grab(CursorGrabMode::Confined))
{
warn!("Failed to grab cursor: {e}");
}
window.set_cursor_visible(false);
self.window = Some(window);
self.renderer = Some(renderer);
@ -111,8 +175,35 @@ impl ApplicationHandler for App {
WindowEvent::CloseRequested => {
event_loop.exit();
}
WindowEvent::KeyboardInput { event, .. } => {
let pressed = event.state == ElementState::Pressed;
if let PhysicalKey::Code(code) = event.physical_key {
match code {
KeyCode::KeyW => self.input.forward = pressed,
KeyCode::KeyS => self.input.backward = pressed,
KeyCode::KeyA => self.input.left = pressed,
KeyCode::KeyD => self.input.right = pressed,
KeyCode::Space => self.input.up = pressed,
KeyCode::ShiftLeft => self.input.down = pressed,
KeyCode::Escape => event_loop.exit(),
_ => {}
}
}
}
WindowEvent::RedrawRequested => {
if let Some(Err(e)) = self.renderer.as_mut().map(renderer::Renderer::draw_frame) {
// Derive delta-time from the previous frame so movement is framerate-independent. The first frame has no predecessor and therefore advances by zero seconds.
let now = Instant::now();
let dt = self
.last_frame
.map_or(0.0, |prev| now.duration_since(prev).as_secs_f32());
self.last_frame = Some(now);
self.camera.update(&self.input, dt);
// The accumulated motion has been applied; clear it so it is not counted twice.
self.input.mouse_delta = (0.0, 0.0);
let view = self.camera.view_matrix();
if let Some(Err(e)) = self.renderer.as_mut().map(|r| r.draw_frame(view)) {
error!("Failed to draw frame: {e}");
event_loop.exit();
}
@ -124,6 +215,19 @@ impl ApplicationHandler for App {
_ => (),
}
}
fn device_event(
&mut self,
_event_loop: &ActiveEventLoop,
_device_id: DeviceId,
event: DeviceEvent,
) {
// Raw mouse motion is used for look control; it is unaffected by pointer acceleration or the desktop cursor position, which absolute window coordinates would not guarantee.
if let DeviceEvent::MouseMotion { delta } = event {
self.input.mouse_delta.0 += delta.0;
self.input.mouse_delta.1 += delta.1;
}
}
}
fn main() -> Result<()> {

View file

@ -76,7 +76,7 @@ pub struct Renderer {
impl Renderer {
/// Renders a single frame.
pub fn draw_frame(&mut self) -> Result<(), RendererError> {
pub fn draw_frame(&mut self, camera_view: glam::Mat4) -> Result<(), RendererError> {
let sync = self
.sync
.as_ref()
@ -122,7 +122,7 @@ impl Renderer {
let view = self.swapchain_image_views[image_index as usize];
// 4. Record the actual rendering commands
self.record_commands(cmd, view, image)?;
self.record_commands(cmd, view, image, camera_view)?;
// 5. Submit the work to the GPU
let submit_info = vk::SubmitInfo::default()
@ -159,6 +159,7 @@ impl Renderer {
cmd: vk::CommandBuffer,
view: vk::ImageView,
image: vk::Image,
camera_view: glam::Mat4,
) -> Result<(), RendererError> {
// Transition layouts for drawing
self.transition_to_draw_layout(cmd, image);
@ -198,7 +199,7 @@ impl Renderer {
unsafe {
self.device.cmd_begin_rendering(cmd, &rendering_info);
self.issue_draw_calls(cmd);
self.issue_draw_calls(cmd, camera_view);
self.device.cmd_end_rendering(cmd);
}
@ -253,7 +254,7 @@ impl Renderer {
}
/// Issues the actual draw calls for the frame.
fn issue_draw_calls(&self, cmd: vk::CommandBuffer) {
fn issue_draw_calls(&self, cmd: vk::CommandBuffer, camera_view: glam::Mat4) {
unsafe {
self.device.cmd_bind_pipeline(
cmd,
@ -289,16 +290,11 @@ impl Renderer {
#[allow(clippy::cast_possible_truncation)]
let mut projection =
glam::Mat4::perspective_rh(45.0_f32.to_radians(), aspect as f32, 0.1, 500.0);
// Vulkan clip space inverts the Y axis relative to the OpenGL convention glam targets.
projection.col_mut(1).y *= -1.0;
let view = glam::Mat4::look_at_rh(
glam::vec3(16.0, 40.0, 60.0),
glam::vec3(16.0, 16.0, 16.0),
glam::vec3(0.0, 1.0, 0.0),
);
let model = glam::Mat4::from_rotation_y(0.0);
let mvp = projection * view * model;
// The view matrix is supplied by the caller (the client's camera); the renderer owns only the projection, which depends on the swapchain aspect ratio it manages.
let mvp = projection * camera_view;
let mvp_bytes = bytemuck::cast_slice(mvp.as_ref());
self.device.cmd_push_constants(