From a1e442cd2ab7f61376fa551ea7230a5861edd3f3 Mon Sep 17 00:00:00 2001 From: Johan Klokkhammer Helsing Date: Mon, 24 Apr 2023 17:28:53 +0200 Subject: [PATCH] Add gamepad rumble support to bevy_input (#8398) # 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 Co-authored-by: Alice Cecile Co-authored-by: Nicola Papale Co-authored-by: Bruce Reif (Buswolley) --- Cargo.toml | 10 ++ crates/bevy_gilrs/Cargo.toml | 3 + crates/bevy_gilrs/src/lib.rs | 12 ++- crates/bevy_gilrs/src/rumble.rs | 177 +++++++++++++++++++++++++++++++ crates/bevy_input/src/gamepad.rs | 122 +++++++++++++++++++++ crates/bevy_input/src/lib.rs | 5 +- examples/README.md | 1 + examples/input/gamepad_rumble.rs | 78 ++++++++++++++ 8 files changed, 404 insertions(+), 4 deletions(-) create mode 100644 crates/bevy_gilrs/src/rumble.rs create mode 100644 examples/input/gamepad_rumble.rs diff --git a/Cargo.toml b/Cargo.toml index 600d195fa6ce1..ae769ee50f652 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/bevy_gilrs/Cargo.toml b/crates/bevy_gilrs/Cargo.toml index db9629bedf1e3..28800939fe055 100644 --- a/crates/bevy_gilrs/Cargo.toml +++ b/crates/bevy_gilrs/Cargo.toml @@ -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" \ No newline at end of file diff --git a/crates/bevy_gilrs/src/lib.rs b/crates/bevy_gilrs/src/lib.rs index 1377a0aa7973e..1afcf515f6613 100644 --- a/crates/bevy_gilrs/src/lib.rs +++ b/crates/bevy_gilrs/src/lib.rs @@ -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() @@ -22,8 +28,10 @@ impl Plugin for GilrsPlugin { { Ok(gilrs) => { app.insert_non_send_resource(gilrs) + .init_non_send_resource::() .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), } diff --git a/crates/bevy_gilrs/src/rumble.rs b/crates/bevy_gilrs/src/rumble.rs new file mode 100644 index 0000000000000..3f3e8aa4c7f18 --- /dev/null +++ b/crates/bevy_gilrs/src/rumble.rs @@ -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>, +} + +/// 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 { + 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