Camera Frame Smoothing



In the last article we built a camera that can move around our grid, rotate, and zoom in and out. The camera works pretty well, but we need to make sure its movement isn’t tied to the frame rate. While we’re improving this code, we should also place bounds on the camera to keep it attached to the grid.

Smoothing Zoom

Let’s start by smoothing the zoom function. In order to do this, we need to change our zoom_level parameter to be a f32 instead of a usize. We will make the value be in the range [0.0, 1.0] and in order to calculate the offset, we will mix the two closest zoom_level_offset positions to determine the offset. We will scale the delta to the zoom level in each frame by the time delta. By doing this, our zoom function will no longer be tied to frame rate.

main.rs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
#[derive(Debug, Component)]
pub struct CameraTarget {
    /// value in range [0.0, 1.0] which determines which zoom_level_offsets to use
    zoom_level: f32,

    /// list of offsets to position the camera relative to the look_at point
    zoom_level_offsets: Vec<Vec3>,

    /// point in space the camera should look at
    look_at: Vec3,

    /// rotation angle around the up axis
    rotation: f32,

    /// normal vector representing up for the camera
    up: Vec3,

    /// true wheh zoom_level, look_at, or up change
    /// this let's our system know to update the camera transform in the scene
    is_dirty: bool,
}

...

impl CameraTarget {
    pub fn update(
        mut scroll_evr: EventReader<MouseWheel>,
        keys: Res<Input<KeyCode>>,
        time: Res<Time>,
        mut camera_query: Query<(&mut CameraTarget, &mut Transform), With<Camera>>,
    ) {
        let delta_y: f32 = scroll_evr.read().map(|ev| ev.y).sum();
        let mut delta_zoom_level: f32 = if delta_y < 0.0 {
            -1.0
        } else if delta_y > 0.0 {
            1.0
        } else {
            0.0
        };

        if keys.pressed(KeyCode::Equals) || keys.pressed(KeyCode::Plus) {
            delta_zoom_level = -1.0;
        }
        if keys.pressed(KeyCode::Minus) {
            delta_zoom_level = 1.0;
        }

        ...

        if delta_zoom_level != 0.0 {
            target.change_zoom_to(delta_zoom_level * time.delta_seconds().clamp(0.0, 1.0));
        }

        ...
    }

    pub fn update_transform(&mut self, transform: &mut Transform) {
        if self.is_dirty {
            let zoom_level_a = self.zoom_level_offsets
                [(self.zoom_level * (self.zoom_level_offsets.len() - 1) as f32).floor() as usize];
            let zoom_level_b = self.zoom_level_offsets
                [(self.zoom_level * (self.zoom_level_offsets.len() - 1) as f32).ceil() as usize];
            let mut mix = self.zoom_level * (self.zoom_level_offsets.len() - 1) as f32;
            mix -= mix as u32 as f32;
            let offset = zoom_level_a.lerp(zoom_level_b, mix);
            transform.translation = self.look_at + offset;
            transform.rotate_around(self.look_at, Quat::from_axis_angle(self.up, self.rotation));
            transform.look_at(self.look_at, self.up);
            self.is_dirty = false;
        }
    }

    ...

    pub fn change_zoom_to(&mut self, delta_zoom_level: f32) {
        self.zoom_to(self.zoom_level + delta_zoom_level)
    }

    pub fn zoom_to(&mut self, zoom_level: f32) {
        let old_zoom_level = self.zoom_level;
        self.zoom_level = zoom_level.clamp(0.0, 1.0);

        if old_zoom_level != self.zoom_level {
            self.is_dirty = true
        }
    }

    pub fn zooming_to(mut self, zoom_level: f32) -> Self {
        self.zoom_to(zoom_level);
        self
    }
}

Run our application with cargo run and you may notice no difference at all, or you may notice that the zoom is now a bit smoother. These changes really only have an effect for big differences in frame rate between systems.

Smoothing Rotation + Movement

Next, let’s smooth our rotation and movement functions using the time delta between updates. Rotation and movement are already modeled as f32 values, so we just need to change our update function to scale the rotation/movement deltas using the time delta. See the full update function below, with smoothing for zoom, rotation, and movement.

