diff --git a/src/main.rs b/src/main.rs index 6877de0..a001539 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,7 +30,7 @@ fn setup(mut cmds: Commands) { transform: Transform::from_xyz(2.0, 160.0, 2.0).looking_at(Vec3::ZERO, Vec3::Y), ..Default::default() }) - .insert(voxel::player::PlayerController::default()) + .insert(voxel::player::PlayerBundle::default()) .insert(Fxaa::default()) .insert(bevy_atmosphere::plugin::AtmosphereCamera::default()); diff --git a/src/voxel/mod.rs b/src/voxel/mod.rs index 60302c5..b2f5533 100644 --- a/src/voxel/mod.rs +++ b/src/voxel/mod.rs @@ -1,17 +1,17 @@ -///! Storage primitives for storing voxel data +/// Storage primitives for storing voxel data pub mod storage; -///! Utils for managing a voxel world. +/// Utils for managing a voxel world. mod world; pub use world::*; -///! Terrain generator. +/// Terrain generator. pub mod terraingen; -///! Systems and utilities for rendering voxels. +/// Systems and utilities for rendering voxels. pub mod render; -///! Systems for defining voxel materials with physical properties. +/// Systems for defining voxel materials with physical properties. pub mod material; /// rust ports of signed distance field functions for use in world generation. diff --git a/src/voxel/voxel.rs b/src/voxel/voxel.rs index 5dae8f6..83ad229 100644 --- a/src/voxel/voxel.rs +++ b/src/voxel/voxel.rs @@ -5,6 +5,10 @@ pub struct Voxel(pub u8); impl Voxel { pub const EMPTY_VOXEL: Self = Self(0); + + pub fn collidable(self) -> bool { + !(self == Self::EMPTY_VOXEL) + } } impl Default for Voxel { diff --git a/src/voxel/world/mod.rs b/src/voxel/world/mod.rs index f36df45..af03103 100644 --- a/src/voxel/world/mod.rs +++ b/src/voxel/world/mod.rs @@ -8,6 +8,7 @@ use super::{storage::ChunkMap, terraingen, Voxel}; /// Systems for dynamically loading / unloading regions (aka chunks) of the world according to camera position. mod chunks; +pub mod physics; pub use chunks::{ ChunkCommandQueue, ChunkEntities, ChunkLoadRadius, CurrentLocalPlayerChunk, DirtyChunks, }; @@ -36,7 +37,8 @@ impl Plugin for VoxelWorldPlugin { .add_plugin(chunks_anim::ChunkAppearanceAnimatorPlugin) .add_plugin(bevy_atmosphere::plugin::AtmospherePlugin) .add_plugin(player::VoxelWorldPlayerControllerPlugin) - .add_plugin(sky::InteractiveSkyboxPlugin); + .add_plugin(sky::InteractiveSkyboxPlugin) + .add_plugin(physics::VoxelWorldPhysicsPlugin); } } diff --git a/src/voxel/world/physics.rs b/src/voxel/world/physics.rs new file mode 100644 index 0000000..9d0913f --- /dev/null +++ b/src/voxel/world/physics.rs @@ -0,0 +1,239 @@ +//! Voxel collision detection and resolution. +//! +//! Based on Based on https://www.gamedev.net/tutorials/programming/general-and-gameplay-programming/swept-aabb-collision-detection-and-response-r3084/ . + +use bevy::math::Vec3A; +use bevy::prelude::{ + Component, CoreSet, Deref, DerefMut, Entity, IVec3, IntoSystemConfig, Plugin, Query, Res, + SystemSet, Transform, Vec3, +}; +use bevy::render::primitives::Aabb; +use itertools::{iproduct, Itertools}; +use std::cmp::Ordering; +use std::f32::{INFINITY, NEG_INFINITY}; + +use crate::voxel::storage::ChunkMap; +use crate::voxel::Voxel; + +use super::ChunkShape; + +#[derive(Component, Deref, DerefMut, Default)] +pub struct Velocity(pub Vec3A); + +#[derive(Component, Deref, DerefMut, Default)] +pub struct Acceleration(pub Vec3A); + +#[derive(Component, Deref, DerefMut)] +pub struct Drag(pub f32); + +/// Threshold to consider floats equal. +const EPSILON: f32 = 1e-5; +const SIM_TIME: f32 = 1.0 / 20.0; + +#[derive(Component)] +/// Marker component for entities that can collide with voxels. +pub struct Collider { + pub aabb: Aabb, +} + +#[derive(Debug)] +struct Collision { + normal: Vec3A, + time: f32, +} + +#[cfg(test)] +impl PartialEq for Collision { + fn eq(&self, other: &Self) -> bool { + (self.time - other.time).abs() <= EPSILON + && (self.normal - other.normal) + .abs() + .cmple(Vec3A::splat(EPSILON)) + .all() + } +} + +// Returns the time of collision (in [0, 1)) and normal if the given [Aabb] collides with a voxel at the given position. +fn swept_voxel_collision(b1: Aabb, displacement: Vec3A, b2: Aabb) -> Option { + // find the distance between the objects on the near and far sides for each axis + let is_displacement_positive = displacement.cmpgt(Vec3A::ZERO); + + let inv_entry = Vec3A::select( + is_displacement_positive, + b2.min() - b1.max(), + b2.max() - b1.min(), + ); + let inv_exit = Vec3A::select( + is_displacement_positive, + b2.max() - b1.min(), + b2.min() - b1.max(), + ); + + let is_displacement_nonzero: [bool; 3] = displacement.abs().cmpgt(Vec3A::splat(EPSILON)).into(); + let (mut entry, mut exit) = (Vec3A::splat(NEG_INFINITY), Vec3A::splat(INFINITY)); + for axis in 0..3 { + if is_displacement_nonzero[axis] { + entry[axis] = inv_entry[axis] / displacement[axis]; + exit[axis] = inv_exit[axis] / displacement[axis]; + } + } + + let entry_time = entry.max_element(); // @todo: why is this not min/max (flipped)?? + let exit_time = exit.min_element(); + + if entry_time > exit_time || entry.cmplt(Vec3A::ZERO).all() || entry.cmpge(Vec3A::ONE).any() { + // no collision in [0, 1) + None + } else { + // we collided! need to determine the normal vector based on which axis collided first. + let comp: [bool; 3] = entry.cmpeq(Vec3A::splat(entry_time)).into(); + let axis_idx = comp.into_iter().position(|x| x).unwrap(); + let axis = Vec3A::AXES[axis_idx]; + if entry[axis_idx] > 0.0 { + Some(Collision { + time: entry_time, + normal: -axis, + }) + } else { + Some(Collision { + time: entry_time, + normal: axis, + }) + } + } +} + +fn voxel_aabb(voxel: IVec3) -> Aabb { + let center = voxel.as_vec3a() + Vec3A::splat(0.5); + Aabb { + center, + half_extents: Vec3A::splat(0.5), + } +} + +fn step( + mut colliders: Query<( + Entity, + &mut Transform, + &mut Velocity, + Option<&Acceleration>, + Option<&Collider>, + Option<&Drag>, + )>, + voxels: Res>, +) { + for (_entity, mut transform, mut velocity, acceleration, collider, drag) in &mut colliders { + if let Some(acceleration) = acceleration { + **velocity += **acceleration * SIM_TIME; + } + + let mut displacement = **velocity * SIM_TIME; + if let Some(&Collider { aabb }) = collider { + let aabb = Aabb { + center: Vec3A::from(transform.translation) + aabb.center, + ..aabb + }; + let start = (Vec3A::min(aabb.min(), aabb.min() + displacement).floor()).as_ivec3() + + IVec3::NEG_Y; + let end = Vec3A::max(aabb.max(), aabb.max() + displacement) + .floor() + .as_ivec3(); + + // println!("pos: {}, start: {}, end: {}", aabb.center, start, end); + + assert!(start.cmple(end).all()); + + let all_voxels = iproduct!(start.x..=end.x, start.y..=end.y, start.z..=end.z) + .map(|(x, y, z)| IVec3::new(x, y, z)) + .collect_vec(); + + let interesting_voxels = all_voxels + .iter() + .filter(|voxel| voxels.voxel_at(**voxel).is_some_and(Voxel::collidable)) + .collect_vec(); + + // println!( + // "voxels: {:?}, interesting voxels: {:?}", + // all_voxels, interesting_voxels + // ); + + if let Some(collision) = interesting_voxels + .into_iter() + .flat_map(|voxel| swept_voxel_collision(aabb, displacement, voxel_aabb(*voxel))) + .min_by(|a, b| { + if a.time < b.time { + Ordering::Less + } else { + Ordering::Greater + } + }) + { + println!("resolving collision!"); + + // clip the displacement to avoid overlap + displacement = **velocity * collision.time * SIM_TIME; + + let previous_velocity = **velocity; + // cancel velocity in the normal direction + **velocity -= Vec3A::dot(previous_velocity, collision.normal) * collision.normal; + } + } + transform.translation += Vec3::from(displacement); + if let Some(drag) = drag { + **velocity *= **drag; + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, SystemSet)] +pub struct PhysicsSet; + +pub struct VoxelWorldPhysicsPlugin; + +impl Plugin for VoxelWorldPhysicsPlugin { + fn build(&self, app: &mut bevy::prelude::App) { + app.add_system(step.in_set(PhysicsSet).in_base_set(CoreSet::Update)); + } +} + +#[cfg(test)] +mod test { + use super::{swept_voxel_collision, voxel_aabb, Collision}; + use bevy::{math::Vec3A, prelude::IVec3, render::primitives::Aabb}; + + #[test] + fn test_swept_collisions() { + let player = Aabb { + center: Vec3A::new(0., 0.9, 0.0), // player is 1.8m tall, standing at the origin with feet touching the xz plane. + half_extents: Vec3A::new(0.25, 0.9, 0.25), // player is 0.5m wide and 1.8m tall + }; + + // no collision with a voxel centered at (1.5, 0.5, 1.5) + assert!( + swept_voxel_collision(player, Vec3A::ZERO, voxel_aabb(IVec3::new(1, 0, 1))).is_none() + ); + // no collision with a voxel centered at (1.5, 0.5, 0.5) + assert!( + swept_voxel_collision(player, Vec3A::ZERO, voxel_aabb(IVec3::new(1, 0, 0))).is_none() + ); + // player is now moving in the +x direction, and should collide: + assert_eq!( + swept_voxel_collision(player, Vec3A::X, voxel_aabb(IVec3::new(1, 0, 0))).unwrap(), + Collision { + time: 0.75, + normal: Vec3A::NEG_X + } + ); + assert!( + swept_voxel_collision(player, Vec3A::Y, voxel_aabb(IVec3::new(0, -1, 0))).is_none() + ); + assert!(swept_voxel_collision(player, Vec3A::Y, voxel_aabb(IVec3::new(1, 0, 0))).is_none()); + assert_eq!( + swept_voxel_collision(player, Vec3A::Y, voxel_aabb(IVec3::new(0, 2, 0))).unwrap(), + Collision { + time: 0.2, + normal: Vec3A::NEG_Y + } + ); + } +} diff --git a/src/voxel/world/player.rs b/src/voxel/world/player.rs index 0628f9e..96b0588 100644 --- a/src/voxel/world/player.rs +++ b/src/voxel/world/player.rs @@ -1,13 +1,44 @@ -use bevy::{input::mouse::MouseMotion, prelude::*, window::CursorGrabMode}; +use bevy::{ + input::mouse::MouseMotion, math::Vec3A, prelude::*, render::primitives::Aabb, + window::CursorGrabMode, +}; use bevy_egui::EguiContexts; use std::f32::consts::FRAC_PI_2; use crate::debug::DebugUISet; +use super::physics::{Acceleration, Collider, Drag, PhysicsSet, Velocity}; + // Reusing the player controller impl for now. pub const DEFAULT_CAMERA_SENS: f32 = 0.005; +#[derive(Bundle)] +pub struct PlayerBundle { + pub controller: PlayerController, + pub velocity: Velocity, + pub acceleration: Acceleration, + pub collider: Collider, + pub drag: Drag, +} + +impl Default for PlayerBundle { + fn default() -> Self { + Self { + controller: Default::default(), + velocity: Default::default(), + acceleration: Default::default(), + collider: Collider { + aabb: Aabb { + center: Vec3A::new(0.0, -0.7, 0.0), // the collision center is ~0.6m below the eyes, which are ~0.2m below the top of the collision box + half_extents: Vec3A::new(0.4, 0.9, 0.4), + }, + }, + drag: Drag(0.98), + } + } +} + #[derive(Default, Component)] pub struct PlayerController { yaw: f32, @@ -55,11 +86,11 @@ pub fn handle_player_mouse_move( pub fn handle_player_input( mut egui: EguiContexts, - mut query: Query<(&mut PlayerController, &mut Transform)>, + mut query: Query<(&mut PlayerController, &Transform, &mut Acceleration)>, keys: Res>, btns: Res>, ) { - let (mut controller, mut transform) = query.single_mut(); + let (mut controller, transform, mut acceleration) = query.single_mut(); // cursor grabbing // @todo: this should prevent cursor grabbing when the user is interacting with a debug UI. Why doesn't this work? @@ -76,7 +107,7 @@ pub fn handle_player_input( let forward = transform.rotation.mul_vec3(Vec3::Z).normalize() * Vec3::new(1.0, 0., 1.0); let right = transform.rotation.mul_vec3(Vec3::X).normalize(); - let mut acceleration = 1.0f32; + let mut speed = 1.0f32; if keys.pressed(KeyCode::W) { direction.z -= 1.0; @@ -103,17 +134,17 @@ pub fn handle_player_input( } if keys.pressed(KeyCode::LControl) { - acceleration *= 8.0; - } - - if direction == Vec3::ZERO { - return; + speed *= 8.0; } - // hardcoding 0.10 as a factor for now to not go zoomin across the world. - transform.translation += direction.x * right * acceleration - + direction.z * forward * acceleration - + direction.y * Vec3::Y * acceleration; + // // hardcoding 0.10 as a factor for now to not go zoomin across the world. + // transform.translation += direction.x * right * acceleration + // + direction.z * forward * acceleration + // + direction.y * Vec3::Y * acceleration; + **acceleration = (speed * direction.x * right + + direction.z * forward * speed + + direction.y * Vec3::Y * speed) + .into(); } #[derive(Hash, Copy, Clone, PartialEq, Eq, Debug, SystemSet)] @@ -128,6 +159,7 @@ impl Plugin for VoxelWorldPlayerControllerPlugin { (handle_player_input, handle_player_mouse_move) .chain() .in_base_set(CoreSet::Update) + .before(PhysicsSet) .after(DebugUISet::Display), ); }