Skip to content

Commit

Permalink
Add gamepad rumble support to bevy_input (#8398)
Browse files Browse the repository at this point in the history
# Objective

Provide the ability to trigger controller rumbling (force-feedback) with
a cross-platform API.

## Solution

This adds the `GamepadRumbleRequest` event to `bevy_input` and adds a
system in `bevy_gilrs` to read them and rumble controllers accordingly.

It's a relatively primitive API with a `duration` in seconds and
`GamepadRumbleIntensity` with values for the weak and strong gamepad
motors. It's is an almost 1-to-1 mapping to platform APIs. Some
platforms refer to these motors as left and right, and low frequency and
high frequency, but by convention, they're usually the same.

I used #3868 as a starting point, updated to main, removed the low-level
gilrs effect API, and moved the requests to `bevy_input` and exposed the
strong and weak intensities.

I intend this to hopefully be a non-controversial cross-platform
starting point we can build upon to eventually support more fine-grained
control (closer to the gilrs effect API)

---

## Changelog

### Added

- Gamepads can now be rumbled by sending the `GamepadRumbleRequest`
event.

---------

Co-authored-by: Nicola Papale <nico@nicopap.ch>
Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
Co-authored-by: Nicola Papale <nicopap@users.noreply.github.com>
Co-authored-by: Bruce Reif (Buswolley) <bruce.reif@dynata.com>
  • Loading branch information
5 people committed Apr 24, 2023
1 parent 288009a commit a1e442c
Show file tree
Hide file tree
Showing 8 changed files with 404 additions and 4 deletions.
10 changes: 10 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1274,6 +1274,16 @@ description = "Iterates and prints gamepad input and connection events"
category = "Input"
wasm = false

[[example]]
name = "gamepad_rumble"
path = "examples/input/gamepad_rumble.rs"

[package.metadata.example.gamepad_rumble]
name = "Gamepad Rumble"
description = "Shows how to rumble a gamepad using force feedback"
category = "Input"
wasm = false

[[example]]
name = "keyboard_input"
path = "examples/input/keyboard_input.rs"
Expand Down
3 changes: 3 additions & 0 deletions crates/bevy_gilrs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ keywords = ["bevy"]
bevy_app = { path = "../bevy_app", version = "0.11.0-dev" }
bevy_ecs = { path = "../bevy_ecs", version = "0.11.0-dev" }
bevy_input = { path = "../bevy_input", version = "0.11.0-dev" }
bevy_log = { path = "../bevy_log", version = "0.11.0-dev" }
bevy_utils = { path = "../bevy_utils", version = "0.11.0-dev" }
bevy_time = { path = "../bevy_time", version = "0.11.0-dev" }

# other
gilrs = "0.10.1"
thiserror = "1.0"
12 changes: 10 additions & 2 deletions crates/bevy_gilrs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,23 @@

mod converter;
mod gilrs_system;
mod rumble;

use bevy_app::{App, Plugin, PreStartup, PreUpdate};
use bevy_app::{App, Plugin, PostUpdate, PreStartup, PreUpdate};
use bevy_ecs::prelude::*;
use bevy_input::InputSystem;
use bevy_utils::tracing::error;
use gilrs::GilrsBuilder;
use gilrs_system::{gilrs_event_startup_system, gilrs_event_system};
use rumble::{play_gilrs_rumble, RunningRumbleEffects};

#[derive(Default)]
pub struct GilrsPlugin;

/// Updates the running gamepad rumble effects.
#[derive(Debug, PartialEq, Eq, Clone, Hash, SystemSet)]
pub struct RumbleSystem;

impl Plugin for GilrsPlugin {
fn build(&self, app: &mut App) {
match GilrsBuilder::new()
Expand All @@ -22,8 +28,10 @@ impl Plugin for GilrsPlugin {
{
Ok(gilrs) => {
app.insert_non_send_resource(gilrs)
.init_non_send_resource::<RunningRumbleEffects>()
.add_systems(PreStartup, gilrs_event_startup_system)
.add_systems(PreUpdate, gilrs_event_system.before(InputSystem));
.add_systems(PreUpdate, gilrs_event_system.before(InputSystem))
.add_systems(PostUpdate, play_gilrs_rumble.in_set(RumbleSystem));
}
Err(err) => error!("Failed to start Gilrs. {}", err),
}
Expand Down
177 changes: 177 additions & 0 deletions crates/bevy_gilrs/src/rumble.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
//! Handle user specified rumble request events.
use bevy_ecs::{
prelude::{EventReader, Res},
system::NonSendMut,
};
use bevy_input::gamepad::{GamepadRumbleIntensity, GamepadRumbleRequest};
use bevy_log::{debug, warn};
use bevy_time::Time;
use bevy_utils::{Duration, HashMap};
use gilrs::{
ff::{self, BaseEffect, BaseEffectType, Repeat, Replay},
GamepadId, Gilrs,
};
use thiserror::Error;

use crate::converter::convert_gamepad_id;

/// A rumble effect that is currently in effect.
struct RunningRumble {
/// Duration from app startup when this effect will be finished
deadline: Duration,
/// A ref-counted handle to the specific force-feedback effect
///
/// Dropping it will cause the effect to stop
#[allow(dead_code)]
effect: ff::Effect,
}

#[derive(Error, Debug)]
enum RumbleError {
#[error("gamepad not found")]
GamepadNotFound,
#[error("gilrs error while rumbling gamepad: {0}")]
GilrsError(#[from] ff::Error),
}

/// Contains the gilrs rumble effects that are currently running for each gamepad
#[derive(Default)]
pub(crate) struct RunningRumbleEffects {
/// If multiple rumbles are running at the same time, their resulting rumble
/// will be the saturated sum of their strengths up until [`u16::MAX`]
rumbles: HashMap<GamepadId, Vec<RunningRumble>>,
}

/// gilrs uses magnitudes from 0 to [`u16::MAX`], while ours go from `0.0` to `1.0` ([`f32`])
fn to_gilrs_magnitude(ratio: f32) -> u16 {
(ratio * u16::MAX as f32) as u16
}

fn get_base_effects(
GamepadRumbleIntensity {
weak_motor,
strong_motor,
}: GamepadRumbleIntensity,
duration: Duration,
) -> Vec<ff::BaseEffect> {
let mut effects = Vec::new();
if strong_motor > 0. {
effects.push(BaseEffect {
kind: BaseEffectType::Strong {
magnitude: to_gilrs_magnitude(strong_motor),
},
scheduling: Replay {
play_for: duration.into(),
..Default::default()
},
..Default::default()
});
}
if weak_motor > 0. {
effects.push(BaseEffect {
kind: BaseEffectType::Strong {
magnitude: to_gilrs_magnitude(weak_motor),
},
..Default::default()
});
}
effects
}

fn handle_rumble_request(
running_rumbles: &mut RunningRumbleEffects,
gilrs: &mut Gilrs,
rumble: GamepadRumbleRequest,
current_time: Duration,
) -> Result<(), RumbleError> {
let gamepad = rumble.gamepad();

let (gamepad_id, _) = gilrs
.gamepads()
.find(|(pad_id, _)| convert_gamepad_id(*pad_id) == gamepad)
.ok_or(RumbleError::GamepadNotFound)?;

match rumble {
GamepadRumbleRequest::Stop { .. } => {
// `ff::Effect` uses RAII, dropping = deactivating
running_rumbles.rumbles.remove(&gamepad_id);
}
GamepadRumbleRequest::Add {
duration,
intensity,
..
} => {
let mut effect_builder = ff::EffectBuilder::new();

for effect in get_base_effects(intensity, duration) {
effect_builder.add_effect(effect);
effect_builder.repeat(Repeat::For(duration.into()));
}

let effect = effect_builder.gamepads(&[gamepad_id]).finish(gilrs)?;
effect.play()?;

let gamepad_rumbles = running_rumbles.rumbles.entry(gamepad_id).or_default();
let deadline = current_time + duration;
gamepad_rumbles.push(RunningRumble { deadline, effect });
}
}

Ok(())
}
pub(crate) fn play_gilrs_rumble(
time: Res<Time>,
mut gilrs: NonSendMut<Gilrs>,
mut requests: EventReader<GamepadRumbleRequest>,
mut running_rumbles: NonSendMut<RunningRumbleEffects>,
) {
let current_time = time.raw_elapsed();
// Remove outdated rumble effects.
for rumbles in running_rumbles.rumbles.values_mut() {
// `ff::Effect` uses RAII, dropping = deactivating
rumbles.retain(|RunningRumble { deadline, .. }| *deadline >= current_time);
}
running_rumbles
.rumbles
.retain(|_gamepad, rumbles| !rumbles.is_empty());

// Add new effects.
for rumble in requests.iter().cloned() {
let gamepad = rumble.gamepad();
match handle_rumble_request(&mut running_rumbles, &mut gilrs, rumble, current_time) {
Ok(()) => {}
Err(RumbleError::GilrsError(err)) => {
if let ff::Error::FfNotSupported(_) = err {
debug!("Tried to rumble {gamepad:?}, but it doesn't support force feedback");
} else {
warn!(
"Tried to handle rumble request for {gamepad:?} but an error occurred: {err}"
);
}
}
Err(RumbleError::GamepadNotFound) => {
warn!("Tried to handle rumble request {gamepad:?} but it doesn't exist!");
}
};
}
}

#[cfg(test)]
mod tests {
use super::to_gilrs_magnitude;

#[test]
fn magnitude_conversion() {
assert_eq!(to_gilrs_magnitude(1.0), u16::MAX);
assert_eq!(to_gilrs_magnitude(0.0), 0);

// bevy magnitudes of 2.0 don't really make sense, but just make sure
// they convert to something sensible in gilrs anyway.
assert_eq!(to_gilrs_magnitude(2.0), u16::MAX);

// negative bevy magnitudes don't really make sense, but just make sure
// they convert to something sensible in gilrs anyway.
assert_eq!(to_gilrs_magnitude(-1.0), 0);
assert_eq!(to_gilrs_magnitude(-0.1), 0);
}
}
122 changes: 122 additions & 0 deletions crates/bevy_input/src/gamepad.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use bevy_ecs::{
system::{Res, ResMut, Resource},
};
use bevy_reflect::{std_traits::ReflectDefault, FromReflect, Reflect};
use bevy_utils::Duration;
use bevy_utils::{tracing::info, HashMap};
use thiserror::Error;

Expand Down Expand Up @@ -1240,6 +1241,127 @@ const ALL_AXIS_TYPES: [GamepadAxisType; 6] = [
GamepadAxisType::RightZ,
];

/// The intensity at which a gamepad's force-feedback motors may rumble.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GamepadRumbleIntensity {
/// The rumble intensity of the strong gamepad motor
///
/// Ranges from 0.0 to 1.0
///
/// By convention, this is usually a low-frequency motor on the left-hand
/// side of the gamepad, though it may vary across platforms and hardware.
pub strong_motor: f32,
/// The rumble intensity of the weak gamepad motor
///
/// Ranges from 0.0 to 1.0
///
/// By convention, this is usually a high-frequency motor on the right-hand
/// side of the gamepad, though it may vary across platforms and hardware.
pub weak_motor: f32,
}

impl GamepadRumbleIntensity {
/// Rumble both gamepad motors at maximum intensity
pub const MAX: Self = GamepadRumbleIntensity {
strong_motor: 1.0,
weak_motor: 1.0,
};

/// Rumble the weak motor at maximum intensity
pub const WEAK_MAX: Self = GamepadRumbleIntensity {
strong_motor: 0.0,
weak_motor: 1.0,
};

/// Rumble the strong motor at maximum intensity
pub const STRONG_MAX: Self = GamepadRumbleIntensity {
strong_motor: 1.0,
weak_motor: 0.0,
};

/// Creates a new rumble intensity with weak motor intensity set to the given value
///
/// Clamped within the 0 to 1 range
pub const fn weak_motor(intensity: f32) -> Self {
Self {
weak_motor: intensity,
strong_motor: 0.0,
}
}

/// Creates a new rumble intensity with strong motor intensity set to the given value
///
/// Clamped within the 0 to 1 range
pub const fn strong_motor(intensity: f32) -> Self {
Self {
strong_motor: intensity,
weak_motor: 0.0,
}
}
}

/// An event that controls force-feedback rumbling of a [`Gamepad`]
///
/// # Notes
///
/// Does nothing if the gamepad or platform does not support rumble.
///
/// # Example
///
/// ```
/// # use bevy_input::gamepad::{Gamepad, Gamepads, GamepadRumbleRequest, GamepadRumbleIntensity};
/// # use bevy_ecs::prelude::{EventWriter, Res};
/// # use bevy_utils::Duration;
/// fn rumble_gamepad_system(
/// mut rumble_requests: EventWriter<GamepadRumbleRequest>,
/// gamepads: Res<Gamepads>
/// ) {
/// for gamepad in gamepads.iter() {
/// rumble_requests.send(GamepadRumbleRequest::Add {
/// gamepad,
/// intensity: GamepadRumbleIntensity::MAX,
/// duration: Duration::from_secs_f32(0.5),
/// });
/// }
/// }
/// ```
#[doc(alias = "haptic feedback")]
#[doc(alias = "force feedback")]
#[doc(alias = "vibration")]
#[doc(alias = "vibrate")]
#[derive(Clone)]
pub enum GamepadRumbleRequest {
/// Add a rumble to the given gamepad.
///
/// Simultaneous rumble effects add up to the sum of their strengths.
///
/// Consequently, if two rumbles at half intensity are added at the same
/// time, their intensities will be added up, and the controller will rumble
/// at full intensity until one of the rumbles finishes, then the rumble
/// will continue at the intensity of the remaining event.
///
/// To replace an existing rumble, send a [`GamepadRumbleRequest::Stop`] event first.
Add {
/// How long the gamepad should rumble
duration: Duration,
/// How intense the rumble should be
intensity: GamepadRumbleIntensity,
/// The gamepad to rumble
gamepad: Gamepad,
},
/// Stop all running rumbles on the given [`Gamepad`]
Stop { gamepad: Gamepad },
}

impl GamepadRumbleRequest {
/// Get the [`Gamepad`] associated with this request
pub fn gamepad(&self) -> Gamepad {
match self {
Self::Add { gamepad, .. } | Self::Stop { gamepad } => *gamepad,
}
}
}

#[cfg(test)]
mod tests {
use crate::gamepad::{AxisSettingsError, ButtonSettingsError};
Expand Down
5 changes: 3 additions & 2 deletions crates/bevy_input/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ use gamepad::{
gamepad_axis_event_system, gamepad_button_event_system, gamepad_connection_system,
gamepad_event_system, AxisSettings, ButtonAxisSettings, ButtonSettings, Gamepad, GamepadAxis,
GamepadAxisChangedEvent, GamepadAxisType, GamepadButton, GamepadButtonChangedEvent,
GamepadButtonType, GamepadConnection, GamepadConnectionEvent, GamepadEvent, GamepadSettings,
Gamepads,
GamepadButtonType, GamepadConnection, GamepadConnectionEvent, GamepadEvent,
GamepadRumbleRequest, GamepadSettings, Gamepads,
};

#[cfg(feature = "serialize")]
Expand Down Expand Up @@ -72,6 +72,7 @@ impl Plugin for InputPlugin {
.add_event::<GamepadButtonChangedEvent>()
.add_event::<GamepadAxisChangedEvent>()
.add_event::<GamepadEvent>()
.add_event::<GamepadRumbleRequest>()
.init_resource::<GamepadSettings>()
.init_resource::<Gamepads>()
.init_resource::<Input<GamepadButton>>()
Expand Down
Loading

0 comments on commit a1e442c

Please sign in to comment.