Sonic Battle (GBA) Renderer Series - Camera and tilemaps

Posted on November 14, 2018

Table of Contents

Welcome to the sixth article of the Sonic Battle (GBA) Renderer series.


Background info

A general camera system is described in Joey de Vries’ series:


For reference, here’s the coordinate system used in this series.

Our camera has very little “primary” state. The state that the user can set is:

  • Position in 3D space
  • Heading (rotation around the Z axis)
  • Pitch (rotation around the X axis)

It also contains “secondary” state which is derived once per frame from the primary state:

  • Facing direction in 3D
  • Facing direction in 2D (on the XY plane)
  • Transformation matrix and its inverse (the inverse is the conventional view matrix)

Transformation matrix

  • Create translation matrix using position
  • Apply heading rotation
  • Apply pitch rotation

In conventional matrix notation this would be: translationMatrix * headingMatrix * pitchMatrix.

The GLM math library takes care of the specifics. A few details:

  • Base axis rotation (like rotation around the Z or X axis) is a special case which can be computed cheaply. GLM’s euler angle functions are used for that.
  • The inverse can be computed from a mat3, then casted back to mat4 and multiplied with the opposite translation matrix (mat3 inverse is a ton faster than the mat4 inverse)

Facing direction in 2D

vec(cos(heading), sin(heading))

(Using the trigonometric circle.)

Facing direction in 3D

  • The default direction is vec(0, 1, 0, 0)
  • The 3D direction is transformMatrix * defaultDirection

Which simplifies to just taking the second column of the transformation matrix.

Note - canvas space

I’ll ommit the subject of canvas/screen space (see this note for details). For the sake of simplicity the series will treat view/camera space as the final space for rendering.


To render a tilemap we need to compute its affine matrix and its view-space position.

We’re dealing with two tilemaps (bottom and top). They can both use the same affine matrix because only their positions differ.

View-space position

First, to get the view-space (or camera-space) position: camera.transformMatrixInverse * worldSpacePosition

Affine matrix

Our affine matrix is a 2D (2x2) matrix. To compute it we:

  • Create a scale matrix from vec(1, cos(camera.pitch))
  • Apply rotation to it using the opposite of the camera heading

In conventional matrix notation this would be: pitchScaleMatrix * oppositeHeadingRotationMatrix.

Scale logic

The orthographic projection used by the renderer can be represented by vector projection. In the case of unit vectors, by a dot product more specifically.

The tilemap’s normal vector needs to be projected on the opposite of the camera’s direction.

The algebraic dot product formula for 3D vectors is v1.x * v2.x + v1.y * v2.y + v1.z * v2.z.

We deal only with the YZ plane here, so let’s ignore x: v1.y * v2.y + v1.z * v2.z.

The tilemap normal is vec(0, 0, 1). Let’s plug it into our 2D formula (v1 would be the tilemap normal, and v2 the opposite of the camera direction): 0 * v2.y + 1 * v2.z which simplifies to v2.z. This is cos(camera.pitch - pi) (the minus pi accounts for the opposite direction).

We’re not quite done though. Because we want to look towards +Z by default instead of -Z, we need to use the opposite direction again. So we get cos(camera.pitch - pi - pi) which simplifies to cos(camera.pitch).

Scale logic sketch

Camera pitch = 0 degrees

Camera pitch = 45 degrees

Camera pitch = 90 degrees




Scale and rotation

The next article deals with walls (“how to transform the 2D surfaces to make them to look 3D”).