feat(client): add free-fly camera with wasd and mouse-look
This commit is contained in:
parent
eb7a297f3e
commit
ea6fbf1624
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -236,6 +236,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"ash-window",
|
||||
"glam 0.29.3",
|
||||
"raw-window-handle",
|
||||
"renderer",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -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
101
crates/client/src/camera.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<()> {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue