Add WASD camera controls (#1)

This PR adds FPS-style controls to the camera in addition to the existing DCC-style pan/orbit/dolly controls. Additionally, some architectural changes have been made to facilitate this and pave the way toward making all of the app controls configurable in the future. Namely, a new `input` module has been added, loosely inspired by Unreal Engine's "Enhanced Input" system (and as I understand it, conceptually similar to Unity's "new" input system as well).

The basic idea is that raw device inputs (coming from `bevy::input`) are mapped to named, app-specific "input actions" before being consumed by other parts of the application. For example, in the camera motion system, instead of checking for mouse motion while the middle mouse button is pressed, we now check for a `CameraMotion::Pan` input action event.

Input actions carry information about their lifecycle phase (`Start`, `Ongoing`, and `Stop` — a stand-in for Bevy's `just_pressed`, `pressed`, and `just_released` methods) and some type of magnitude (`f32`, `Vec2`, or `Vec3` depending on the action).

At present, we've simply moved the hard-coded device input checks from the `orbit_cam` module to the new `input` module, but theoretically, this could eventually be refactored to read the mappings between device inputs and input actions from some kind of user configuration.
This commit is contained in:
Danny McGee
2023-10-05 22:29:01 -04:00
committed by GitHub
parent 05644b55bd
commit 699c1baa4b
4 changed files with 391 additions and 94 deletions
+184
View File
@@ -0,0 +1,184 @@
use bevy::{input::mouse::MouseMotion, prelude::*, utils::HashSet};
pub struct StudioInputPlugin;
impl Plugin for StudioInputPlugin {
fn build(&self, app: &mut App) {
app.add_event::<InputAction>();
app.add_systems(
PreUpdate,
translate_bevy_events.after(bevy::input::InputSystem),
);
}
}
#[derive(Event, Clone, Copy)]
pub struct InputAction(pub Lifecycle, pub Action);
#[derive(Clone, Copy)]
pub enum Lifecycle {
Start,
Ongoing,
Stop,
}
#[derive(Clone, Copy)]
pub enum Action {
Camera(CameraMotion),
}
#[derive(Clone, Copy)]
pub enum CameraMotion {
Translate(Vec3),
Pan(Vec2),
Zoom(f32),
Orbit(Vec2),
Rotate(Vec2),
}
impl From<CameraMotion> for Action {
fn from(value: CameraMotion) -> Self {
Action::Camera(value)
}
}
// TODO: Make the bindings of raw device inputs to InputActions configurable
fn translate_bevy_events(
mouse_motion: EventReader<MouseMotion>,
mouse_buttons: Res<Input<MouseButton>>,
keyboard: Res<Input<KeyCode>>,
mut input: EventWriter<InputAction>,
) {
// Camera Pan
if mouse_buttons.pressed(MouseButton::Middle) {
let delta = read_mouse_delta(mouse_motion);
let lifecycle = if mouse_buttons.just_pressed(MouseButton::Middle) {
Lifecycle::Start
} else {
Lifecycle::Ongoing
};
input.send(InputAction(lifecycle, CameraMotion::Pan(delta).into()));
}
// Stop Camera Panning
else if mouse_buttons.just_released(MouseButton::Middle) {
input.send(InputAction(
Lifecycle::Stop,
CameraMotion::Pan(Vec2::ZERO).into(),
));
}
// Camera Orbit/Zoom
else if keyboard.any_pressed([KeyCode::AltLeft, KeyCode::AltRight]) {
if mouse_buttons.any_pressed([MouseButton::Left, MouseButton::Right]) {
let delta = read_mouse_delta(mouse_motion);
let lifecycle =
if mouse_buttons.any_just_pressed([MouseButton::Left, MouseButton::Right]) {
Lifecycle::Start
} else {
Lifecycle::Ongoing
};
let motion = if mouse_buttons.pressed(MouseButton::Left) {
CameraMotion::Orbit(delta)
} else {
CameraMotion::Zoom(delta.y)
};
input.send(InputAction(lifecycle, motion.into()));
}
// Stop Camera Orbit
else if mouse_buttons.just_released(MouseButton::Left) {
input.send(InputAction(
Lifecycle::Stop,
CameraMotion::Orbit(Vec2::ZERO).into(),
));
}
// Stop Camera Zoom
else if mouse_buttons.just_released(MouseButton::Right) {
input.send(InputAction(Lifecycle::Stop, CameraMotion::Zoom(0.).into()));
}
}
// Stop Camera Orbit & Zoom
else if (keyboard.just_released(KeyCode::AltLeft) && !keyboard.pressed(KeyCode::AltRight))
|| (keyboard.just_released(KeyCode::AltRight) && !keyboard.pressed(KeyCode::AltLeft))
{
input.send(InputAction(
Lifecycle::Stop,
CameraMotion::Orbit(Vec2::ZERO).into(),
));
input.send(InputAction(Lifecycle::Stop, CameraMotion::Zoom(0.).into()));
}
// Camera Rotation
else if mouse_buttons.pressed(MouseButton::Right) {
let lifecycle = if mouse_buttons.just_pressed(MouseButton::Right) {
Lifecycle::Start
} else {
Lifecycle::Ongoing
};
let delta = read_mouse_delta(mouse_motion);
input.send(InputAction(lifecycle, CameraMotion::Rotate(delta).into()));
}
// Stop Camera Rotation
else if mouse_buttons.just_released(MouseButton::Right) {
input.send(InputAction(
Lifecycle::Stop,
CameraMotion::Rotate(Vec2::ZERO).into(),
))
}
// Camera Translation (FPS-like)
use KeyCode::*;
let wasd_keys = [W, A, S, D, E, Q];
let wasd_keys_set = wasd_keys.into_iter().collect::<HashSet<_>>();
// A little unfortunate that `get_pressed()` doesn't just return a reference
// to the original HashSet >_< Maybe the compiler will be nice to us and
// optimize away the redundant clone?
let keys_pressed = keyboard.get_pressed().cloned().collect::<HashSet<_>>();
let wasd_keys_pressed = wasd_keys_set
.intersection(&keys_pressed)
.collect::<HashSet<_>>();
if !wasd_keys_pressed.is_empty() {
let mut mvmt = Vec3::ZERO;
#[rustfmt::skip] {
if wasd_keys_pressed.contains(&W) { mvmt.z -= 1.; }
if wasd_keys_pressed.contains(&A) { mvmt.x -= 1.; }
if wasd_keys_pressed.contains(&S) { mvmt.z += 1.; }
if wasd_keys_pressed.contains(&D) { mvmt.x += 1.; }
if wasd_keys_pressed.contains(&E) { mvmt.y += 1.; }
if wasd_keys_pressed.contains(&Q) { mvmt.y -= 1.; }
};
let mvmt = mvmt.normalize_or_zero();
// Compare WASD keys pressed with WASD keys _just_ pressed to determine lifecycle
let keys_just_pressed = keyboard.get_just_pressed().cloned().collect::<HashSet<_>>();
let wasd_keys_just_pressed = keys_just_pressed
.intersection(&wasd_keys_set)
.collect::<HashSet<_>>();
let lifecycle = if wasd_keys_pressed.len() == wasd_keys_just_pressed.len() {
Lifecycle::Start
} else {
Lifecycle::Ongoing
};
input.send(InputAction(lifecycle, CameraMotion::Translate(mvmt).into()));
}
// Stop Translation
else if keyboard.any_just_released(wasd_keys) {
input.send(InputAction(
Lifecycle::Stop,
CameraMotion::Translate(Vec3::ZERO).into(),
));
}
}
#[inline]
fn read_mouse_delta(mut mouse_motion: EventReader<MouseMotion>) -> Vec2 {
mouse_motion
.into_iter()
.fold(Vec2::ZERO, |accum, current| accum + current.delta)
}
+5 -2
View File
@@ -1,13 +1,15 @@
#![allow(clippy::type_complexity)]
use bevy::prelude::*;
use orbit_cam::{OrbitCam, OrbitCamPlugin};
use input::StudioInputPlugin;
use orbit_cam::{CameraMotionSystem, OrbitCam, OrbitCamPlugin};
use skybox::SkyboxPlugin;
use test_scene::TestScenePlugin;
use ui::UiLayerPlugin;
use viewport::ViewportSize;
use wasm_bindgen::prelude::*;
mod input;
mod orbit_cam;
mod skybox;
mod test_scene;
@@ -32,12 +34,13 @@ pub fn init(viewport_size: ViewportSize) {
}),
..default()
}),
StudioInputPlugin,
OrbitCamPlugin,
TestScenePlugin,
SkyboxPlugin,
UiLayerPlugin,
))
.add_systems(Update, draw_world_axes.after(orbit_cam::pan_orbit_camera))
.add_systems(Update, draw_world_axes.after(CameraMotionSystem))
.run();
}
+200 -87
View File
@@ -1,21 +1,46 @@
use std::f32::consts;
use std::{f32::consts, mem};
use bevy::{
input::mouse::{MouseButton, MouseMotion},
math::vec3,
prelude::*,
window::{CursorGrabMode, PrimaryWindow},
};
use crate::input::{Action, CameraMotion, InputAction, Lifecycle};
pub struct OrbitCamPlugin;
#[derive(SystemSet, Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct CameraMotionSystem;
impl Plugin for OrbitCamPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, spawn_camera)
.add_systems(Update, pan_orbit_camera);
app.add_systems(Startup, spawn_camera);
app.add_systems(
Update,
(
camera_control_kinematic.in_set(CameraMotionSystem),
update_velocity
.in_set(CameraMotionSystem)
.after(camera_control_kinematic),
camera_control_physical
.in_set(CameraMotionSystem)
.after(update_velocity),
),
);
}
}
// TODO: These don't really belong in this module
#[derive(Component)]
pub struct Velocity(pub Vec3);
#[derive(Component)]
pub struct TargetVelocity(pub Vec3);
#[derive(Component)]
pub struct Interp(pub f32);
#[derive(Component)]
pub struct OrbitCam {
pub focus: Vec3,
@@ -23,13 +48,6 @@ pub struct OrbitCam {
pub upside_down: bool,
}
impl OrbitCam {
// TODO: Make these configurable
const ORBIT_BTN: MouseButton = MouseButton::Left;
const PAN_BTN: MouseButton = MouseButton::Middle;
const ZOOM_BTN: MouseButton = MouseButton::Right;
}
impl Default for OrbitCam {
fn default() -> Self {
Self {
@@ -40,12 +58,6 @@ impl Default for OrbitCam {
}
}
enum ControlMode {
Orbit,
Pan,
Zoom,
}
fn spawn_camera(mut cmd: Commands) {
let focus = vec3(0., 1.5, 0.);
let camera_xform = Transform::from_xyz(-2.5, 4.5, 5.0).looking_at(focus, Vec3::Y);
@@ -61,99 +73,144 @@ fn spawn_camera(mut cmd: Commands) {
},
OrbitCam {
focus,
radius: camera_xform.translation.length(),
radius: Vec3::distance(camera_xform.translation, focus),
..default()
},
Velocity(Vec3::ZERO),
TargetVelocity(Vec3::ZERO),
Interp(1.),
));
}
pub fn pan_orbit_camera(
mut window: Query<&mut Window, With<PrimaryWindow>>,
mut mouse_motion: EventReader<MouseMotion>,
mouse_buttons: Res<Input<MouseButton>>,
keyboard: Res<Input<KeyCode>>,
mut camera: Query<(&mut OrbitCam, &mut Transform, &Projection)>,
fn camera_control_kinematic(
mut q_window: Query<&mut Window, With<PrimaryWindow>>,
mut input: EventReader<InputAction>,
mut q_camera: Query<(
&mut OrbitCam,
&mut TargetVelocity,
&mut Interp,
&mut Transform,
&Projection,
)>,
) {
if (keyboard.just_released(KeyCode::AltLeft) && !keyboard.pressed(KeyCode::AltRight))
|| (keyboard.just_released(KeyCode::AltRight) && !keyboard.pressed(KeyCode::AltLeft))
|| mouse_buttons.just_released(OrbitCam::ORBIT_BTN)
|| mouse_buttons.just_released(OrbitCam::PAN_BTN)
|| mouse_buttons.just_released(OrbitCam::ZOOM_BTN)
{
let mut window = window.single_mut();
window.cursor.visible = true;
window.cursor.grab_mode = CursorGrabMode::None;
}
if !(keyboard.pressed(KeyCode::AltLeft)
|| keyboard.pressed(KeyCode::AltRight)
|| mouse_buttons.pressed(OrbitCam::PAN_BTN))
{
if input.is_empty() {
return;
}
let (mut orbit, mut xform, proj) = camera.single_mut();
let (mut orbit_cam, mut target_vel, mut t, mut xform, proj) = q_camera.single_mut();
let mut window = q_window.single_mut();
if mouse_buttons.just_pressed(OrbitCam::ORBIT_BTN)
|| mouse_buttons.just_released(OrbitCam::ORBIT_BTN)
{
orbit.upside_down = xform.up().y <= 0.0;
let mut any_ongoing = false;
let mut any_started = false;
let mut any_stopped = false;
for action in input.iter() {
#[allow(irrefutable_let_patterns)] // Will eventually be more variants
if let InputAction(lifecycle, Action::Camera(motion)) = action {
use CameraMotion::*;
// Ignore translation actions for the sake of pointer capture
if mem::discriminant(motion) != mem::discriminant(&Translate(Vec3::ZERO)) {
match lifecycle {
Lifecycle::Start => any_started = true,
Lifecycle::Ongoing => any_ongoing = true,
Lifecycle::Stop => any_stopped = true,
}
}
match motion {
Translate(delta) => do_translate(*delta, &mut target_vel, &mut t),
Pan(delta) => do_pan(proj, &window, *delta, &mut xform, &mut orbit_cam),
Zoom(delta) => do_zoom(&window, *delta, &mut xform, &mut orbit_cam),
Orbit(delta) => do_orbit(&window, *delta, *lifecycle, &mut xform, &mut orbit_cam),
Rotate(delta) => do_rotate(&window, *delta, &mut xform, &mut orbit_cam),
}
}
}
let control_mode = if mouse_buttons.pressed(OrbitCam::ORBIT_BTN) {
ControlMode::Orbit
} else if mouse_buttons.pressed(OrbitCam::ZOOM_BTN) {
ControlMode::Zoom
} else if mouse_buttons.pressed(OrbitCam::PAN_BTN) {
ControlMode::Pan
} else {
return;
};
if any_started {
capture_pointer(&mut window);
} else if any_stopped && !any_ongoing {
release_pointer(&mut window);
}
}
let mouse_delta = mouse_motion
.into_iter()
.fold(Vec2::ZERO, |accum, current| accum + current.delta);
// TODO: This doesn't really belong in this module
fn update_velocity(
time: Res<Time>,
mut q_velocity: Query<(&mut Velocity, &TargetVelocity, &mut Interp)>,
) {
let dt = time.delta_seconds();
// TODO: Move these somewhere else -- component maybe?
let accel_zero2max = 0.333;
let max_speed = 0.075;
// TODO: This is nice because it allows movement input beyond the limits of
// the viewport, but it's a little awkward that the cursor doesn't
// move while confined and hidden. We have a `set_cursor_position`
// API, so maybe do the Blender thing and let the cursor wrap around
// at the viewport edges?
let mut window = window.single_mut();
for (mut velocity, target_vel, mut t) in q_velocity.iter_mut() {
let target_vel = target_vel.0 * max_speed;
t.0 = (t.0 + dt / accel_zero2max).clamp(0., 1.);
velocity.0 = if t.0.nearly_eq(0.) {
Vec3::ZERO
} else if t.0.nearly_eq(1.) {
target_vel
} else {
Vec3::lerp(velocity.0, target_vel, curve_sin(t.0))
};
}
}
fn camera_control_physical(mut q_camera: Query<(&mut OrbitCam, &mut Transform, &Velocity)>) {
let (mut orbit_cam, mut xform, velocity) = q_camera.single_mut();
let delta_pos = xform.rotation * velocity.0;
xform.translation += delta_pos;
orbit_cam.focus += delta_pos;
}
#[inline]
fn capture_pointer(window: &mut Window) {
window.cursor.visible = false;
window.cursor.grab_mode = CursorGrabMode::Confined;
}
let window = window.as_ref();
#[inline]
fn release_pointer(window: &mut Window) {
window.cursor.visible = true;
window.cursor.grab_mode = CursorGrabMode::None;
}
match control_mode {
ControlMode::Orbit => {
do_orbit(window, mouse_delta, &mut xform, &orbit);
}
ControlMode::Pan => {
do_pan(proj, window, mouse_delta, &mut xform, &mut orbit);
}
ControlMode::Zoom => {
let delta = (mouse_delta.y * 1.5) / (window.physical_height() as f32);
orbit.radius -= orbit.radius * delta;
xform.translation = orbit.focus + xform.back() * orbit.radius;
}
#[inline]
fn do_translate(delta: Vec3, target_vel: &mut TargetVelocity, t: &mut Interp) {
if !delta.nearly_eq(target_vel.0) {
target_vel.0 = delta;
t.0 = 0.;
}
}
#[inline]
fn do_orbit(win: &Window, mouse_delta: Vec2, xform: &mut Transform, orbit: &OrbitCam) {
fn do_orbit(
win: &Window,
delta: Vec2,
event_lifecycle: Lifecycle,
xform: &mut Transform,
orbit_cam: &mut OrbitCam,
) {
// Set `upside_down` flag if the event just started or stopped
if matches!(event_lifecycle, Lifecycle::Start | Lifecycle::Stop) {
orbit_cam.upside_down = xform.up().y <= 0.;
}
// Translate to origin
xform.translation += xform.forward() * orbit.radius;
xform.translation += xform.forward() * orbit_cam.radius;
// Calculate rotation
let delta_x = {
let delta = mouse_delta.x / (win.physical_width() as f32) * consts::PI * 2.0;
match orbit.upside_down {
let delta = delta.x / (win.physical_width() as f32) * consts::PI * 2.0;
match orbit_cam.upside_down {
true => -delta,
false => delta,
}
};
let delta_y = mouse_delta.y / (win.physical_height() as f32) * consts::PI;
let delta_y = delta.y / (win.physical_height() as f32) * consts::PI;
let yaw = Quat::from_rotation_y(-delta_x);
let pitch = Quat::from_rotation_x(-delta_y);
@@ -163,35 +220,91 @@ fn do_orbit(win: &Window, mouse_delta: Vec2, xform: &mut Transform, orbit: &Orbi
xform.rotation *= pitch;
// Translate back to the original position
xform.translation += xform.back() * orbit.radius;
xform.translation += xform.back() * orbit_cam.radius;
}
#[inline]
fn do_pan(
proj: &Projection,
win: &Window,
mouse_delta: Vec2,
delta: Vec2,
xform: &mut Transform,
orbit: &mut OrbitCam,
orbit_cam: &mut OrbitCam,
) {
// Normalize the pan distance with respect to the window resolution
let pan = match proj {
Projection::Perspective(p) => {
mouse_delta
delta
* Vec2 {
x: (p.fov * p.aspect_ratio) / (win.physical_width() as f32),
y: p.fov / (win.physical_height() as f32),
}
}
_ => mouse_delta,
_ => delta,
};
// Pan along local XY plane, with the distance scaled by the radius
let up = xform.up();
let left = xform.left();
let delta = (pan.y * up + pan.x * left) * orbit.radius;
let delta = (pan.y * up + pan.x * left) * orbit_cam.radius;
// Update camera translation and focus point
xform.translation += delta;
orbit.focus += delta;
orbit_cam.focus += delta;
}
#[inline]
fn do_zoom(win: &Window, delta: f32, xform: &mut Transform, orbit_cam: &mut OrbitCam) {
let delta = (delta * 1.5) / (win.physical_height() as f32);
orbit_cam.radius -= orbit_cam.radius * delta;
xform.translation = orbit_cam.focus + xform.back() * orbit_cam.radius;
}
#[inline]
fn do_rotate(win: &Window, delta: Vec2, xform: &mut Transform, orbit_cam: &mut OrbitCam) {
// TODO: Move this somewhere else
let sensitivity = 0.75;
let delta_x = delta.x / (win.physical_width() as f32) * consts::PI * 2.0 * sensitivity;
let delta_y = delta.y / (win.physical_height() as f32) * consts::PI * sensitivity;
let yaw = Quat::from_rotation_y(-delta_x);
let pitch = Quat::from_rotation_x(-delta_y);
// Rotate around global Y and local X
xform.rotation = yaw * xform.rotation;
xform.rotation *= pitch;
// Update focus point
orbit_cam.focus = xform.translation + xform.forward() * orbit_cam.radius;
}
/// Fit a [0, 1] `t` interpolation parameter to a sine curve
///
/// https://www.desmos.com/calculator/k9x4c4cr5y
#[inline]
fn curve_sin(t: f32) -> f32 {
-1. * (f32::cos(t * consts::PI) / 2.) + 0.5
}
trait NearlyEq {
type Rhs;
fn nearly_eq(self, rhs: Self::Rhs) -> bool;
}
impl NearlyEq for f32 {
type Rhs = Self;
fn nearly_eq(self, rhs: Self::Rhs) -> bool {
(self - rhs).abs() < Self::EPSILON
}
}
impl NearlyEq for Vec3 {
type Rhs = Self;
fn nearly_eq(self, rhs: Self::Rhs) -> bool {
self.x.nearly_eq(rhs.x) && self.y.nearly_eq(rhs.y) && self.z.nearly_eq(rhs.z)
}
}
+2 -5
View File
@@ -6,7 +6,7 @@ use bevy::{
render::render_resource::{AsBindGroup, ShaderRef},
};
use crate::orbit_cam::{self, OrbitCam};
use crate::orbit_cam::{CameraMotionSystem, OrbitCam};
pub struct TestScenePlugin;
@@ -15,10 +15,7 @@ impl Plugin for TestScenePlugin {
app.insert_resource(ClearColor(Color::hsl(0.0, 0.0, 0.25)))
.add_plugins(MaterialPlugin::<ProceduralGridMaterial>::default())
.add_systems(Startup, spawn_scene)
.add_systems(
PostUpdate,
move_plane_with_camera.after(orbit_cam::pan_orbit_camera),
);
.add_systems(PostUpdate, move_plane_with_camera.after(CameraMotionSystem));
}
}