From 5c2ed39f52e48961464a7aea0104ccb1ccd91b34 Mon Sep 17 00:00:00 2001 From: Patrick Owen Date: Wed, 26 Jul 2023 06:58:02 -0400 Subject: [PATCH 1/6] Separate out character-specific configuration into a new struct --- common/src/character_controller.rs | 9 ++--- common/src/sim_config.rs | 54 +++++++++++++++++++++--------- server/src/sim.rs | 4 +-- 3 files changed, 45 insertions(+), 22 deletions(-) diff --git a/common/src/character_controller.rs b/common/src/character_controller.rs index 2bb77a91..e24c1b12 100644 --- a/common/src/character_controller.rs +++ b/common/src/character_controller.rs @@ -44,14 +44,15 @@ impl CharacterControllerPass<'_> { // save velocity from when no-clip was off. *self.velocity = na::Vector3::zeros(); self.position.local *= math::translate_along( - &(movement * self.cfg.no_clip_movement_speed * self.dt_seconds), + &(movement * self.cfg.character.no_clip_movement_speed * self.dt_seconds), ); } else { let old_velocity = *self.velocity; // Update velocity - let current_to_target_velocity = movement * self.cfg.max_ground_speed - *self.velocity; - let max_delta_velocity = self.cfg.ground_acceleration * self.dt_seconds; + let current_to_target_velocity = + movement * self.cfg.character.max_ground_speed - *self.velocity; + let max_delta_velocity = self.cfg.character.ground_acceleration * self.dt_seconds; if current_to_target_velocity.norm_squared() > math::sqr(max_delta_velocity) { *self.velocity += current_to_target_velocity.normalize() * max_delta_velocity; } else { @@ -139,7 +140,7 @@ impl CharacterControllerPass<'_> { let tanh_distance = displacement_norm.tanh(); let cast_hit = graph_collision::sphere_cast( - self.cfg.character_radius, + self.cfg.character.character_radius, self.graph, &ChunkLayout::new(self.cfg.chunk_size as usize), self.position, diff --git a/common/src/sim_config.rs b/common/src/sim_config.rs index d82aa5a0..72101968 100644 --- a/common/src/sim_config.rs +++ b/common/src/sim_config.rs @@ -24,14 +24,9 @@ pub struct SimConfigRaw { /// Note that exact voxel size varies within each chunk. We reference the mean width of the voxels /// along the X axis through the center of a chunk. pub voxel_size: Option, - /// Character movement speed in m/s during no-clip - pub no_clip_movement_speed: Option, - /// Character maximumum movement speed while on the ground in m/s - pub max_ground_speed: Option, - /// Character acceleration while on the ground in m/s^2 - pub ground_acceleration: Option, - /// Radius of the character in meters - pub character_radius: Option, + /// Static configuration information relevant to character physics + #[serde(default)] + pub character: CharacterConfigRaw, } /// Complete simulation config parameters @@ -42,10 +37,7 @@ pub struct SimConfig { pub view_distance: f32, pub input_queue_size: Duration, pub chunk_size: u8, - pub no_clip_movement_speed: f32, - pub max_ground_speed: f32, - pub ground_acceleration: f32, - pub character_radius: f32, + pub character: CharacterConfig, /// Scaling factor converting meters to absolute units pub meters_to_absolute: f32, } @@ -60,10 +52,7 @@ impl SimConfig { view_distance: x.view_distance.unwrap_or(90.0) * meters_to_absolute, input_queue_size: Duration::from_millis(x.input_queue_size_ms.unwrap_or(50).into()), chunk_size, - no_clip_movement_speed: x.no_clip_movement_speed.unwrap_or(12.0) * meters_to_absolute, - max_ground_speed: x.max_ground_speed.unwrap_or(6.0) * meters_to_absolute, - ground_acceleration: x.ground_acceleration.unwrap_or(30.0) * meters_to_absolute, - character_radius: x.character_radius.unwrap_or(0.4) * meters_to_absolute, + character: CharacterConfig::from_raw(&x.character, meters_to_absolute), meters_to_absolute, } } @@ -78,3 +67,36 @@ fn meters_to_absolute(chunk_size: u8, voxel_size: f32) -> f32 { let absolute_voxel_size = minimum_chunk_face_separation / f64::from(chunk_size); absolute_voxel_size as f32 / voxel_size } + +/// Static configuration information relevant to character physics as provided in configuration files +#[derive(Default, Clone, Debug, Serialize, Deserialize)] +pub struct CharacterConfigRaw { + /// Character movement speed in m/s during no-clip + pub no_clip_movement_speed: Option, + /// Character maximumum movement speed while on the ground in m/s + pub max_ground_speed: Option, + /// Character acceleration while on the ground in m/s^2 + pub ground_acceleration: Option, + /// Radius of the character in meters + pub character_radius: Option, +} + +/// Static configuration information relevant to character physics +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CharacterConfig { + pub no_clip_movement_speed: f32, + pub max_ground_speed: f32, + pub ground_acceleration: f32, + pub character_radius: f32, +} + +impl CharacterConfig { + pub fn from_raw(x: &CharacterConfigRaw, meters_to_absolute: f32) -> Self { + CharacterConfig { + no_clip_movement_speed: x.no_clip_movement_speed.unwrap_or(12.0) * meters_to_absolute, + max_ground_speed: x.max_ground_speed.unwrap_or(4.0) * meters_to_absolute, + ground_acceleration: x.ground_acceleration.unwrap_or(20.0) * meters_to_absolute, + character_radius: x.character_radius.unwrap_or(0.4) * meters_to_absolute, + } + } +} diff --git a/server/src/sim.rs b/server/src/sim.rs index 6f87c300..1f4e8dc9 100644 --- a/server/src/sim.rs +++ b/server/src/sim.rs @@ -248,8 +248,8 @@ impl Sim { // is set up to cover that distance. // TODO: Use actual max speed instead of max ground speed. let chunk_generation_distance = dodeca::BOUNDING_SPHERE_RADIUS - + self.cfg.character_radius as f64 - + self.cfg.max_ground_speed as f64 * self.cfg.step_interval.as_secs_f64() + + self.cfg.character.character_radius as f64 + + self.cfg.character.max_ground_speed as f64 * self.cfg.step_interval.as_secs_f64() + 0.001; // Load all chunks around entities corresponding to clients, which correspond to entities From 8eb612160779c4ce05f351fc7b7c2b6e25d36f66 Mon Sep 17 00:00:00 2001 From: Patrick Owen Date: Sun, 21 May 2023 12:06:58 -0400 Subject: [PATCH 2/6] Reorganize CharacterControllerPass into standalone functions and modules --- common/src/character_controller.rs | 299 ------------------- common/src/character_controller/collision.rs | 108 +++++++ common/src/character_controller/mod.rs | 231 ++++++++++++++ 3 files changed, 339 insertions(+), 299 deletions(-) delete mode 100644 common/src/character_controller.rs create mode 100644 common/src/character_controller/collision.rs create mode 100644 common/src/character_controller/mod.rs diff --git a/common/src/character_controller.rs b/common/src/character_controller.rs deleted file mode 100644 index e24c1b12..00000000 --- a/common/src/character_controller.rs +++ /dev/null @@ -1,299 +0,0 @@ -use tracing::{error, warn}; - -use crate::{ - graph_collision, math, - node::{ChunkLayout, DualGraph}, - proto::{CharacterInput, Position}, - sanitize_motion_input, SimConfig, -}; - -pub fn run_character_step( - cfg: &SimConfig, - graph: &DualGraph, - position: &mut Position, - velocity: &mut na::Vector3, - input: &CharacterInput, - dt_seconds: f32, -) { - CharacterControllerPass { - cfg, - graph, - position, - velocity, - input, - dt_seconds, - } - .step(); -} - -struct CharacterControllerPass<'a> { - cfg: &'a SimConfig, - graph: &'a DualGraph, - position: &'a mut Position, - velocity: &'a mut na::Vector3, - input: &'a CharacterInput, - dt_seconds: f32, -} - -impl CharacterControllerPass<'_> { - fn step(&mut self) { - let movement = sanitize_motion_input(self.input.movement); - - if self.input.no_clip { - // 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. - *self.velocity = na::Vector3::zeros(); - self.position.local *= math::translate_along( - &(movement * self.cfg.character.no_clip_movement_speed * self.dt_seconds), - ); - } else { - let old_velocity = *self.velocity; - - // Update velocity - let current_to_target_velocity = - movement * self.cfg.character.max_ground_speed - *self.velocity; - let max_delta_velocity = self.cfg.character.ground_acceleration * self.dt_seconds; - if current_to_target_velocity.norm_squared() > math::sqr(max_delta_velocity) { - *self.velocity += current_to_target_velocity.normalize() * max_delta_velocity; - } else { - *self.velocity += current_to_target_velocity; - } - - // Estimate the average velocity by using the average of the old velocity and new velocity, - // which has the effect of modeling a velocity that changes linearly over the timestep. - // This is necessary to avoid the following two issues: - // 1. Input lag, which would occur if only the old velocity was used - // 2. Movement artifacts, which would occur if only the new velocity was used. One - // example of such an artifact is the character moving backwards slightly when they - // stop moving after releasing a direction key. - let estimated_average_velocity = (*self.velocity + old_velocity) * 0.5; - - self.apply_velocity(&estimated_average_velocity); - } - - // Renormalize - self.position.local = math::renormalize_isometry(&self.position.local); - let (next_node, transition_xf) = self - .graph - .normalize_transform(self.position.node, &self.position.local); - if next_node != self.position.node { - self.position.node = next_node; - self.position.local = transition_xf * self.position.local; - } - } - - /// Updates the position based on the given average velocity while handling collisions. Also updates the velocity - /// based on collisions that occur. - fn apply_velocity(&mut self, estimated_average_velocity: &na::Vector3) { - // To prevent an unbounded runtime, we only allow a limited number of collisions to be processed in - // a single step. If the player encounters excessively complex geometry, it is possible to hit this limit, - // in which case further movement processing is delayed until the next time step. - const MAX_COLLISION_ITERATIONS: u32 = 5; - - let mut expected_displacement = estimated_average_velocity * self.dt_seconds; - let mut active_normals = Vec::>::with_capacity(3); - - let mut all_collisions_resolved = false; - - for _ in 0..MAX_COLLISION_ITERATIONS { - let collision_result = self.check_collision(&expected_displacement); - self.position.local *= collision_result.displacement_transform; - - if let Some(collision) = collision_result.collision { - // We maintain a list of surface normals that should restrict player movement. We remove normals for - // surfaces the player is pushed away from and add the surface normal of the latest collision. - active_normals.retain(|n| n.dot(&collision.normal) < 0.0); - active_normals.push(collision.normal); - - // Update the expected displacement to whatever is remaining. - expected_displacement -= collision_result.displacement_vector; - apply_normals(&active_normals, &mut expected_displacement); - - // Also update the velocity to ensure that walls kill momentum. - apply_normals(&active_normals, self.velocity); - } else { - all_collisions_resolved = true; - break; - } - } - - if !all_collisions_resolved { - warn!("A character entity processed too many collisions and collision resolution was cut short."); - } - } - - /// Checks for collisions when a character moves with a character-relative displacement vector of `relative_displacement`. - fn check_collision(&self, relative_displacement: &na::Vector3) -> CollisionCheckingResult { - // Split relative_displacement into its norm and a unit vector - let relative_displacement = relative_displacement.to_homogeneous(); - let displacement_sqr = relative_displacement.norm_squared(); - if displacement_sqr < 1e-16 { - // Fallback for if the displacement vector isn't large enough to reliably be normalized. - // Any value that is sufficiently large compared to f32::MIN_POSITIVE should work as the cutoff. - return CollisionCheckingResult::stationary(); - } - - let displacement_norm = displacement_sqr.sqrt(); - let displacement_normalized = relative_displacement / displacement_norm; - - let ray = graph_collision::Ray::new(math::origin(), displacement_normalized); - let tanh_distance = displacement_norm.tanh(); - - let cast_hit = graph_collision::sphere_cast( - self.cfg.character.character_radius, - self.graph, - &ChunkLayout::new(self.cfg.chunk_size as usize), - self.position, - &ray, - tanh_distance, - ); - - let cast_hit = match cast_hit { - Ok(r) => r, - Err(e) => { - error!("Collision checking returned {:?}", e); - return CollisionCheckingResult::stationary(); - } - }; - - let distance = cast_hit - .as_ref() - .map_or(tanh_distance, |hit| hit.tanh_distance) - .atanh(); - - let displacement_vector = displacement_normalized.xyz() * distance; - let displacement_transform = math::translate_along(&displacement_vector); - - CollisionCheckingResult { - displacement_vector, - displacement_transform, - collision: cast_hit.map(|hit| Collision { - // `CastEndpoint` has its `normal` given relative to the character's original position, - // but we want the normal relative to the character after the character moves to meet the wall. - // This normal now represents a contact point at the origin, so we omit the w-coordinate - // to ensure that it's orthogonal to the origin. - normal: na::UnitVector3::new_normalize( - (math::mtranspose(&displacement_transform) * hit.normal).xyz(), - ), - }), - } - } -} - -/// Modifies the `subject` by a linear combination of the `normals` to ensure that it is approximately -/// orthogonal to all the normals. The normals are assumed to be linearly independent, and, assuming the final -/// result is nonzero, a small correction is applied to ensure that the subject is moving away from the surfaces -/// the normals represent even when floating point approximation is involved. -fn apply_normals(normals: &[na::UnitVector3], subject: &mut na::Vector3) { - if normals.len() >= 3 { - // The normals are assumed to be linearly independent, so applying all of them will zero out the subject. - // There is no need to do any extra logic to handle precision limitations in this case. - *subject = na::Vector3::zeros(); - } - - // Corrective term to ensure that normals face away from any potential collision surfaces - const RELATIVE_EPSILON: f32 = 1e-4; - apply_normals_internal(normals, subject, subject.magnitude() * RELATIVE_EPSILON); -} - -/// Modifies the `subject` by a linear combination of the `normals` so that the dot product with each normal is -/// `distance`. The `normals` must be linearly independent for this function to work as expected. -fn apply_normals_internal( - normals: &[na::UnitVector3], - subject: &mut na::Vector3, - distance: f32, -) { - let mut ortho_normals: Vec> = normals.iter().map(|n| n.into_inner()).collect(); - for i in 0..normals.len() { - // Perform the Gram-Schmidt process on `normals` to produce `ortho_normals`. - for j in i + 1..normals.len() { - ortho_normals[j] = (ortho_normals[j] - - ortho_normals[i] * ortho_normals[j].dot(&ortho_normals[i])) - .normalize(); - } - - // The following formula ensures that the dot product of `subject` and `normals[i]` is `distance. - // Because we only move the subject along `ortho_normals[i]`, this adjustment does not affect the - // subject's dot product with any earlier normals. - *subject += ortho_normals[i] - * ((distance - subject.dot(&normals[i])) / ortho_normals[i].dot(&normals[i])); - } -} - -struct CollisionCheckingResult { - /// The displacement allowed for the character before hitting a wall. The result of - /// `math::translate_along(&displacement_vector)` is `displacement_transform`. - displacement_vector: na::Vector3, - - /// Multiplying the character's position by this matrix will move the character as far as it can up to its intended - /// displacement until it hits the wall. - displacement_transform: na::Matrix4, - - collision: Option, -} - -struct Collision { - /// This collision normal faces away from the collision surface and is given in the perspective of the character - /// _after_ it is transformed by `allowed_displacement`. The 4th coordinate of this normal vector is assumed to be - /// 0.0 and is therefore omitted. - normal: na::UnitVector3, -} - -impl CollisionCheckingResult { - /// Return a CollisionCheckingResult with no movement and no collision; useful if the character is not moving - /// and has nothing to check collision against. Also useful as a last resort fallback if an unexpected error occurs. - fn stationary() -> CollisionCheckingResult { - CollisionCheckingResult { - displacement_vector: na::Vector3::zeros(), - displacement_transform: na::Matrix4::identity(), - collision: None, - } - } -} - -#[cfg(test)] -mod tests { - use approx::assert_abs_diff_eq; - - use super::*; - - #[test] - fn apply_normals_internal_examples() { - // Zero vectors (No-op but should not panic) - test_apply_normals_internal(&[], [0.60, -0.85, 0.90], 0.2); - - // One vector - test_apply_normals_internal(&[[-0.48, -0.10, -0.67]], [0.85, -0.53, -0.61], 0.2); - - // Two vectors - test_apply_normals_internal( - &[[-0.17, 0.07, -0.38], [-0.85, 0.19, -0.84]], - [0.19, -0.84, -0.62], - 0.2, - ); - - // Three vectors (Not in use as of the creation of this test but should work anyways) - test_apply_normals_internal( - &[ - [-0.24, 0.90, -0.06], - [-0.91, 0.01, 0.44], - [0.02, -0.65, -0.12], - ], - [0.91, -0.01, -0.61], - 0.2, - ); - } - - fn test_apply_normals_internal(normals: &[[f32; 3]], subject: [f32; 3], distance: f32) { - let normals: Vec> = normals - .iter() - .map(|n| na::UnitVector3::new_normalize(na::Vector3::new(n[0], n[1], n[2]))) - .collect(); - let mut subject = na::Vector3::new(subject[0], subject[1], subject[2]); - - apply_normals_internal(&normals, &mut subject, distance); - for normal in normals { - assert_abs_diff_eq!(subject.dot(&normal), distance, epsilon = 1.0e-5); - } - } -} diff --git a/common/src/character_controller/collision.rs b/common/src/character_controller/collision.rs new file mode 100644 index 00000000..c4249b05 --- /dev/null +++ b/common/src/character_controller/collision.rs @@ -0,0 +1,108 @@ +//! This module is used to encapsulate character collision checking for the character controller + +use tracing::error; + +use crate::{ + graph_collision, math, + node::{ChunkLayout, DualGraph}, + proto::Position, +}; + +/// Checks for collisions when a character moves with a character-relative displacement vector of `relative_displacement`. +pub fn check_collision( + collision_context: &CollisionContext, + position: &Position, + relative_displacement: &na::Vector3, +) -> CollisionCheckingResult { + // Split relative_displacement into its norm and a unit vector + let relative_displacement = relative_displacement.to_homogeneous(); + let displacement_sqr = relative_displacement.norm_squared(); + if displacement_sqr < 1e-16 { + // Fallback for if the displacement vector isn't large enough to reliably be normalized. + // Any value that is sufficiently large compared to f32::MIN_POSITIVE should work as the cutoff. + return CollisionCheckingResult::stationary(); + } + + let displacement_norm = displacement_sqr.sqrt(); + let displacement_normalized = relative_displacement / displacement_norm; + + let ray = graph_collision::Ray::new(math::origin(), displacement_normalized); + let tanh_distance = displacement_norm.tanh(); + + let cast_hit = graph_collision::sphere_cast( + collision_context.radius, + collision_context.graph, + &collision_context.chunk_layout, + position, + &ray, + tanh_distance, + ); + + let cast_hit = match cast_hit { + Ok(r) => r, + Err(e) => { + error!("Collision checking returned {:?}", e); + return CollisionCheckingResult::stationary(); + } + }; + + let distance = cast_hit + .as_ref() + .map_or(tanh_distance, |hit| hit.tanh_distance) + .atanh(); + + let displacement_vector = displacement_normalized.xyz() * distance; + let displacement_transform = math::translate_along(&displacement_vector); + + CollisionCheckingResult { + displacement_vector, + displacement_transform, + collision: cast_hit.map(|hit| Collision { + // `CastEndpoint` has its `normal` given relative to the character's original position, + // but we want the normal relative to the character after the character moves to meet the wall. + // This normal now represents a contact point at the origin, so we omit the w-coordinate + // to ensure that it's orthogonal to the origin. + normal: na::UnitVector3::new_normalize( + (math::mtranspose(&displacement_transform) * hit.normal).xyz(), + ), + }), + } +} + +/// Contains information about the character and the world that is only relevant for collision checking +pub struct CollisionContext<'a> { + pub graph: &'a DualGraph, + pub chunk_layout: ChunkLayout, + pub radius: f32, +} + +pub struct CollisionCheckingResult { + /// The displacement allowed for the character before hitting a wall. The result of + /// `math::translate_along(&displacement_vector)` is `displacement_transform`. + pub displacement_vector: na::Vector3, + + /// Multiplying the character's position by this matrix will move the character as far as it can up to its intended + /// displacement until it hits the wall. + pub displacement_transform: na::Matrix4, + + pub collision: Option, +} + +impl CollisionCheckingResult { + /// Return a CollisionCheckingResult with no movement and no collision; useful if the character is not moving + /// and has nothing to check collision against. Also useful as a last resort fallback if an unexpected error occurs. + pub fn stationary() -> CollisionCheckingResult { + CollisionCheckingResult { + displacement_vector: na::Vector3::zeros(), + displacement_transform: na::Matrix4::identity(), + collision: None, + } + } +} + +pub struct Collision { + /// This collision normal faces away from the collision surface and is given in the perspective of the character + /// _after_ it is transformed by `allowed_displacement`. The 4th coordinate of this normal vector is assumed to be + /// 0.0 and is therefore omitted. + pub normal: na::UnitVector3, +} diff --git a/common/src/character_controller/mod.rs b/common/src/character_controller/mod.rs new file mode 100644 index 00000000..9ed39bfd --- /dev/null +++ b/common/src/character_controller/mod.rs @@ -0,0 +1,231 @@ +mod collision; + +use tracing::warn; + +use crate::{ + character_controller::collision::{check_collision, CollisionContext}, + math, + node::{ChunkLayout, DualGraph}, + proto::{CharacterInput, Position}, + sanitize_motion_input, + sim_config::CharacterConfig, + SimConfig, +}; + +/// Runs a single step of character movement +pub fn run_character_step( + sim_config: &SimConfig, + graph: &DualGraph, + position: &mut Position, + velocity: &mut na::Vector3, + input: &CharacterInput, + dt_seconds: f32, +) { + let ctx = CharacterControllerContext { + cfg: &sim_config.character, + collision_context: CollisionContext { + graph, + chunk_layout: ChunkLayout::new(sim_config.chunk_size as usize), + radius: sim_config.character.character_radius, + }, + dt_seconds, + movement_input: sanitize_motion_input(input.movement), + }; + + if input.no_clip { + run_no_clip_character_step(&ctx, position, velocity); + } else { + run_standard_character_step(&ctx, position, velocity); + } + + // Renormalize + position.local = math::renormalize_isometry(&position.local); + let (next_node, transition_xf) = graph.normalize_transform(position.node, &position.local); + if next_node != position.node { + position.node = next_node; + position.local = transition_xf * position.local; + } +} + +fn run_standard_character_step( + ctx: &CharacterControllerContext, + position: &mut Position, + velocity: &mut na::Vector3, +) { + let old_velocity = *velocity; + + // Update velocity + let current_to_target_velocity = ctx.movement_input * ctx.cfg.max_ground_speed - *velocity; + let max_delta_velocity = ctx.cfg.ground_acceleration * ctx.dt_seconds; + if current_to_target_velocity.norm_squared() > math::sqr(max_delta_velocity) { + *velocity += current_to_target_velocity.normalize() * max_delta_velocity; + } else { + *velocity += current_to_target_velocity; + } + + // Estimate the average velocity by using the average of the old velocity and new velocity, + // which has the effect of modeling a velocity that changes linearly over the timestep. + // This is necessary to avoid the following two issues: + // 1. Input lag, which would occur if only the old velocity was used + // 2. Movement artifacts, which would occur if only the new velocity was used. One + // example of such an artifact is the character moving backwards slightly when they + // stop moving after releasing a direction key. + let estimated_average_velocity = (*velocity + old_velocity) * 0.5; + + apply_velocity(ctx, estimated_average_velocity, position, velocity); +} + +fn run_no_clip_character_step( + ctx: &CharacterControllerContext, + 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), + ); +} + +/// Updates the character's position based on the given average velocity while handling collisions. +/// Also updates the velocity based on collisions that occur. +fn apply_velocity( + ctx: &CharacterControllerContext, + estimated_average_velocity: na::Vector3, + position: &mut Position, + velocity: &mut na::Vector3, +) { + // To prevent an unbounded runtime, we only allow a limited number of collisions to be processed in + // a single step. If the character encounters excessively complex geometry, it is possible to hit this limit, + // in which case further movement processing is delayed until the next time step. + const MAX_COLLISION_ITERATIONS: u32 = 5; + + let mut expected_displacement = estimated_average_velocity * ctx.dt_seconds; + let mut active_normals = Vec::>::with_capacity(3); + + let mut all_collisions_resolved = false; + + for _ in 0..MAX_COLLISION_ITERATIONS { + let collision_result = + check_collision(&ctx.collision_context, position, &expected_displacement); + position.local *= collision_result.displacement_transform; + + if let Some(collision) = collision_result.collision { + // We maintain a list of surface normals that should restrict player movement. We remove normals for + // surfaces the player is pushed away from and add the surface normal of the latest collision. + active_normals.retain(|n| n.dot(&collision.normal) < 0.0); + active_normals.push(collision.normal); + + // Update the expected displacement to whatever is remaining. + expected_displacement -= collision_result.displacement_vector; + apply_normals(&active_normals, &mut expected_displacement); + + // Also update the velocity to ensure that walls kill momentum. + apply_normals(&active_normals, velocity); + } else { + all_collisions_resolved = true; + break; + } + } + + if !all_collisions_resolved { + warn!("A character entity processed too many collisions and collision resolution was cut short."); + } +} + +/// Modifies the `subject` by a linear combination of the `normals` to ensure that it is approximately +/// orthogonal to all the normals. The normals are assumed to be linearly independent, and, assuming the final +/// result is nonzero, a small correction is applied to ensure that the subject is moving away from the surfaces +/// the normals represent even when floating point approximation is involved. +fn apply_normals(normals: &[na::UnitVector3], subject: &mut na::Vector3) { + if normals.len() >= 3 { + // The normals are assumed to be linearly independent, so applying all of them will zero out the subject. + // There is no need to do any extra logic to handle precision limitations in this case. + *subject = na::Vector3::zeros(); + } + + // Corrective term to ensure that normals face away from any potential collision surfaces + const RELATIVE_EPSILON: f32 = 1e-4; + apply_normals_internal(normals, subject, subject.magnitude() * RELATIVE_EPSILON); +} + +/// Modifies the `subject` by a linear combination of the `normals` so that the dot product with each normal is +/// `distance`. The `normals` must be linearly independent for this function to work as expected. +fn apply_normals_internal( + normals: &[na::UnitVector3], + subject: &mut na::Vector3, + distance: f32, +) { + let mut ortho_normals: Vec> = normals.iter().map(|n| n.into_inner()).collect(); + for i in 0..normals.len() { + // Perform the Gram-Schmidt process on `normals` to produce `ortho_normals`. + for j in i + 1..normals.len() { + ortho_normals[j] = (ortho_normals[j] + - ortho_normals[i] * ortho_normals[j].dot(&ortho_normals[i])) + .normalize(); + } + + // The following formula ensures that the dot product of `subject` and `normals[i]` is `distance. + // Because we only move the subject along `ortho_normals[i]`, this adjustment does not affect the + // subject's dot product with any earlier normals. + *subject += ortho_normals[i] + * ((distance - subject.dot(&normals[i])) / ortho_normals[i].dot(&normals[i])); + } +} + +/// Contains all information about a character that the character controller doesn't change during +/// one of its simulation steps +struct CharacterControllerContext<'a> { + collision_context: CollisionContext<'a>, + cfg: &'a CharacterConfig, + dt_seconds: f32, + movement_input: na::Vector3, +} + +#[cfg(test)] +mod tests { + use approx::assert_abs_diff_eq; + + use super::*; + + #[test] + fn apply_normals_internal_examples() { + // Zero vectors (No-op but should not panic) + test_apply_normals_internal(&[], [0.60, -0.85, 0.90], 0.2); + + // One vector + test_apply_normals_internal(&[[-0.48, -0.10, -0.67]], [0.85, -0.53, -0.61], 0.2); + + // Two vectors + test_apply_normals_internal( + &[[-0.17, 0.07, -0.38], [-0.85, 0.19, -0.84]], + [0.19, -0.84, -0.62], + 0.2, + ); + + // Three vectors (Not in use as of the creation of this test but should work anyways) + test_apply_normals_internal( + &[ + [-0.24, 0.90, -0.06], + [-0.91, 0.01, 0.44], + [0.02, -0.65, -0.12], + ], + [0.91, -0.01, -0.61], + 0.2, + ); + } + + fn test_apply_normals_internal(normals: &[[f32; 3]], subject: [f32; 3], distance: f32) { + let normals: Vec> = normals + .iter() + .map(|n| na::UnitVector3::new_normalize(na::Vector3::new(n[0], n[1], n[2]))) + .collect(); + let mut subject = na::Vector3::new(subject[0], subject[1], subject[2]); + + apply_normals_internal(&normals, &mut subject, distance); + for normal in normals { + assert_abs_diff_eq!(subject.dot(&normal), distance, epsilon = 1.0e-5); + } + } +} From 9307aa4e55711a388ac351d756cd3a1b7e8c4613 Mon Sep 17 00:00:00 2001 From: Patrick Owen Date: Sun, 21 May 2023 12:21:42 -0400 Subject: [PATCH 3/6] Replace apply_normals with a more flexible and accurate vector_bounds module --- common/src/character_controller/mod.rs | 132 ++------ .../src/character_controller/vector_bounds.rs | 302 ++++++++++++++++++ common/src/math.rs | 28 ++ 3 files changed, 357 insertions(+), 105 deletions(-) create mode 100644 common/src/character_controller/vector_bounds.rs diff --git a/common/src/character_controller/mod.rs b/common/src/character_controller/mod.rs index 9ed39bfd..01885eb1 100644 --- a/common/src/character_controller/mod.rs +++ b/common/src/character_controller/mod.rs @@ -1,9 +1,13 @@ mod collision; +mod vector_bounds; use tracing::warn; use crate::{ - character_controller::collision::{check_collision, CollisionContext}, + character_controller::{ + collision::{check_collision, CollisionContext}, + vector_bounds::{BoundedVectors, VectorBound}, + }, math, node::{ChunkLayout, DualGraph}, proto::{CharacterInput, Position}, @@ -72,7 +76,12 @@ fn run_standard_character_step( // stop moving after releasing a direction key. let estimated_average_velocity = (*velocity + old_velocity) * 0.5; - apply_velocity(ctx, estimated_average_velocity, position, velocity); + apply_velocity( + ctx, + estimated_average_velocity * ctx.dt_seconds, + position, + velocity, + ); } fn run_no_clip_character_step( @@ -92,7 +101,7 @@ fn run_no_clip_character_step( /// Also updates the velocity based on collisions that occur. fn apply_velocity( ctx: &CharacterControllerContext, - estimated_average_velocity: na::Vector3, + expected_displacement: na::Vector3, position: &mut Position, velocity: &mut na::Vector3, ) { @@ -101,28 +110,26 @@ fn apply_velocity( // in which case further movement processing is delayed until the next time step. const MAX_COLLISION_ITERATIONS: u32 = 5; - let mut expected_displacement = estimated_average_velocity * ctx.dt_seconds; - let mut active_normals = Vec::>::with_capacity(3); + let mut bounded_vectors = BoundedVectors::new(expected_displacement, Some(*velocity)); let mut all_collisions_resolved = false; - for _ in 0..MAX_COLLISION_ITERATIONS { - let collision_result = - check_collision(&ctx.collision_context, position, &expected_displacement); + let collision_result = check_collision( + &ctx.collision_context, + position, + bounded_vectors.displacement(), + ); position.local *= collision_result.displacement_transform; if let Some(collision) = collision_result.collision { - // We maintain a list of surface normals that should restrict player movement. We remove normals for - // surfaces the player is pushed away from and add the surface normal of the latest collision. - active_normals.retain(|n| n.dot(&collision.normal) < 0.0); - active_normals.push(collision.normal); - - // Update the expected displacement to whatever is remaining. - expected_displacement -= collision_result.displacement_vector; - apply_normals(&active_normals, &mut expected_displacement); - - // Also update the velocity to ensure that walls kill momentum. - apply_normals(&active_normals, velocity); + // Update the expected displacement to represent a reduction in the remaining dt + let displacement_reduction_factor = 1.0 + - collision_result.displacement_vector.magnitude() + / bounded_vectors.displacement().magnitude(); + bounded_vectors.scale_displacement(displacement_reduction_factor); + + // Block further movement towards the wall. + bounded_vectors.add_bound(VectorBound::new(collision.normal, collision.normal)); } else { all_collisions_resolved = true; break; @@ -132,46 +139,8 @@ fn apply_velocity( if !all_collisions_resolved { warn!("A character entity processed too many collisions and collision resolution was cut short."); } -} - -/// Modifies the `subject` by a linear combination of the `normals` to ensure that it is approximately -/// orthogonal to all the normals. The normals are assumed to be linearly independent, and, assuming the final -/// result is nonzero, a small correction is applied to ensure that the subject is moving away from the surfaces -/// the normals represent even when floating point approximation is involved. -fn apply_normals(normals: &[na::UnitVector3], subject: &mut na::Vector3) { - if normals.len() >= 3 { - // The normals are assumed to be linearly independent, so applying all of them will zero out the subject. - // There is no need to do any extra logic to handle precision limitations in this case. - *subject = na::Vector3::zeros(); - } - - // Corrective term to ensure that normals face away from any potential collision surfaces - const RELATIVE_EPSILON: f32 = 1e-4; - apply_normals_internal(normals, subject, subject.magnitude() * RELATIVE_EPSILON); -} -/// Modifies the `subject` by a linear combination of the `normals` so that the dot product with each normal is -/// `distance`. The `normals` must be linearly independent for this function to work as expected. -fn apply_normals_internal( - normals: &[na::UnitVector3], - subject: &mut na::Vector3, - distance: f32, -) { - let mut ortho_normals: Vec> = normals.iter().map(|n| n.into_inner()).collect(); - for i in 0..normals.len() { - // Perform the Gram-Schmidt process on `normals` to produce `ortho_normals`. - for j in i + 1..normals.len() { - ortho_normals[j] = (ortho_normals[j] - - ortho_normals[i] * ortho_normals[j].dot(&ortho_normals[i])) - .normalize(); - } - - // The following formula ensures that the dot product of `subject` and `normals[i]` is `distance. - // Because we only move the subject along `ortho_normals[i]`, this adjustment does not affect the - // subject's dot product with any earlier normals. - *subject += ortho_normals[i] - * ((distance - subject.dot(&normals[i])) / ortho_normals[i].dot(&normals[i])); - } + *velocity = *bounded_vectors.velocity().unwrap(); } /// Contains all information about a character that the character controller doesn't change during @@ -182,50 +151,3 @@ struct CharacterControllerContext<'a> { dt_seconds: f32, movement_input: na::Vector3, } - -#[cfg(test)] -mod tests { - use approx::assert_abs_diff_eq; - - use super::*; - - #[test] - fn apply_normals_internal_examples() { - // Zero vectors (No-op but should not panic) - test_apply_normals_internal(&[], [0.60, -0.85, 0.90], 0.2); - - // One vector - test_apply_normals_internal(&[[-0.48, -0.10, -0.67]], [0.85, -0.53, -0.61], 0.2); - - // Two vectors - test_apply_normals_internal( - &[[-0.17, 0.07, -0.38], [-0.85, 0.19, -0.84]], - [0.19, -0.84, -0.62], - 0.2, - ); - - // Three vectors (Not in use as of the creation of this test but should work anyways) - test_apply_normals_internal( - &[ - [-0.24, 0.90, -0.06], - [-0.91, 0.01, 0.44], - [0.02, -0.65, -0.12], - ], - [0.91, -0.01, -0.61], - 0.2, - ); - } - - fn test_apply_normals_internal(normals: &[[f32; 3]], subject: [f32; 3], distance: f32) { - let normals: Vec> = normals - .iter() - .map(|n| na::UnitVector3::new_normalize(na::Vector3::new(n[0], n[1], n[2]))) - .collect(); - let mut subject = na::Vector3::new(subject[0], subject[1], subject[2]); - - apply_normals_internal(&normals, &mut subject, distance); - for normal in normals { - assert_abs_diff_eq!(subject.dot(&normal), distance, epsilon = 1.0e-5); - } - } -} diff --git a/common/src/character_controller/vector_bounds.rs b/common/src/character_controller/vector_bounds.rs new file mode 100644 index 00000000..97b25949 --- /dev/null +++ b/common/src/character_controller/vector_bounds.rs @@ -0,0 +1,302 @@ +//! This module is used to transform vectors to ensure that they fit constraints discovered during collision checking. + +use rand_distr::num_traits::Zero; +use tracing::warn; + +use crate::math; + +/// Encapsulates all the information needed to constrain a vector (displacement) based on a set of `VectorBound`s and apply those +/// same constraints to a secondary vector (velocity). +#[derive(Clone)] +pub struct BoundedVectors { + displacement: na::Vector3, + velocity: Option>, + bounds: Vec, + error_margin: f32, +} + +impl BoundedVectors { + /// Initializes a `BoundedVectors` with an empty list of bounds. The `displacement` is the vector + /// we will apply the bounds to. The size of this vector also determins the error margin + /// to prevent floating point approximation limits from causing phantom collisions. Note that this + /// error margin is not needed if the resulting vector is zero, since no phantom collision can occur + /// if the character is stopped. The `velocity` is a vector that should have similar bounds applied to + /// it as `displacement`, but it is not used to compute which bounds to apply. + pub fn new(displacement: na::Vector3, velocity: Option>) -> Self { + let error_margin = displacement.magnitude() * 1e-4; + + BoundedVectors { + displacement, + velocity, + bounds: vec![], + error_margin, + } + } + + pub fn displacement(&self) -> &na::Vector3 { + &self.displacement + } + + /// Scales the displacement vector without invalidating any of the `VectorBound`s + pub fn scale_displacement(&mut self, scale_factor: f32) { + self.displacement *= scale_factor; + self.error_margin *= scale_factor; + } + + pub fn velocity(&self) -> Option<&na::Vector3> { + self.velocity.as_ref() + } + + /// Constrains `vector` with `new_bound` while keeping the existing constraints satisfied. All projection + /// transformations applied to `vector` are also applied to `tagalong` to allow two vectors to be transformed consistently + /// with each other. + pub fn add_bound(&mut self, new_bound: VectorBound) { + self.apply_bound(&new_bound); + self.bounds.push(new_bound); + } + + /// Helper function to apply a new bound without adding it to any lists. + fn apply_bound(&mut self, new_bound: &VectorBound) { + // There likely isn't a perfect way to get a vector properly constrained with a list of bounds. The main + // difficulty is finding which set of linearly independent bounds need to be applied so that all bounds are + // satisfied. Since bounds are one-sided and not guaranteed to be linearly independent from each other, this + // requires some ad-hoc choices. The algorithm we choose here is to (1) assume that `new_bound` is one of these + // linearly independent bounds, (2) if necessary, pair it up with each existing bound to find the first such + // bound that allows all bounds to be satisfied, and (3) zero out the vector if no such pairing works, as we + // assume that we need to apply three linearly independent bounds. + + // Apply new_bound if necessary. + if !new_bound.check_vector(&self.displacement, self.error_margin) { + new_bound.constrain_vector(&mut self.displacement, self.error_margin); + if let Some(ref mut velocity) = self.velocity { + // Note: The velocity vector does not need an error margin. + new_bound.constrain_vector(velocity, 0.0); + } + } + + // Check if all constraints are satisfied + if (self.bounds.iter()).all(|b| b.check_vector(&self.displacement, self.error_margin)) { + return; + } + + // If not all constraints are satisfied, find the first constraint that if applied will satisfy + // the remaining constriants + for bound in + (self.bounds.iter()).filter(|b| !b.check_vector(&self.displacement, self.error_margin)) + { + let Some(ortho_bound) = bound.get_self_constrained_with_bound(new_bound) else { + warn!("Unsatisfied existing bound is parallel to new bound. Is the character squeezed between two walls?"); + continue; + }; + + let mut candidate = self.displacement; + ortho_bound.constrain_vector(&mut candidate, self.error_margin); + + if (self.bounds.iter()).all(|b| b.check_vector(&candidate, self.error_margin)) { + self.displacement = candidate; + if let Some(ref mut velocity) = self.velocity { + ortho_bound.constrain_vector(velocity, 0.0); + } + return; + } + } + + // If no choice satisfies all constraints, keep all bounds and set the vector to 0 + self.displacement.set_zero(); + if let Some(ref mut velocity) = self.velocity { + velocity.set_zero(); + } + } +} + +/// Represents a single constraint for a vector. `VectorBound`s alone conceptually contain +/// enough information to apply to a vector, but practically, one other piece of information +/// is needed: `error_margin`, which exists in `BoundedVectors`. +#[derive(Clone)] +pub struct VectorBound { + normal: na::UnitVector3, + projection_direction: na::UnitVector3, +} + +impl VectorBound { + /// Creates a `VectorBound` that pushes vectors away from the plane given + /// by the normal in `projection_direction`. After applying such a bound to + /// a vector, its dot product with `normal` should be close to zero but positive + /// even considering floating point error. + pub fn new(normal: na::UnitVector3, projection_direction: na::UnitVector3) -> Self { + VectorBound { + normal, + projection_direction, + } + } + + /// Updates `subject` with a projection transformation based on the constraint given by `self`. + /// This function does not check whether such a constraint is needed. + fn constrain_vector(&self, subject: &mut na::Vector3, error_margin: f32) { + math::project_to_plane( + subject, + &self.normal, + &self.projection_direction, + error_margin, + ); + } + + /// Checks whether `subject` satisfies the constraint given by `self`. Note that `check_vector` will + /// return `true` after a vector is constrained by `constrain_vector` with the same error margin, even + /// if it's perturbed slightly. However, that property only holds if the error margin is not too small. + fn check_vector(&self, subject: &na::Vector3, error_margin: f32) -> bool { + if subject.is_zero() { + return true; + } + + // An additional margin of error is needed when the bound is checked to ensure that an + // applied bound always passes the check. Ostensibly, for an applied bound, the dot + // product is equal to the error margin. + + // Using 0.5 here should ensure that the check will pass after the bound is applied, and it will fail if the + // dot product is too close to zero to guarantee that it won't be treated as negative during collision checking + subject.dot(&self.normal) >= error_margin * 0.5 + } + + /// Returns a `VectorBound` that is an altered version of `self` so that it no longer interferes + /// with `bound`. This is achieved by altering the projection direction by a factor of + /// `bound`'s projection direction to be orthogonal to `bound`'s normal. If this is not + /// possible, returns `None`. + fn get_self_constrained_with_bound(&self, bound: &VectorBound) -> Option { + let mut ortho_bound_projection_direction = self.projection_direction.into_inner(); + math::project_to_plane( + &mut ortho_bound_projection_direction, + &bound.normal, + &bound.projection_direction, + 0.0, + ); + + na::UnitVector3::try_new(ortho_bound_projection_direction, 1e-5).map(|d| VectorBound { + normal: self.normal, + projection_direction: d, + }) + } +} + +#[cfg(test)] +mod tests { + use approx::assert_abs_diff_eq; + + use super::*; + + #[test] + fn vector_bound_group_example() { + let mut bounded_vector = BoundedVectors::new(na::Vector3::new(-4.0, -3.0, 1.0), None); + + // Add a bunch of bounds that are achievable with nonzero vectors + bounded_vector.add_bound(VectorBound::new( + unit_vector(1.0, 3.0, 4.0), + unit_vector(1.0, 2.0, 2.0), + )); + + assert_ne!(bounded_vector.displacement, na::Vector3::zero()); + assert_bounds_achieved(&bounded_vector); + + bounded_vector.add_bound(VectorBound::new( + unit_vector(2.0, -3.0, -4.0), + unit_vector(1.0, -2.0, -1.0), + )); + + assert_ne!(bounded_vector.displacement, na::Vector3::zero()); + assert_bounds_achieved(&bounded_vector); + + bounded_vector.add_bound(VectorBound::new( + unit_vector(2.0, -3.0, -5.0), + unit_vector(1.0, -2.0, -2.0), + )); + + assert_ne!(bounded_vector.displacement, na::Vector3::zero()); + assert_bounds_achieved(&bounded_vector); + + // Finally, add a bound that overconstrains the system + bounded_vector.add_bound(VectorBound::new( + unit_vector(-3.0, 3.0, -2.0), + unit_vector(-3.0, 3.0, -2.0), + )); + + // Using assert_eq instead of assert_ne here + assert_eq!(bounded_vector.displacement, na::Vector3::zero()); + // Special logic allows bounds checking to work with the zero vector + assert_bounds_achieved(&bounded_vector); + } + + #[test] + fn constrain_vector_example() { + let normal = unit_vector(1.0, 3.0, 4.0); + let projection_direction = unit_vector(1.0, 2.0, 2.0); + let error_margin = 1e-4; + let bound = VectorBound::new(normal, projection_direction); + + let initial_vector = na::Vector3::new(-4.0, -3.0, 1.0); + + assert!(!bound.check_vector(&initial_vector, error_margin)); + + let mut constrined_vector = initial_vector; + bound.constrain_vector(&mut constrined_vector, error_margin); + + assert!(bound.check_vector(&constrined_vector, error_margin)); + assert_collinear( + constrined_vector - initial_vector, + projection_direction.into_inner(), + 1e-5, + ); + } + + #[test] + fn get_self_constrained_with_bound_example() { + // For simplicity, we test with an error margin of 0. + let normal0 = unit_vector(1.0, 3.0, 4.0); + let projection_direction0 = unit_vector(1.0, 2.0, 2.0); + + let normal1 = unit_vector(1.0, -4.0, 3.0); + let projection_direction1 = unit_vector(1.0, -2.0, 1.0); + + let bound0 = VectorBound::new(normal0, projection_direction0); + let bound1 = VectorBound::new(normal1, projection_direction1); + + let initial_vector = na::Vector3::new(2.0, -1.0, -3.0); + let mut constrained_vector = initial_vector; + bound0.constrain_vector(&mut constrained_vector, 0.0); + + let ortho_bound1 = bound1.get_self_constrained_with_bound(&bound0).unwrap(); + ortho_bound1.constrain_vector(&mut constrained_vector, 0.0); + + // Check that the constrained vector is on the intersection between the two bound planes + assert_abs_diff_eq!(constrained_vector.dot(&normal0), 0.0, epsilon = 1e-5); + assert_abs_diff_eq!(constrained_vector.dot(&normal1), 0.0, epsilon = 1e-5); + + // Check that the delta of the constrained vector is a linear combination of the projection directions. + // To do this, we check whether the vector is orthogonal to the normal of the plane produced by the two + // projection directions. + assert_abs_diff_eq!( + (constrained_vector - initial_vector) + .dot(&projection_direction0.cross(&projection_direction1)), + 0.0, + epsilon = 1e-5 + ); + } + + fn assert_bounds_achieved(bounds: &BoundedVectors) { + for bound in &bounds.bounds { + assert!(bound.check_vector(&bounds.displacement, bounds.error_margin)); + } + } + + fn assert_collinear(v0: na::Vector3, v1: na::Vector3, epsilon: f32) { + assert_abs_diff_eq!( + v0.normalize(), + v1.normalize() * (v0.dot(&v1)).signum(), + epsilon = epsilon + ); + } + + /// Unit vector + fn unit_vector(x: f32, y: f32, z: f32) -> na::UnitVector3 { + na::UnitVector3::new_normalize(na::Vector3::new(x, y, z)) + } +} diff --git a/common/src/math.rs b/common/src/math.rs index 65a76d32..c49de725 100644 --- a/common/src/math.rs +++ b/common/src/math.rs @@ -136,6 +136,22 @@ pub fn mtranspose(m: &na::Matrix4) -> na::Matrix4 { ) } +/// Updates `subject` by moving it along the line determined by `projection_direction` so that +/// its dot product with `normal` is `distance`. This effectively projects vectors onto the plane +/// `distance` units away from the origin with normal `normal`. The projection is non-orthogonal in +/// general, only orthogonal when `normal` is equal to `projection_direction`. +/// +/// Precondition: For this to be possible, `projection_direction` cannot be orthogonal to `normal`. +pub fn project_to_plane( + subject: &mut na::Vector3, + normal: &na::UnitVector3, + projection_direction: &na::UnitVector3, + distance: N, +) { + *subject += projection_direction.as_ref() + * ((distance - subject.dot(normal)) / projection_direction.dot(normal)); +} + fn minkowski_outer_product( a: &na::Vector4, b: &na::Vector4, @@ -281,4 +297,16 @@ mod tests { epsilon = 1e-5 ); } + + #[test] + fn project_to_plane_example() { + let distance = 4.0; + let projection_direction: na::UnitVector3 = + na::UnitVector3::new_normalize(na::Vector3::new(3.0, -2.0, 7.0)); + let normal: na::UnitVector3 = + na::UnitVector3::new_normalize(na::Vector3::new(3.0, -2.0, 7.0)); + let mut subject = na::Vector3::new(-6.0, -3.0, 4.0); + project_to_plane(&mut subject, &normal, &projection_direction, distance); + assert_abs_diff_eq!(normal.dot(&subject), distance, epsilon = 1.0e-5); + } } From a9930fd9500a8c638e88d050494184326744eca0 Mon Sep 17 00:00:00 2001 From: Patrick Owen Date: Sun, 21 May 2023 12:32:38 -0400 Subject: [PATCH 4/6] 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 01885eb1..65eae136 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 { From 6a5a2d417019144d42078c9dfac4c5b98201bdf0 Mon Sep 17 00:00:00 2001 From: Patrick Owen Date: Sun, 21 May 2023 12:45:56 -0400 Subject: [PATCH 5/6] Add character controller physics --- client/src/graphics/window.rs | 10 ++ client/src/local_character_controller.rs | 25 +++- client/src/prediction.rs | 15 ++- client/src/sim.rs | 42 +++++- common/src/character_controller/mod.rs | 162 +++++++++++++++++++++-- common/src/proto.rs | 2 + common/src/sim_config.rs | 29 ++++ server/src/sim.rs | 7 +- 8 files changed, 270 insertions(+), 22 deletions(-) diff --git a/client/src/graphics/window.rs b/client/src/graphics/window.rs index 4c1064c4..55e461b1 100644 --- a/client/src/graphics/window.rs +++ b/client/src/graphics/window.rs @@ -120,6 +120,7 @@ impl Window { let mut right = false; let mut up = false; let mut down = false; + let mut jump = false; let mut clockwise = false; let mut anticlockwise = false; let mut last_frame = Instant::now(); @@ -141,6 +142,7 @@ impl Window { up as u8 as f32 - down as u8 as f32, back as u8 as f32 - forward as u8 as f32, )); + sim.set_jump_held(jump); sim.look( 0.0, @@ -225,6 +227,14 @@ impl Window { VirtualKeyCode::F => { down = state == ElementState::Pressed; } + VirtualKeyCode::Space => { + if let Some(sim) = self.sim.as_mut() { + if !jump && state == ElementState::Pressed { + sim.set_jump_pressed_true(); + } + jump = state == ElementState::Pressed; + } + } VirtualKeyCode::V if state == ElementState::Pressed => { if let Some(sim) = self.sim.as_mut() { sim.toggle_no_clip(); diff --git a/client/src/local_character_controller.rs b/client/src/local_character_controller.rs index a8b8c16b..a4197d7a 100644 --- a/client/src/local_character_controller.rs +++ b/client/src/local_character_controller.rs @@ -122,6 +122,24 @@ impl LocalCharacterController { } } + /// Returns an orientation quaternion that is as faithful as possible to the current orientation quaternion + /// while being restricted to ensuring the view is level and does not look up or down. This function's main + /// purpose is to figure out what direction the character should go when a movement key is pressed. + pub fn horizontal_orientation(&mut self) -> na::UnitQuaternion { + // Get orientation-relative up + let up = self.orientation.inverse() * self.up; + + let forward = if up.x.abs() < 0.9 { + // Rotate the local forward vector about the locally horizontal axis until it is horizontal + na::Vector3::new(0.0, -up.z, up.y) + } else { + // Project the local forward vector to the level plane + na::Vector3::z() - up.into_inner() * up.z + }; + + self.orientation * na::UnitQuaternion::face_towards(&forward, &up) + } + pub fn renormalize_orientation(&mut self) { self.orientation.renormalize_fast(); } @@ -156,7 +174,7 @@ mod tests { } #[test] - fn look_level_examples() { + fn look_level_and_horizontal_orientation_examples() { let mut subject = LocalCharacterController::new(); // Pick an arbitrary orientation @@ -172,6 +190,7 @@ mod tests { // Sanity check that the setup makes sense assert_aligned_to_gravity(&subject); assert_yaw_and_pitch_correct(base_orientation, yaw, pitch, subject.orientation); + assert_yaw_and_pitch_correct(base_orientation, yaw, 0.0, subject.horizontal_orientation()); // Standard look_level expression subject.look_level(0.5, -0.4); @@ -179,6 +198,7 @@ mod tests { pitch -= 0.4; assert_aligned_to_gravity(&subject); assert_yaw_and_pitch_correct(base_orientation, yaw, pitch, subject.orientation); + assert_yaw_and_pitch_correct(base_orientation, yaw, 0.0, subject.horizontal_orientation()); // Look up past the cap subject.look_level(-0.2, 3.0); @@ -186,6 +206,7 @@ mod tests { pitch = std::f32::consts::FRAC_PI_2; assert_aligned_to_gravity(&subject); assert_yaw_and_pitch_correct(base_orientation, yaw, pitch, subject.orientation); + assert_yaw_and_pitch_correct(base_orientation, yaw, 0.0, subject.horizontal_orientation()); // Look down past the cap subject.look_level(6.2, -7.2); @@ -193,6 +214,7 @@ mod tests { pitch = -std::f32::consts::FRAC_PI_2; assert_aligned_to_gravity(&subject); assert_yaw_and_pitch_correct(base_orientation, yaw, pitch, subject.orientation); + assert_yaw_and_pitch_correct(base_orientation, yaw, 0.0, subject.horizontal_orientation()); // Go back to a less unusual orientation subject.look_level(-1.2, 0.8); @@ -200,6 +222,7 @@ mod tests { pitch += 0.8; assert_aligned_to_gravity(&subject); assert_yaw_and_pitch_correct(base_orientation, yaw, pitch, subject.orientation); + assert_yaw_and_pitch_correct(base_orientation, yaw, 0.0, subject.horizontal_orientation()); } #[test] diff --git a/client/src/prediction.rs b/client/src/prediction.rs index a5011f7b..87ddcdcd 100644 --- a/client/src/prediction.rs +++ b/client/src/prediction.rs @@ -19,6 +19,7 @@ pub struct PredictedMotion { generation: u16, predicted_position: Position, predicted_velocity: na::Vector3, + predicted_on_ground: bool, } impl PredictedMotion { @@ -28,6 +29,7 @@ impl PredictedMotion { generation: 0, predicted_position: initial_position, predicted_velocity: na::Vector3::zeros(), + predicted_on_ground: false, } } @@ -39,6 +41,7 @@ impl PredictedMotion { graph, &mut self.predicted_position, &mut self.predicted_velocity, + &mut self.predicted_on_ground, input, cfg.step_interval.as_secs_f32(), ); @@ -55,6 +58,7 @@ impl PredictedMotion { generation: u16, position: Position, velocity: na::Vector3, + on_ground: bool, ) { let first_gen = self.generation.wrapping_sub(self.log.len() as u16); let obsolete = usize::from(generation.wrapping_sub(first_gen)); @@ -65,6 +69,7 @@ impl PredictedMotion { self.log.drain(..obsolete); self.predicted_position = position; self.predicted_velocity = velocity; + self.predicted_on_ground = on_ground; for input in self.log.iter() { character_controller::run_character_step( @@ -72,6 +77,7 @@ impl PredictedMotion { graph, &mut self.predicted_position, &mut self.predicted_velocity, + &mut self.predicted_on_ground, input, cfg.step_interval.as_secs_f32(), ); @@ -86,6 +92,10 @@ impl PredictedMotion { pub fn predicted_velocity(&self) -> &na::Vector3 { &self.predicted_velocity } + + pub fn predicted_on_ground(&self) -> &bool { + &self.predicted_on_ground + } } #[cfg(test)] @@ -103,9 +113,11 @@ mod tests { #[test] fn wraparound() { let mock_cfg = SimConfig::from_raw(&common::SimConfigRaw::default()); - let mock_graph = DualGraph::new(); + let mut mock_graph = DualGraph::new(); + common::node::populate_fresh_nodes(&mut mock_graph); let mock_character_input = CharacterInput { movement: na::Vector3::x(), + jump: false, no_clip: true, }; @@ -121,6 +133,7 @@ mod tests { generation, pos(), na::Vector3::zeros(), + false, ) }; diff --git a/client/src/sim.rs b/client/src/sim.rs index 4dc5cc30..9dae2f2b 100644 --- a/client/src/sim.rs +++ b/client/src/sim.rs @@ -41,6 +41,12 @@ pub struct Sim { no_clip: bool, /// Whether no_clip will be toggled next step toggle_no_clip: bool, + /// Whether the current step starts with a jump + is_jumping: bool, + /// Whether the jump button has been pressed since the last step + jump_pressed: bool, + /// Whether the jump button is currently held down + jump_held: bool, prediction: PredictedMotion, local_character_controller: LocalCharacterController, } @@ -64,6 +70,9 @@ impl Sim { average_movement_input: na::zero(), no_clip: true, toggle_no_clip: false, + is_jumping: false, + jump_pressed: false, + jump_held: false, prediction: PredictedMotion::new(proto::Position { node: NodeId::ROOT, local: na::one(), @@ -102,6 +111,15 @@ impl Sim { self.toggle_no_clip = true; } + pub fn set_jump_held(&mut self, jump_held: bool) { + self.jump_held = jump_held; + self.jump_pressed = jump_held || self.jump_pressed; + } + + pub fn set_jump_pressed_true(&mut self) { + self.jump_pressed = true; + } + pub fn cfg(&self) -> &SimConfig { &self.cfg } @@ -130,6 +148,9 @@ impl Sim { self.toggle_no_clip = false; } + self.is_jumping = self.jump_held || self.jump_pressed; + self.jump_pressed = false; + // Reset state for the next step if overflow > step_interval { // If it's been more than two timesteps since we last sent input, skip ahead @@ -233,6 +254,7 @@ impl Sim { latest_input, *pos, ch.state.velocity, + ch.state.on_ground, ); } @@ -292,10 +314,14 @@ impl Sim { } fn send_input(&mut self, net: &mut Net) { + let orientation = if self.no_clip { + self.local_character_controller.orientation() + } else { + self.local_character_controller.horizontal_orientation() + }; let character_input = CharacterInput { - movement: sanitize_motion_input( - self.local_character_controller.orientation() * self.average_movement_input, - ), + movement: sanitize_motion_input(orientation * self.average_movement_input), + jump: self.is_jumping, no_clip: self.no_clip, }; let generation = self @@ -313,14 +339,21 @@ impl Sim { fn update_view_position(&mut self) { let mut view_position = *self.prediction.predicted_position(); let mut view_velocity = *self.prediction.predicted_velocity(); + let mut view_on_ground = *self.prediction.predicted_on_ground(); + let orientation = if self.no_clip { + self.local_character_controller.orientation() + } else { + self.local_character_controller.horizontal_orientation() + }; // 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.local_character_controller.orientation() * self.average_movement_input + movement: orientation * self.average_movement_input / (self.since_input_sent.as_secs_f32() / self.cfg.step_interval.as_secs_f32()), + jump: self.is_jumping, no_clip: self.no_clip, }; character_controller::run_character_step( @@ -328,6 +361,7 @@ impl Sim { &self.graph, &mut view_position, &mut view_velocity, + &mut view_on_ground, &predicted_input, self.since_input_sent.as_secs_f32(), ); diff --git a/common/src/character_controller/mod.rs b/common/src/character_controller/mod.rs index 65eae136..9b555194 100644 --- a/common/src/character_controller/mod.rs +++ b/common/src/character_controller/mod.rs @@ -5,7 +5,7 @@ use tracing::warn; use crate::{ character_controller::{ - collision::{check_collision, CollisionContext}, + collision::{check_collision, Collision, CollisionContext}, vector_bounds::{BoundedVectors, VectorBound}, }, math, @@ -22,6 +22,7 @@ pub fn run_character_step( graph: &DualGraph, position: &mut Position, velocity: &mut na::Vector3, + on_ground: &mut bool, input: &CharacterInput, dt_seconds: f32, ) { @@ -32,14 +33,16 @@ pub fn run_character_step( chunk_layout: ChunkLayout::new(sim_config.chunk_size as usize), radius: sim_config.character.character_radius, }, + up: graph.get_relative_up(position).unwrap(), dt_seconds, movement_input: sanitize_motion_input(input.movement), + jump_input: input.jump, }; if input.no_clip { - run_no_clip_character_step(&ctx, position, velocity); + run_no_clip_character_step(&ctx, position, velocity, on_ground); } else { - run_standard_character_step(&ctx, position, velocity); + run_standard_character_step(&ctx, position, velocity, on_ground); } // Renormalize @@ -55,18 +58,38 @@ fn run_standard_character_step( ctx: &CharacterControllerContext, position: &mut Position, velocity: &mut na::Vector3, + on_ground: &mut bool, ) { + let mut ground_normal = None; + if *on_ground { + ground_normal = get_ground_normal(ctx, position); + } + + // Handle jumping + if ctx.jump_input && ground_normal.is_some() { + let horizontal_velocity = *velocity - *ctx.up * ctx.up.dot(velocity); + *velocity = horizontal_velocity + *ctx.up * ctx.cfg.jump_speed; + ground_normal = None; + } + let old_velocity = *velocity; // Update velocity - let current_to_target_velocity = ctx.movement_input * ctx.cfg.max_ground_speed - *velocity; - let max_delta_velocity = ctx.cfg.ground_acceleration * ctx.dt_seconds; - if current_to_target_velocity.norm_squared() > math::sqr(max_delta_velocity) { - *velocity += current_to_target_velocity.normalize() * max_delta_velocity; + if let Some(ground_normal) = ground_normal { + apply_ground_controls(ctx, &ground_normal, velocity); } else { - *velocity += current_to_target_velocity; + apply_air_controls(ctx, velocity); + + // Apply air resistance + *velocity *= (-ctx.cfg.air_resistance * ctx.dt_seconds).exp(); } + // Apply gravity + *velocity -= *ctx.up * ctx.cfg.gravity_acceleration * ctx.dt_seconds; + + // Apply speed cap + *velocity = velocity.cap_magnitude(ctx.cfg.speed_cap); + // Estimate the average velocity by using the average of the old velocity and new velocity, // which has the effect of modeling a velocity that changes linearly over the timestep. // This is necessary to avoid the following two issues: @@ -74,37 +97,128 @@ fn run_standard_character_step( // 2. Movement artifacts, which would occur if only the new velocity was used. One // example of such an artifact is the character moving backwards slightly when they // stop moving after releasing a direction key. - let estimated_average_velocity = (*velocity + old_velocity) * 0.5; + let average_velocity = (*velocity + old_velocity) * 0.5; + // Handle actual movement apply_velocity( ctx, - estimated_average_velocity * ctx.dt_seconds, + average_velocity * ctx.dt_seconds, position, velocity, + &mut ground_normal, ); + + *on_ground = ground_normal.is_some(); } fn run_no_clip_character_step( ctx: &CharacterControllerContext, position: &mut Position, velocity: &mut na::Vector3, + on_ground: &mut bool, ) { *velocity = ctx.movement_input * ctx.cfg.no_clip_movement_speed; + *on_ground = false; position.local *= math::translate_along(&(*velocity * ctx.dt_seconds)); } +/// Returns the normal corresponding to the ground below the character, up to the `allowed_distance`. If +/// no such ground exists, returns `None`. +fn get_ground_normal( + ctx: &CharacterControllerContext, + position: &Position, +) -> Option> { + // Since the character can be at a corner between a slanted wall and the ground, the first collision + // directly below the character is not guaranteed to be part of the ground regardless of whether the + // character is on the ground. To handle this, we repeatedly redirect the direction we search to be + // parallel to walls we collide with to ensure that we find the ground if is indeed below the character. + const MAX_COLLISION_ITERATIONS: u32 = 6; + let mut allowed_displacement = BoundedVectors::new( + -ctx.up.into_inner() * ctx.cfg.ground_distance_tolerance, + None, + ); + + for _ in 0..MAX_COLLISION_ITERATIONS { + let collision_result = check_collision( + &ctx.collision_context, + position, + allowed_displacement.displacement(), + ); + if let Some(collision) = collision_result.collision.as_ref() { + if is_ground(ctx, &collision.normal) { + // We found the ground, so return its normal. + return Some(collision.normal); + } + allowed_displacement.add_bound(VectorBound::new(collision.normal, collision.normal)); + } else { + // Return `None` if we travel the whole `allowed_displacement` and don't find the ground. + return None; + } + } + // Return `None` if we fail to find the ground after the maximum number of attempts + None +} + +/// Checks whether the given normal is flat enough to be considered part of the ground +fn is_ground(ctx: &CharacterControllerContext, normal: &na::UnitVector3) -> bool { + let min_slope_up_component = 1.0 / (ctx.cfg.max_ground_slope.powi(2) + 1.0).sqrt(); + normal.dot(&ctx.up) > min_slope_up_component +} + +/// Updates the velocity based on user input assuming the character is on the ground +fn apply_ground_controls( + ctx: &CharacterControllerContext, + ground_normal: &na::UnitVector3, + velocity: &mut na::Vector3, +) { + // Set `target_ground_velocity` to have a consistent magnitude regardless + // of the movement direction, but ensure that the horizontal direction matches + // the horizontal direction of the intended movement direction. + let movement_norm = ctx.movement_input.norm(); + let target_ground_velocity = if movement_norm < 1e-16 { + na::Vector3::zeros() + } else { + let mut unit_movement = ctx.movement_input / movement_norm; + math::project_to_plane(&mut unit_movement, ground_normal, &ctx.up, 0.0); + unit_movement.try_normalize_mut(1e-16); + unit_movement * movement_norm * ctx.cfg.max_ground_speed + }; + + // Set `ground_velocity` to be the current velocity's ground-parallel component, + // using a basis that contains the up vector to ensure that the result is unaffected + // by gravity. + let mut ground_velocity = *velocity; + math::project_to_plane(&mut ground_velocity, ground_normal, &ctx.up, 0.0); + + // Adjust the ground-parallel component of the velocity vector to be closer to the + // target velocity. + let current_to_target_velocity = target_ground_velocity - ground_velocity; + let max_delta_velocity = ctx.cfg.ground_acceleration * ctx.dt_seconds; + if current_to_target_velocity.norm_squared() > max_delta_velocity.powi(2) { + *velocity += current_to_target_velocity.normalize() * max_delta_velocity; + } else { + *velocity += current_to_target_velocity; + } +} + +/// Updates the velocity based on user input assuming the character is in the air +fn apply_air_controls(ctx: &CharacterControllerContext, velocity: &mut na::Vector3) { + *velocity += ctx.movement_input * ctx.cfg.air_acceleration * ctx.dt_seconds; +} + /// Updates the character's position based on the given average velocity while handling collisions. -/// Also updates the velocity based on collisions that occur. +/// Also updates the velocity and ground normal based on collisions that occur. fn apply_velocity( ctx: &CharacterControllerContext, expected_displacement: na::Vector3, position: &mut Position, velocity: &mut na::Vector3, + ground_normal: &mut Option>, ) { // To prevent an unbounded runtime, we only allow a limited number of collisions to be processed in // a single step. If the character encounters excessively complex geometry, it is possible to hit this limit, // in which case further movement processing is delayed until the next time step. - const MAX_COLLISION_ITERATIONS: u32 = 5; + const MAX_COLLISION_ITERATIONS: u32 = 6; let mut bounded_vectors = BoundedVectors::new(expected_displacement, Some(*velocity)); @@ -124,8 +238,7 @@ fn apply_velocity( / bounded_vectors.displacement().magnitude(); bounded_vectors.scale_displacement(displacement_reduction_factor); - // Block further movement towards the wall. - bounded_vectors.add_bound(VectorBound::new(collision.normal, collision.normal)); + handle_collision(ctx, collision, &mut bounded_vectors, ground_normal); } else { all_collisions_resolved = true; break; @@ -139,11 +252,32 @@ fn apply_velocity( *velocity = *bounded_vectors.velocity().unwrap(); } +/// Updates character information based on the results of a single collision +fn handle_collision( + ctx: &CharacterControllerContext, + collision: Collision, + bounded_vectors: &mut BoundedVectors, + ground_normal: &mut Option>, +) { + // Collisions are divided into two categories: Ground collisions and wall collisions. + // Ground collisions will only affect vertical movement of the character, while wall collisions will + // push the character away from the wall in a perpendicular direction. + if is_ground(ctx, &collision.normal) { + bounded_vectors.add_bound(VectorBound::new(collision.normal, ctx.up)); + + *ground_normal = Some(collision.normal); + } else { + bounded_vectors.add_bound(VectorBound::new(collision.normal, collision.normal)); + } +} + /// Contains all information about a character that the character controller doesn't change during /// one of its simulation steps struct CharacterControllerContext<'a> { collision_context: CollisionContext<'a>, + up: na::UnitVector3, cfg: &'a CharacterConfig, dt_seconds: f32, movement_input: na::Vector3, + jump_input: bool, } diff --git a/common/src/proto.rs b/common/src/proto.rs index df701969..e1dd0002 100644 --- a/common/src/proto.rs +++ b/common/src/proto.rs @@ -40,6 +40,7 @@ pub struct StateDelta { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CharacterState { pub velocity: na::Vector3, + pub on_ground: bool, pub orientation: na::UnitQuaternion, } @@ -62,6 +63,7 @@ pub struct Command { pub struct CharacterInput { /// Relative to the character's current position, excluding orientation pub movement: na::Vector3, + pub jump: bool, pub no_clip: bool, } diff --git a/common/src/sim_config.rs b/common/src/sim_config.rs index 72101968..2c60567e 100644 --- a/common/src/sim_config.rs +++ b/common/src/sim_config.rs @@ -75,8 +75,22 @@ pub struct CharacterConfigRaw { pub no_clip_movement_speed: Option, /// Character maximumum movement speed while on the ground in m/s pub max_ground_speed: Option, + /// Character artificial speed cap to avoid overloading the server in m/s + pub speed_cap: Option, + /// Maximum ground slope (0=horizontal, 1=45 degrees) + pub max_ground_slope: Option, /// Character acceleration while on the ground in m/s^2 pub ground_acceleration: Option, + /// Character acceleration while in the air in m/s^2 + pub air_acceleration: Option, + /// Acceleration of gravity in m/s^2 + pub gravity_acceleration: Option, + /// Air resistance in (m/s^2) per (m/s); scales linearly with respect to speed + pub air_resistance: Option, + /// How fast the player jumps off the ground in m/s + pub jump_speed: Option, + /// How far away the player needs to be from the ground in meters to be considered in the air + pub ground_distance_tolerance: Option, /// Radius of the character in meters pub character_radius: Option, } @@ -86,7 +100,14 @@ pub struct CharacterConfigRaw { pub struct CharacterConfig { pub no_clip_movement_speed: f32, pub max_ground_speed: f32, + pub speed_cap: f32, + pub max_ground_slope: f32, pub ground_acceleration: f32, + pub air_acceleration: f32, + pub gravity_acceleration: f32, + pub air_resistance: f32, + pub jump_speed: f32, + pub ground_distance_tolerance: f32, pub character_radius: f32, } @@ -95,7 +116,15 @@ impl CharacterConfig { CharacterConfig { no_clip_movement_speed: x.no_clip_movement_speed.unwrap_or(12.0) * meters_to_absolute, max_ground_speed: x.max_ground_speed.unwrap_or(4.0) * meters_to_absolute, + speed_cap: x.speed_cap.unwrap_or(30.0) * meters_to_absolute, + max_ground_slope: x.max_ground_slope.unwrap_or(1.73), // 60 degrees ground_acceleration: x.ground_acceleration.unwrap_or(20.0) * meters_to_absolute, + air_acceleration: x.air_acceleration.unwrap_or(2.0) * meters_to_absolute, + gravity_acceleration: x.gravity_acceleration.unwrap_or(20.0) * meters_to_absolute, + air_resistance: x.air_resistance.unwrap_or(0.2), + jump_speed: x.jump_speed.unwrap_or(8.0) * meters_to_absolute, + ground_distance_tolerance: x.ground_distance_tolerance.unwrap_or(0.2) + * meters_to_absolute, character_radius: x.character_radius.unwrap_or(0.4) * meters_to_absolute, } } diff --git a/server/src/sim.rs b/server/src/sim.rs index 1f4e8dc9..3d267212 100644 --- a/server/src/sim.rs +++ b/server/src/sim.rs @@ -136,10 +136,12 @@ impl Sim { state: CharacterState { orientation: na::one(), velocity: na::Vector3::zeros(), + on_ground: false, }, }; let initial_input = CharacterInput { movement: na::Vector3::zeros(), + jump: false, no_clip: true, }; let entity = self.world.spawn((id, position, character, initial_input)); @@ -204,6 +206,7 @@ impl Sim { &self.graph, position, &mut character.state.velocity, + &mut character.state.on_ground, input, self.cfg.step_interval.as_secs_f32(), ); @@ -246,10 +249,10 @@ impl Sim { // We want to load all chunks that a player can interact with in a single step, so chunk_generation_distance // is set up to cover that distance. - // TODO: Use actual max speed instead of max ground speed. let chunk_generation_distance = dodeca::BOUNDING_SPHERE_RADIUS + self.cfg.character.character_radius as f64 - + self.cfg.character.max_ground_speed as f64 * self.cfg.step_interval.as_secs_f64() + + self.cfg.character.speed_cap as f64 * self.cfg.step_interval.as_secs_f64() + + self.cfg.character.ground_distance_tolerance as f64 + 0.001; // Load all chunks around entities corresponding to clients, which correspond to entities From f0f223b667bee82e52baa19bbb01a693bc23bfeb Mon Sep 17 00:00:00 2001 From: Patrick Owen Date: Sun, 21 May 2023 12:48:51 -0400 Subject: [PATCH 6/6] Add logic to prevent slanted walls from lifting the player up --- common/src/character_controller/mod.rs | 61 +++++++++++++++-- .../src/character_controller/vector_bounds.rs | 68 +++++++++++++++---- 2 files changed, 111 insertions(+), 18 deletions(-) diff --git a/common/src/character_controller/mod.rs b/common/src/character_controller/mod.rs index 9b555194..6781b68d 100644 --- a/common/src/character_controller/mod.rs +++ b/common/src/character_controller/mod.rs @@ -1,6 +1,8 @@ mod collision; mod vector_bounds; +use std::mem::replace; + use tracing::warn; use crate::{ @@ -149,7 +151,11 @@ fn get_ground_normal( // We found the ground, so return its normal. return Some(collision.normal); } - allowed_displacement.add_bound(VectorBound::new(collision.normal, collision.normal)); + allowed_displacement.add_bound(VectorBound::new( + collision.normal, + collision.normal, + true, + )); } else { // Return `None` if we travel the whole `allowed_displacement` and don't find the ground. return None; @@ -221,6 +227,9 @@ fn apply_velocity( const MAX_COLLISION_ITERATIONS: u32 = 6; let mut bounded_vectors = BoundedVectors::new(expected_displacement, Some(*velocity)); + let mut bounded_vectors_without_collisions = bounded_vectors.clone(); + + let mut ground_collision_handled = false; let mut all_collisions_resolved = false; for _ in 0..MAX_COLLISION_ITERATIONS { @@ -237,8 +246,16 @@ fn apply_velocity( - collision_result.displacement_vector.magnitude() / bounded_vectors.displacement().magnitude(); bounded_vectors.scale_displacement(displacement_reduction_factor); - - handle_collision(ctx, collision, &mut bounded_vectors, ground_normal); + bounded_vectors_without_collisions.scale_displacement(displacement_reduction_factor); + + handle_collision( + ctx, + collision, + &bounded_vectors_without_collisions, + &mut bounded_vectors, + ground_normal, + &mut ground_collision_handled, + ); } else { all_collisions_resolved = true; break; @@ -256,18 +273,50 @@ fn apply_velocity( fn handle_collision( ctx: &CharacterControllerContext, collision: Collision, + bounded_vectors_without_collisions: &BoundedVectors, bounded_vectors: &mut BoundedVectors, ground_normal: &mut Option>, + ground_collision_handled: &mut bool, ) { // Collisions are divided into two categories: Ground collisions and wall collisions. // Ground collisions will only affect vertical movement of the character, while wall collisions will - // push the character away from the wall in a perpendicular direction. + // push the character away from the wall in a perpendicular direction. If the character is on the ground, + // we have extra logic: Using a temporary bound to ensure that slanted wall collisions do not lift the + // character off the ground. if is_ground(ctx, &collision.normal) { - bounded_vectors.add_bound(VectorBound::new(collision.normal, ctx.up)); + if !*ground_collision_handled { + // Wall collisions can turn vertical momentum into unwanted horizontal momentum. This can + // occur if the character jumps at the corner between the ground and a slanted wall. If the wall + // collision is handled first, this horizontal momentum will push the character away from the wall. + // This can also occur if the character is on the ground and walks into a slanted wall. A single frame + // of downward momentum caused by gravity can turn into unwanted horizontal momentum that pushes + // the character away from the wall. Neither of these issues can occur if the ground collision is + // handled first, so when computing how the velocity vectors change, we rewrite history as if + // the ground collision was first. This is only necessary for the first ground collision, since + // afterwards, there is no more unexpected vertical momentum. + let old_bounded_vectors = + replace(bounded_vectors, bounded_vectors_without_collisions.clone()); + bounded_vectors.add_temp_bound(VectorBound::new(collision.normal, ctx.up, false)); + bounded_vectors.add_bound(VectorBound::new(collision.normal, ctx.up, true)); + for bound in old_bounded_vectors.bounds() { + bounded_vectors.add_bound(bound.clone()); + } + bounded_vectors.clear_temp_bounds(); + + *ground_collision_handled = true; + } else { + bounded_vectors.add_temp_bound(VectorBound::new(collision.normal, ctx.up, false)); + bounded_vectors.add_bound(VectorBound::new(collision.normal, ctx.up, true)); + bounded_vectors.clear_temp_bounds(); + } *ground_normal = Some(collision.normal); } else { - bounded_vectors.add_bound(VectorBound::new(collision.normal, collision.normal)); + if let Some(ground_normal) = ground_normal { + bounded_vectors.add_temp_bound(VectorBound::new(*ground_normal, ctx.up, false)); + } + bounded_vectors.add_bound(VectorBound::new(collision.normal, collision.normal, true)); + bounded_vectors.clear_temp_bounds(); } } diff --git a/common/src/character_controller/vector_bounds.rs b/common/src/character_controller/vector_bounds.rs index 97b25949..a9896f5b 100644 --- a/common/src/character_controller/vector_bounds.rs +++ b/common/src/character_controller/vector_bounds.rs @@ -12,6 +12,7 @@ pub struct BoundedVectors { displacement: na::Vector3, velocity: Option>, bounds: Vec, + temp_bounds: Vec, error_margin: f32, } @@ -29,6 +30,7 @@ impl BoundedVectors { displacement, velocity, bounds: vec![], + temp_bounds: vec![], error_margin, } } @@ -47,6 +49,11 @@ impl BoundedVectors { self.velocity.as_ref() } + /// Returns the internal list of `VectorBound`s contained in the `BoundedVectors` struct. + pub fn bounds(&self) -> &[VectorBound] { + &self.bounds + } + /// Constrains `vector` with `new_bound` while keeping the existing constraints satisfied. All projection /// transformations applied to `vector` are also applied to `tagalong` to allow two vectors to be transformed consistently /// with each other. @@ -55,6 +62,19 @@ impl BoundedVectors { self.bounds.push(new_bound); } + /// Temporarily constrains `vector` with `new_bound` while keeping the existing constraints satisfied. All projection + /// transformations applied to `vector` are also applied to `tagalong` to allow two vectors to be transformed consistently + /// with each other. Use `clear_temporary_bounds` to get rid of any existing temporary bounds + pub fn add_temp_bound(&mut self, new_bound: VectorBound) { + self.apply_bound(&new_bound); + self.temp_bounds.push(new_bound); + } + + /// Removes all temporary bounds + pub fn clear_temp_bounds(&mut self) { + self.temp_bounds.clear(); + } + /// Helper function to apply a new bound without adding it to any lists. fn apply_bound(&mut self, new_bound: &VectorBound) { // There likely isn't a perfect way to get a vector properly constrained with a list of bounds. The main @@ -65,6 +85,9 @@ impl BoundedVectors { // bound that allows all bounds to be satisfied, and (3) zero out the vector if no such pairing works, as we // assume that we need to apply three linearly independent bounds. + // Combine existing bounds with temporary bounds into an iterator + let bounds_iter = self.bounds.iter().chain(self.temp_bounds.iter()); + // Apply new_bound if necessary. if !new_bound.check_vector(&self.displacement, self.error_margin) { new_bound.constrain_vector(&mut self.displacement, self.error_margin); @@ -75,14 +98,14 @@ impl BoundedVectors { } // Check if all constraints are satisfied - if (self.bounds.iter()).all(|b| b.check_vector(&self.displacement, self.error_margin)) { + if (bounds_iter.clone()).all(|b| b.check_vector(&self.displacement, self.error_margin)) { return; } // If not all constraints are satisfied, find the first constraint that if applied will satisfy // the remaining constriants for bound in - (self.bounds.iter()).filter(|b| !b.check_vector(&self.displacement, self.error_margin)) + (bounds_iter.clone()).filter(|b| !b.check_vector(&self.displacement, self.error_margin)) { let Some(ortho_bound) = bound.get_self_constrained_with_bound(new_bound) else { warn!("Unsatisfied existing bound is parallel to new bound. Is the character squeezed between two walls?"); @@ -92,7 +115,7 @@ impl BoundedVectors { let mut candidate = self.displacement; ortho_bound.constrain_vector(&mut candidate, self.error_margin); - if (self.bounds.iter()).all(|b| b.check_vector(&candidate, self.error_margin)) { + if (bounds_iter.clone()).all(|b| b.check_vector(&candidate, self.error_margin)) { self.displacement = candidate; if let Some(ref mut velocity) = self.velocity { ortho_bound.constrain_vector(velocity, 0.0); @@ -116,6 +139,7 @@ impl BoundedVectors { pub struct VectorBound { normal: na::UnitVector3, projection_direction: na::UnitVector3, + front_facing: bool, // Only used for `check_vector` function } impl VectorBound { @@ -123,10 +147,21 @@ impl VectorBound { /// by the normal in `projection_direction`. After applying such a bound to /// a vector, its dot product with `normal` should be close to zero but positive /// even considering floating point error. - pub fn new(normal: na::UnitVector3, projection_direction: na::UnitVector3) -> Self { + /// + /// The `VectorBound` will only push vectors that do not currently fulfill the bounds. + /// If `front_facing` is true, the bound wants the vector to be "in front" of the plane, + /// in the direction given by `normal`. Otherwise, the bound wants the vector to be "behind" + /// the plane. Error margins are set so that two planes, one front_facing and one not, with the + /// same `normal` and `projection_direction`, can both act on a vector without interfering. + pub fn new( + normal: na::UnitVector3, + projection_direction: na::UnitVector3, + front_facing: bool, + ) -> Self { VectorBound { normal, projection_direction, + front_facing, } } @@ -152,10 +187,14 @@ impl VectorBound { // An additional margin of error is needed when the bound is checked to ensure that an // applied bound always passes the check. Ostensibly, for an applied bound, the dot // product is equal to the error margin. - - // Using 0.5 here should ensure that the check will pass after the bound is applied, and it will fail if the - // dot product is too close to zero to guarantee that it won't be treated as negative during collision checking - subject.dot(&self.normal) >= error_margin * 0.5 + if self.front_facing { + // Using 0.5 here should ensure that the check will pass after the bound is applied, and it will fail if the + // dot product is too close to zero to guarantee that it won't be treated as negative during collision checking + subject.dot(&self.normal) >= error_margin * 0.5 + } else { + // Using 1.5 here keeps the additional margin of error equivalent in magnitude to the front-facing case + subject.dot(&self.normal) <= error_margin * 1.5 + } } /// Returns a `VectorBound` that is an altered version of `self` so that it no longer interferes @@ -174,6 +213,7 @@ impl VectorBound { na::UnitVector3::try_new(ortho_bound_projection_direction, 1e-5).map(|d| VectorBound { normal: self.normal, projection_direction: d, + front_facing: self.front_facing, }) } } @@ -192,6 +232,7 @@ mod tests { bounded_vector.add_bound(VectorBound::new( unit_vector(1.0, 3.0, 4.0), unit_vector(1.0, 2.0, 2.0), + true, )); assert_ne!(bounded_vector.displacement, na::Vector3::zero()); @@ -200,6 +241,7 @@ mod tests { bounded_vector.add_bound(VectorBound::new( unit_vector(2.0, -3.0, -4.0), unit_vector(1.0, -2.0, -1.0), + true, )); assert_ne!(bounded_vector.displacement, na::Vector3::zero()); @@ -208,6 +250,7 @@ mod tests { bounded_vector.add_bound(VectorBound::new( unit_vector(2.0, -3.0, -5.0), unit_vector(1.0, -2.0, -2.0), + true, )); assert_ne!(bounded_vector.displacement, na::Vector3::zero()); @@ -217,6 +260,7 @@ mod tests { bounded_vector.add_bound(VectorBound::new( unit_vector(-3.0, 3.0, -2.0), unit_vector(-3.0, 3.0, -2.0), + true, )); // Using assert_eq instead of assert_ne here @@ -230,7 +274,7 @@ mod tests { let normal = unit_vector(1.0, 3.0, 4.0); let projection_direction = unit_vector(1.0, 2.0, 2.0); let error_margin = 1e-4; - let bound = VectorBound::new(normal, projection_direction); + let bound = VectorBound::new(normal, projection_direction, true); let initial_vector = na::Vector3::new(-4.0, -3.0, 1.0); @@ -256,8 +300,8 @@ mod tests { let normal1 = unit_vector(1.0, -4.0, 3.0); let projection_direction1 = unit_vector(1.0, -2.0, 1.0); - let bound0 = VectorBound::new(normal0, projection_direction0); - let bound1 = VectorBound::new(normal1, projection_direction1); + let bound0 = VectorBound::new(normal0, projection_direction0, true); + let bound1 = VectorBound::new(normal1, projection_direction1, true); let initial_vector = na::Vector3::new(2.0, -1.0, -3.0); let mut constrained_vector = initial_vector; @@ -282,7 +326,7 @@ mod tests { } fn assert_bounds_achieved(bounds: &BoundedVectors) { - for bound in &bounds.bounds { + for bound in bounds.bounds() { assert!(bound.check_vector(&bounds.displacement, bounds.error_margin)); } }