Sonic Battle (GBA) Renderer Series - Walls and tilemaps

Posted on January 23, 2019

Table of Contents


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


Coordinate system

Quick reminder (just in case): here’s a link to the coordinate system.

Walls

Directions

All walls are axis-aligned. (Except for the 45-degree walls but they will be discussed in a later article.)

It means that walls only face 4 directions which I call:

  • Y_PLUS
  • X_MINUS
  • Y_MINUS
  • X_PLUS

Visibility

Our walls represent boxes so it’s safe to assume that if a given side is visible, then the opposite is invisible. (Ex: X_MINUS and X_PLUS visibility is exclusive)

This fact can be used for an optimization: once a side is determined to be visible, the processing of the opposite one can be skipped. Note that there’s a special case where two opposite sides can be both not visible, it happens when the dot product for both is 0.

The usual algorithm for determining if a wall is facing away from a view is to check if the dot product of its normal against (the opposite of) the camera’s direction is smaller than 0.

In short: visible = dot(wallNormal, -cam.directionIn2D) > 0.

Our wall directions are the +/-X axis and +/-Y axis.

Then the dot product for the X_PLUS direction is dot(vec(0, 1), -cam.directionIn2D). It simplifies to -cam.directionIn2D.y. The dot product for X_MINUS is cam.directionIn2D.y.

Similarly, for Y_PLUS it’s -cam.directionIn2D.x and for Y_MINUS it’s cam.directionIn2D.x.

This translates to:

WallVisible[WallDirection::Y_PLUS] = camDirXY.x < 0.0f;
WallVisible[WallDirection::Y_MINUS] = camDirXY.x > 0.0f;
WallVisible[WallDirection::X_PLUS] = camDirXY.y > 0.0f;
WallVisible[WallDirection::X_MINUS] = camDirXY.y < 0.0f;

Another way to look at it is to visualize the camera angle from the top, where the relation between wall directions, camera direction, and XY axes is clear:

Re-using affine matrices (optimization)

Once the visible wall directions are determined, the corresponding affine matrices are computed.

We re-use these affine matrices for every wall of the same facing direction because an individual wall’s position doesn’t affect its projection (only wall normal and camera direction matter - as will be described below).

So all visible X_PLUS walls share the same X_PLUS affine matrix for example.

This is a significant optimization: each frame we only compute 1 or 2 affine matrices instead of 1 per wall.

Affine matrix

In a nutshell we need to find the matrix that will transform the wall texture’s local coordinate space to the coordinate space of the texture as it would appear on screen. We feed that matrix to the hardware so that it transforms the texture accordingly when it renders it on the screen.

Here’s an illustration of the full process:

We can pre-compute the world-space texture X axis (wall tangents). The texture Y axis points straight down (-Z in world space). So we create a matrix which uses those two as basis vectors.

Next we transform that matrix into camera space (inCamSpace = mat3(camTransformInverse) * inWorldSpace).

Then we perform the orthographic projection (by discarding any depth-axis information). Finally we flip the vertical axis to account for screen/canvas coordinates.

Note: on the real hardware we wouldn’t be done just yet. There’s a few gotchas with affine matrices.

Loop unrolling method

There exists a messier but more efficient method.

The concept is that we simplify the wall affine matrix computation by writing out the multiplication in full (ex for Y_PLUS direction):

Note how it’s possible to skip the entire multiplication and obtain the result instead by copying over four terms from the right-hand matrix (and applying the signs). Our three other directions give us similar results, only with different terms from the right-hand matrix and different signs.

This is do-able because our wall tangents/bi-tangents are aligned with the basis (axes) of the 3D space the camera matrices are in.

We also notice that the bi-tangent in camera-space is the same for all four directions. This is because they all share the same bi-tangent in world-space. So we don’t even need to treat it separately for each direction.

View-space position

The view space positions of individual walls are computed as follows: camera.transformMatrixInverse * worldSpacePosition.

Sorting

Walls are sorted by the view-space depth position (.z component) of their midpoints. It’s essentially the painter’s algorithm.

It’s not perfect and breaks down in at least one circumstance (where perpendicular walls partially occlude one another). Here’s a capture from the original game:

Illustrated wall bounds and sorting reference points

Tilemaps

A map always has two tilemaps. Both can share the same affine matrix and they don’t need to be sorted - they already have established draw orders.

This affine matrix consists of the same tangent as the Y_MINUS direction and of a bi-tangent in the direction of +Y ((0, 1, 0)).

The tilemap affine matrix can be computed the exact same way as the wall matrices.

Note - Tilemap and wall base scale

All dimensions of the game world (save for the characters) have been doubled! Tilemaps and most wall sprites are exactly x2 larger than their source bitmaps. That really tripped me up until I figured it out with mGBA’s inspection tools.

This wall is rendered ~64 pixels wide at native screen resolution

But its bitmap is only 32 pixels wide (the "Double Size" flag is unrelated by the way)

The wall behind it though has the same width as its bitmap (it's only scaled in height) - so there's some flexibility here

This could have been done for performance reasons, to use ~4 times fewer hardware sprites to cover the same area (which reduces load on the entire renderer).

Or for artistic reasons, to reduce the visual noise.

Note - Wall height scale

For gameplay reasons (to prevent the action from vertically overflowing the screen) all walls are additionally scaled in height to make them shorter. It also looks better that way.

Just the basic x2 scale - that's really tall

x2 scale and additional height scale (I eyeballed the value, it's ~1.4)



The next article discusses the implementation of inset walls, or inverted walls. It’s surprisingly simple for the visual impact it adds to the game!