Table of Contents
- Coordinate system
Welcome to the seventh article of the Sonic Battle (GBA) Renderer series.
Quick reminder (just in case): here’s a link to the coordinate system.
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:
Our walls represent boxes so it’s safe to assume that if a given side is visible, then the opposite is invisible. (Ex:
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.
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
-cam.directionIn2D.x and for
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.
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
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.
The view space positions of individual walls are computed as follows:
camera.transformMatrixInverse * worldSpacePosition.
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
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
(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!