From 10724b75427fbb3c08865eddfb5fbd478070f272 Mon Sep 17 00:00:00 2001 From: Dmitry Stepanov Date: Wed, 24 Jun 2026 21:44:54 +0300 Subject: [PATCH] 2d kinematic character controller --- .../src/scene/dim2/physics/character.rs | 183 ++++++++++++++++++ .../scene/dim2/{physics.rs => physics/mod.rs} | 109 +++++++---- .../src/scene/graph/physics/character.rs | 19 ++ fyrox-impl/src/scene/graph/physics/mod.rs | 2 +- 4 files changed, 272 insertions(+), 41 deletions(-) create mode 100644 fyrox-impl/src/scene/dim2/physics/character.rs rename fyrox-impl/src/scene/dim2/{physics.rs => physics/mod.rs} (97%) diff --git a/fyrox-impl/src/scene/dim2/physics/character.rs b/fyrox-impl/src/scene/dim2/physics/character.rs new file mode 100644 index 000000000..4eb20419f --- /dev/null +++ b/fyrox-impl/src/scene/dim2/physics/character.rs @@ -0,0 +1,183 @@ +// Copyright (c) 2019-present Dmitry Stepanov and Fyrox Engine contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//! A kinematic character controller for player/NPC movement (walking, climbing, sliding). +//! See [`KinematicCharacterController`] docs for more info. + +use crate::{ + core::{ + algebra::Vector2, num_traits::FloatConst, pool::Handle, reflect::prelude::*, + visitor::prelude::*, + }, + scene::{ + dim2::{ + self, + physics::{filter_by_predicate, QueryFilter}, + }, + graph::{ + physics::character::{CharacterAutostep, CharacterLength}, + Graph, + }, + }, +}; +use fyrox_graph::SceneGraph; +use rapier2d::{ + geometry::{Collider, ColliderHandle, Shape}, + math::Pose, + na::Isometry2, +}; + +/// A kinematic character controller for player/NPC movement (walking, climbing, sliding). +/// +/// This provides classic game character movement: walking on floors, sliding on slopes, +/// climbing stairs, and snapping to ground. It's kinematic (not physics-based), meaning +/// you control movement directly rather than applying forces. +/// +/// TODO: Example +#[derive(Visit, Reflect, Debug, Clone)] +#[reflect(type_uuid = "42995595-7e06-41b5-b8fc-8aa9b7feaf36", non_comparable)] +pub struct KinematicCharacterController { + /// The direction that goes "up". Used to determine where the floor is, and the floor’s angle. + pub up: Vector2, + /// A small gap to preserve between the character and its surroundings. + /// + /// This value should not be too large to avoid visual artifacts, but shouldn’t be too small + /// (must not be zero) to improve numerical stability of the character controller. + pub offset: CharacterLength, + /// Should the character try to slide against the floor if it hits it? + pub slide: bool, + /// Should the character automatically step over small obstacles? (disabled by default) + /// + /// Note that autostepping is currently a very computationally expensive feature, so it + /// is disabled by default. + pub autostep: Option, + /// The maximum angle (radians) between the floor’s normal and the `up` vector that the + /// character is able to climb. + pub max_slope_climb_angle: f32, + /// The minimum angle (radians) between the floor’s normal and the `up` vector before the + /// character starts to slide down automatically. + pub min_slope_slide_angle: f32, + /// Should the character be automatically snapped to the ground if the distance between + /// the ground and its feed are smaller than the specified threshold? + pub snap_to_ground: Option, + /// Increase this number if your character appears to get stuck when sliding against surfaces. + /// + /// This is a small distance applied to the movement toward the contact normals of shapes hit + /// by the character controller. This helps shape-casting not getting stuck in an always-penetrating + /// state during the sliding calculation. + /// + /// This value should remain fairly small since it can introduce artificial "bumps" when sliding + /// along a flat surface. + pub normal_nudge_factor: f32, +} + +/// The effective movement computed by the character controller. +#[derive(Debug)] +pub struct CharacterMovement { + /// The movement to apply. + pub translation: Vector2, + /// Is the character touching the ground after applying `EffectiveKinematicMovement::translation`? + pub grounded: bool, + /// Is the character sliding down a slope due to slope angle being larger than `min_slope_slide_angle`? + pub is_sliding_down_slope: bool, +} + +impl Default for KinematicCharacterController { + fn default() -> Self { + Self { + up: Vector2::y(), + offset: CharacterLength::Relative(0.01), + slide: true, + autostep: None, + max_slope_climb_angle: f32::FRAC_PI_4(), + min_slope_slide_angle: f32::FRAC_PI_4(), + snap_to_ground: Some(CharacterLength::Relative(0.2)), + normal_nudge_factor: 1.0e-4, + } + } +} + +impl KinematicCharacterController { + /// Computes the possible movement for a shape. + pub fn move_shape( + &self, + dt: f32, + shape: &dyn Shape, + position: Isometry2, + desired_translation: Vector2, + graph: &Graph, + filter: QueryFilter, + ) -> CharacterMovement { + let controller = rapier2d::control::KinematicCharacterController { + up: self.up.into(), + offset: self.offset.into(), + slide: self.slide, + autostep: self.autostep.map(Into::into), + max_slope_climb_angle: self.max_slope_climb_angle, + min_slope_slide_angle: self.min_slope_slide_angle, + snap_to_ground: self.snap_to_ground.map(Into::into), + normal_nudge_factor: self.normal_nudge_factor, + }; + + let predicate = |handle: ColliderHandle, _: &Collider| -> bool { + filter_by_predicate(filter.predicate, handle, graph, &graph.physics2d.colliders) + }; + let filter = filter.to_native(graph, &predicate); + let query_pipeline = graph.physics2d.query_pipeline(filter); + let movement = controller.move_shape( + dt, + &query_pipeline, + shape, + &Pose::from(position), + desired_translation.into(), + |_| {}, + ); + CharacterMovement { + translation: movement.translation.into(), + grounded: movement.grounded, + is_sliding_down_slope: movement.is_sliding_down_slope, + } + } + + /// Same as [`Self::move_shape`], but tries to use the shape of the specified collider. Can + /// return [`None`] if the `collider_handle` is invalid. + pub fn move_collider_shape( + &self, + dt: f32, + collider_handle: Handle, + position: Isometry2, + desired_translation: Vector2, + graph: &Graph, + filter: QueryFilter, + ) -> Option { + let collider = graph + .physics2d + .colliders + .get(graph.try_get(collider_handle).ok()?.native.get())?; + Some(self.move_shape( + dt, + collider.shape(), + position, + desired_translation, + graph, + filter, + )) + } +} diff --git a/fyrox-impl/src/scene/dim2/physics.rs b/fyrox-impl/src/scene/dim2/physics/mod.rs similarity index 97% rename from fyrox-impl/src/scene/dim2/physics.rs rename to fyrox-impl/src/scene/dim2/physics/mod.rs index 485ad48d6..bd7fa7d20 100644 --- a/fyrox-impl/src/scene/dim2/physics.rs +++ b/fyrox-impl/src/scene/dim2/physics/mod.rs @@ -20,6 +20,8 @@ //! Scene physics module. +pub mod character; + use super::collider::GeometrySource; use crate::{ core::{ @@ -61,6 +63,7 @@ use crate::{ pub use rapier2d::geometry; use rapier2d::math::{Pose2, Vec2}; use rapier2d::parry::query::DefaultQueryDispatcher; +use rapier2d::pipeline::QueryPipeline; use rapier2d::{ dynamics::{ CCDSolver, GenericJoint, GenericJointBuilder, ImpulseJointHandle, ImpulseJointSet, @@ -570,6 +573,8 @@ fn u32_to_group(v: u32) -> rapier2d::geometry::Group { rapier2d::geometry::Group::from_bits(v).unwrap_or_else(rapier2d::geometry::Group::all) } +pub(crate) type FilterPredicate<'a> = Option<&'a dyn Fn(Handle, &collider::Collider) -> bool>; + /// A filter tha describes what collider should be included or excluded from a scene query. #[derive(Copy, Clone, Default)] #[allow(clippy::type_complexity)] @@ -584,7 +589,55 @@ pub struct QueryFilter<'a> { /// If set, any collider attached to this rigid-body will be excluded from the scene query. pub exclude_rigid_body: Option>, /// If set, any collider for which this closure returns false will be excluded from the scene query. - pub predicate: Option<&'a dyn Fn(Handle, &collider::Collider) -> bool>, + pub predicate: FilterPredicate<'a>, +} + +pub(crate) fn filter_by_predicate( + pred: FilterPredicate, + handle: ColliderHandle, + graph: &Graph, + colliders: &ColliderSet, +) -> bool { + if let Some(pred) = pred { + let h = Handle::decode_from_u128(colliders.get(handle).unwrap().user_data); + pred( + h, + graph + .node(h) + .self_or_field_ref::() + .unwrap(), + ) + } else { + true + } +} + +impl<'a> QueryFilter<'a> { + pub(crate) fn to_native( + self, + graph: &Graph, + predicate: &'a dyn Fn(ColliderHandle, &Collider) -> bool, + ) -> rapier2d::pipeline::QueryFilter<'a> { + rapier2d::pipeline::QueryFilter { + flags: rapier2d::pipeline::QueryFilterFlags::from_bits(self.flags.bits()).unwrap(), + groups: self.groups.map(|g| { + InteractionGroups::new( + u32_to_group(g.memberships.0), + u32_to_group(g.filter.0), + Default::default(), + ) + }), + exclude_collider: self + .exclude_collider + .and_then(|h| graph.try_get_of_type::(h).ok()) + .map(|c| c.native.get()), + exclude_rigid_body: self + .exclude_collider + .and_then(|h| graph.try_get_of_type::(h).ok()) + .map(|c| c.native.get()), + predicate: Some(predicate), + } + } } /// The result of a time-of-impact (TOI) computation. @@ -770,6 +823,18 @@ impl PhysicsWorld { ); } + pub(crate) fn query_pipeline<'a>( + &'a self, + filter: rapier2d::pipeline::QueryFilter<'a>, + ) -> QueryPipeline<'a> { + self.broad_phase.as_query_pipeline( + &DefaultQueryDispatcher, + &self.bodies, + &self.colliders, + filter, + ) + } + /// Casts a ray with given options. pub fn cast_ray(&self, opts: RayCastOptions, query_buffer: &mut S) { let time = instant::Instant::now(); @@ -850,46 +915,10 @@ impl PhysicsWorld { filter: QueryFilter, ) -> Option<(Handle, TOI)> { let predicate = |handle: ColliderHandle, _: &Collider| -> bool { - if let Some(pred) = filter.predicate { - let h = Handle::decode_from_u128(self.colliders.get(handle).unwrap().user_data); - pred( - h, - graph - .node(h) - .self_or_field_ref::() - .unwrap(), - ) - } else { - true - } + filter_by_predicate(filter.predicate, handle, graph, &self.colliders) }; - - let filter = rapier2d::pipeline::QueryFilter { - flags: rapier2d::pipeline::QueryFilterFlags::from_bits(filter.flags.bits()).unwrap(), - groups: filter.groups.map(|g| { - InteractionGroups::new( - u32_to_group(g.memberships.0), - u32_to_group(g.filter.0), - Default::default(), - ) - }), - exclude_collider: filter - .exclude_collider - .and_then(|h| graph.try_get_of_type::(h).ok()) - .map(|c| c.native.get()), - exclude_rigid_body: filter - .exclude_collider - .and_then(|h| graph.try_get_of_type::(h).ok()) - .map(|c| c.native.get()), - predicate: Some(&predicate), - }; - - let query = self.broad_phase.as_query_pipeline( - &DefaultQueryDispatcher, - &self.bodies, - &self.colliders, - filter, - ); + let filter = filter.to_native(graph, &predicate); + let query = self.query_pipeline(filter); let opts = ShapeCastOptions { max_time_of_impact: max_toi, diff --git a/fyrox-impl/src/scene/graph/physics/character.rs b/fyrox-impl/src/scene/graph/physics/character.rs index a28e66828..d006cad24 100644 --- a/fyrox-impl/src/scene/graph/physics/character.rs +++ b/fyrox-impl/src/scene/graph/physics/character.rs @@ -70,6 +70,15 @@ impl Into for CharacterLength { } } +impl Into for CharacterLength { + fn into(self) -> rapier2d::control::CharacterLength { + match self { + CharacterLength::Relative(v) => rapier2d::control::CharacterLength::Relative(v), + CharacterLength::Absolute(v) => rapier2d::control::CharacterLength::Absolute(v), + } + } +} + /// Configuration for the auto-stepping character controller feature. #[derive(Visit, Reflect, Copy, Clone, Debug, PartialEq)] #[reflect(type_uuid = "3b458b79-159c-4ab9-a01a-b0c866e997c3")] @@ -102,6 +111,16 @@ impl Into for CharacterAutostep { } } +impl Into for CharacterAutostep { + fn into(self) -> rapier2d::control::CharacterAutostep { + rapier2d::control::CharacterAutostep { + max_height: self.max_height.into(), + min_width: self.min_width.into(), + include_dynamic_bodies: self.include_dynamic_bodies, + } + } +} + /// A kinematic character controller for player/NPC movement (walking, climbing, sliding). /// /// This provides classic game character movement: walking on floors, sliding on slopes, diff --git a/fyrox-impl/src/scene/graph/physics/mod.rs b/fyrox-impl/src/scene/graph/physics/mod.rs index f48c755cf..1805bfdcb 100644 --- a/fyrox-impl/src/scene/graph/physics/mod.rs +++ b/fyrox-impl/src/scene/graph/physics/mod.rs @@ -1055,7 +1055,7 @@ pub(crate) fn filter_by_predicate( } impl<'a> QueryFilter<'a> { - fn to_native( + pub(crate) fn to_native( self, graph: &Graph, predicate: &'a dyn Fn(ColliderHandle, &Collider) -> bool,