diff --git a/Cargo.lock b/Cargo.lock index b188fd4..ac09b17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -236,6 +236,7 @@ version = "0.1.0" dependencies = [ "anyhow", "ash-window", + "glam 0.29.3", "raw-window-handle", "renderer", "serde_json", diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index f7c2274..9f85e4b 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -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" diff --git a/crates/client/src/camera.rs b/crates/client/src/camera.rs new file mode 100644 index 0000000..1c30224 --- /dev/null +++ b/crates/client/src/camera.rs @@ -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; + } + } +} diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index dc79c0e..8347645 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -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, + /// The application window, created on resume. window: Option, + /// 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, +} + +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<()> { diff --git a/crates/renderer/src/renderer.rs b/crates/renderer/src/renderer.rs index a89b665..80b3275 100644 --- a/crates/renderer/src/renderer.rs +++ b/crates/renderer/src/renderer.rs @@ -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(