Camera



In the last article we were able to render rather large grids. In most games with large grids, you can zoom in and out of the grid, rotate the camera, and move the camera around. At this point, we should be able to render a grid that looks like the one below. If you don’t see this on your screen, then download the code for the previous post and start from there.

Grid Box Large

Camera Requirements

There are a few requirements we have for this kind of camera, let’s list them out here.

  • I should be able to move the camera left to right, and forward to backward on the grid using WASD or the arrow keys.
  • I should be able to zoom in and zoom out of the grid using the mouse scroll wheel or the + and - keys.
    • As the camera zooms in and out of the grid, it should stay focused on the same point.
    • As the camera zooms in and out of the grid, it should become more parallel with the grid, instead of looking directly at the top of the grid.
  • I should be able to rotate the camera left to right using the Q and E keys.

Building the Camera

We already have a camera in the scene that we have positioned manually.

1
2
3
4
5
commands.spawn(Camera3dBundle {
    transform: Transform::from_xyz(4000.0, 1000.0, 4000.0)
        .looking_at(Vec3::new(0.0, 0.0, 0.0), Vec3::Y),
    ..default()
});

We see that it is quite easy to configure the camera’s position and have it look at a specific point. What we want to do is change the rotation of the camera relative to the plane while zooming in and out as well as allow for the player to change what the camera is looking at using directional keys. In order to model this, we will need to store some data in a new Bevy Component. We will call our new component CameraTarget.

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
#[derive(Debug, Component)]
pub struct CameraTarget {
    /// which offset to use for the camera
    zoom_level: usize,

    /// 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 Default for CameraTarget {
    fn default() -> Self {
        Self {
            zoom_level: 0,
            zoom_level_offsets: vec![],
            look_at: Vec3::default(),
            rotation: 0.0,
            up: Vec3::Y,
            is_dirty: true,
        }
    }
}
  • zoom_level tells us how zoomed in the camera should be, let’s say lower values are zoomed in, while higher values are zoomed out. This is an index into the zoom_level_offsets vector.
  • zoom_level_offsets these are vectors that describe where to position the camera relative to the look_at position for all of the zoom levels we have defined.
  • look_at is the point on the grid that we want the camera to look at.
  • rotation is the rotation in radians around the up axis.
  • up is the unit vector representing up for the camera.
  • is_dirty is a flag that gets set to true whenever we need to update the camera transform in the scene. The camera transform describes how to rotate and position the camera in the scene.

Let’s implement some methods to help us work with our new CameraTarget struct.

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
impl CameraTarget {
    pub fn update_transform(&mut self, transform: &mut Transform) {
        if self.is_dirty {
            transform.translation = self.look_at + self.zoom_level_offsets[self.zoom_level];
            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: i32) {
        let new_zoom_level =
            (self.zoom_level as i32 + delta_zoom_level).clamp(0, i32::MAX) as usize;
        self.zoom_to(new_zoom_level);
    }

    pub fn zoom_to(&mut self, zoom_level: usize) {
        let old_zoom_level = self.zoom_level;
        self.zoom_level = zoom_level.clamp(0, self.zoom_level_offsets.len() - 1);

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

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

    pub fn get_look_at(&self) -> Vec3 {
        self.look_at
    }

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

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

    pub fn looking_at(mut self, look_at: Vec3) -> CameraTarget {
        self.look_at(look_at);
        self
    }

    pub fn change_rotation(&mut self, delta_rotation: f32) {
        self.rotate(self.rotation + delta_rotation)
    }

    pub fn rotate(&mut self, rotation: f32) {
        let old_rotation = self.rotation;
        self.rotation = rotation;

        if old_rotation != self.rotation {
            self.is_dirty = true
        }
    }

    pub fn rotating(mut self, rotation: f32) -> Self {
        self.rotate(rotation);
        self
    }

    pub fn get_up(&self) -> Vec3 {
        self.up
    }

    pub fn set_up(&mut self, up: Vec3) {
        self.up = up;
    }

    pub fn with_up(mut self, up: Vec3) -> Self {
        self.set_up(up);
        self
    }

    pub fn add_zoom_level_offset(&mut self, zoom_level_offset: Vec3) {
        self.zoom_level_offsets.push(zoom_level_offset)
    }

    pub fn with_zoom_level_offset(mut self, zoom_level_offset: Vec3) -> Self {
        self.add_zoom_level_offset(zoom_level_offset);
        self
    }
}

Great work! Now let’s add a new instance of CameraTarget to our application after configuring it with our desired zoom targets.

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
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);

        let min_x = 1000.0;
        let max_x = 1200.0; // move further away from the look_at point as we zoom out
        let min_y = 500.0;
        let max_y = 4000.0; // zoom out by this much at the maximum zoom level
        let num_steps = 100; // we will have 100 zoom levels
        for i in 0..num_steps {
            let progress = i as f32 / num_steps as f32;
            let y = min_y + (max_y - min_y) * progress;
            let x = min_x + (max_x - min_x) * progress;

            camera_target.add_zoom_level_offset(Vec3::new(x, y, 0.0))
        }

        // Camera
        commands.spawn((Camera3dBundle::default(), camera_target));
    }

    ...
}

If you run the application now with cargo run you will notice that we are rendering a blank screen. This is because we aren’t actually positioning the Camera at our CameraTarget.

Blank Screen

Update Camera Position

In order to keep the scene Camera in sync with our CameraTarget, we need to create an update function that runs every frame.

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
// Update use statements
use std::f32::consts::PI;

use bevy::{
    input::mouse::MouseWheel,
    pbr::{ExtendedMaterial, MaterialExtension},
    prelude::*,
    render::{
        mesh::Indices,
        render_resource::{AsBindGroup, PrimitiveTopology, ShaderRef},
    },
};

...

impl Plugin for TextGem {
    fn build(&self, app: &mut App) {
        let mut material_plugin =
            MaterialPlugin::<ExtendedMaterial<StandardMaterial, GridMaterial>>::default();
        material_plugin.prepass_enabled = false;

        app.add_plugins(material_plugin)
            .add_systems(Startup, Self::hello_world)
            .add_systems(Startup, Self::startup)
            .add_systems(Update, CameraTarget::update);
    }
}

...

impl CameraTarget {
        pub fn update(
        mut scroll_evr: EventReader<MouseWheel>,
        keys: Res<Input<KeyCode>>,
        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 = if delta_y < 0.0 {
            -1
        } else if delta_y > 0.0 {
            1
        } else {
            0
        };

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

        let mut delta_rotation: f32 = 0.0;

        if keys.pressed(KeyCode::Q) {
            delta_rotation = -0.005;
        }
        if keys.pressed(KeyCode::E) {
            delta_rotation = 0.005;
        }

        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 {
            target.change_zoom_to(delta_zoom_level);
        }
        if delta_rotation != 0.0 {
            target.change_rotation(delta_rotation);
        }

        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 new_look_at = look_at + (forward * delta_x * 10.0) + (right * delta_z * 10.0);
            target.look_at(new_look_at);
            target.update_transform(&mut camera_transform);
        }
    }

    ...
}

Run the application again with cargo run and you should be able to move the camera around and zoom in/out using the controls we have configured!

Next Up…

This is a pretty good first pass and fulfills all of our requirements! However, there is still a major issue with it that we will need to fix up. The issue is that the speed of moving the camera is tied to the frame rate. In order to fix this, we will need to incorporate the time delta between frames into our calculations. We’ll take care of this in the next post before moving on to rendering text to our grid!

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