diff --git a/Cargo.toml b/Cargo.toml index ab9da009fd..9c43da5a15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2284,6 +2284,17 @@ description = "Illustrates how to (constantly) rotate an object around an axis" category = "Transforms" wasm = true +[[example]] +name = "align" +path = "examples/transforms/align.rs" +doc-scrape-examples = true + +[package.metadata.example.align] +name = "Alignment" +description = "A demonstration of Transform's axis-alignment feature" +category = "Transforms" +wasm = true + [[example]] name = "scale" path = "examples/transforms/scale.rs" diff --git a/crates/bevy_transform/src/components/transform.rs b/crates/bevy_transform/src/components/transform.rs index ed8b004074..ae58c50d85 100644 --- a/crates/bevy_transform/src/components/transform.rs +++ b/crates/bevy_transform/src/components/transform.rs @@ -145,6 +145,39 @@ impl Transform { self } + /// Returns this [`Transform`] with a rotation so that the `handle` vector, reinterpreted in local coordinates, + /// points in the given `direction`, while `weak_handle` points towards `weak_direction`. + /// + /// For example, if a spaceship model has its nose pointing in the X-direction in its own local coordinates + /// and its dorsal fin pointing in the Y-direction, then `Transform::aligned_by(Vec3::X, v, Vec3::Y, w)` will + /// make the spaceship's nose point in the direction of `v`, while the dorsal fin does its best to point in the + /// direction `w`. + /// + /// In some cases a rotation cannot be constructed. Another axis will be picked in those cases: + /// * if `handle` or `direction` is zero, `Vec3::X` takes its place + /// * if `weak_handle` or `weak_direction` is zero, `Vec3::Y` takes its place + /// * if `handle` is parallel with `weak_handle` or `direction` is parallel with `weak_direction`, a rotation is + /// constructed which takes `handle` to `direction` but ignores the weak counterparts (i.e. is otherwise unspecified) + /// + /// See [`Transform::align`] for additional details. + #[inline] + #[must_use] + pub fn aligned_by( + mut self, + main_axis: Vec3, + main_direction: Vec3, + secondary_axis: Vec3, + secondary_direction: Vec3, + ) -> Self { + self.align( + main_axis, + main_direction, + secondary_axis, + secondary_direction, + ); + self + } + /// Returns this [`Transform`] with a new translation. #[inline] #[must_use] @@ -366,6 +399,87 @@ impl Transform { self.rotation = Quat::from_mat3(&Mat3::from_cols(right, up, back)); } + /// Rotates this [`Transform`] so that the `main_axis` vector, reinterpreted in local coordinates, points + /// in the given `main_direction`, while `secondary_axis` points towards `secondary_direction`. + /// + /// For example, if a spaceship model has its nose pointing in the X-direction in its own local coordinates + /// and its dorsal fin pointing in the Y-direction, then `align(Vec3::X, v, Vec3::Y, w)` will make the spaceship's + /// nose point in the direction of `v`, while the dorsal fin does its best to point in the direction `w`. + /// + /// More precisely, the [`Transform::rotation`] produced will be such that: + /// * applying it to `main_axis` results in `main_direction` + /// * applying it to `secondary_axis` produces a vector that lies in the half-plane generated by `main_direction` and + /// `secondary_direction` (with positive contribution by `secondary_direction`) + /// + /// [`Transform::look_to`] is recovered, for instance, when `main_axis` is `Vec3::NEG_Z` (the [`Transform::forward`] + /// direction in the default orientation) and `secondary_axis` is `Vec3::Y` (the [`Transform::up`] direction in the default + /// orientation). (Failure cases may differ somewhat.) + /// + /// In some cases a rotation cannot be constructed. Another axis will be picked in those cases: + /// * if `main_axis` or `main_direction` is zero, `Vec3::X` takes its place + /// * if `secondary_axis` or `secondary_direction` is zero, `Vec3::Y` takes its place + /// * if `main_axis` is parallel with `secondary_axis` or `main_direction` is parallel with `secondary_direction`, + /// a rotation is constructed which takes `main_axis` to `main_direction` along a great circle, ignoring the secondary + /// counterparts + /// + /// Example + /// ``` + /// # use bevy_math::{Vec3, Quat}; + /// # use bevy_transform::components::Transform; + /// # let mut t1 = Transform::IDENTITY; + /// # let mut t2 = Transform::IDENTITY; + /// t1.align(Vec3::X, Vec3::Y, Vec3::new(1., 1., 0.), Vec3::Z); + /// let main_axis_image = t1.rotation * Vec3::X; + /// let secondary_axis_image = t1.rotation * Vec3::new(1., 1., 0.); + /// assert!(main_axis_image.abs_diff_eq(Vec3::Y, 1e-5)); + /// assert!(secondary_axis_image.abs_diff_eq(Vec3::new(0., 1., 1.), 1e-5)); + /// + /// t1.align(Vec3::ZERO, Vec3::Z, Vec3::ZERO, Vec3::X); + /// t2.align(Vec3::X, Vec3::Z, Vec3::Y, Vec3::X); + /// assert_eq!(t1.rotation, t2.rotation); + /// + /// t1.align(Vec3::X, Vec3::Z, Vec3::X, Vec3::Y); + /// assert_eq!(t1.rotation, Quat::from_rotation_arc(Vec3::X, Vec3::Z)); + /// ``` + #[inline] + pub fn align( + &mut self, + main_axis: Vec3, + main_direction: Vec3, + secondary_axis: Vec3, + secondary_direction: Vec3, + ) { + let main_axis = main_axis.try_normalize().unwrap_or(Vec3::X); + let main_direction = main_direction.try_normalize().unwrap_or(Vec3::X); + let secondary_axis = secondary_axis.try_normalize().unwrap_or(Vec3::Y); + let secondary_direction = secondary_direction.try_normalize().unwrap_or(Vec3::Y); + + // The solution quaternion will be constructed in two steps. + // First, we start with a rotation that takes `main_axis` to `main_direction`. + let first_rotation = Quat::from_rotation_arc(main_axis, main_direction); + + // Let's follow by rotating about the `main_direction` axis so that the image of `secondary_axis` + // is taken to something that lies in the plane of `main_direction` and `secondary_direction`. Since + // `main_direction` is fixed by this rotation, the first criterion is still satisfied. + let secondary_image = first_rotation * secondary_axis; + let secondary_image_ortho = secondary_image + .reject_from_normalized(main_direction) + .try_normalize(); + let secondary_direction_ortho = secondary_direction + .reject_from_normalized(main_direction) + .try_normalize(); + + // If one of the two weak vectors was parallel to `main_direction`, then we just do the first part + self.rotation = match (secondary_image_ortho, secondary_direction_ortho) { + (Some(secondary_img_ortho), Some(secondary_dir_ortho)) => { + let second_rotation = + Quat::from_rotation_arc(secondary_img_ortho, secondary_dir_ortho); + second_rotation * first_rotation + } + _ => first_rotation, + }; + } + /// Multiplies `self` with `transform` component by component, returning the /// resulting [`Transform`] #[inline] diff --git a/examples/README.md b/examples/README.md index 47c59cb906..74795cbaef 100644 --- a/examples/README.md +++ b/examples/README.md @@ -388,6 +388,7 @@ Example | Description Example | Description --- | --- [3D Rotation](../examples/transforms/3d_rotation.rs) | Illustrates how to (constantly) rotate an object around an axis +[Alignment](../examples/transforms/align.rs) | A demonstration of Transform's axis-alignment feature [Scale](../examples/transforms/scale.rs) | Illustrates how to scale an object in each direction [Transform](../examples/transforms/transform.rs) | Shows multiple transformations of objects [Translation](../examples/transforms/translation.rs) | Illustrates how to move an object along an axis diff --git a/examples/transforms/align.rs b/examples/transforms/align.rs new file mode 100644 index 0000000000..1879868da0 --- /dev/null +++ b/examples/transforms/align.rs @@ -0,0 +1,260 @@ +//! This example shows how to align the orientations of objects in 3D space along two axes using the `Transform::align` API. + +use bevy::color::{ + palettes::basic::{GRAY, RED, WHITE}, + Color, +}; +use bevy::input::mouse::{MouseButton, MouseButtonInput, MouseMotion}; +use bevy::prelude::*; +use rand::random; +use std::f32::consts::PI; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, setup) + .add_systems(Update, (draw_cube_axes, draw_random_axes)) + .add_systems(Update, (handle_keypress, handle_mouse, rotate_cube).chain()) + .run(); +} + +/// This struct stores metadata for a single rotational move of the cube +#[derive(Component, Default)] +struct Cube { + /// The initial transform of the cube move, the starting point of interpolation + initial_transform: Transform, + + /// The target transform of the cube move, the endpoint of interpolation + target_transform: Transform, + + /// The progress of the cube move in percentage points + progress: u16, + + /// Whether the cube is currently in motion; allows motion to be paused + in_motion: bool, +} + +#[derive(Component)] +struct RandomAxes(Vec3, Vec3); + +#[derive(Component)] +struct Instructions; + +#[derive(Resource)] +struct MousePressed(bool); + +// Setup + +fn setup( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + // A camera looking at the origin + commands.spawn(Camera3dBundle { + transform: Transform::from_xyz(3., 2.5, 4.).looking_at(Vec3::ZERO, Vec3::Y), + ..default() + }); + + // A plane that we can sit on top of + commands.spawn(PbrBundle { + transform: Transform::from_xyz(0., -2., 0.), + mesh: meshes.add(Plane3d::default().mesh().size(100.0, 100.0)), + material: materials.add(Color::srgb(0.3, 0.5, 0.3)), + ..default() + }); + + // A light source + commands.spawn(PointLightBundle { + point_light: PointLight { + shadows_enabled: true, + ..default() + }, + transform: Transform::from_xyz(4.0, 7.0, -4.0), + ..default() + }); + + // Initialize random axes + let first = random_direction(); + let second = random_direction(); + commands.spawn(RandomAxes(first, second)); + + // Finally, our cube that is going to rotate + commands.spawn(( + PbrBundle { + mesh: meshes.add(Cuboid::new(1.0, 1.0, 1.0)), + material: materials.add(Color::srgb(0.5, 0.5, 0.5)), + ..default() + }, + Cube { + initial_transform: Transform::IDENTITY, + target_transform: random_axes_target_alignment(&RandomAxes(first, second)), + ..default() + }, + )); + + // Instructions for the example + commands.spawn(( + TextBundle::from_section( + "The bright red axis is the primary alignment axis, and it will always be\n\ + made to coincide with the primary target direction (white) exactly.\n\ + The fainter red axis is the secondary alignment axis, and it is made to\n\ + line up with the secondary target direction (gray) as closely as possible.\n\ + Press 'R' to generate random target directions.\n\ + Press 'T' to align the cube to those directions.\n\ + Click and drag the mouse to rotate the camera.\n\ + Press 'H' to hide/show these instructions.", + TextStyle { + font_size: 20., + ..default() + }, + ) + .with_style(Style { + position_type: PositionType::Absolute, + top: Val::Px(12.0), + left: Val::Px(12.0), + ..default() + }), + Instructions, + )); + + commands.insert_resource(MousePressed(false)); +} + +// Update systems + +// Draw the main and secondary axes on the rotating cube +fn draw_cube_axes(mut gizmos: Gizmos, query: Query<&Transform, With>) { + let cube_transform = query.single(); + + // Local X-axis arrow + let x_ends = arrow_ends(cube_transform, Vec3::X, 1.5); + gizmos.arrow(x_ends.0, x_ends.1, RED); + + // local Y-axis arrow + let y_ends = arrow_ends(cube_transform, Vec3::Y, 1.5); + gizmos.arrow(y_ends.0, y_ends.1, Color::srgb(0.65, 0., 0.)); +} + +// Draw the randomly generated axes +fn draw_random_axes(mut gizmos: Gizmos, query: Query<&RandomAxes>) { + let RandomAxes(v1, v2) = query.single(); + gizmos.arrow(Vec3::ZERO, 1.5 * *v1, WHITE); + gizmos.arrow(Vec3::ZERO, 1.5 * *v2, GRAY); +} + +// Actually update the cube's transform according to its initial source and target +fn rotate_cube(mut cube: Query<(&mut Cube, &mut Transform)>) { + let (mut cube, mut cube_transform) = cube.single_mut(); + + if !cube.in_motion { + return; + } + + let start = cube.initial_transform.rotation; + let end = cube.target_transform.rotation; + + let p: f32 = cube.progress.into(); + let t = p / 100.; + + *cube_transform = Transform::from_rotation(start.slerp(end, t)); + + if cube.progress == 100 { + cube.in_motion = false; + } else { + cube.progress += 1; + } +} + +// Handle user inputs from the keyboard for dynamically altering the scenario +fn handle_keypress( + mut cube: Query<(&mut Cube, &Transform)>, + mut random_axes: Query<&mut RandomAxes>, + mut instructions: Query<&mut Visibility, With>, + keyboard: Res>, +) { + let (mut cube, cube_transform) = cube.single_mut(); + let mut random_axes = random_axes.single_mut(); + + if keyboard.just_pressed(KeyCode::KeyR) { + // Randomize the target axes + let first = random_direction(); + let second = random_direction(); + *random_axes = RandomAxes(first, second); + + // Stop the cube and set it up to transform from its present orientation to the new one + cube.in_motion = false; + cube.initial_transform = *cube_transform; + cube.target_transform = random_axes_target_alignment(&random_axes); + cube.progress = 0; + } + + if keyboard.just_pressed(KeyCode::KeyT) { + cube.in_motion ^= true; + } + + if keyboard.just_pressed(KeyCode::KeyH) { + let mut instructions_viz = instructions.single_mut(); + if *instructions_viz == Visibility::Hidden { + *instructions_viz = Visibility::Visible; + } else { + *instructions_viz = Visibility::Hidden; + } + } +} + +// Handle user mouse input for panning the camera around +fn handle_mouse( + mut button_events: EventReader, + mut motion_events: EventReader, + mut camera: Query<&mut Transform, With>, + mut mouse_pressed: ResMut, +) { + // Store left-pressed state in the MousePressed resource + for button_event in button_events.read() { + if button_event.button != MouseButton::Left { + continue; + } + *mouse_pressed = MousePressed(button_event.state.is_pressed()); + } + + // If the mouse is not pressed, just ignore motion events + if !mouse_pressed.0 { + return; + } + let displacement = motion_events + .read() + .fold(0., |acc, mouse_motion| acc + mouse_motion.delta.x); + let mut camera_transform = camera.single_mut(); + camera_transform.rotate_around(Vec3::ZERO, Quat::from_rotation_y(-displacement / 75.)); +} + +// Helper functions (i.e. non-system functions) + +fn arrow_ends(transform: &Transform, axis: Vec3, length: f32) -> (Vec3, Vec3) { + let local_vector = length * (transform.rotation * axis); + (transform.translation, transform.translation + local_vector) +} + +fn random_direction() -> Vec3 { + let height = random::() * 2. - 1.; + let theta = random::() * 2. * PI; + + build_direction(height, theta) +} + +fn build_direction(height: f32, theta: f32) -> Vec3 { + let z = height; + let m = f32::acos(z).sin(); + let x = theta.cos() * m; + let y = theta.sin() * m; + + Vec3::new(x, y, z) +} + +// This is where `Transform::align` is actually used! +// Note that the choice of `Vec3::X` and `Vec3::Y` here matches the use of those in `draw_cube_axes`. +fn random_axes_target_alignment(random_axes: &RandomAxes) -> Transform { + let RandomAxes(first, second) = random_axes; + Transform::IDENTITY.aligned_by(Vec3::X, *first, Vec3::Y, *second) +}