docs/bevy/examples/camera/2d_screen_shake.rs 14 K · 288 lines · raw · history
| 1 | //! This example showcases how to implement 2D screen shake. |
| 2 | //! It follows the GDC talk ["Math for Game Programmers: Juicing Your Cameras With Math"](https://www.youtube.com/watch?v=tu-Qe66AvtY) by Squirrel Eiserloh |
| 3 | //! |
| 4 | //! The key features are: |
| 5 | //! - Camera shake is dependent on a "trauma" value between 0.0 and 1.0. The more trauma, the stronger the shake. |
| 6 | //! - Trauma automatically decays over time. |
| 7 | //! - The camera shake will always only affect the camera `Transform` up to a maximum displacement. |
| 8 | //! - The camera's `Transform` is only affected by the shake for the rendering. The `Transform` stays "normal" for the rest of the game logic. |
| 9 | //! - All displacements are governed by a noise function, guaranteeing that the shake is smooth and continuous. |
| 10 | //! This means that the camera won't jump around wildly. |
| 11 | //! |
| 12 | //! ## Controls |
| 13 | //! |
| 14 | //! | Key Binding | Action | |
| 15 | //! |:---------------------------------|:---------------------------| |
| 16 | //! | Space (pressed repeatedly) | Increase camera trauma | |
| 17 | |
| 18 | use bevy::{ |
| 19 | input::common_conditions::input_just_pressed, math::ops::powf, prelude::*, |
| 20 | sprite_render::MeshMaterial2d, |
| 21 | }; |
| 22 | |
| 23 | // Before we implement the code, let's quickly introduce the underlying constants. |
| 24 | // They are later encoded in a `CameraShakeConfig` component, but introduced here so we can easily tweak them. |
| 25 | // Try playing around with them and see how the shake behaves! |
| 26 | |
| 27 | /// The trauma decay rate controls how quickly the trauma decays. |
| 28 | /// 0.5 means that a full trauma of 1.0 will decay to 0.0 in 2 seconds. |
| 29 | const TRAUMA_DECAY_PER_SECOND: f32 = 0.5; |
| 30 | |
| 31 | /// The trauma exponent controls how the trauma affects the shake. |
| 32 | /// Camera shakes don't feel punchy when they go up linearly, so we use an exponent of 2.0. |
| 33 | /// The higher the exponent, the more abrupt is the transition between no shake and full shake. |
| 34 | const TRAUMA_EXPONENT: f32 = 2.0; |
| 35 | |
| 36 | /// The maximum angle the camera can rotate on full trauma. |
| 37 | /// 10.0 degrees is a somewhat high but still reasonable shake. Try bigger values for something more silly and wiggly. |
| 38 | const MAX_ANGLE: f32 = 10.0_f32.to_radians(); |
| 39 | |
| 40 | /// The maximum translation the camera will move on full trauma in both the x and y directions. |
| 41 | /// 20.0 px is a low enough displacement to not be distracting. Try higher values for an effect that looks like the camera is wandering around. |
| 42 | const MAX_TRANSLATION: f32 = 20.0; |
| 43 | |
| 44 | /// How much we are traversing the noise function in arbitrary units per second. |
| 45 | /// This dictates how fast the camera shakes. |
| 46 | /// 20.0 is a fairly fast shake. Try lower values for a more dreamy effect. |
| 47 | const NOISE_SPEED: f32 = 20.0; |
| 48 | |
| 49 | /// How much trauma we add per press of the space key. |
| 50 | /// A value of 1.0 would mean that a single press would result in a maximum trauma, i.e. 1.0. |
| 51 | const TRAUMA_PER_PRESS: f32 = 0.4; |
| 52 | |
| 53 | fn main() { |
| 54 | App::new() |
| 55 | .add_plugins(DefaultPlugins) |
| 56 | .add_systems(Startup, (setup_scene, setup_instructions, setup_camera)) |
| 57 | // At the start of the frame, restore the camera's transform to its unshaken state. |
| 58 | .add_systems(PreUpdate, reset_transform) |
| 59 | .add_systems( |
| 60 | Update, |
| 61 | // Increase trauma when the space key is pressed. |
| 62 | increase_trauma.run_if(input_just_pressed(KeyCode::Space)), |
| 63 | ) |
| 64 | // Just before the end of the frame, apply the shake. |
| 65 | // This is ordered so that the transform propagation produces correct values for the global transform, which is used by Bevy's rendering. |
| 66 | .add_systems(PostUpdate, shake_camera.before(TransformSystems::Propagate)) |
| 67 | .run(); |
| 68 | } |
| 69 | |
| 70 | /// Let's start with the core mechanic: how do we shake the camera? |
| 71 | /// This system runs right at the end of the frame, so that we can sneak in the shake effect before rendering kicks in. |
| 72 | fn shake_camera( |
| 73 | camera_shake: Single<(&mut CameraShakeState, &CameraShakeConfig, &mut Transform)>, |
| 74 | time: Res<Time>, |
| 75 | ) { |
| 76 | let (mut camera_shake, config, mut transform) = camera_shake.into_inner(); |
| 77 | |
| 78 | // Before we even start thinking about the shake, we save the original transform so it's not lost. |
| 79 | // At the start of the next frame, we will restore the camera's transform to this original transform. |
| 80 | camera_shake.original_transform = *transform; |
| 81 | |
| 82 | // To generate the transform offset, we use a noise function. Noise is like a random number generator, but cooler. |
| 83 | // Let's start with a visual intuition: <https://assets-global.website-files.com/64b6d182aee713bd0401f4b9/64b95974ec292aabac45fc8e_image.png> |
| 84 | // The image on the left is made from pure randomness, the image on the right is made from a kind of noise called Perlin noise. |
| 85 | // Notice how the noise has much more "structure" than the randomness? How it looks like it has peaks and valleys? |
| 86 | // This property makes noise very desirable for a variety of visual effects. In our case, what we want is that the |
| 87 | // camera does not wildly teleport around the world, but instead *moves* through the world frame by frame. |
| 88 | // We can use 1D Perlin noise for this, which takes one input and outputs a value between -1.0 and 1.0. If we increase the input by a little bit, |
| 89 | // like by the time since the last frame, we get a different output that is still "close" to the previous one. |
| 90 | |
| 91 | // This is the input to the noise function. Just using the elapsed time is pretty good input, |
| 92 | // since it means that noise generations that are close in time will be close in output. |
| 93 | // We simply multiply it by a constant to be able to "speed up" or "slow down" the noise. |
| 94 | let t = time.elapsed_secs() * config.noise_speed; |
| 95 | |
| 96 | // Now we generate three noise values. One for the rotation, one for the x-offset, and one for the y-offset. |
| 97 | // But if we generated those three noise values with the same input, we would get the same output three times! |
| 98 | // To avoid this, we simply add a random offset to each input. |
| 99 | // You can think of this as the seed value you would give a random number generator. |
| 100 | let rotation_noise = perlin_noise::generate(t + 0.0); |
| 101 | let x_noise = perlin_noise::generate(t + 100.0); |
| 102 | let y_noise = perlin_noise::generate(t + 200.0); |
| 103 | |
| 104 | // Games often deal with linear increments. For example, if an enemy deals 10 damage and attacks you 2 times, you will take 20 damage. |
| 105 | // But that's not how impact feels! Human senses are much more attuned to exponential changes. |
| 106 | // So, we make sure that the `shake` value we use is an exponential function of the trauma. |
| 107 | // But doesn't this make the value explode? Fortunately not: since `trauma` is between 0.0 and 1.0, exponentiating it will actually make it smaller! |
| 108 | // See <https://www.wolframalpha.com/input?i=plot+x+and+x%5E2+and+x%5E3+for+x+in+%5B0%2C+1%5D> for a graph. |
| 109 | let shake = powf(camera_shake.trauma, config.exponent); |
| 110 | |
| 111 | // Now, to get the final offset, we multiply this noise value by the shake value and the maximum value. |
| 112 | // The noise value is in [-1, 1], so by multiplying it with a maximum value, we get a value in [-max_value, +max_value]. |
| 113 | // Multiply this by the shake value to get the exponential effect, and we're done! |
| 114 | let roll_offset = rotation_noise * shake * config.max_angle; |
| 115 | let x_offset = x_noise * shake * config.max_translation; |
| 116 | let y_offset = y_noise * shake * config.max_translation; |
| 117 | |
| 118 | // Finally, we apply the offset to the camera's transform. Since we already stored the original transform, |
| 119 | // and this system runs right at the end of the frame, we can't accidentally break any game logic by changing the transform. |
| 120 | transform.translation.x += x_offset; |
| 121 | transform.translation.y += y_offset; |
| 122 | transform.rotate_z(roll_offset); |
| 123 | |
| 124 | // Some bookkeeping at the end: trauma should decay over time. |
| 125 | camera_shake.trauma -= config.trauma_decay_per_second * time.delta_secs(); |
| 126 | camera_shake.trauma = camera_shake.trauma.clamp(0.0, 1.0); |
| 127 | } |
| 128 | |
| 129 | /// Increase the trauma when the space key is pressed. |
| 130 | fn increase_trauma(mut camera_shake: Single<&mut CameraShakeState>) { |
| 131 | camera_shake.trauma += TRAUMA_PER_PRESS; |
| 132 | camera_shake.trauma = camera_shake.trauma.clamp(0.0, 1.0); |
| 133 | } |
| 134 | |
| 135 | /// Restore the camera's transform to its unshaken state. |
| 136 | /// Runs at the start of the frame, so that gameplay logic doesn't need to care about camera shake. |
| 137 | fn reset_transform(camera_shake: Single<(&CameraShakeState, &mut Transform)>) { |
| 138 | let (camera_shake, mut transform) = camera_shake.into_inner(); |
| 139 | *transform = camera_shake.original_transform; |
| 140 | } |
| 141 | |
| 142 | /// The current state of the camera shake that is updated every frame. |
| 143 | #[derive(Component, Debug, Default)] |
| 144 | struct CameraShakeState { |
| 145 | /// The current trauma level in [0.0, 1.0]. |
| 146 | trauma: f32, |
| 147 | /// The original transform of the camera before applying the shake. |
| 148 | /// We store this so that we can restore the camera's transform to its original state at the start of the next frame. |
| 149 | original_transform: Transform, |
| 150 | } |
| 151 | |
| 152 | /// Configuration for the camera shake. |
| 153 | /// See the constants at the top of the file for some good default values and detailed explanations. |
| 154 | #[derive(Component, Debug)] |
| 155 | #[require(CameraShakeState)] |
| 156 | struct CameraShakeConfig { |
| 157 | trauma_decay_per_second: f32, |
| 158 | exponent: f32, |
| 159 | max_angle: f32, |
| 160 | max_translation: f32, |
| 161 | noise_speed: f32, |
| 162 | } |
| 163 | |
| 164 | fn setup_camera(mut commands: Commands) { |
| 165 | commands.spawn(( |
| 166 | Camera2d, |
| 167 | // Enable camera shake for this camera. |
| 168 | CameraShakeConfig { |
| 169 | trauma_decay_per_second: TRAUMA_DECAY_PER_SECOND, |
| 170 | exponent: TRAUMA_EXPONENT, |
| 171 | max_angle: MAX_ANGLE, |
| 172 | max_translation: MAX_TRANSLATION, |
| 173 | noise_speed: NOISE_SPEED, |
| 174 | }, |
| 175 | )); |
| 176 | } |
| 177 | |
| 178 | /// Spawn a scene so we have something to look at. |
| 179 | fn setup_scene( |
| 180 | mut commands: Commands, |
| 181 | mut meshes: ResMut<Assets<Mesh>>, |
| 182 | mut materials: ResMut<Assets<ColorMaterial>>, |
| 183 | ) { |
| 184 | // Background tile |
| 185 | commands.spawn(( |
| 186 | Mesh2d(meshes.add(Rectangle::new(1000., 700.))), |
| 187 | MeshMaterial2d(materials.add(Color::srgb(0.2, 0.2, 0.3))), |
| 188 | )); |
| 189 | |
| 190 | // The shape in the middle could be our player character. |
| 191 | commands.spawn(( |
| 192 | Mesh2d(meshes.add(Rectangle::new(50.0, 100.0))), |
| 193 | MeshMaterial2d(materials.add(Color::srgb(0.25, 0.94, 0.91))), |
| 194 | Transform::from_xyz(0., 0., 2.), |
| 195 | )); |
| 196 | |
| 197 | // These two shapes could be obstacles. |
| 198 | commands.spawn(( |
| 199 | Mesh2d(meshes.add(Rectangle::new(50.0, 50.0))), |
| 200 | MeshMaterial2d(materials.add(Color::srgb(0.85, 0.0, 0.2))), |
| 201 | Transform::from_xyz(-450.0, 200.0, 2.), |
| 202 | )); |
| 203 | |
| 204 | commands.spawn(( |
| 205 | Mesh2d(meshes.add(Rectangle::new(70.0, 50.0))), |
| 206 | MeshMaterial2d(materials.add(Color::srgb(0.5, 0.8, 0.2))), |
| 207 | Transform::from_xyz(450.0, -150.0, 2.), |
| 208 | )); |
| 209 | } |
| 210 | |
| 211 | fn setup_instructions(mut commands: Commands) { |
| 212 | commands.spawn(( |
| 213 | Text::new("Press space repeatedly to trigger a progressively stronger screen shake"), |
| 214 | Node { |
| 215 | position_type: PositionType::Absolute, |
| 216 | bottom: px(12), |
| 217 | left: px(12), |
| 218 | ..default() |
| 219 | }, |
| 220 | )); |
| 221 | } |
| 222 | |
| 223 | /// Tiny 1D Perlin noise implementation. The mathematical details are not important here. |
| 224 | mod perlin_noise { |
| 225 | use super::*; |
| 226 | |
| 227 | pub fn generate(x: f32) -> f32 { |
| 228 | // Left coordinate of the unit-line that contains the input. |
| 229 | let x_floor = x.floor() as usize; |
| 230 | |
| 231 | // Input location in the unit-line. |
| 232 | let xf0 = x - x_floor as f32; |
| 233 | let xf1 = xf0 - 1.0; |
| 234 | |
| 235 | // Wrap to range 0-255. |
| 236 | let xi0 = x_floor & 0xFF; |
| 237 | let xi1 = (x_floor + 1) & 0xFF; |
| 238 | |
| 239 | // Apply the fade function to the location. |
| 240 | let t = fade(xf0).clamp(0.0, 1.0); |
| 241 | |
| 242 | // Generate hash values for each point of the unit-line. |
| 243 | let h0 = PERMUTATION_TABLE[xi0]; |
| 244 | let h1 = PERMUTATION_TABLE[xi1]; |
| 245 | |
| 246 | // Linearly interpolate between dot products of each gradient with its distance to the input location. |
| 247 | let a = dot_grad(h0, xf0); |
| 248 | let b = dot_grad(h1, xf1); |
| 249 | a.interpolate_stable(&b, t) |
| 250 | } |
| 251 | |
| 252 | // A cubic curve that smoothly transitions from 0 to 1 as t goes from 0 to 1 |
| 253 | fn fade(t: f32) -> f32 { |
| 254 | t * t * t * (t * (t * 6.0 - 15.0) + 10.0) |
| 255 | } |
| 256 | |
| 257 | fn dot_grad(hash: u8, xf: f32) -> f32 { |
| 258 | // In 1D case, the gradient may be either 1 or -1. |
| 259 | // The distance vector is the input offset (relative to the smallest bound). |
| 260 | if hash & 0x1 != 0 { |
| 261 | xf |
| 262 | } else { |
| 263 | -xf |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | // Perlin noise permutation table. This is a random sequence of the numbers 0-255. |
| 268 | const PERMUTATION_TABLE: [u8; 256] = [ |
| 269 | 0x97, 0xA0, 0x89, 0x5B, 0x5A, 0x0F, 0x83, 0x0D, 0xC9, 0x5F, 0x60, 0x35, 0xC2, 0xE9, 0x07, |
| 270 | 0xE1, 0x8C, 0x24, 0x67, 0x1E, 0x45, 0x8E, 0x08, 0x63, 0x25, 0xF0, 0x15, 0x0A, 0x17, 0xBE, |
| 271 | 0x06, 0x94, 0xF7, 0x78, 0xEA, 0x4B, 0x00, 0x1A, 0xC5, 0x3E, 0x5E, 0xFC, 0xDB, 0xCB, 0x75, |
| 272 | 0x23, 0x0B, 0x20, 0x39, 0xB1, 0x21, 0x58, 0xED, 0x95, 0x38, 0x57, 0xAE, 0x14, 0x7D, 0x88, |
| 273 | 0xAB, 0xA8, 0x44, 0xAF, 0x4A, 0xA5, 0x47, 0x86, 0x8B, 0x30, 0x1B, 0xA6, 0x4D, 0x92, 0x9E, |
| 274 | 0xE7, 0x53, 0x6F, 0xE5, 0x7A, 0x3C, 0xD3, 0x85, 0xE6, 0xDC, 0x69, 0x5C, 0x29, 0x37, 0x2E, |
| 275 | 0xF5, 0x28, 0xF4, 0x66, 0x8F, 0x36, 0x41, 0x19, 0x3F, 0xA1, 0x01, 0xD8, 0x50, 0x49, 0xD1, |
| 276 | 0x4C, 0x84, 0xBB, 0xD0, 0x59, 0x12, 0xA9, 0xC8, 0xC4, 0x87, 0x82, 0x74, 0xBC, 0x9F, 0x56, |
| 277 | 0xA4, 0x64, 0x6D, 0xC6, 0xAD, 0xBA, 0x03, 0x40, 0x34, 0xD9, 0xE2, 0xFA, 0x7C, 0x7B, 0x05, |
| 278 | 0xCA, 0x26, 0x93, 0x76, 0x7E, 0xFF, 0x52, 0x55, 0xD4, 0xCF, 0xCE, 0x3B, 0xE3, 0x2F, 0x10, |
| 279 | 0x3A, 0x11, 0xB6, 0xBD, 0x1C, 0x2A, 0xDF, 0xB7, 0xAA, 0xD5, 0x77, 0xF8, 0x98, 0x02, 0x2C, |
| 280 | 0x9A, 0xA3, 0x46, 0xDD, 0x99, 0x65, 0x9B, 0xA7, 0x2B, 0xAC, 0x09, 0x81, 0x16, 0x27, 0xFD, |
| 281 | 0x13, 0x62, 0x6C, 0x6E, 0x4F, 0x71, 0xE0, 0xE8, 0xB2, 0xB9, 0x70, 0x68, 0xDA, 0xF6, 0x61, |
| 282 | 0xE4, 0xFB, 0x22, 0xF2, 0xC1, 0xEE, 0xD2, 0x90, 0x0C, 0xBF, 0xB3, 0xA2, 0xF1, 0x51, 0x33, |
| 283 | 0x91, 0xEB, 0xF9, 0x0E, 0xEF, 0x6B, 0x31, 0xC0, 0xD6, 0x1F, 0xB5, 0xC7, 0x6A, 0x9D, 0xB8, |
| 284 | 0x54, 0xCC, 0xB0, 0x73, 0x79, 0x32, 0x2D, 0x7F, 0x04, 0x96, 0xFE, 0x8A, 0xEC, 0xCD, 0x5D, |
| 285 | 0xDE, 0x72, 0x43, 0x1D, 0x18, 0x48, 0xF3, 0x8D, 0x80, 0xC3, 0x4E, 0x42, 0xD7, 0x3D, 0x9C, |
| 286 | 0xB4, |
| 287 | ]; |
| 288 | } |