From ce6db7fc3cb14e2274475dec04af6fd947c2dbc8 Mon Sep 17 00:00:00 2001 From: Danny McGee Date: Tue, 31 Oct 2023 16:39:10 -0400 Subject: [PATCH] Refactoring, reorganization and cleanup (#7) ### New `math` module * Traits for error-tolerant floating-point comparisons * Traits for interpolation and easing * Bevy plugin to manage 3D interpolations with ECS components ### `skybox` module refactor * ECS resource to enable external skybox selection/updates * System run conditions for more idiomatic/ECS-friendly control flow ### Camera controller refactor * Renamed from `orbit_cam` to `camera_controller` * Now delegates to the new interpolation plugin for WASD motion smoothing * Simple API for attaching the `CameraController` component to the camera from another module -- this resolves an awkward separation-of-concerns situation where we had to configure a bunch of render-quality settings in the camera controller module ### Scene module refactor * Renamed from `test_scene` to `scene` * This module is now responsible for configuring the skybox * This module is now responsible for spawning the camera and configuring the render settings --- studio/src/camera_controller.rs | 267 ++++++++++++++++++++ studio/src/lib.rs | 19 +- studio/src/math/float_comparison.rs | 81 ++++++ studio/src/math/internal.rs | 34 +++ studio/src/math/interp/ecs.rs | 129 ++++++++++ studio/src/math/interp/mod.rs | 19 ++ studio/src/math/interp/traits.rs | 152 ++++++++++++ studio/src/math/mod.rs | 6 + studio/src/orbit_cam.rs | 329 ------------------------- studio/src/{test_scene.rs => scene.rs} | 50 +++- studio/src/skybox.rs | 124 +++++----- 11 files changed, 811 insertions(+), 399 deletions(-) create mode 100644 studio/src/camera_controller.rs create mode 100644 studio/src/math/float_comparison.rs create mode 100644 studio/src/math/internal.rs create mode 100644 studio/src/math/interp/ecs.rs create mode 100644 studio/src/math/interp/mod.rs create mode 100644 studio/src/math/interp/traits.rs create mode 100644 studio/src/math/mod.rs delete mode 100644 studio/src/orbit_cam.rs rename studio/src/{test_scene.rs => scene.rs} (68%) diff --git a/studio/src/camera_controller.rs b/studio/src/camera_controller.rs new file mode 100644 index 0000000..0e7c15b --- /dev/null +++ b/studio/src/camera_controller.rs @@ -0,0 +1,267 @@ +use std::f32::consts; + +use bevy::{ + prelude::*, + window::{CursorGrabMode, PrimaryWindow}, +}; + +use crate::{ + input::{Action, CameraMotion, InputAction, Lifecycle}, + math::{Easing, InterpVelocity, Interpolation, InterpolationSystem, NearlyEq, Velocity}, +}; + +pub struct CameraControllerPlugin; + +#[derive(SystemSet, Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub struct CameraMotionSystem; + +impl Plugin for CameraControllerPlugin { + fn build(&self, app: &mut App) { + app.add_systems( + Update, + ( + camera_control_kinematic, + process_physical_inputs.before(InterpolationSystem), + camera_control_physical.after(InterpolationSystem), + ) + .in_set(CameraMotionSystem), + ); + } +} + +#[derive(Component)] +pub struct CameraController { + pub focus: Vec3, + pub radius: f32, + pub upside_down: bool, +} + +impl Default for CameraController { + fn default() -> Self { + Self { + focus: Vec3::ZERO, + radius: 10.0, + upside_down: false, + } + } +} + +#[derive(Bundle)] +pub struct CameraControllerBundle { + controller: CameraController, + velocity: Velocity, +} + +impl CameraControllerBundle { + pub fn new(focus: Vec3, position: Vec3) -> Self { + Self { + controller: CameraController { + focus, + radius: Vec3::distance(position, focus), + ..default() + }, + velocity: Velocity(Vec3::ZERO), + } + } +} + +fn camera_control_kinematic( + mut q_window: Query<&mut Window, With>, + mut input: EventReader, + mut q_camera: Query<(&mut CameraController, &mut Transform, &Projection)>, +) { + if input.is_empty() { + return; + } + + let (mut controller, mut xform, proj) = q_camera.single_mut(); + let mut window = q_window.single_mut(); + + let mut any_ongoing = false; + let mut any_started = false; + let mut any_stopped = false; + + for action in input.iter().filter(|action| { + !matches!( + action, + InputAction(_, Action::Camera(CameraMotion::Translate(_))) + ) + }) { + #[allow(irrefutable_let_patterns)] // Will eventually be more variants + if let InputAction(lifecycle, Action::Camera(motion)) = action { + use CameraMotion::*; + + match lifecycle { + Lifecycle::Start => any_started = true, + Lifecycle::Ongoing => any_ongoing = true, + Lifecycle::Stop => any_stopped = true, + } + + match motion { + Pan(delta) => do_pan(proj, &window, *delta, &mut xform, &mut controller), + Zoom(delta) => do_zoom(&window, *delta, &mut xform, &mut controller), + Orbit(delta) => do_orbit(&window, *delta, *lifecycle, &mut xform, &mut controller), + Rotate(delta) => do_rotate(&window, *delta, &mut xform, &mut controller), + _ => {} + } + } + } + + if any_started { + capture_pointer(&mut window); + } else if any_stopped && !any_ongoing { + release_pointer(&mut window); + } +} + +fn process_physical_inputs( + mut cmd: Commands, + mut input: EventReader, + q_camera: Query<(Entity, &Velocity), With>, + mut q_interp: Query<&mut InterpVelocity>, +) { + for action in input.iter() { + let &InputAction(_, Action::Camera(CameraMotion::Translate(delta))) = action else { + continue; + }; + + for (ent, velocity) in q_camera.iter() { + let target = delta * 0.075; + + if let Ok(mut interp) = q_interp.get_mut(ent) { + if interp.target.nearly_eq_within(target, 1e-3) { + continue; + } + + interp.initial = **velocity; + interp.target = target; + interp.t = 0.; + } else { + cmd.entity(ent).insert(InterpVelocity { + inner: Interpolation { + initial: **velocity, + target, + easing: Easing::Sine, + ..default() + }, + accel_zero2max: 0.333, + }); + } + } + } +} + +fn camera_control_physical( + mut q_camera: Query<(&mut CameraController, &mut Transform, &Velocity)>, +) { + for (mut controller, mut xform, velocity) in q_camera.iter_mut() { + let delta_pos = xform.rotation * (**velocity); + xform.translation += delta_pos; + controller.focus += delta_pos; + } +} + +#[inline] +fn capture_pointer(window: &mut Window) { + window.cursor.visible = false; + window.cursor.grab_mode = CursorGrabMode::Confined; +} + +#[inline] +fn release_pointer(window: &mut Window) { + window.cursor.visible = true; + window.cursor.grab_mode = CursorGrabMode::None; +} + +#[inline] +fn do_orbit( + win: &Window, + delta: Vec2, + event_lifecycle: Lifecycle, + xform: &mut Transform, + controller: &mut CameraController, +) { + // Set `upside_down` flag if the event just started or stopped + if matches!(event_lifecycle, Lifecycle::Start | Lifecycle::Stop) { + controller.upside_down = xform.up().y <= 0.; + } + + // Translate to origin + xform.translation += xform.forward() * controller.radius; + + // Calculate rotation + let delta_x = { + let delta = delta.x / (win.physical_width() as f32) * consts::PI * 2.0; + match controller.upside_down { + true => -delta, + false => delta, + } + }; + 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); + + // Rotate around global Y and local X + xform.rotation = yaw * xform.rotation; + xform.rotation *= pitch; + + // Translate back to the original position + xform.translation += xform.back() * controller.radius; +} + +#[inline] +fn do_pan( + proj: &Projection, + win: &Window, + delta: Vec2, + xform: &mut Transform, + controller: &mut CameraController, +) { + // Normalize the pan distance with respect to the window resolution + let pan = match proj { + Projection::Perspective(p) => { + delta + * Vec2 { + x: (p.fov * p.aspect_ratio) / (win.physical_width() as f32), + y: p.fov / (win.physical_height() as f32), + } + } + _ => 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) * controller.radius; + + // Update camera translation and focus point + xform.translation += delta; + controller.focus += delta; +} + +#[inline] +fn do_zoom(win: &Window, delta: f32, xform: &mut Transform, controller: &mut CameraController) { + let delta = (delta * 1.5) / (win.physical_height() as f32); + controller.radius -= controller.radius * delta; + xform.translation = controller.focus + xform.back() * controller.radius; +} + +#[inline] +fn do_rotate(win: &Window, delta: Vec2, xform: &mut Transform, controller: &mut CameraController) { + // 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 + controller.focus = xform.translation + xform.forward() * controller.radius; +} diff --git a/studio/src/lib.rs b/studio/src/lib.rs index f410558..b22ce9b 100644 --- a/studio/src/lib.rs +++ b/studio/src/lib.rs @@ -1,18 +1,20 @@ #![allow(clippy::type_complexity)] use bevy::prelude::*; +use camera_controller::{CameraController, CameraControllerPlugin, CameraMotionSystem}; use input::StudioInputPlugin; -use orbit_cam::{CameraMotionSystem, OrbitCam, OrbitCamPlugin}; +use math::InterpolationPlugin; +use scene::ScenePlugin; use skybox::SkyboxPlugin; -use test_scene::TestScenePlugin; use ui::UiLayerPlugin; use viewport::ViewportSize; use wasm_bindgen::prelude::*; +mod camera_controller; mod input; -mod orbit_cam; +mod math; +mod scene; mod skybox; -mod test_scene; mod ui; mod viewport; @@ -36,8 +38,9 @@ pub fn init(viewport_size: ViewportSize) { ..default() }), StudioInputPlugin, - OrbitCamPlugin, - TestScenePlugin, + InterpolationPlugin, + CameraControllerPlugin, + ScenePlugin, SkyboxPlugin, UiLayerPlugin, )) @@ -45,8 +48,8 @@ pub fn init(viewport_size: ViewportSize) { .run(); } -fn draw_world_axes(mut gizmos: Gizmos, q: Query<&OrbitCam>) { - let &OrbitCam { focus, radius, .. } = q.single(); +fn draw_world_axes(mut gizmos: Gizmos, q: Query<&CameraController>) { + let &CameraController { focus, radius, .. } = q.single(); let mut draw_axis = |direction: Vec3, color: Color| { let end = focus + direction * radius / 15.; diff --git a/studio/src/math/float_comparison.rs b/studio/src/math/float_comparison.rs new file mode 100644 index 0000000..10cc469 --- /dev/null +++ b/studio/src/math/float_comparison.rs @@ -0,0 +1,81 @@ +//! Helper traits for floating-point equality checks with configurable tolerance. + +use bevy::prelude::Vec3; + +use super::internal::Zero; + +pub trait NearlyEq { + const DEFAULT_TOLERANCE: Tolerance; + + #[inline] + fn nearly_eq(self, rhs: Rhs) -> bool + where + Self: Sized, + { + self.nearly_eq_within(rhs, Self::DEFAULT_TOLERANCE) + } + + fn nearly_eq_within(self, rhs: Rhs, tolerance: Tolerance) -> bool; +} + +impl NearlyEq for f32 { + const DEFAULT_TOLERANCE: f32 = f32::EPSILON; + + #[inline] + fn nearly_eq_within(self, rhs: f32, tolerance: f32) -> bool { + (self - rhs).abs() < tolerance + } +} + +impl NearlyEq for f64 { + const DEFAULT_TOLERANCE: f64 = f64::EPSILON; + + #[inline] + fn nearly_eq_within(self, rhs: f64, tolerance: f64) -> bool { + (self - rhs).abs() < tolerance + } +} + +impl NearlyEq for Vec3 { + const DEFAULT_TOLERANCE: f32 = >::DEFAULT_TOLERANCE; + + fn nearly_eq_within(self, rhs: Vec3, tolerance: f32) -> bool { + self.x.nearly_eq_within(rhs.x, tolerance) + && self.y.nearly_eq_within(rhs.y, tolerance) + && self.z.nearly_eq_within(rhs.z, tolerance) + } +} + +impl Zero for Vec3 { + #[inline] + fn zero() -> Self { + Vec3::ZERO + } +} + +pub trait NearlyZero { + const DEFAULT_TOLERANCE: Tolerance; + + #[inline] + fn nearly_zero(self) -> bool + where + Self: Sized, + { + self.nearly_zero_within(Self::DEFAULT_TOLERANCE) + } + + fn nearly_zero_within(self, tolerance: Tolerance) -> bool; +} + +impl NearlyZero for T +where + T: Zero, + T: NearlyEq, +{ + const DEFAULT_TOLERANCE: Tolerance = >::DEFAULT_TOLERANCE; + + #[inline] + fn nearly_zero_within(self, tolerance: Tolerance) -> bool { + self.nearly_eq_within(::zero(), tolerance) + } +} diff --git a/studio/src/math/internal.rs b/studio/src/math/internal.rs new file mode 100644 index 0000000..881a747 --- /dev/null +++ b/studio/src/math/internal.rs @@ -0,0 +1,34 @@ +//! Helpers to make it easier to implement traits for both `f32` and `f64` +//! without needing to copy and paste identical implementations. + +#[rustfmt::skip] pub(super) trait Zero { fn zero() -> Self; } +#[rustfmt::skip] impl Zero for f32 { #[inline] fn zero() -> Self { 0_f32 } } +#[rustfmt::skip] impl Zero for f64 { #[inline] fn zero() -> Self { 0_f64 } } + +#[rustfmt::skip] pub(super) trait One { fn one() -> Self; } +#[rustfmt::skip] impl One for f32 { #[inline] fn one() -> Self { 1_f32 } } +#[rustfmt::skip] impl One for f64 { #[inline] fn one() -> Self { 1_f64 } } + +pub(super) trait Clamp { + fn clamp(self, min: Self, max: Self) -> Self; +} + +impl Clamp for T +where + T: Sized + Copy + PartialOrd + core::fmt::Debug, +{ + #[inline] + fn clamp(mut self, min: Self, max: Self) -> Self { + assert!( + min <= max, + "min > max, or either was NaN. min = {min:?}, max = {max:?}" + ); + if self < min { + self = min + } + if self > max { + self = max + } + self + } +} diff --git a/studio/src/math/interp/ecs.rs b/studio/src/math/interp/ecs.rs new file mode 100644 index 0000000..bdf9b60 --- /dev/null +++ b/studio/src/math/interp/ecs.rs @@ -0,0 +1,129 @@ +use bevy::prelude::*; + +use crate::math::{NearlyEq, NearlyZero}; + +use super::{CurveSin, Easing, Lerp}; + +pub struct InterpolationPlugin; + +#[derive(SystemSet, Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub struct InterpolationSystem; + +impl Plugin for InterpolationPlugin { + fn build(&self, app: &mut App) { + app.add_systems( + Update, + (interp_velocity, interp_position).in_set(InterpolationSystem), + ) + .add_systems(PostUpdate, cleanup_finished_interps); + } +} + +/// A `newtype` component for a `t` interpolation parameter. +#[derive(Component, Deref, DerefMut, Debug, Clone, Copy)] +pub struct Interp(pub f32); + +pub struct Interpolation +where + f32: Lerp, +{ + pub initial: T, + pub target: T, + pub t: f32, + pub easing: Easing, +} + +impl Interpolation +where + f32: Lerp, + T: Copy, +{ + fn tick(&mut self, dt: f32) -> T { + self.t = (self.t + dt).clamp(0., 1.); + self.current() + } + + pub fn current(&self) -> T { + if self.t.nearly_zero() { + self.initial + } else if self.t.nearly_eq(1.) { + self.target + } else { + let t = match self.easing { + Easing::Linear => self.t, + Easing::Sine => self.t.curve_sin(), + }; + + t.lerp(self.initial, self.target) + } + } +} + +impl Default for Interpolation +where + f32: Lerp, + T: Default, +{ + fn default() -> Self { + Self { + initial: default(), + target: default(), + t: 0., + easing: Easing::Linear, + } + } +} + +#[derive(Component, Deref, DerefMut)] +pub struct InterpVelocity { + #[deref] + pub inner: Interpolation, + pub accel_zero2max: f32, +} + +// TODO: Does this belong here? +#[derive(Component, Deref, DerefMut)] +pub struct Velocity(pub Vec3); + +#[derive(Component, Deref, DerefMut)] +pub struct InterpPosition { + #[deref] + pub inner: Interpolation, + pub duration: f32, +} + +fn interp_velocity(time: Res