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 = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash-window",
|
"ash-window",
|
||||||
|
"glam 0.29.3",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"renderer",
|
"renderer",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
winit = "0.30.13"
|
winit = "0.30.13"
|
||||||
renderer = { path = "../renderer" }
|
renderer = { path = "../renderer" }
|
||||||
|
glam = "0.29"
|
||||||
raw-window-handle = "0.6.2"
|
raw-window-handle = "0.6.2"
|
||||||
ash-window = "0.13.0"
|
ash-window = "0.13.0"
|
||||||
serde_json = "1.0.149"
|
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
|
//! This crate handles window creation, input processing, and drives the
|
||||||
//! renderer to display the game world.
|
//! renderer to display the game world.
|
||||||
|
mod camera;
|
||||||
mod meshing;
|
mod meshing;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use std::time::Instant;
|
||||||
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 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)]
|
#[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 {
|
struct App {
|
||||||
|
/// The Vulkan renderer, initialised once the window exists.
|
||||||
renderer: Option<renderer::Renderer>,
|
renderer: Option<renderer::Renderer>,
|
||||||
|
/// The application window, created on resume.
|
||||||
window: Option<Window>,
|
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 {
|
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.window = Some(window);
|
||||||
self.renderer = Some(renderer);
|
self.renderer = Some(renderer);
|
||||||
|
|
||||||
|
|
@ -111,8 +175,35 @@ impl ApplicationHandler for App {
|
||||||
WindowEvent::CloseRequested => {
|
WindowEvent::CloseRequested => {
|
||||||
event_loop.exit();
|
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 => {
|
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}");
|
error!("Failed to draw frame: {e}");
|
||||||
event_loop.exit();
|
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<()> {
|
fn main() -> Result<()> {
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ pub struct Renderer {
|
||||||
|
|
||||||
impl Renderer {
|
impl Renderer {
|
||||||
/// Renders a single frame.
|
/// 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
|
let sync = self
|
||||||
.sync
|
.sync
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -122,7 +122,7 @@ impl Renderer {
|
||||||
let view = self.swapchain_image_views[image_index as usize];
|
let view = self.swapchain_image_views[image_index as usize];
|
||||||
|
|
||||||
// 4. Record the actual rendering commands
|
// 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
|
// 5. Submit the work to the GPU
|
||||||
let submit_info = vk::SubmitInfo::default()
|
let submit_info = vk::SubmitInfo::default()
|
||||||
|
|
@ -159,6 +159,7 @@ impl Renderer {
|
||||||
cmd: vk::CommandBuffer,
|
cmd: vk::CommandBuffer,
|
||||||
view: vk::ImageView,
|
view: vk::ImageView,
|
||||||
image: vk::Image,
|
image: vk::Image,
|
||||||
|
camera_view: glam::Mat4,
|
||||||
) -> Result<(), RendererError> {
|
) -> Result<(), RendererError> {
|
||||||
// Transition layouts for drawing
|
// Transition layouts for drawing
|
||||||
self.transition_to_draw_layout(cmd, image);
|
self.transition_to_draw_layout(cmd, image);
|
||||||
|
|
@ -198,7 +199,7 @@ impl Renderer {
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
self.device.cmd_begin_rendering(cmd, &rendering_info);
|
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);
|
self.device.cmd_end_rendering(cmd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -253,7 +254,7 @@ impl Renderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Issues the actual draw calls for the frame.
|
/// 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 {
|
unsafe {
|
||||||
self.device.cmd_bind_pipeline(
|
self.device.cmd_bind_pipeline(
|
||||||
cmd,
|
cmd,
|
||||||
|
|
@ -289,16 +290,11 @@ impl Renderer {
|
||||||
#[allow(clippy::cast_possible_truncation)]
|
#[allow(clippy::cast_possible_truncation)]
|
||||||
let mut projection =
|
let mut projection =
|
||||||
glam::Mat4::perspective_rh(45.0_f32.to_radians(), aspect as f32, 0.1, 500.0);
|
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;
|
projection.col_mut(1).y *= -1.0;
|
||||||
|
|
||||||
let view = glam::Mat4::look_at_rh(
|
// 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.
|
||||||
glam::vec3(16.0, 40.0, 60.0),
|
let mvp = projection * camera_view;
|
||||||
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;
|
|
||||||
|
|
||||||
let mvp_bytes = bytemuck::cast_slice(mvp.as_ref());
|
let mvp_bytes = bytemuck::cast_slice(mvp.as_ref());
|
||||||
self.device.cmd_push_constants(
|
self.device.cmd_push_constants(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue