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}