From ac63c491fb0d6bd8852e0d7e57280d4c3da35faa Mon Sep 17 00:00:00 2001 From: Cameron Hart Date: Tue, 25 Jan 2022 22:10:11 +0000 Subject: [PATCH] Simple 2d rotation example (#3065) # Objective Some new bevy users are unfamiliar with quaternions and have trouble working with rotations in 2D. There has been an [issue](https://github.com/bitshifter/glam-rs/issues/226) raised with glam to add helpers to better support these users, however for now I feel could be better to provide examples of how to do this in Bevy as a starting point for new users. ## Solution I've added a 2d_rotation example which demonstrates 3 different rotation examples to try help get people started: - Rotating and translating a player ship based on keyboard input - An enemy ship type that rotates to face the player ship immediately - An enemy ship type that rotates to face the player at a fixed angular velocity I also have a standalone version of this example here https://github.com/bitshifter/bevy-2d-rotation-example but I think it would be more discoverable if it's included with Bevy. --- CREDITS.md | 1 + Cargo.toml | 4 + assets/textures/simplespace/License.txt | 22 +++ assets/textures/simplespace/enemy_A.png | Bin 0 -> 1151 bytes assets/textures/simplespace/enemy_B.png | Bin 0 -> 1095 bytes assets/textures/simplespace/ship_C.png | Bin 0 -> 615 bytes examples/2d/rotation.rs | 251 ++++++++++++++++++++++++ examples/README.md | 1 + 8 files changed, 279 insertions(+) create mode 100644 assets/textures/simplespace/License.txt create mode 100644 assets/textures/simplespace/enemy_A.png create mode 100644 assets/textures/simplespace/enemy_B.png create mode 100644 assets/textures/simplespace/ship_C.png create mode 100644 examples/2d/rotation.rs diff --git a/CREDITS.md b/CREDITS.md index b646a0662d..da6c38d6a8 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -20,3 +20,4 @@ * Cake from [Kenney's Food Kit](https://www.kenney.nl/assets/food-kit) (CC0 1.0 Universal) * Ground tile from [Kenney's Tower Defense Kit](https://www.kenney.nl/assets/tower-defense-kit) (CC0 1.0 Universal) * Game icons from [Kenney's Game Icons](https://www.kenney.nl/assets/game-icons) (CC0 1.0 Universal) +* Space ships from [Kenny's Simple Space Kit](https://www.kenney.nl/assets/simple-space) (CC0 1.0 Universal) diff --git a/Cargo.toml b/Cargo.toml index 812bcd4adf..9e0b659358 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,6 +119,10 @@ path = "examples/2d/contributors.rs" name = "many_sprites" path = "examples/2d/many_sprites.rs" +[[example]] +name = "2d_rotation" +path = "examples/2d/rotation.rs" + [[example]] name = "mesh2d" path = "examples/2d/mesh2d.rs" diff --git a/assets/textures/simplespace/License.txt b/assets/textures/simplespace/License.txt new file mode 100644 index 0000000000..bdba93fb4f --- /dev/null +++ b/assets/textures/simplespace/License.txt @@ -0,0 +1,22 @@ + + + Simple Space + + Created/distributed by Kenney (www.kenney.nl) + Creation date: 03-03-2021 + + ------------------------------ + + License: (Creative Commons Zero, CC0) + http://creativecommons.org/publicdomain/zero/1.0/ + + This content is free to use in personal, educational and commercial projects. + Support us by crediting Kenney or www.kenney.nl (this is not mandatory) + + ------------------------------ + + Donate: http://support.kenney.nl + Patreon: http://patreon.com/kenney/ + + Follow on Twitter for updates: + http://twitter.com/KenneyNL \ No newline at end of file diff --git a/assets/textures/simplespace/enemy_A.png b/assets/textures/simplespace/enemy_A.png new file mode 100644 index 0000000000000000000000000000000000000000..7db03b81a33201386cefb16ea6d919f2021e1b5e GIT binary patch literal 1151 zcmXAneM}Q~7{`Bw3Zsa;jmVf;mw*TZEZG~nu>uP^iGaYsmkj85$z(H?y1-b5(6S{E z{$QP`3pntQIm2cEH_Xr=6lGHr!4_yK(3ZPWD1E!$L9gxg4fb>Xa`!yX@9w$h_x*fx z?FIRtgs@^*0DzF3W7$P8XTG>!@rSW@w8a7w^K4dL765;WgS{2I0AO4#$SpqNc6%KT zH(U+}?eX|1%IWp)*lZ4&Y*{8#lcas;|7Ed|27{F#D1`!5D6~qYc5~AXw{khUvZ6;( zz16z2wq`b&2!jD%T{Yr3xwd98nQ$Dp&CO{jih^T|M(fs=9lp}*34(CybY{4PXL-F| zjb@#qXjs4q2g0N0=2ULC2NpC<%Va9GdToAwWqw{Gmt$}g{8p_I9%)NW|zz3_4;Vq z?Q*$El7h0~Scn4|p*af+7^GWYx9D^xkH-rgK+z4Wb!%e-UtC;WTGByUD@r5VpldEls;1EX0&bRnRmHGb}IbpzPw*hfXN{ zcwJHcr>K|*FP<1dP|2_a<)8=;+`kPs(eqm+;g z6P#flFBC!82<+vBf(JDRssZrh=459TpPs^%8x3;>(E$Q=Bf-}+)~ZkDlU()t#K>5u75TknJ zOj*oiP+!sSmMZ}QztlY2z`LSI5nt6-&A0|wUE`e~4~%sAUZnlg;W?ssk+aMHYw!!u zCtOGr;wT z2*$MB?&U=nqRb&|qJAf(QYeWGv*%&y^#ZKYQro&%gf<9iPDZ literal 0 HcmV?d00001 diff --git a/assets/textures/simplespace/enemy_B.png b/assets/textures/simplespace/enemy_B.png new file mode 100644 index 0000000000000000000000000000000000000000..fbc35f06bf6a6955c53f6b1882e110a543f7fe72 GIT binary patch literal 1095 zcmYk4drXs87{<#*!K!Nw)4Bmc6WDMoLM98sa4k?bU|0}{vLP4e219f@0a3$NE;?$a zLjs6#KoNtZFf3Rd+b~pvbD3aUTA)Dt^($@ZmG8+dmvvUF1N0$i*JLez};?-NVE*tAxLF3TIT0v zYPDfz1_G&kKA%#lSE*KKXJLSYAXdHJWH6XP__;Y57>?bJn$0$V1~M-!ETgDPF4qDO zFhhxCWob#lekTtbBmzoEYXe=0J(O5JXK}aNo1ml1Pg~Ne)vDvW*E+V3Y z#b9ta92}C(<8Vq?91<&*#fV@qXfTU}!(0yg=uZsBKQV)nl3y@s=&Yy}kau4BhFQaO zPZZV;j~9hJ7?}HvkaIZY`oQVdBbR^8*yU8asb#shucWoPqMdoFTHpn=Itw%-(=M+R2l`u50y4r{c3V`PFFl ziz5SPkKY+7N+pMLdB_d?SLELx?;EdAqKp>Aj}dQmP1t3=mwG}%4gJVa%>zP9@LSpI zC->`Vg)iZ9?wM+O8T;a60&a6wjBLuzzMIk<-I4j%CG!Xwu`J2+`MZfzNs=F$12-yI zU724(gE4y-6n$yLydNX2Bc-=p@aUfFx<-Q(AKphzY@eQN+$P_S?hzYbRPn5Xr%Vlk zdr#SMq@5unaL~5ee&|kO)Ol}@T(IlK3IF+Qlb^gY{^uw6huR78bjz*M2)mxnO0WCd zTD;c8BUbe!gsR^Dys`4(hc`bW`6g+RcKsnnme8%v;>3P(|rA{R6N!eoC zv%8_%W8K5C`eyXND2^F-6Us<_=eIiM6f#Z!Hn{GK#h;#?NDy`~>$9f=-FQ@0PUNIq5_TvCA1)8#QBF2!@T7~>{vFY&GV_48^Y9`uhaGl0^Js6`2caG0-m(M7 zw(rehMt<8 literal 0 HcmV?d00001 diff --git a/assets/textures/simplespace/ship_C.png b/assets/textures/simplespace/ship_C.png new file mode 100644 index 0000000000000000000000000000000000000000..4f5e493701a1a54afea9bdaaac0f2ea726892d20 GIT binary patch literal 615 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!SkfJR9T^xl_H+M9WCijM2l#}z z{`m3p?b{C@K79H5_4|t#?_R(D`0Ux+-@ku<{`~Ff(^v1`fByFE*OxCq^yU5gFE3xd z|MlzFlPB*VJb3Zw(W}3I|2}#0@;?lmKY#1|`P)DQH2dn+`xh_Xy?XV*?b}a)%C20w zcm4Xq3m5L(y!q(j#XFZT-~0Xh&(EK~E?v6&{rgX#)mN_E|MTY$(9&zy9^AY4?BhqE zb)RqCc=Y=9hg-KE10DAK`P(~po<4p0`q878_wPS{`}QNy4M3ZL)?L3Z*$=c*wj{_e z_&x~q0|AiG2ZQ_l_x11h>(77h&rox9n>Yglqnf9SV@Sl|x7Wg*4;hHGJ=Eem!l{wE zLvn2(%N_35|Nn=|NJ(FwGx=gs=Dpirs?X&%Ul!%IdBDj3M_hp?oaxruC$DzI%5h9q z>3m$VTZQ5ILTL>PEoPnb&f7D0w=&kr?Rp>&4I0^M#&aTNxD2IrA_2S{wf0z0DVm z&|;=1pD)DZy*xjiH48_Eo%`mWU)R1_UyGgLntf*A|4*{1%tvM&*K5=N+0(G;+1&o${y|j@F`wNo z-Zb%MmbkWuvHMu2NJ4D-i4MayhU=wbOefawnj5$NvM%?5S@sj#cm=ZOCbCR1&3wUF y)XU485TVV+8gYG7Bg65n27l})u>*a(l~vVI@3B7zcMmXV89ZJ6T-G@yGywofz+Nc; literal 0 HcmV?d00001 diff --git a/examples/2d/rotation.rs b/examples/2d/rotation.rs new file mode 100644 index 0000000000..9ca891fd11 --- /dev/null +++ b/examples/2d/rotation.rs @@ -0,0 +1,251 @@ +use bevy::{ + core::FixedTimestep, + math::{const_vec2, Vec3Swizzles}, + prelude::*, +}; + +const TIME_STEP: f32 = 1.0 / 60.0; +const BOUNDS: Vec2 = const_vec2!([1200.0, 640.0]); + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_startup_system(setup) + .add_system_set( + SystemSet::new() + .with_run_criteria(FixedTimestep::step(TIME_STEP as f64)) + .with_system(player_movement_system) + .with_system(snap_to_player_system) + .with_system(rotate_to_player_system), + ) + .add_system(bevy::input::system::exit_on_esc_system) + .run(); +} + +/// player component +#[derive(Component)] +struct Player { + /// linear speed in meters per second + movement_speed: f32, + /// rotation speed in radians per second + rotation_speed: f32, +} + +/// snap to player ship behavior +#[derive(Component)] +struct SnapToPlayer; + +/// rotate to face player ship behavior +#[derive(Component)] +struct RotateToPlayer { + /// rotation speed in radians per second + rotation_speed: f32, +} + +/// Add the game's entities to our world and creates an orthographic camera for 2D rendering. +/// +/// The Bevy coordinate system is the same for 2D and 3D, in terms of 2D this means that: +/// +/// * X axis goes from left to right (+X points right) +/// * Y axis goes from bottom to top (+Y point up) +/// * Z axis goes from far to near (+Z points towards you, out of the screen) +/// +/// The origin is at the center of the screen. +fn setup(mut commands: Commands, asset_server: Res) { + let ship_handle = asset_server.load("textures/simplespace/ship_C.png"); + let enemy_a_handle = asset_server.load("textures/simplespace/enemy_A.png"); + let enemy_b_handle = asset_server.load("textures/simplespace/enemy_B.png"); + + // 2D orthographic camera + commands.spawn_bundle(OrthographicCameraBundle::new_2d()); + + let horizontal_margin = BOUNDS.x / 4.0; + let vertical_margin = BOUNDS.y / 4.0; + + // player controlled ship + commands + .spawn_bundle(SpriteBundle { + texture: ship_handle, + ..Default::default() + }) + .insert(Player { + movement_speed: 500.0, // metres per second + rotation_speed: f32::to_radians(360.0), // degrees per second + }); + + // enemy that snaps to face the player spawns on the bottom and left + commands + .spawn_bundle(SpriteBundle { + texture: enemy_a_handle.clone(), + transform: Transform::from_xyz(0.0 - horizontal_margin, 0.0, 0.0), + ..Default::default() + }) + .insert(SnapToPlayer); + commands + .spawn_bundle(SpriteBundle { + texture: enemy_a_handle, + transform: Transform::from_xyz(0.0, 0.0 - vertical_margin, 0.0), + ..Default::default() + }) + .insert(SnapToPlayer); + + // enemy that rotates to face the player enemy spawns on the top and right + commands + .spawn_bundle(SpriteBundle { + texture: enemy_b_handle.clone(), + transform: Transform::from_xyz(0.0 + horizontal_margin, 0.0, 0.0), + ..Default::default() + }) + .insert(RotateToPlayer { + rotation_speed: f32::to_radians(45.0), // degrees per second + }); + commands + .spawn_bundle(SpriteBundle { + texture: enemy_b_handle, + transform: Transform::from_xyz(0.0, 0.0 + vertical_margin, 0.0), + ..Default::default() + }) + .insert(RotateToPlayer { + rotation_speed: f32::to_radians(90.0), // degrees per second + }); +} + +/// Demonstrates applying rotation and movement based on keyboard input. +fn player_movement_system( + keyboard_input: Res>, + mut query: Query<(&Player, &mut Transform)>, +) { + let (ship, mut transform) = query.single_mut(); + + let mut rotation_factor = 0.0; + let mut movement_factor = 0.0; + + if keyboard_input.pressed(KeyCode::Left) { + rotation_factor += 1.0; + } + + if keyboard_input.pressed(KeyCode::Right) { + rotation_factor -= 1.0; + } + + if keyboard_input.pressed(KeyCode::Up) { + movement_factor += 1.0; + } + + // create the change in rotation around the Z axis (perpendicular to the 2D plane of the screen) + let rotation_delta = Quat::from_rotation_z(rotation_factor * ship.rotation_speed * TIME_STEP); + // update the ship rotation with our rotation delta + transform.rotation *= rotation_delta; + + // get the ship's forward vector by applying the current rotation to the ships initial facing vector + let movement_direction = transform.rotation * Vec3::Y; + // get the distance the ship will move based on direction, the ship's movement speed and delta time + let movement_distance = movement_factor * ship.movement_speed * TIME_STEP; + // create the change in translation using the new movement direction and distance + let translation_delta = movement_direction * movement_distance; + // update the ship translation with our new translation delta + transform.translation += translation_delta; + + // bound the ship within the invisible level bounds + let extents = Vec3::from((BOUNDS / 2.0, 0.0)); + transform.translation = transform.translation.min(extents).max(-extents); +} + +/// Demonstrates snapping the enemy ship to face the player ship immediately. +fn snap_to_player_system( + mut query: Query<&mut Transform, (With, Without)>, + player_query: Query<&Transform, With>, +) { + let player_transform = player_query.single(); + // get the player translation in 2D + let player_translation = player_transform.translation.xy(); + + for mut enemy_transform in query.iter_mut() { + // get the vector from the enemy ship to the player ship in 2D and normalize it. + let to_player = (player_translation - enemy_transform.translation.xy()).normalize(); + + // get the quaternion to rotate from the initial enemy facing direction to the direction + // facing the player + let rotate_to_player = Quat::from_rotation_arc(Vec3::Y, Vec3::from((to_player, 0.0))); + + // rotate the enemy to face the player + enemy_transform.rotation = rotate_to_player; + } +} + +/// Demonstrates rotating an enemy ship to face the player ship at a given rotation speed. +/// +/// This method uses the vector dot product to determine if the enemy is facing the player and +/// if not, which way to rotate to face the player. The dot product on two unit length vectors +/// will return a value between -1.0 and +1.0 which tells us the following about the two vectors: +/// +/// * If the result is 1.0 the vectors are pointing in the same direction, the angle between them +/// is 0 degrees. +/// * If the result is 0.0 the vectors are perpendicular, the angle between them is 90 degrees. +/// * If the result is -1.0 the vectors are parallel but pointing in opposite directions, the angle +/// between them is 180 degrees. +/// * If the result is positive the vectors are pointing in roughly the same direction, the angle +/// between them is greater than 0 and less than 90 degrees. +/// * If the result is negative the vectors are pointing in roughly opposite directions, the angle +/// between them is greater than 90 and less than 180 degrees. +/// +/// It is possible to get the angle by taking the arc cosine (`acos`) of the dot product. It is +/// often unnecessary to do this though. Beware than `acos` will return `NaN` if the input is less +/// than -1.0 or greater than 1.0. This can happen even when working with unit vectors due to +/// floating point precision loss, so it pays to clamp your dot product value before calling +/// `acos`. +fn rotate_to_player_system( + mut query: Query<(&RotateToPlayer, &mut Transform), Without>, + player_query: Query<&Transform, With>, +) { + let player_transform = player_query.single(); + // get the player translation in 2D + let player_translation = player_transform.translation.xy(); + + for (config, mut enemy_transform) in query.iter_mut() { + // get the enemy ship forward vector in 2D (already unit length) + let enemy_forward = (enemy_transform.rotation * Vec3::Y).xy(); + + // get the vector from the enemy ship to the player ship in 2D and normalize it. + let to_player = (player_translation - enemy_transform.translation.xy()).normalize(); + + // get the dot product between the enemy forward vector and the direction to the player. + let forward_dot_player = enemy_forward.dot(to_player); + + // if the dot product is approximately 1.0 then the enemy is already facing the player and + // we can early out. + if (forward_dot_player - 1.0).abs() < f32::EPSILON { + continue; + } + + // get the right vector of the enemy ship in 2D (already unit length) + let enemy_right = (enemy_transform.rotation * Vec3::X).xy(); + + // get the dot product of the enemy right vector and the direction to the player ship. + // if the dot product is negative them we need to rotate counter clockwise, if it is + // positive we need to rotate clockwise. Note that `copysign` will still return 1.0 if the + // dot product is 0.0 (because the player is directly behind the enemy, so perpendicular + // with the right vector). + let right_dot_player = enemy_right.dot(to_player); + + // determine the sign of rotation from the right dot player. We need to negate the sign + // here as the 2D bevy co-ordinate system rotates around +Z, which is pointing out of the + // screen. Due to the right hand rule, positive rotation around +Z is counter clockwise and + // negative is clockwise. + let rotation_sign = -f32::copysign(1.0, right_dot_player); + + // limit rotation so we don't overshoot the target. We need to convert our dot product to + // an angle here so we can get an angle of rotation to clamp against. + let max_angle = forward_dot_player.clamp(-1.0, 1.0).acos(); // clamp acos for safety + + // calculate angle of rotation with limit + let rotation_angle = rotation_sign * (config.rotation_speed * TIME_STEP).min(max_angle); + + // get the quaternion to rotate from the current enemy facing direction towards the + // direction facing the player + let rotation_delta = Quat::from_rotation_z(rotation_angle); + + // rotate the enemy to face the player + enemy_transform.rotation *= rotation_delta; + } +} diff --git a/examples/README.md b/examples/README.md index 1ddbd01a58..e5330e31cb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -92,6 +92,7 @@ Example | File | Description `text2d` | [`2d/text2d.rs`](./2d/text2d.rs) | Generates text in 2d `sprite_flipping` | [`2d/sprite_flipping.rs`](./2d/sprite_flipping.rs) | Renders a sprite flipped along an axis `texture_atlas` | [`2d/texture_atlas.rs`](./2d/texture_atlas.rs) | Generates a texture atlas (sprite sheet) from individual sprites +`rotation` | [`2d/rotation.rs`](./2d/rotation.rs) | Demonstrates rotating entities in 2D with quaternions ## 3D Rendering