main.rs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
impl CameraTarget {
    pub fn update(
        mut scroll_evr: EventReader<MouseWheel>,
        keys: Res<Input<KeyCode>>,
        time: Res<Time>,
        mut camera_query: Query<(&mut CameraTarget, &mut Transform), With<Camera>>,
    ) {
        let (mut target, mut camera_transform) = camera_query.single_mut();

        let delta_y: f32 = scroll_evr.read().map(|ev| ev.y).sum();
        let mut delta_zoom_level: f32 = if delta_y < 0.0 {
            -1.0
        } else if delta_y > 0.0 {
            1.0
        } else {
            0.0
        };

        if keys.pressed(KeyCode::Equals) || keys.pressed(KeyCode::Plus) {
            delta_zoom_level = -1.0;
        }
        if keys.pressed(KeyCode::Minus) {
            delta_zoom_level = 1.0;
        }

        let mut delta_rotation: f32 = 0.0;
        if keys.pressed(KeyCode::Q) {
            delta_rotation = -1.0;
        }
        if keys.pressed(KeyCode::E) {
            delta_rotation = 1.0;
        }

        let mut delta_x: f32 = 0.0;
        let mut delta_z: f32 = 0.0;
        if keys.pressed(KeyCode::W) || keys.pressed(KeyCode::Up) {
            delta_x = 1.0;
        }
        if keys.pressed(KeyCode::S) || keys.pressed(KeyCode::Down) {
            delta_x = -1.0;
        }
        if keys.pressed(KeyCode::D) || keys.pressed(KeyCode::Right) {
            delta_z = -1.0;
        }
        if keys.pressed(KeyCode::A) || keys.pressed(KeyCode::Left) {
            delta_z = 1.0;
        }

        if delta_zoom_level != 0.0 {
            target.change_zoom_to(delta_zoom_level * time.delta_seconds().clamp(0.0, 1.0));
        }
        if delta_rotation != 0.0 {
            target.change_rotation(delta_rotation * 0.8 * time.delta_seconds().clamp(0.0, 1.0));
        }

        target.update_transform(&mut camera_transform);
        if delta_x != 0.0 || delta_z != 0.0 {
            let mask = Vec3::new(1.0, 1.0, 1.0) - target.get_up();
            let look_at = target.get_look_at() * mask; // multiply out the up component
            let camera_at = camera_transform.translation * mask; // multiply out the up component
            let forward = (look_at - camera_at).normalize();
            let mut right_rotation = Transform::from_xyz(0.0, 0.0, 0.0);
            right_rotation
                .rotate_around(Vec3::default(), Quat::from_axis_angle(target.up, PI / 2.0));
            let right = right_rotation * forward;

            let scale_factor = 1000.0 * time.delta_seconds().clamp(0.0, 1.0);
            let new_look_at =
                look_at + (forward * delta_x * scale_factor) + (right * delta_z * scale_factor);
            target.look_at(new_look_at);
            target.update_transform(&mut camera_transform);
        }
    }

    ...

}

Run the application again using cargo run and you may or may not notice any differences.

Bounding Rectangle

The last improvement we want to make to our camera is to clamp it to a bounding rectangle. This will prevent players from moving the camera far away from the grid into no-man’s-land.

First, we need to add a BoundingBox to our CameraTarget struct. The BoundingBox will have an upper and lower position vector that we can use to clamp our look_at value. We’ll modify our CameraTarget implementation to account for the bounding box.

main.rs

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
...

impl TextGem {
    ...

    fn startup(
        mut commands: Commands,
        mut meshes: ResMut<Assets<Mesh>>,
        mut grid_materials: ResMut<Assets<ExtendedMaterial<StandardMaterial, GridMaterial>>>,
    ) {
        // Setup CameraTarget
        let mut camera_target = CameraTarget::default()
            .looking_at(Vec3::new(0.0, 0.0, 0.0))
            .with_up(Vec3::Y)
            .with_bounding_box(BoundingBox::new(
                Vec3::new(-3000.0, 15.0, -3000.0),
                Vec3::new(3000.0, 4000.0, 3000.0),
            ));

        ...
    }
}

...

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BoundingBox {
    a: Vec3,
    b: Vec3,
}

impl Default for BoundingBox {
    fn default() -> Self {
        Self {
            a: Vec3::new(f32::MIN, f32::MIN, f32::MIN),
            b: Vec3::new(f32::MAX, f32::MAX, f32::MAX),
        }
    }
}

impl BoundingBox {
    pub fn new(a: Vec3, b: Vec3) -> Self {
        Self {
            a: a.min(b),
            b: a.max(b),
        }
    }

    pub fn clamp(&self, position: Vec3) -> Vec3 {
        position.clamp(self.a, self.b)
    }
}

#[derive(Debug, Component)]
pub struct CameraTarget {
    /// value in range [0.0, 1.0] which determines which zoom_level_offsets to use
    zoom_level: f32,

    /// list of offsets to position the camera relative to the look_at point
    zoom_level_offsets: Vec<Vec3>,

    /// point in space the camera should look at
    look_at: Vec3,

    /// rotation angle around the up axis
    rotation: f32,

    /// normal vector representing up for the camera
    up: Vec3,

    /// bounding box for camera
    bounding_box: BoundingBox,

    /// true wheh zoom_level, look_at, or up change
    /// this let's our system know to update the camera transform in the scene
    is_dirty: bool,
}

...

impl CameraTarget {
    ...

    pub fn look_at(&mut self, look_at: Vec3) {
        let old_look_at = self.look_at;
        self.look_at = self.bounding_box.clamp(look_at);

        if old_look_at != self.look_at {
            self.is_dirty = true
        }
    }

    ...

    pub fn set_bounding_box(&mut self, bounding_box: BoundingBox) {
        let old_bounding_box = self.bounding_box;
        self.bounding_box = bounding_box;

        if old_bounding_box != self.bounding_box {
            self.is_dirty = true
        }
    }

    pub fn with_bounding_box(mut self, bounding_box: BoundingBox) -> Self {
        self.set_bounding_box(bounding_box);
        self
    }

    ...
}

Run the application with cargo run and we’ll see that the player can no longer move the camera look_at position outside of the grid.

Next Up…

We’ve successfully made the camera work the ame independent of the frame rate, and we’ve also put bounds on it so the player can’t get too far away from the grid. We’re finally ready to start rendering some ASCII characters to the grid so we can start to represent our game objects. That’s what we’ll work on in the next post!

Full code for this post can be found here: TextGem Post 5.