3D Isometric Camera in Rust

Edward Wibowo,

In this blog post, I will go over my implementation of a 3D isometric camera in Rust using the Bevy game engine. I will also be dipping into some of the math involved when using an isometric projection.

Isometric Demo

Dependencies

To begin, you will need to add bevy as a dependency

# Cargo.toml

[dependencies]
bevy = "0.5"

Moving the player

In a regular 3D game, the player’s movement direction can be represented as a normalized vector v\vec v where v=1\| \vec v \| = 1. So, for example, the vector v=(1,0)\vec v = (1, 0) may indicate that the player should go rightwards.

See the following snippet for an example of getting a movement direction (velocity):

fn player_movement(
    key_input: Res<Input<KeyCode>>,
    mut player_query: Query<
        (&mut RigidBodyVelocity, &RigidBodyMassProps, &Transform),
        With<Player>,
    >,
    time: Res<Time>,
) {
    if let Ok((mut rb_velocity, rb_mass_props, transform)) = player_query.single_mut() {
        let velocity = {
            let mut velocity = Vec3::ZERO;
            let local_z = transform.local_z();
            let forward = -Vec3::new(local_z.x, 0., local_z.z);
            let right = Vec3::new(local_z.z, 0., -local_z.x);

            for key in key_input.get_pressed() {
                match key {
                    KeyCode::W => velocity += forward,
                    KeyCode::S => velocity -= forward,
                    KeyCode::A => velocity -= right,
                    KeyCode::D => velocity += right,
                    _ => (),
                }
            }

            velocity.normalize()
        };
        // Move player in the direction of velocity.
    }
}

While this works for a standard perspective, some extra considerations must be taken into account when dealing with an isometric perspective.

In an isometric projection, the camera’s perspective is not parallel to the world’s axis. For example, let’s say we are trying to implement a simple player controller using WASD keys. What should happen when we press W? Take a look at this diagram depicting the player as a cube:

Isometric Perspective

Game world from an isometric perspective

A naive implementation of this situation would result in the player moving along the AA direction, which is parallel to the world’s axis. However, the isometric perspective means that this movement direction is incorrect. The isometric projection places us at an angle to the world’s axis, meaning that the direction itself should be shifted as well. Intuitively, pressing W should move the player away from the camera (“upwards”) along the BB direction instead.

So how do we account for this? Well, let’s take a look at the same situation from a top-down perspective:

Isometric Top-Down

Top-down perspective of isometric perspective

As illustrated in the drawing above, the AA vector and BB vector are separated by an angle of θ\theta. So, to transform AA into BB, AA must be shifted by θ\theta anti-clockwise. The camera is rotated 4545^\circ from the world’s axis, so we can set θ=45=π4\theta = 45^\circ = \frac{\pi}{4}.

Given that A=(0,1)A = (0, 1) and BB is in the second quadrant, we can evaluate the value of BB like so:

B=(cosθ,sinθ)B=(cosπ4,sinπ4)B=(22,22)\begin{align*} B &= (-\cos \theta, \, \sin \theta) \\ B &= (-\cos \frac{\pi}{4}, \, \sin \frac{\pi}{4}) \\ B &= (-\frac{\sqrt 2}{2}, \, \frac{\sqrt 2}{2}) \end{align*}

Now, let’s go through the rest for vectors of the form v=(x,y)\vec v = (x, y) where x,yZx, y \in \mathbb{Z}:

Original VectorTransformation Vector
(1,0)(1, 0)(22,22)(\frac{\sqrt 2}{2}, \, \frac{\sqrt 2}{2})
(0,1)(0, 1)(22,22)(\frac{-\sqrt 2}{2}, \, \frac{\sqrt 2}{2})
(1,0)(-1, 0)(22,22)(\frac{-\sqrt 2}{2}, \, \frac{-\sqrt 2}{2})
(0,1)(0, -1)(22,22)(\frac{\sqrt 2}{2}, \, \frac{-\sqrt 2}{2})

Of course, we could just easily hard code this with a sequence of if statements, but that’s no fun! Anyways, it is important to generalize when considering the case where x,yRx, y \in \mathbb{R}

So how do we do this? Well, we could generalize by using a rotation matrix:

R=[cosθsinθsinθcosθ]R = \begin{bmatrix} \cos \theta & -\sin \theta \\ \sin \theta & \cos \theta \end{bmatrix}

