From fed63f894f46162e855e3606fc13acf6cd12079e Mon Sep 17 00:00:00 2001 From: Patrick Owen Date: Sun, 21 May 2023 12:32:38 -0400 Subject: [PATCH] Align the player camera to the environment --- client/src/graphics/window.rs | 33 +-- client/src/lib.rs | 1 + client/src/local_character_controller.rs | 290 +++++++++++++++++++++++ client/src/sim.rs | 67 ++++-- common/src/character_controller/mod.rs | 8 +- common/src/math.rs | 23 ++ common/src/node.rs | 12 +- common/src/worldgen.rs | 4 + 8 files changed, 393 insertions(+), 45 deletions(-) create mode 100644 client/src/local_character_controller.rs diff --git a/client/src/graphics/window.rs b/client/src/graphics/window.rs index 6c772658..4c1064c4 100644 --- a/client/src/graphics/window.rs +++ b/client/src/graphics/window.rs @@ -136,23 +136,19 @@ impl Window { if let Some(sim) = self.sim.as_mut() { let this_frame = Instant::now(); let dt = this_frame - last_frame; - let move_direction: na::Vector3 = na::Vector3::x() - * (right as u8 as f32 - left as u8 as f32) - + na::Vector3::y() * (up as u8 as f32 - down as u8 as f32) - + na::Vector3::z() * (back as u8 as f32 - forward as u8 as f32); - sim.set_movement_input(if move_direction.norm_squared() > 1.0 { - move_direction.normalize() - } else { - move_direction - }); - - sim.rotate(&na::UnitQuaternion::from_axis_angle( - &-na::Vector3::z_axis(), - (clockwise as u8 as f32 - anticlockwise as u8 as f32) - * 2.0 - * dt.as_secs_f32(), + sim.set_movement_input(na::Vector3::new( + right as u8 as f32 - left as u8 as f32, + up as u8 as f32 - down as u8 as f32, + back as u8 as f32 - forward as u8 as f32, )); + sim.look( + 0.0, + 0.0, + 2.0 * (anticlockwise as u8 as f32 - clockwise as u8 as f32) + * dt.as_secs_f32(), + ); + sim.step(dt, &mut self.net); last_frame = this_frame; } @@ -163,14 +159,11 @@ impl Window { DeviceEvent::MouseMotion { delta } if mouse_captured => { if let Some(sim) = self.sim.as_mut() { const SENSITIVITY: f32 = 2e-3; - let rot = na::UnitQuaternion::from_axis_angle( - &na::Vector3::y_axis(), + sim.look( -delta.0 as f32 * SENSITIVITY, - ) * na::UnitQuaternion::from_axis_angle( - &na::Vector3::x_axis(), -delta.1 as f32 * SENSITIVITY, + 0.0, ); - sim.rotate(&rot); } } _ => {} diff --git a/client/src/lib.rs b/client/src/lib.rs index a30034ae..1b555de8 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -15,6 +15,7 @@ mod config; pub mod graphics; mod lahar_deprecated; mod loader; +mod local_character_controller; pub mod metrics; pub mod net; mod prediction; diff --git a/client/src/local_character_controller.rs b/client/src/local_character_controller.rs new file mode 100644 index 00000000..a8b8c16b --- /dev/null +++ b/client/src/local_character_controller.rs @@ -0,0 +1,290 @@ +use common::{math, proto::Position}; + +pub struct LocalCharacterController { + /// The last extrapolated inter-frame view position, used for rendering and gravity-specific + /// orientation computations + position: Position, + + /// The up vector relative to position, ignoring orientation + up: na::UnitVector3, + + /// The quaternion adjustment to the character position to represent its actual apparent orientation + orientation: na::UnitQuaternion, +} + +impl LocalCharacterController { + pub fn new() -> Self { + LocalCharacterController { + position: Position::origin(), + orientation: na::UnitQuaternion::identity(), + up: na::Vector::z_axis(), + } + } + + /// Get the current position with orientation applied to it + pub fn oriented_position(&self) -> Position { + Position { + node: self.position.node, + local: self.position.local * self.orientation.to_homogeneous(), + } + } + + pub fn orientation(&self) -> na::UnitQuaternion { + self.orientation + } + + /// Updates the LocalCharacter based on outside information. Note that the `up` parameter is relative + /// only to `position`, not the character's orientation. + pub fn update_position( + &mut self, + position: Position, + up: na::UnitVector3, + preserve_up_alignment: bool, + ) { + if preserve_up_alignment { + // Rotate the character orientation to stay consistent with changes in gravity + self.orientation = math::rotation_between_axis(&self.up, &up, 1e-5) + .unwrap_or(na::UnitQuaternion::identity()) + * self.orientation; + } + + self.position = position; + self.up = up; + } + + /// Rotates the camera's view by locally adding pitch and yaw. + pub fn look_free(&mut self, delta_yaw: f32, delta_pitch: f32, delta_roll: f32) { + self.orientation *= na::UnitQuaternion::from_axis_angle(&na::Vector3::y_axis(), delta_yaw) + * na::UnitQuaternion::from_axis_angle(&na::Vector3::x_axis(), delta_pitch) + * na::UnitQuaternion::from_axis_angle(&na::Vector3::z_axis(), delta_roll); + } + + /// Rotates the camera's view with standard first-person walking simulator mouse controls. This function + /// is designed to be flexible enough to work with any starting orientation, but it works best when the + /// camera is level, not rolled to the left or right. + pub fn look_level(&mut self, delta_yaw: f32, delta_pitch: f32) { + // Get orientation-relative up + let up = self.orientation.inverse() * self.up; + + // Handle yaw. This is as simple as rotating the view about the up vector + self.orientation *= na::UnitQuaternion::from_axis_angle(&up, delta_yaw); + + // Handling pitch is more compicated because the view angle needs to be capped. The rotation axis + // is the camera's local x-axis (left-right axis). If the camera is level, this axis is perpendicular + // to the up vector. + + // We need to know the current pitch to properly cap pitch changes, and this is only well-defined + // if the pitch axis is not too similar to the up vector, so we skip applying pitch changes if this + // isn't the case. + if up.x.abs() < 0.9 { + // Compute the current pitch by ignoring the x-component of the up vector and assuming the camera + // is level. + let current_pitch = -up.z.atan2(up.y); + let mut target_pitch = current_pitch + delta_pitch; + if delta_pitch > 0.0 { + target_pitch = target_pitch + .min(std::f32::consts::FRAC_PI_2) // Cap the view angle at looking straight up + .max(current_pitch); // But if already upside-down, don't make any corrections. + } else { + target_pitch = target_pitch + .max(-std::f32::consts::FRAC_PI_2) // Cap the view angle at looking straight down + .min(current_pitch); // But if already upside-down, don't make any corrections. + } + + self.orientation *= na::UnitQuaternion::from_axis_angle( + &na::Vector3::x_axis(), + target_pitch - current_pitch, + ); + } + } + + /// Instantly updates the current orientation quaternion to make the camera level. This function + /// is designed to be numerically stable for any camera orientation. + pub fn align_to_gravity(&mut self) { + // Get orientation-relative up + let up = self.orientation.inverse() * self.up; + + if up.z.abs() < 0.9 { + // If facing not too vertically, roll the camera to make it level. + let delta_roll = -up.x.atan2(up.y); + self.orientation *= + na::UnitQuaternion::from_axis_angle(&na::Vector3::z_axis(), delta_roll); + } else if up.y > 0.0 { + // Otherwise, if not upside-down, yaw the camera to make it level. + let delta_yaw = (up.x / up.z).atan(); + self.orientation *= + na::UnitQuaternion::from_axis_angle(&na::Vector3::y_axis(), delta_yaw); + } else { + // Otherwise, rotate the camera to look straight up or down. + self.orientation *= + na::UnitQuaternion::rotation_between(&(na::Vector3::z() * up.z.signum()), &up) + .unwrap(); + } + } + + pub fn renormalize_orientation(&mut self) { + self.orientation.renormalize_fast(); + } +} + +#[cfg(test)] +mod tests { + use approx::assert_abs_diff_eq; + + use super::*; + + fn assert_aligned_to_gravity(subject: &LocalCharacterController) { + let up = subject.orientation.inverse() * subject.up; + + // Make sure up vector doesn't point downwards, as that would mean the character is upside-down + assert!(up.y >= -1e-5); + + // Make sure the up vector has no sideways component, as that would mean the character view is tilted + assert_abs_diff_eq!(up.x, 0.0, epsilon = 1.0e-5); + } + + fn assert_yaw_and_pitch_correct( + base_orientation: na::UnitQuaternion, + yaw: f32, + pitch: f32, + actual_orientation: na::UnitQuaternion, + ) { + let expected_orientation = base_orientation + * na::UnitQuaternion::from_axis_angle(&na::Vector3::y_axis(), yaw) + * na::UnitQuaternion::from_axis_angle(&na::Vector3::x_axis(), pitch); + assert_abs_diff_eq!(expected_orientation, actual_orientation, epsilon = 1.0e-5); + } + + #[test] + fn look_level_examples() { + let mut subject = LocalCharacterController::new(); + + // Pick an arbitrary orientation + let base_orientation = na::UnitQuaternion::new(na::Vector3::new(1.3, -2.1, 0.5)); + subject.orientation = base_orientation; + + // Choose the up vector that makes the current orientation a horizontal orientation + subject.up = subject.orientation * na::Vector3::y_axis(); + + let mut yaw = 0.0; + let mut pitch = 0.0; + + // Sanity check that the setup makes sense + assert_aligned_to_gravity(&subject); + assert_yaw_and_pitch_correct(base_orientation, yaw, pitch, subject.orientation); + + // Standard look_level expression + subject.look_level(0.5, -0.4); + yaw += 0.5; + pitch -= 0.4; + assert_aligned_to_gravity(&subject); + assert_yaw_and_pitch_correct(base_orientation, yaw, pitch, subject.orientation); + + // Look up past the cap + subject.look_level(-0.2, 3.0); + yaw -= 0.2; + pitch = std::f32::consts::FRAC_PI_2; + assert_aligned_to_gravity(&subject); + assert_yaw_and_pitch_correct(base_orientation, yaw, pitch, subject.orientation); + + // Look down past the cap + subject.look_level(6.2, -7.2); + yaw += 6.2; + pitch = -std::f32::consts::FRAC_PI_2; + assert_aligned_to_gravity(&subject); + assert_yaw_and_pitch_correct(base_orientation, yaw, pitch, subject.orientation); + + // Go back to a less unusual orientation + subject.look_level(-1.2, 0.8); + yaw -= 1.2; + pitch += 0.8; + assert_aligned_to_gravity(&subject); + assert_yaw_and_pitch_correct(base_orientation, yaw, pitch, subject.orientation); + } + + #[test] + fn align_to_gravity_examples() { + // Pick an arbitrary orientation + let base_orientation = na::UnitQuaternion::new(na::Vector3::new(1.3, -2.1, 0.5)); + + // Choose the up vector that makes the current orientation close to horizontal orientation + let mut subject = LocalCharacterController::new(); + subject.orientation = base_orientation; + subject.up = + subject.orientation * na::UnitVector3::new_normalize(na::Vector3::new(0.1, 1.0, 0.2)); + let look_direction = subject.orientation * na::Vector3::z_axis(); + + subject.align_to_gravity(); + + assert_aligned_to_gravity(&subject); + // The look_direction shouldn't change + assert_abs_diff_eq!( + look_direction, + subject.orientation * na::Vector3::z_axis(), + epsilon = 1e-5 + ); + + // Choose the up vector that makes the current orientation close to horizontal orientation but upside-down + let mut subject = LocalCharacterController::new(); + subject.orientation = base_orientation; + subject.up = + subject.orientation * na::UnitVector3::new_normalize(na::Vector3::new(0.1, -1.0, 0.2)); + let look_direction = subject.orientation * na::Vector3::z_axis(); + + subject.align_to_gravity(); + + assert_aligned_to_gravity(&subject); + // The look_direction still shouldn't change + assert_abs_diff_eq!( + look_direction, + subject.orientation * na::Vector3::z_axis(), + epsilon = 1e-5 + ); + + // Make the character face close to straight up + let mut subject = LocalCharacterController::new(); + subject.orientation = base_orientation; + subject.up = subject.orientation + * na::UnitVector3::new_normalize(na::Vector3::new(-0.03, 0.05, 1.0)); + subject.align_to_gravity(); + assert_aligned_to_gravity(&subject); + + // Make the character face close to straight down and be slightly upside-down + let mut subject = LocalCharacterController::new(); + subject.orientation = base_orientation; + subject.up = subject.orientation + * na::UnitVector3::new_normalize(na::Vector3::new(-0.03, -0.05, -1.0)); + subject.align_to_gravity(); + assert_aligned_to_gravity(&subject); + } + + #[test] + fn update_position_example() { + // Pick an arbitrary orientation + let base_orientation = na::UnitQuaternion::new(na::Vector3::new(1.3, -2.1, 0.5)); + + let mut subject = LocalCharacterController::new(); + subject.orientation = base_orientation; + subject.up = + subject.orientation * na::UnitVector3::new_normalize(na::Vector3::new(0.0, 1.0, 0.2)); + + // Sanity check setup (character should already be aligned to gravity) + assert_aligned_to_gravity(&subject); + let old_up_vector_y_component = (subject.orientation.inverse() * subject.up).y; + + subject.update_position( + Position::origin(), + na::UnitVector3::new_normalize(na::Vector3::new(0.1, 0.2, 0.5)), + true, + ); + assert_aligned_to_gravity(&subject); + let new_up_vector_y_component = (subject.orientation.inverse() * subject.up).y; + + // We don't want the camera pitch to drift as the up vector changes + assert_abs_diff_eq!( + old_up_vector_y_component, + new_up_vector_y_component, + epsilon = 1e-5 + ); + } +} diff --git a/client/src/sim.rs b/client/src/sim.rs index 4112abc2..4dc5cc30 100644 --- a/client/src/sim.rs +++ b/client/src/sim.rs @@ -4,7 +4,9 @@ use fxhash::FxHashMap; use hecs::Entity; use tracing::{debug, error, trace}; -use crate::{net, prediction::PredictedMotion, Net}; +use crate::{ + local_character_controller::LocalCharacterController, net, prediction::PredictedMotion, Net, +}; use common::{ character_controller, graph::NodeId, @@ -23,7 +25,6 @@ pub struct Sim { pub cfg: SimConfig, pub local_character_id: EntityId, pub local_character: Option, - orientation: na::UnitQuaternion, step: Option, // Input state @@ -41,6 +42,7 @@ pub struct Sim { /// Whether no_clip will be toggled next step toggle_no_clip: bool, prediction: PredictedMotion, + local_character_controller: LocalCharacterController, } impl Sim { @@ -55,7 +57,6 @@ impl Sim { cfg, local_character_id, local_character: None, - orientation: na::one(), step: None, since_input_sent: Duration::new(0, 0), @@ -67,15 +68,31 @@ impl Sim { node: NodeId::ROOT, local: na::one(), }), + local_character_controller: LocalCharacterController::new(), } } - pub fn rotate(&mut self, delta: &na::UnitQuaternion) { - self.orientation *= delta; + /// Rotates the camera's view in a context-dependent manner based on the desired yaw and pitch angles. + pub fn look(&mut self, delta_yaw: f32, delta_pitch: f32, delta_roll: f32) { + if self.no_clip { + self.local_character_controller + .look_free(delta_yaw, delta_pitch, delta_roll); + } else { + self.local_character_controller + .look_level(delta_yaw, delta_pitch); + } } - pub fn set_movement_input(&mut self, movement_input: na::Vector3) { - self.movement_input = movement_input; + pub fn set_movement_input(&mut self, mut raw_movement_input: na::Vector3) { + if !self.no_clip { + // Vertical movement keys shouldn't do anything unless no-clip is on. + raw_movement_input.y = 0.0; + } + if raw_movement_input.norm_squared() >= 1.0 { + // Cap movement input at 1 + raw_movement_input.normalize_mut(); + } + self.movement_input = raw_movement_input; } pub fn toggle_no_clip(&mut self) { @@ -90,7 +107,7 @@ impl Sim { } pub fn step(&mut self, dt: Duration, net: &mut Net) { - self.orientation.renormalize_fast(); + self.local_character_controller.renormalize_orientation(); let step_interval = self.cfg.step_interval; self.since_input_sent += dt; @@ -130,6 +147,10 @@ impl Sim { self.average_movement_input += self.movement_input * dt.as_secs_f32() / step_interval.as_secs_f32(); } + self.update_view_position(); + if !self.no_clip { + self.local_character_controller.align_to_gravity(); + } } pub fn handle_net(&mut self, msg: net::Message) { @@ -272,7 +293,9 @@ impl Sim { fn send_input(&mut self, net: &mut Net) { let character_input = CharacterInput { - movement: sanitize_motion_input(self.orientation * self.average_movement_input), + movement: sanitize_motion_input( + self.local_character_controller.orientation() * self.average_movement_input, + ), no_clip: self.no_clip, }; let generation = self @@ -283,33 +306,41 @@ impl Sim { let _ = net.outgoing.send(Command { generation, character_input, - orientation: self.orientation, + orientation: self.local_character_controller.orientation(), }); } - pub fn view(&self) -> Position { - let mut result = *self.prediction.predicted_position(); - let mut predicted_velocity = *self.prediction.predicted_velocity(); + fn update_view_position(&mut self) { + let mut view_position = *self.prediction.predicted_position(); + let mut view_velocity = *self.prediction.predicted_velocity(); // Apply input that hasn't been sent yet let predicted_input = CharacterInput { // We divide by how far we are through the timestep because self.average_movement_input // is always over the entire timestep, filling in zeroes for the future, and we // want to use the average over what we have so far. Dividing by zero is handled // by the character_controller sanitizing this input. - movement: self.orientation * self.average_movement_input + movement: self.local_character_controller.orientation() * self.average_movement_input / (self.since_input_sent.as_secs_f32() / self.cfg.step_interval.as_secs_f32()), no_clip: self.no_clip, }; character_controller::run_character_step( &self.cfg, &self.graph, - &mut result, - &mut predicted_velocity, + &mut view_position, + &mut view_velocity, &predicted_input, self.since_input_sent.as_secs_f32(), ); - result.local *= self.orientation.to_homogeneous(); - result + + self.local_character_controller.update_position( + view_position, + self.graph.get_relative_up(&view_position).unwrap(), + !self.no_clip, + ) + } + + pub fn view(&self) -> Position { + self.local_character_controller.oriented_position() } /// Destroy all aspects of an entity diff --git a/common/src/character_controller/mod.rs b/common/src/character_controller/mod.rs index 51934a5e..6476c1d1 100644 --- a/common/src/character_controller/mod.rs +++ b/common/src/character_controller/mod.rs @@ -89,12 +89,8 @@ fn run_no_clip_character_step( position: &mut Position, velocity: &mut na::Vector3, ) { - // If no-clip is on, the velocity field is useless, and we don't want to accidentally - // save velocity from when no-clip was off. - *velocity = na::Vector3::zeros(); - position.local *= math::translate_along( - &(ctx.movement_input * ctx.cfg.no_clip_movement_speed * ctx.dt_seconds), - ); + *velocity = ctx.movement_input * ctx.cfg.no_clip_movement_speed; + position.local *= math::translate_along(&(*velocity * ctx.dt_seconds)); } /// Updates the character's position based on the given average velocity while handling collisions. diff --git a/common/src/math.rs b/common/src/math.rs index c49de725..021643c4 100644 --- a/common/src/math.rs +++ b/common/src/math.rs @@ -152,6 +152,20 @@ pub fn project_to_plane( * ((distance - subject.dot(normal)) / projection_direction.dot(normal)); } +/// Returns the UnitQuaternion that rotates the `from` vector to the `to` vector, or `None` if +/// `from` and `to` face opposite directions such that their sum has norm less than `epsilon`. +/// This version is more numerically stable than nalgebra's equivalent function. +pub fn rotation_between_axis( + from: &na::UnitVector3, + to: &na::UnitVector3, + epsilon: N, +) -> Option> { + let angle_bisector = na::UnitVector3::try_new(from.into_inner() + to.into_inner(), epsilon)?; + Some(na::UnitQuaternion::new_unchecked( + na::Quaternion::from_parts(from.dot(&angle_bisector), from.cross(&angle_bisector)), + )) +} + fn minkowski_outer_product( a: &na::Vector4, b: &na::Vector4, @@ -309,4 +323,13 @@ mod tests { project_to_plane(&mut subject, &normal, &projection_direction, distance); assert_abs_diff_eq!(normal.dot(&subject), distance, epsilon = 1.0e-5); } + + #[test] + fn rotation_between_axis_example() { + let from = na::UnitVector3::new_normalize(na::Vector3::new(1.0, 1.0, 3.0)); + let to = na::UnitVector3::new_normalize(na::Vector3::new(2.0, 3.0, 2.0)); + let expected = na::UnitQuaternion::rotation_between_axis(&from, &to).unwrap(); + let actual = rotation_between_axis(&from, &to, 1e-5).unwrap(); + assert_abs_diff_eq!(expected, actual, epsilon = 1.0e-5); + } } diff --git a/common/src/node.rs b/common/src/node.rs index 1581df3d..da8254ba 100644 --- a/common/src/node.rs +++ b/common/src/node.rs @@ -5,9 +5,10 @@ use std::ops::{Index, IndexMut}; use crate::dodeca::Vertex; use crate::graph::{Graph, NodeId}; use crate::lru_slab::SlotId; +use crate::proto::Position; use crate::world::Material; use crate::worldgen::NodeState; -use crate::Chunks; +use crate::{math, Chunks}; pub type DualGraph = Graph; @@ -31,6 +32,15 @@ impl DualGraph { pub fn get_chunk(&self, chunk: ChunkId) -> Option<&Chunk> { Some(&self.get(chunk.node).as_ref()?.chunks[chunk.vertex]) } + + /// Returns the up-direction relative to the given position, or `None` if the + /// position is in an unpopulated node. + pub fn get_relative_up(&self, position: &Position) -> Option> { + let node = self.get(position.node).as_ref()?; + Some(na::UnitVector3::new_normalize( + (math::mtranspose(&position.local) * node.state.up_direction()).xyz(), + )) + } } impl Index for DualGraph { diff --git a/common/src/worldgen.rs b/common/src/worldgen.rs index 7890b37a..e7c8c5b7 100644 --- a/common/src/worldgen.rs +++ b/common/src/worldgen.rs @@ -118,6 +118,10 @@ impl NodeState { enviro, } } + + pub fn up_direction(&self) -> na::Vector4 { + self.surface.normal().cast() + } } struct VoxelCoords {