2d_screen_shake/2d_screen_shake.rs
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
18use 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.
29const 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.
34const 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.
38const 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.
42const 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.
47const 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.
51const TRAUMA_PER_PRESS: f32 = 0.4;
52
53fn 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.
72fn 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.
130fn 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.
137fn 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)]
144struct 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)]
156struct CameraShakeConfig {
157 trauma_decay_per_second: f32,
158 exponent: f32,
159 max_angle: f32,
160 max_translation: f32,
161 noise_speed: f32,
162}
163
164fn 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.
179fn 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
211fn 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.
224mod 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}