Basically, applying this transformation matrix to our movement vector rotates the vector in a Cartesian plane by θ\theta anti-clockwise. Just what we needed. Hence, by writing our movement vector as a column vector, we can apply the transformation by multiplying the two:

Rv=[cosθsinθsinθcosθ][xy]=[xcosθysinθxsinθ+ycosθ]R \vec v = \begin{bmatrix} \cos \theta & -\sin \theta \\ \sin \theta & \cos \theta \end{bmatrix} \begin{bmatrix} x \\ y \end{bmatrix} = \begin{bmatrix} x \cos \theta - y \sin \theta \\ x \sin \theta + y \cos \theta \end{bmatrix}

Awesome, now we have a generalized formula to correct the movement direction! Or do we? Well, since this is a 3-dimensional world, it makes sense to apply the transformation to a Vec3 (vector with 33 elements: xx, yy, and zz). We are still talking about moving along the plane, so our yy coordinate (representing the height) should remain the same. So, our new transformation can be written like so:

R=[cosθ0sinθ010sinθ0cosθ]R = \begin{bmatrix} \cos \theta & 0 & -\sin \theta \\ 0 & 1 & 0 \\ \sin \theta & 0 & \cos \theta \end{bmatrix} Rv=[cosθ0sinθ010sinθ0cosθ][xyz]=[xcosθzsinθyxsinθ+zcosθ]R \vec v = \begin{bmatrix} \cos \theta & 0 & -\sin \theta \\ 0 & 1 & 0 \\ \sin \theta & 0 & \cos \theta \end{bmatrix} \begin{bmatrix} x \\ y \\ z \end{bmatrix} = \begin{bmatrix} x \cos \theta - z \sin \theta \\ y \\ x \sin \theta + z \cos \theta \end{bmatrix}

That’s better. Let’s start writing some code…

The first step is to assign the 3×33 \times 3 rotation matrix to some variable. The rotation matrix can be derived using the function Mat3::from_axis_angle. This function takes a normalized rotation along with an angle to return a 3D rotation matrix, more explanation can be found here.

let rotation = Mat3::from_axis_angle(Vec3::new(0.0, 1.0, 0.0), 45_f32.to_radians());
dbg!(rotation);

This outputs the following to the console:

rotation = $mat3 {
    x_axis: Vec3(
        0.70710677,
        0.0,
        -0.70710677,
    ),
    y_axis: Vec3(
        0.0,
        1.0,
        0.0,
    ),
    z_axis: Vec3(
        0.70710677,
        0.0,
        0.70710677,
    ),
}

Thus, rotation is equal to the 3D rotation matrix we required.

Getting the correct direction is simply a matter of multiplication:

const PLAYER_SPEED: f32 = 128.0;
let isometric_velocity = rotation.mul_vec3(velocity * time.delta_seconds() * PLAYER_SPEED);

The original velocity is also multiplied by time.delta_seconds() to achieve continuous movement.

Camera implementation

A camera in an isometric game generally has two core responsibilities:

  1. Follow the player.
  2. Look at the player.

Follow the player

Following the player insinuates that the camera simply moves along with the player. We can accomplish this by setting the camera’s translation relative to the player’s translation. The camera must be offset by a fixed amount or else the camera would be inside the player:

const CAMERA_TRANSLATION_OFFSET: f32 = 20.0;
camera_transform.translation =
    player_transform.translation + Vec3::splat(CAMERA_TRANSLATION_OFFSET);

Running this within a system would successfully make the camera follow the player. We could optionally supplement this with some linear interpolation:

const CAMERA_TRANSLATION_OFFSET: f32 = 20.0;
camera_transform.translation = camera_transform.translation.lerp(
    player_transform.translation + Vec3::splat(CAMERA_TRANSLATION_OFFSET),
    0.01,
);

Look at the player

The simplest way to make the camera face the player is to use Transform::look_at:

camera_transform.look_at(player_transform.translation, Vec3::Y);

While this technically works, we can make this process frame rate independent by multiplying the change in movement by time.delta_seconds(). This makes the camera’s rotations smoother and avoids the game looking “jittery”.

const CAMERA_ROTATION_SPEED: f32 = 5.0;
let movement = (player_transform.translation - cam_state.current_target)
    * time.delta_seconds()
    * CAMERA_ROTATION_SPEED;
let new_target = cam_state.current_target + movement;
camera_transform.look_at(new_target, Vec3::Y);
cam_state.current_target = new_target;

This snippet relies on a resource (CamState) that keeps track of the camera’s previous target direction.